diff --git a/README.md b/README.md index 703271d..8af3952 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ anyr login # browser / device code / paste; key saved on this m anyr login --device # headless / SSH anyr usage # credit balance anyr models use # persist default model -anyr keys list # switch with: anyr keys use +anyr keys audit # keys that look unused, with the reason anyr account use work # switch saved profiles anyr onboard impl # paste-ready prompt for a coding agent anyr plan # migration plan prompt (no code changes) diff --git a/src/auth.rs b/src/auth.rs index 3695fcd..42a64fe 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -208,10 +208,12 @@ pub fn start_device_flow(_base_url: &str, _tool: Option<&str>) -> Result) -> Result { let url = join_api(base_url, "/v1/auth/cli/device/start"); - let body = match tool { - Some(t) if !t.is_empty() => serde_json::json!({ "tool": t }).to_string(), - _ => "{}".into(), - }; + let env: BTreeMap = std::env::vars().collect(); + let label = crate::cmd::key_audit::device_key_label( + &crate::cmd::key_audit::machine_host(&env), + crate::civil::now_secs(), + ); + let body = device_start_body(tool, &label); let (status, resp) = http_post(&url, None, Some(&body))?; if !(200..300).contains(&status) { return Err(format!("Failed to start device login (HTTP {status}).")); @@ -219,6 +221,15 @@ pub fn start_device_flow(base_url: &str, tool: Option<&str>) -> Result, key_label: &str) -> String { + let mut body = serde_json::json!({ "key_label": key_label }); + if let Some(t) = tool.filter(|t| !t.is_empty()) { + body["tool"] = serde_json::Value::String(t.to_string()); + } + body.to_string() +} + /// What the poll loop should do next, derived from one server response or /// transport outcome plus the running clock budget. Pure — unit-testable. #[derive(Debug)] @@ -439,6 +450,18 @@ pub fn acquire_api_key( mod tests { use super::*; + #[test] + fn device_start_names_the_key_after_the_device() { + let body: serde_json::Value = + serde_json::from_str(&device_start_body(Some("cli"), "studio anyr 2026-10-27")) + .unwrap(); + assert_eq!(body["key_label"], "studio anyr 2026-10-27"); + assert_eq!(body["tool"], "cli"); + let bare: serde_json::Value = + serde_json::from_str(&device_start_body(None, "studio anyr 2026-10-27")).unwrap(); + assert!(bare.get("tool").is_none()); + } + #[test] fn parse_device_start_reads_fields() { let start = parse_device_start( diff --git a/src/buildinfo.rs b/src/buildinfo.rs index 7feaf94..a1cb3ea 100644 --- a/src/buildinfo.rs +++ b/src/buildinfo.rs @@ -33,7 +33,7 @@ fn local_time_from_utc(stamp: &str) -> Option { let num = |r: std::ops::Range| stamp.get(r)?.parse::().ok(); let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); - let epoch = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; + let epoch = crate::civil::days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; let mut tm: libc::tm = unsafe { std::mem::zeroed() }; let t: libc::time_t = epoch; @@ -51,18 +51,6 @@ fn local_time_from_utc(stamp: &str) -> Option { )) } -/// Days since 1970-01-01 from a civil date (Howard Hinnant's algorithm). -#[cfg(unix)] -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = y - i64::from(m <= 2); - let era = y.div_euclid(400); - let yoe = y - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 -} - #[cfg(test)] mod tests { use super::*; @@ -76,20 +64,6 @@ mod tests { assert!(BUILD_TIME.ends_with('Z'), "{BUILD_TIME}"); } - #[test] - #[cfg(unix)] // days_from_civil is unix-only (its only caller is) - fn civil_roundtrip_known_dates() { - // (civil date, expected epoch seconds) pairs verified with `date -u`. - for (y, mo, d, h, mi, s, epoch) in [ - (1970, 1, 1, 0, 0, 0, 0), - (2026, 8, 21, 9, 2, 36, 1_787_302_956), - (2000, 2, 29, 12, 0, 0, 951_825_600), - ] { - let computed = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; - assert_eq!(computed, epoch, "{y}-{mo}-{d} {h}:{mi}:{s}"); - } - } - #[test] fn display_time_falls_back_to_raw_stamp_on_bad_input() { // display_time never panics even if the stamp were malformed. diff --git a/src/civil.rs b/src/civil.rs new file mode 100644 index 0000000..231679a --- /dev/null +++ b/src/civil.rs @@ -0,0 +1,79 @@ +//! UTC calendar math without a date crate (Howard Hinnant's algorithms). + +/// Days since 1970-01-01 from a civil date. +pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = y - i64::from(m <= 2); + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Civil `(year, month, day)` for a count of days since 1970-01-01. +pub fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + 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 }; + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + +/// `YYYY-MM-DD` (UTC) for unix seconds. +pub fn ymd(epoch_secs: i64) -> String { + let (y, m, d) = civil_from_days(epoch_secs.div_euclid(86_400)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Unix seconds for `YYYY-MM-DDTHH:MM:SS` (a `T` or a space), read as UTC. +/// Any fraction or zone suffix after the seconds is ignored; the API sends UTC. +pub fn parse_utc(stamp: &str) -> Option { + let b = stamp.as_bytes(); + if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b' ') { + return None; + } + let num = |r: std::ops::Range| stamp.get(r)?.parse::().ok(); + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s) +} + +/// Seconds since the unix epoch, now. +pub fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn civil_roundtrip_known_dates() { + // (civil date, expected epoch seconds) pairs verified with `date -u`. + for (y, mo, d, h, mi, s, epoch) in [ + (1970, 1, 1, 0, 0, 0, 0), + (2026, 8, 21, 9, 2, 36, 1_787_302_956), + (2000, 2, 29, 12, 0, 0, 951_825_600), + ] { + let computed = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; + assert_eq!(computed, epoch, "{y}-{mo}-{d} {h}:{mi}:{s}"); + assert_eq!(civil_from_days(epoch.div_euclid(86_400)), (y, mo, d)); + } + } + + #[test] + fn parse_utc_reads_iso_and_sqlite_stamps() { + assert_eq!(parse_utc("2026-08-21T09:02:36Z"), Some(1_787_302_956)); + assert_eq!(parse_utc("2026-08-21 09:02:36"), Some(1_787_302_956)); + assert_eq!(parse_utc("2026-08-21T09:02:36.123Z"), Some(1_787_302_956)); + assert_eq!(parse_utc("yesterday"), None); + assert_eq!(ymd(1_787_302_956), "2026-08-21"); + } +} diff --git a/src/cmd/config_tui.rs b/src/cmd/config_tui.rs index 5b4e8c3..bf7e869 100644 --- a/src/cmd/config_tui.rs +++ b/src/cmd/config_tui.rs @@ -25,7 +25,7 @@ use crate::cmd::dispatch::{ }; #[cfg(feature = "native")] use crate::cmd::dispatch::{tui_dump_settings, tui_settings_select}; -use crate::cmd::keys::{run_keys, stored_api_key}; +use crate::cmd::keys::stored_api_key; use crate::cmd::login::run_login; use crate::cmd::models::{ flag_agent, models_for_picker, pick_list, pick_model, run_models, save_model_slot, @@ -164,6 +164,17 @@ pub(crate) enum SettingKind { AgentRouting(&'static str, RoutingField), } +/// The CLI's own key cannot read another key's secret (reveal takes a browser +/// session), so a different key means a fresh login or a pasted key. +fn print_new_key_hint() { + println!( + "{}", + term::dim(&crate::help::with_bin( + "New key for this machine: {bin} login · paste one: {bin} login --key - · unused keys: {bin} keys audit" + )) + ); +} + pub(crate) fn run_config_tui( parsed: &ParsedArgs, env: &BTreeMap, @@ -778,13 +789,8 @@ pub(crate) fn config_edit_row( match kind { SettingKind::Account => config_account_actions(parsed, env), SettingKind::ApiKey => { - let mut next = parsed.clone(); - next.command = "keys".into(); - next.passthrough = vec!["use".into()]; - match run_keys(&next, env) { - Err(err) if err == "Cancelled." => Ok(0), - other => other, - } + print_new_key_hint(); + Ok(0) } SettingKind::Model(slot) => { let existing = load_config_if_present(path); @@ -886,16 +892,9 @@ pub(crate) fn config_edit_row( .insert("agent".into(), FlagValue::Value(id.to_string())); config_account_actions(&next, env) } - SettingKind::AgentKey(id) => { - let mut next = parsed.clone(); - next.command = "keys".into(); - next.passthrough = vec!["use".into()]; - next.flags - .insert("agent".into(), FlagValue::Value(id.to_string())); - match run_keys(&next, env) { - Err(err) if err == "Cancelled." => Ok(0), - other => other, - } + SettingKind::AgentKey(_) => { + print_new_key_hint(); + Ok(0) } SettingKind::AgentModel(id) => { let mut next = parsed.clone(); @@ -1151,7 +1150,6 @@ pub(crate) fn config_menu_loop_legacy( path: &Path, ) -> Result { let items = vec![ - "Switch key".into(), "Switch account".into(), "Switch model".into(), "Credits".into(), @@ -1166,26 +1164,20 @@ pub(crate) fn config_menu_loop_legacy( }; let result = match idx { 0 => { - let mut next = parsed.clone(); - next.command = "keys".into(); - next.passthrough = vec!["use".into()]; - run_keys(&next, env) - } - 1 => { let mut next = parsed.clone(); next.passthrough = Vec::new(); run_auth_switch(&next, env) } - 2 => { + 1 => { let mut next = parsed.clone(); next.command = "models".into(); next.flags.insert("pick".into(), FlagValue::Bool(true)); next.passthrough = Vec::new(); run_models(&next, env) } - 3 => run_usage(parsed, env), - 4 => run_login(parsed, env), - 5 => run_logout(parsed, env), + 2 => run_usage(parsed, env), + 3 => run_login(parsed, env), + 4 => run_logout(parsed, env), _ => return Ok(0), }; if let Err(err) = result { diff --git a/src/cmd/key_audit.rs b/src/cmd/key_audit.rs new file mode 100644 index 0000000..14f93b5 --- /dev/null +++ b/src/cmd/key_audit.rs @@ -0,0 +1,507 @@ +//! Device key labels, the unused-key audit rules, and the revoke offer after a +//! re-login. Pure: callers pass the clock, the key rows and the prompt. +//! +//! The rules mirror the dashboard's keys page +//! (`apps/dashboard/src/app/keys/-components/key-audit.ts` in duyet/anyrouter); +//! both suites check the same fixed-date cases. + +use std::collections::BTreeMap; + +use crate::civil::{parse_utc, ymd}; +use crate::http::{is_active_key_row, RemoteKey}; + +const DAY: i64 = 86_400; +const DEVICE_MARK: &str = " anyr "; +const MAX_HOST_CHARS: usize = 40; + +/// The name device login gives its key: ` anyr `. +pub(crate) fn device_key_label(host: &str, now: i64) -> String { + let host: String = host + .split('.') + .next() + .unwrap_or("") + .trim() + .chars() + .take(MAX_HOST_CHARS) + .collect(); + let host = if host.is_empty() { + "device".into() + } else { + host + }; + format!("{host}{DEVICE_MARK}{}", ymd(now)) +} + +/// The host part of a device-login key name, or None for any other name. +pub(crate) fn device_of(name: &str) -> Option<&str> { + let (host, date) = name.rsplit_once(DEVICE_MARK)?; + let b = date.as_bytes(); + let is_date = b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && date + .chars() + .enumerate() + .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()); + (is_date && !host.trim().is_empty()).then_some(host) +} + +/// This machine's host name: the OS call first, then the environment. +pub(crate) fn machine_host(env: &BTreeMap) -> String { + #[cfg(unix)] + { + let mut buf = [0u8; 256]; + // SAFETY: the buffer outlives the call and its length is passed in. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if rc == 0 { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + ["HOSTNAME", "COMPUTERNAME", "HOST"] + .iter() + .find_map(|k| env.get(*k).map(|v| v.trim()).filter(|v| !v.is_empty())) + .unwrap_or("device") + .to_string() +} + +/// The last four characters of a key, from the server's `sk-ar-v1-...abcd` label. +pub(crate) fn last4(masked: &str) -> &str { + let tail = masked + .rsplit_once("...") + .or_else(|| masked.rsplit_once('…')) + .map_or("", |(_, t)| t); + if tail.len() == 4 { + tail + } else { + "" + } +} + +/// `…abcd`, or the hash head when the label has no tail. +pub(crate) fn short_ref(row: &RemoteKey) -> String { + match last4(&row.masked) { + "" => row.hash.chars().take(8).collect(), + tail => format!("…{tail}"), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Reason { + /// Never used and older than 7 days. + NeverUsed { age_days: i64 }, + /// No use in the last 30 days or more. + Unused { idle_days: i64 }, + /// An older device-login key from a device that has a newer key, and not + /// used since that newer key was made. + ReplacedOnDevice { device: String, newer: String }, +} + +impl Reason { + pub(crate) fn code(&self) -> &'static str { + match self { + Reason::NeverUsed { .. } => "never_used", + Reason::Unused { .. } => "unused_30d", + Reason::ReplacedOnDevice { .. } => "replaced_on_device", + } + } + + pub(crate) fn text(&self) -> String { + match self { + Reason::NeverUsed { age_days } => format!("never used, created {age_days} days ago"), + Reason::Unused { idle_days } => format!("not used for {idle_days} days"), + Reason::ReplacedOnDevice { device, newer } => { + format!("older login on {device}; {newer} replaced it") + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct Suggestion { + pub row: RemoteKey, + pub reason: Reason, +} + +/// Keys worth revoking. Never the key `current` (the one making this request), +/// never a key with requests in the last 30 days. Suggestions only. +pub(crate) fn suggest_removals( + rows: &[RemoteKey], + current: Option<&str>, + now: i64, +) -> Vec { + let ts = |s: &Option| s.as_deref().and_then(parse_utc); + let mut out = Vec::new(); + for row in rows { + if is_active_key_row(&row.masked, current) || row.requests_30d > 0 { + continue; + } + let created = ts(&row.created_at); + let used = ts(&row.last_used_at); + let reason = match (used, created) { + (None, Some(c)) if now - c >= 7 * DAY => Some(Reason::NeverUsed { + age_days: (now - c) / DAY, + }), + (Some(u), _) if now - u >= 30 * DAY => Some(Reason::Unused { + idle_days: (now - u) / DAY, + }), + _ => None, + } + .or_else(|| replaced_on_device(row, rows, created, used)); + if let Some(reason) = reason { + out.push(Suggestion { + row: row.clone(), + reason, + }); + } + } + out +} + +fn replaced_on_device( + row: &RemoteKey, + rows: &[RemoteKey], + created: Option, + used: Option, +) -> Option { + if !row.origin.as_deref().is_some_and(|o| o.starts_with("cli")) { + return None; + } + let device = device_of(&row.name)?; + let created = created?; + let newer = rows + .iter() + .filter(|o| o.hash != row.hash && o.active && device_of(&o.name) == Some(device)) + .filter_map(|o| Some((o, o.created_at.as_deref().and_then(parse_utc)?))) + .filter(|(_, oc)| *oc > created) + // Same host name on two live machines: still in use since then. + .filter(|(_, oc)| used.is_none_or(|u| u <= *oc)) + .max_by_key(|(_, oc)| *oc)?; + Some(Reason::ReplacedOnDevice { + device: device.to_string(), + newer: short_ref(newer.0), + }) +} + +/// The device-login key this profile held before a re-login, when it is still +/// on the server and is not the key just issued. +pub(crate) fn previous_device_key( + rows: &[RemoteKey], + old_key: Option<&str>, + new_key: &str, +) -> Option { + let old_key = old_key.map(str::trim).filter(|k| !k.is_empty())?; + if old_key == new_key { + return None; + } + rows.iter() + .find(|r| { + is_active_key_row(&r.masked, Some(old_key)) + && !is_active_key_row(&r.masked, Some(new_key)) + && r.origin.as_deref().is_some_and(|o| o.starts_with("cli")) + }) + .cloned() +} + +/// What happened to the previous key after a re-login. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RevokeOutcome { + Revoked, + Kept, + Hinted, +} + +/// Ask before revoking the previous device key. Interactive runs prompt and +/// revoke only on yes; anything else prints the command and revokes nothing. +pub(crate) fn offer_revoke_previous( + prev: &RemoteKey, + interactive: bool, + confirm: impl FnOnce(&str) -> bool, + revoke: impl FnOnce(&str) -> Result<(), String>, + out: &mut dyn std::io::Write, +) -> Result { + let label = format!("\"{}\" ({})", prev.name, short_ref(prev)); + if !interactive { + let _ = writeln!( + out, + "Previous key from this device is still active: {label}\nRevoke it: {} keys revoke {}", + crate::help::invoked_bin(), + revoke_ref(prev) + ); + return Ok(RevokeOutcome::Hinted); + } + if !confirm(&format!( + "Revoke the previous key from this device, {label}?" + )) { + return Ok(RevokeOutcome::Kept); + } + revoke(&prev.hash)?; + let _ = writeln!(out, "Revoked {label}"); + Ok(RevokeOutcome::Revoked) +} + +/// The shortest argument `keys revoke` accepts for this row. +pub(crate) fn revoke_ref(row: &RemoteKey) -> String { + match last4(&row.masked) { + "" => row.hash.chars().take(12).collect(), + tail => tail.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 2026-10-27T12:00:00Z, the fixed "now" both rule suites share. + const NOW: i64 = 1_793_102_400; + + fn key(name: &str, tail: &str, origin: Option<&str>) -> RemoteKey { + RemoteKey { + name: name.into(), + hash: format!("hash-{tail}"), + masked: format!("sk-ar-v1-...{tail}"), + created_at: Some("2026-10-01T00:00:00Z".into()), + last_used_at: Some("2026-10-26T00:00:00Z".into()), + active: true, + id: format!("key_{tail}"), + origin: origin.map(str::to_string), + requests_30d: 0, + } + } + + fn with(mut k: RemoteKey, created: &str, used: Option<&str>, req: u64) -> RemoteKey { + k.created_at = Some(created.into()); + k.last_used_at = used.map(str::to_string); + k.requests_30d = req; + k + } + + fn reasons(rows: &[RemoteKey], current: Option<&str>) -> Vec<(String, &'static str)> { + suggest_removals(rows, current, NOW) + .into_iter() + .map(|s| (s.row.name, s.reason.code())) + .collect() + } + + #[test] + fn now_constant_is_the_shared_fixture_date() { + assert_eq!(ymd(NOW), "2026-10-27"); + } + + #[test] + fn device_label_is_host_anyr_date_and_round_trips() { + let label = device_key_label("studio.lan", NOW); + assert_eq!(label, "studio anyr 2026-10-27"); + assert_eq!(device_of(&label), Some("studio")); + assert_eq!(device_key_label("", NOW), "device anyr 2026-10-27"); + assert!(device_key_label(&"x".repeat(80), NOW).len() <= 60); + // Legacy and dashboard names carry no device. + assert_eq!(device_of("AnyRouter CLI"), None); + assert_eq!(device_of("ci anyr later"), None); + } + + #[test] + fn never_used_needs_seven_days() { + let rows = vec![ + with( + key("old-unused", "aaaa", None), + "2026-10-19T12:00:00Z", + None, + 0, + ), + with( + key("new-unused", "bbbb", None), + "2026-10-21T12:00:00Z", + None, + 0, + ), + ]; + assert_eq!( + reasons(&rows, None), + vec![("old-unused".into(), "never_used")] + ); + } + + #[test] + fn idle_needs_thirty_days_and_zero_recent_requests() { + let rows = vec![ + with( + key("idle", "aaaa", None), + "2026-01-01T00:00:00Z", + Some("2026-09-26T00:00:00Z"), + 0, + ), + with( + key("recent", "bbbb", None), + "2026-01-01T00:00:00Z", + Some("2026-09-30T00:00:00Z"), + 0, + ), + // A stale last_used_at with requests in the window is still in use. + with( + key("busy", "cccc", None), + "2026-01-01T00:00:00Z", + Some("2026-09-01T00:00:00Z"), + 40, + ), + ]; + assert_eq!(reasons(&rows, None), vec![("idle".into(), "unused_30d")]); + } + + #[test] + fn the_current_key_is_never_suggested() { + let rows = vec![with( + key("this-machine", "aaaa", None), + "2026-01-01T00:00:00Z", + None, + 0, + )]; + assert_eq!(reasons(&rows, None).len(), 1); + let current = "sk-ar-v1-secretsecretsecretsecretaaaa"; + assert!(reasons(&rows, Some(current)).is_empty()); + } + + #[test] + fn older_device_key_is_replaced_by_a_newer_one_on_the_same_device() { + let cli = Some("cli:cli"); + let rows = vec![ + with( + key("studio anyr 2026-10-20", "aaaa", cli), + "2026-10-20T00:00:00Z", + Some("2026-10-24T00:00:00Z"), + 5, + ), + with( + key("studio anyr 2026-10-25", "bbbb", cli), + "2026-10-25T00:00:00Z", + Some("2026-10-27T00:00:00Z"), + 9, + ), + ]; + // requests_30d > 0 keeps the older one: it still saw traffic this month. + assert!(reasons(&rows, None).is_empty()); + let mut rows = rows; + rows[0].requests_30d = 0; + assert_eq!( + reasons(&rows, None), + vec![("studio anyr 2026-10-20".into(), "replaced_on_device")] + ); + } + + #[test] + fn same_host_name_still_in_use_after_the_newer_key_is_kept() { + let cli = Some("cli:cli"); + let rows = vec![ + with( + key("macbook anyr 2026-10-20", "aaaa", cli), + "2026-10-20T00:00:00Z", + Some("2026-10-26T00:00:00Z"), + 0, + ), + with( + key("macbook anyr 2026-10-25", "bbbb", cli), + "2026-10-25T00:00:00Z", + None, + 0, + ), + ]; + assert!(reasons(&rows, None).is_empty()); + } + + #[test] + fn dashboard_keys_and_legacy_cli_names_never_group_by_device() { + let rows = vec![ + with( + key("AnyRouter CLI", "aaaa", Some("cli:cli")), + "2026-10-20T00:00:00Z", + Some("2026-10-21T00:00:00Z"), + 0, + ), + with( + key("AnyRouter CLI", "bbbb", Some("cli:cli")), + "2026-10-25T00:00:00Z", + Some("2026-10-26T00:00:00Z"), + 0, + ), + with( + key("studio anyr 2026-10-20", "cccc", None), + "2026-10-20T00:00:00Z", + Some("2026-10-21T00:00:00Z"), + 0, + ), + with( + key("studio anyr 2026-10-25", "dddd", None), + "2026-10-25T00:00:00Z", + Some("2026-10-26T00:00:00Z"), + 0, + ), + ]; + assert!(reasons(&rows, None).is_empty()); + } + + #[test] + fn previous_device_key_is_the_old_stored_cli_key_only() { + let old = "sk-ar-v1-oldoldoldoldoldoldoldoldaaaa"; + let new = "sk-ar-v1-newnewnewnewnewnewnewnewbbbb"; + let rows = vec![ + key("studio anyr 2026-10-20", "aaaa", Some("cli:cli")), + key("studio anyr 2026-10-27", "bbbb", Some("cli:cli")), + ]; + let prev = previous_device_key(&rows, Some(old), new).unwrap(); + assert_eq!(prev.hash, "hash-aaaa"); + // Same key back from the consent screen, no old key, or a pasted + // dashboard key: nothing to offer. + assert!(previous_device_key(&rows, Some(old), old).is_none()); + assert!(previous_device_key(&rows, None, new).is_none()); + let pasted = vec![key("ci", "aaaa", None)]; + assert!(previous_device_key(&pasted, Some(old), new).is_none()); + } + + #[test] + fn relogin_revokes_only_after_yes() { + let prev = key("studio anyr 2026-10-20", "aaaa", Some("cli:cli")); + for (answer, want) in [(true, RevokeOutcome::Revoked), (false, RevokeOutcome::Kept)] { + let mut revoked = Vec::new(); + let mut asked = String::new(); + let mut out = Vec::new(); + let got = offer_revoke_previous( + &prev, + true, + |q| { + asked = q.to_string(); + answer + }, + |h| { + revoked.push(h.to_string()); + Ok(()) + }, + &mut out, + ) + .unwrap(); + assert_eq!(got, want); + assert!(asked.contains("…aaaa"), "{asked}"); + assert_eq!(revoked.len(), usize::from(answer)); + } + } + + #[test] + fn non_interactive_relogin_prints_a_hint_and_never_revokes() { + let prev = key("studio anyr 2026-10-20", "aaaa", Some("cli:cli")); + let mut out = Vec::new(); + let got = offer_revoke_previous( + &prev, + false, + |_| panic!("must not prompt"), + |_| panic!("must not revoke"), + &mut out, + ) + .unwrap(); + assert_eq!(got, RevokeOutcome::Hinted); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("keys revoke aaaa"), "{text}"); + assert!(!text.contains("sk-ar-v1-"), "{text}"); + } +} diff --git a/src/cmd/keys.rs b/src/cmd/keys.rs index aa25437..7dd597e 100644 --- a/src/cmd/keys.rs +++ b/src/cmd/keys.rs @@ -2,18 +2,18 @@ use std::collections::BTreeMap; use std::path::Path; use crate::config::write_config; -use crate::http::{delete_key, fetch_keys, is_active_key_row, reveal_key}; +use crate::http::{delete_key, fetch_keys, is_active_key_row}; use crate::key::{ - config_for_credential, load_config_if_present, mask_api_key, profile_or_default, - resolve_api_key, resolve_base_url, + config_for_credential, load_config_if_present, profile_or_default, resolve_api_key, + resolve_base_url, }; use crate::parse::{get_string_flag, ParsedArgs}; use crate::term; use crate::api::{get, send, Ctx}; use crate::cmd::dispatch::{config_path, hint}; +use crate::cmd::key_audit::{last4, revoke_ref, short_ref, suggest_removals}; use crate::cmd::key_scope::{render_show, scope_fields}; -use crate::cmd::models::{flag_agent, pick_list, save_agent_key}; pub(crate) fn keys_credential( parsed: &ParsedArgs, @@ -30,12 +30,6 @@ pub(crate) fn keys_credential( Ok((path, cfg, base, api_key)) } -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs() as i64) -} - fn key_ctx(parsed: &ParsedArgs, base: &str, cred: &str) -> Ctx { Ctx { base: base.to_string(), @@ -45,22 +39,31 @@ fn key_ctx(parsed: &ParsedArgs, base: &str, cred: &str) -> Ctx { } } -/// The one key whose hash equals or starts with `hash`. +/// The one key matching `needle`: its id, its hash or a hash prefix, or the +/// last four characters of the key. fn resolve_key( rows: &[crate::http::RemoteKey], - hash: &str, + needle: &str, ) -> Result { let matches: Vec<_> = rows .iter() - .filter(|r| r.hash == hash || r.hash.starts_with(hash)) + .filter(|r| { + r.id == needle + || r.hash == needle + || r.hash.starts_with(needle) + || (needle.len() == 4 && last4(&r.masked) == needle) + }) .collect(); match matches.as_slice() { [one] => Ok((*one).clone()), - [] => Err(hint(&format!( - "No key matches \"{hash}\". See: {{bin}} keys list" - ))), + [] => Err(crate::exit::tag( + crate::exit::NOT_FOUND, + hint(&format!( + "No key matches \"{needle}\". See: {{bin}} keys list" + )), + )), _ => Err(format!( - "\"{hash}\" matches {} keys — use a longer hash prefix.", + "\"{needle}\" matches {} keys — use the key id or a longer hash prefix.", matches.len() )), } @@ -132,7 +135,9 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> R println!(); println!( "{}", - term::dim(&hint("* = key this profile uses · switch: {bin} keys use")) + term::dim(&crate::help::with_bin( + "* = key this machine uses · new key: {bin} login · unused keys: {bin} keys audit" + )) ); Ok(0) } @@ -144,7 +149,7 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> R .cloned() .filter(|s| !s.is_empty()) .unwrap_or_else(default_key_name); - let mut body = scope_fields(parsed, now_secs())?; + let mut body = scope_fields(parsed, crate::civil::now_secs())?; body.remove("disabled"); body.insert("name".into(), serde_json::Value::String(name.clone())); let ctx = key_ctx(parsed, &base, &cred); @@ -190,7 +195,7 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> R let row = resolve_key(&fetch_keys(&base, &cred)?, &hash)?; let url = format!("/v1/keys/{}", row.hash); if sub == "update" { - let body = scope_fields(parsed, now_secs())?; + let body = scope_fields(parsed, crate::civil::now_secs())?; if body.is_empty() { return Err(hint( "Nothing to update. Pass --limit, --reset, --endpoints, --expires, --disable or --enable.", @@ -221,114 +226,32 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> R } Ok(0) } - "use" => { - let (path, mut cfg, base, api_key) = keys_credential(parsed, env)?; - let rows = crate::http::keys_newest_first( - fetch_keys(&base, &api_key)? - .into_iter() - .filter(|r| r.active) - .collect(), - ); - if rows.is_empty() { - return Err(hint("No active keys. Create one: {bin} keys create")); - } - let hash_arg = parsed.passthrough.get(1).cloned(); - let row = if let Some(hash) = hash_arg { - let matches: Vec<_> = rows - .iter() - .filter(|r| r.hash == hash || r.hash.starts_with(&hash)) - .collect(); - match matches.as_slice() { - [one] => (*one).clone(), - [] => { - return Err(hint(&format!( - "No key matches \"{hash}\". See: {{bin}} keys list" - ))) - } - _ => { - return Err(format!( - "\"{hash}\" matches {} keys — use a longer hash prefix.", - matches.len() - )) - } - } - } else if term::is_interactive() { - let current = rows - .iter() - .position(|r| is_active_key_row(&r.masked, Some(&api_key))) - .or(Some(0)); - let labels: Vec = rows - .iter() - .enumerate() - .map(|(i, r)| key_pick_label(r, current == Some(i))) - .collect(); - let idx = pick_list( - "API key", - &[ - "newest first · type to search".into(), - format!("current {}", mask_api_key(Some(&api_key))), - ], - &labels, - current, - )?; - rows[idx].clone() - } else { - return Err(hint( - "Usage: {bin} keys use (interactive picker needs a terminal)", - )); - }; - let revealed = reveal_key(&base, &api_key, &row.hash)?; - if let Some(agent) = flag_agent(parsed) { - return save_agent_key(&path, &agent, &revealed); - } - let active = cfg.active_profile.clone(); - if let Some(p) = cfg.profiles.get_mut(&active) { - p.api_key = Some(revealed); - } - write_config(&cfg, &path)?; - println!( - "{} now using {} ({})", - term::ok("Switched"), - term::accent(&row.name), - mask_api_key(cfg.profiles.get(&active).and_then(|p| p.api_key.as_deref())) - ); - Ok(0) - } + "use" => Err(hint( + "This machine cannot fetch another key's secret. Get a fresh key: {bin} login, or paste one copied from the dashboard: {bin} login --key -", + )), + "audit" => run_keys_audit(parsed, env), "revoke" | "rm" | "delete" => { let hash = parsed.passthrough.get(1).cloned().ok_or_else(|| { - hint("Usage: {bin} keys revoke (find hashes: {bin} keys list)") + hint("Usage: {bin} keys revoke (find keys: {bin} keys list)") })?; let (_path, _cfg, base, cred) = keys_credential(parsed, env)?; - let rows = fetch_keys(&base, &cred)?; - let matches: Vec<_> = rows - .iter() - .filter(|r| r.hash == hash || r.hash.starts_with(&hash)) - .collect(); - let row = match matches.as_slice() { - [one] => *one, - [] => { - return Err(crate::exit::tag( - crate::exit::NOT_FOUND, - format!( - "No key matches \"{hash}\". See: {} keys list", - crate::help::invoked_bin() - ), - )) - } - _ => { - return Err(format!( - "\"{hash}\" matches {} keys — use a longer hash prefix.", - matches.len() - )) - } - }; + let row = resolve_key(&fetch_keys(&base, &cred)?, &hash)?; if !parsed.skip_confirm() { if !term::is_interactive() { return Err(crate::cmd::dispatch::hint( "Revoking a key is destructive; pass --yes to run non-interactively.", )); } - if !term::confirm(&format!("Revoke \"{}\" ({})?", row.name, row.masked)) { + let this_machine = if is_active_key_row(&row.masked, Some(&cred)) { + " This is the key this machine uses." + } else { + "" + }; + if !term::confirm(&format!( + "Revoke \"{}\" ({})?{this_machine}", + row.name, + short_ref(&row) + )) { return Ok(1); } } @@ -337,43 +260,67 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> R "{} revoked \"{}\" ({})", term::ok("Revoked"), row.name, - row.masked + short_ref(&row) ); Ok(0) } other => Err(crate::cmd::dispatch::hint(&format!( - "Unknown keys subcommand \"{other}\". Try: list | create | show | update | use | revoke" + "Unknown keys subcommand \"{other}\". Try: list | create | show | update | audit | revoke" ))), } } -pub(crate) fn resolve_latest_key(base: &str, current: &str) -> String { - let Ok(rows) = fetch_keys(base, current) else { - return current.to_string(); - }; - let rows = crate::http::keys_newest_first(rows.into_iter().filter(|r| r.active).collect()); - let Some(latest) = rows.first() else { - return current.to_string(); - }; - if is_active_key_row(&latest.masked, Some(current)) { - return current.to_string(); +fn run_keys_audit(parsed: &ParsedArgs, env: &BTreeMap) -> Result { + let (_path, _cfg, base, cred) = keys_credential(parsed, env)?; + let rows = fetch_keys(&base, &cred)?; + let found = suggest_removals(&rows, Some(&cred), crate::civil::now_secs()); + if parsed.flag_true("json") { + let payload: Vec<_> = found + .iter() + .map(|s| { + serde_json::json!({ + "id": s.row.id, + "name": s.row.name, + "last4": last4(&s.row.masked), + "created_at": s.row.created_at, + "last_used_at": s.row.last_used_at, + "requests_30d": s.row.requests_30d, + "reason": s.reason.code(), + "detail": s.reason.text(), + }) + }) + .collect(); + crate::cmd::view::print_json(&serde_json::Value::Array(payload)); + return Ok(0); } - if !latest.can_reveal { - // Reveal would 409 for pre-reveal-support rows; the stored key stays. - return current.to_string(); - } - reveal_key(base, current, &latest.hash).unwrap_or_else(|_| current.to_string()) -} - -pub(crate) fn key_pick_label(row: &crate::http::RemoteKey, current: bool) -> String { - let mut parts = vec![row.name.clone(), row.masked.clone()]; - if let Some(created) = row.created_at.as_deref() { - parts.push(created.get(..10).unwrap_or(created).to_string()); + if found.is_empty() { + println!("No unused keys. Checked {} keys.", rows.len()); + return Ok(0); } - if current { - parts.push("●".into()); + println!( + "{} of {} keys look unused. Nothing is removed until you revoke it.", + found.len(), + rows.len() + ); + println!("{}", term::dim(&"─".repeat(40))); + let name_w = found.iter().map(|s| s.row.name.len()).max().unwrap_or(4); + for s in &found { + println!( + "{:name_w$} {} {}", + s.row.name, + short_ref(&s.row), + term::dim(&s.reason.text()) + ); } - parts.join(" · ") + println!(); + println!( + "{}", + term::dim(&crate::help::with_bin(&format!( + "Revoke one: {{bin}} keys revoke {}", + revoke_ref(&found[0].row) + ))) + ); + Ok(0) } pub(crate) fn stored_api_key( diff --git a/src/cmd/login.rs b/src/cmd/login.rs index 18b7461..ba236f8 100644 --- a/src/cmd/login.rs +++ b/src/cmd/login.rs @@ -10,7 +10,7 @@ use crate::parse::{get_string_flag, ParsedArgs}; use crate::term; use crate::cmd::dispatch::{canonical_command, config_path}; -use crate::cmd::keys::resolve_latest_key; +use crate::cmd::key_audit::{offer_revoke_previous, previous_device_key}; pub(crate) fn persist_login( parsed: &ParsedArgs, @@ -25,13 +25,17 @@ pub(crate) fn persist_login( .and_then(|c| c.profiles.get(&c.active_profile)); let base = resolve_base_url(&parsed.flags, stored); validate_key(&base, key)?; - let key = resolve_latest_key(&base, key); + let key = key.to_string(); let name = get_string_flag(&parsed.flags, "profile").unwrap_or_else(|| { existing .as_ref() .map(|c| c.active_profile.clone()) .unwrap_or_else(|| DEFAULT_PROFILE.into()) }); + let previous_key = existing + .as_ref() + .and_then(|c| c.profiles.get(&name)) + .and_then(|p| p.api_key.clone()); let timeout = get_string_flag(&parsed.flags, "timeout").and_then(|s| s.parse().ok()); let mut profile = create_default_profile(DefaultProfileInput { api_key: Some(key.clone()), @@ -73,6 +77,9 @@ pub(crate) fn persist_login( term::dim(&format!("key {}", mask_api_key(Some(&key)))) ); println!("{} {}", term::dim("Saved"), path.display()); + if matches!(source, "browser" | "device code") { + offer_revoke_after_relogin(parsed, env, &base, previous_key.as_deref(), &key); + } if login_next_hint(&parsed.command) { println!( "{} {} claude", @@ -83,6 +90,45 @@ pub(crate) fn persist_login( Ok(0) } +/// After a device login replaced this profile's device key, offer to revoke +/// the old one. Asks first and never revokes without a yes; scripted runs get +/// the command instead. A failed lookup only warns: the login already worked. +fn offer_revoke_after_relogin( + parsed: &ParsedArgs, + env: &BTreeMap, + base: &str, + previous_key: Option<&str>, + new_key: &str, +) { + if previous_key.is_none_or(|k| k.trim() == new_key) { + return; + } + let rows = match crate::http::fetch_keys(base, new_key) { + Ok(rows) => rows, + Err(err) => { + eprintln!( + "{}", + term::warn(&format!("Could not check for the previous key: {err}")) + ); + return; + } + }; + let Some(prev) = previous_device_key(&rows, previous_key, new_key) else { + return; + }; + let interactive = term::is_interactive() && !term::machine_output(parsed, env); + let result = offer_revoke_previous( + &prev, + interactive, + term::confirm, + |hash| crate::http::delete_key(base, new_key, hash), + &mut std::io::stderr(), + ); + if let Err(err) = result { + eprintln!("{}", term::warn(&err)); + } +} + pub(crate) fn apply_first_run_defaults( cfg: &mut crate::config::Config, profile_name: &str, diff --git a/src/cmd/menu.rs b/src/cmd/menu.rs index f1a585c..e1b3ce6 100644 --- a/src/cmd/menu.rs +++ b/src/cmd/menu.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::{Arc, Mutex}; -use crate::http::{fetch_keys, fetch_models, is_active_key_row, reveal_key}; +use crate::http::fetch_models; use crate::install::{available_agents, ensure_tool_installed, missing_agents, Install}; use crate::key::{ load_config_if_present, mask_api_key, no_key_error, resolve_api_key, resolve_base_url, @@ -17,18 +17,18 @@ use crate::cmd::config_tui::{ #[cfg(feature = "native")] use crate::cmd::dispatch::kick_credits_refresh; use crate::cmd::dispatch::{ - config_path, hint, launcher_last_tool, launcher_uses_palette, persist_tool_command, + config_path, launcher_last_tool, launcher_uses_palette, persist_tool_command, pick_palette_action, tool_command_for, tui_dump_palette, tui_wants_dump, CreditsCache, }; #[cfg(not(feature = "native"))] use crate::cmd::dispatch::InlineEntry; -use crate::cmd::keys::{key_pick_label, keys_credential, run_keys, stored_api_key}; +use crate::cmd::keys::stored_api_key; use crate::cmd::launch::run_launch; use crate::cmd::login::run_login; use crate::cmd::models::{ palette_bind_detail, pick_list, pick_model, routing_toggle_detail, run_models, - save_agent_account, save_agent_key, save_agent_model, toggle_agent_routing_field, RoutingField, + save_agent_account, save_agent_model, toggle_agent_routing_field, RoutingField, }; use crate::cmd::usage::run_usage; @@ -256,12 +256,6 @@ pub(crate) fn push_inline_configure( format!("configure · {agent}"), format!("Switch account {agent}"), )); - entries.push(InlineEntry::new( - "key…", - palette_bind_detail(agent), - format!("configure · {agent}"), - format!("Switch key {agent}"), - )); entries.push(InlineEntry::new( "exacto", routing_toggle_detail(routing.wants_exacto(), agent), @@ -511,12 +505,6 @@ pub(crate) fn push_agent_configure_entries( group.clone(), format!("Switch account {agent}"), )); - entries.push(PaletteEntry::new( - "key…", - palette_bind_detail(agent), - group.clone(), - format!("Switch key {agent}"), - )); entries.push(PaletteEntry::new( "exacto", routing_toggle_detail(routing.wants_exacto(), agent), @@ -642,7 +630,7 @@ pub(crate) fn launcher_dispatch( } return Ok(LauncherNext::Continue); } - if action == "Switch account" || action == "Switch account / key" { + if action == "Switch account" { let agent = launcher_last_tool(path, parsed, env); let mut next = parsed.clone(); next.flags.insert("agent".into(), FlagValue::Value(agent)); @@ -656,26 +644,6 @@ pub(crate) fn launcher_dispatch( if let Some(agent) = action.strip_prefix("Switch account ") { return switch_agent_account(parsed, env, path, agent.trim()); } - if action == "Switch key" { - if !launcher_signed_in(path, parsed, env) { - eprintln!("{}", term::err("Sign in first (Login / sign in).")); - return Ok(LauncherNext::Continue); - } - let agent = launcher_last_tool(path, parsed, env); - let mut next = parsed.clone(); - next.command = "keys".into(); - next.passthrough = vec!["use".into()]; - next.flags.insert("agent".into(), FlagValue::Value(agent)); - match run_keys(&next, env) { - Ok(_) => {} - Err(err) if err == "Cancelled." => {} - Err(err) => eprintln!("{}", term::err(&err)), - } - return Ok(LauncherNext::Continue); - } - if let Some(agent) = action.strip_prefix("Switch key ") { - return switch_agent_key(parsed, env, path, agent.trim()); - } if let Some(agent) = action.strip_prefix("Toggle exacto ") { toggle_agent_routing_field(path, agent.trim(), RoutingField::Exacto)?; return Ok(LauncherNext::Continue); @@ -801,70 +769,6 @@ pub(crate) fn bind_agent_account(path: &Path, agent: &str) -> Result, - path: &Path, - agent: &str, -) -> Result { - if agent.is_empty() { - return Ok(LauncherNext::Continue); - } - if !launcher_signed_in(path, parsed, env) { - eprintln!("{}", term::err("Sign in first (Login / sign in).")); - return Ok(LauncherNext::Continue); - } - match bind_agent_key(parsed, env, path, agent) { - Ok(_) => {} - Err(err) if err == "Cancelled." => {} - Err(err) => eprintln!("{}", term::err(&err)), - } - Ok(LauncherNext::Continue) -} - -pub(crate) fn bind_agent_key( - parsed: &ParsedArgs, - env: &BTreeMap, - path: &Path, - agent: &str, -) -> Result { - let (_keys_path, cfg, base, api_key) = keys_credential(parsed, env)?; - let rows = crate::http::keys_newest_first( - fetch_keys(&base, &api_key)? - .into_iter() - .filter(|r| r.active) - .collect(), - ); - if rows.is_empty() { - return Err(hint("No active keys. Create one: {bin} keys create")); - } - let current_key = cfg - .agent_binding(agent) - .and_then(|b| b.api_key.as_deref()) - .map(str::trim) - .filter(|s| !s.is_empty()); - let current = rows - .iter() - .position(|r| is_active_key_row(&r.masked, current_key)) - .or(Some(0)); - let labels: Vec = rows - .iter() - .enumerate() - .map(|(i, r)| key_pick_label(r, current == Some(i))) - .collect(); - let idx = pick_list( - &format!("API key for {agent}"), - &[ - "applies only to this agent · newest first".into(), - format!("current {}", mask_api_key(current_key)), - ], - &labels, - current, - )?; - let revealed = reveal_key(&base, &api_key, &rows[idx].hash)?; - save_agent_key(path, agent, &revealed) -} - pub(crate) fn launch_agent_picker( parsed: &ParsedArgs, env: &BTreeMap, diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 977a892..0b4261c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod dispatch; pub(crate) mod embed; pub(crate) mod eval; pub(crate) mod generation; +pub(crate) mod key_audit; pub(crate) mod key_scope; pub(crate) mod keys; pub(crate) mod launch; diff --git a/src/cmd/models.rs b/src/cmd/models.rs index 3d919af..5762b0a 100644 --- a/src/cmd/models.rs +++ b/src/cmd/models.rs @@ -5,9 +5,7 @@ use std::path::Path; use crate::config::{write_config, Profile}; use crate::http::{fetch_credits, fetch_models, format_models_list, CatalogModel}; use crate::install::KNOWN_AGENTS; -use crate::key::{ - load_config_if_present, mask_api_key, no_key_error, resolve_api_key, resolve_base_url, -}; +use crate::key::{load_config_if_present, no_key_error, resolve_api_key, resolve_base_url}; use crate::parse::{get_string_flag, ParsedArgs}; use crate::spawn::{ canonical_tool, catalog_model_id, display_model_id, is_auto_model, session_model_label, @@ -305,19 +303,6 @@ pub(crate) fn save_agent_account(path: &Path, agent: &str, profile: &str) -> Res Ok(0) } -pub(crate) fn save_agent_key(path: &Path, agent: &str, key: &str) -> Result { - let agent = known_agent(agent)?; - let mut cfg = load_config_if_present(path).ok_or_else(no_key_error)?; - cfg.agent_binding_mut(&agent).api_key = Some(key.to_string()); - write_config(&cfg, path)?; - println!( - "{} {agent} key {}", - term::ok("Switched"), - mask_api_key(Some(key)) - ); - Ok(0) -} - pub(crate) fn palette_bind_detail(agent: &str) -> String { format!("for {agent}") } diff --git a/src/completion.rs b/src/completion.rs index 07a407e..91a232e 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -29,9 +29,9 @@ pub fn subcommands(command: &str) -> &'static [(&'static str, &'static str)] { "keys" => &[ ("list", "List API keys"), ("create", "Create an API key"), - ("use", "Store a key on this machine"), ("show", "Limit, spent, endpoints and expiry of a key"), ("update", "Change a key's limit, endpoints, expiry or state"), + ("audit", "List keys that look unused"), ("revoke", "Revoke an API key"), ], "config" => &[ diff --git a/src/help.rs b/src/help.rs index c7cac18..6a7f779 100644 --- a/src/help.rs +++ b/src/help.rs @@ -67,6 +67,11 @@ pub fn set_invoked_bin(name: impl Into) { INVOKED_BIN.with(|slot| *slot.borrow_mut() = name); } +/// `template` with `{bin}` replaced by the name this binary was run as. +pub fn with_bin(template: &str) -> String { + template.replace("{bin}", &invoked_bin()) +} + pub fn invoked_bin() -> String { INVOKED_BIN.with(|slot| { let current = slot.borrow(); @@ -525,9 +530,8 @@ Usage: {bin} keys show [--json] {bin} keys update [--limit USD] [--reset ...] [--endpoints ...] [--expires 30d] [--disable|--enable] - {bin} keys use [hash] - {bin} keys use [hash] --agent - {bin} keys revoke [--yes] + {bin} keys audit [--json] + {bin} keys revoke [--yes] Scoped keys for CI: `--limit` caps spend in USD, `--reset` sets its window, `--endpoints` restricts the key to named endpoints (chat covers chat, @@ -539,8 +543,13 @@ decisions, mcp, me, relay, models, keys, credits, logs, dashboard, trace. The signed-in API key must have Key Management permission. CLI login (`{bin} auth login`) creates a key with full access. -`--agent` stores the revealed key on that coding agent so launch does not -fall back to the default profile key. +`keys audit` lists keys that look unused (never used after 7 days, idle 30 +days, or replaced by a newer login on the same device) with the reason. It +never removes anything; revoke each one yourself. + +A different key on this machine comes from `{bin} login` (a new key named +` anyr `) or `{bin} login --key -`. After a browser login replaces +this machine's key, the CLI asks before revoking the old one. "; const LOGOUT: &str = "\ diff --git a/src/http.rs b/src/http.rs index 06441f2..dbb490a 100644 --- a/src/http.rs +++ b/src/http.rs @@ -830,7 +830,10 @@ pub struct RemoteKey { pub created_at: Option, pub last_used_at: Option, pub active: bool, - pub can_reveal: bool, + pub id: String, + /// How the key was made: `cli:` for device login, null for the dashboard. + pub origin: Option, + pub requests_30d: u64, } pub fn parse_key_list(body: &str) -> Result, String> { @@ -876,7 +879,19 @@ pub fn parse_key_list(body: &str) -> Result, String> { .and_then(|v| v.as_str()) .map(str::to_string), active: item.get("disabled").and_then(|v| v.as_bool()) != Some(true), - can_reveal: item.get("can_reveal").and_then(|v| v.as_bool()) == Some(true), + id: item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + origin: item + .get("origin") + .and_then(|v| v.as_str()) + .map(str::to_string), + requests_30d: item + .get("real_usage_monthly_requests") + .and_then(|v| v.as_u64()) + .unwrap_or(0), }) }) .collect()) @@ -894,28 +909,6 @@ pub fn fetch_keys(base_url: &str, credential: &str) -> Result, St parse_key_list(&body) } -pub fn reveal_key(base_url: &str, credential: &str, hash: &str) -> Result { - let url = join_api(base_url, &format!("/v1/keys/{hash}/reveal")); - let (status, resp) = http_post(&url, Some(credential), None)?; - if status == 409 { - return Err( - "That key cannot be revealed (created before reveal support). Create a fresh one: anyr keys create" - .into(), - ); - } - if !(200..300).contains(&status) { - return Err(format!("Could not fetch the key (HTTP {status}).")); - } - let value: serde_json::Value = - serde_json::from_str(&resp).map_err(|e| format!("Invalid reveal response: {e}"))?; - value - .get("key") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .ok_or_else(|| "Reveal response did not include a key.".into()) -} - pub fn delete_key(base_url: &str, credential: &str, hash: &str) -> Result<(), String> { let url = join_api(base_url, &format!("/v1/keys/{hash}")); let (status, _body) = http_delete(&url, Some(credential))?; @@ -1023,13 +1016,15 @@ mod tests { #[test] fn parse_key_list_accepts_data_wrapper() { let keys = parse_key_list( - r#"{"data":[{"hash":"abc123def456","name":"laptop","key_prefix":"sk-ar-v1-abcd","can_reveal":true}]}"#, + r#"{"data":[{"id":"key_1","hash":"abc123def456","name":"laptop","key_prefix":"sk-ar-v1-abcd","origin":"cli:cli","real_usage_monthly_requests":12}]}"#, ) .unwrap(); assert_eq!(keys.len(), 1); assert_eq!(keys[0].name, "laptop"); assert_eq!(keys[0].masked, "sk-ar-v1-abcd"); - assert!(keys[0].can_reveal); + assert_eq!(keys[0].id, "key_1"); + assert_eq!(keys[0].origin.as_deref(), Some("cli:cli")); + assert_eq!(keys[0].requests_30d, 12); } #[test] @@ -1102,7 +1097,9 @@ mod tests { created_at: Some("2026-01-01T00:00:00Z".into()), last_used_at: None, active: true, - can_reveal: true, + id: String::new(), + origin: None, + requests_30d: 0, }, RemoteKey { name: "new".into(), @@ -1111,7 +1108,9 @@ mod tests { created_at: Some("2026-08-24T12:00:00Z".into()), last_used_at: None, active: true, - can_reveal: true, + id: String::new(), + origin: None, + requests_30d: 0, }, RemoteKey { name: "undated".into(), @@ -1120,7 +1119,9 @@ mod tests { created_at: None, last_used_at: None, active: true, - can_reveal: true, + id: String::new(), + origin: None, + requests_30d: 0, }, ]; let sorted = keys_newest_first(keys); diff --git a/src/lib.rs b/src/lib.rs index cf02fb3..5da88fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod buildinfo; pub mod channel; #[cfg(feature = "native")] pub mod chat; +pub mod civil; pub mod cmd; pub mod commands; pub mod completion; diff --git a/src/registry.rs b/src/registry.rs index cc19308..5e44e42 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -87,7 +87,7 @@ pub fn subcommands(canonical: &str) -> &'static [&'static str] { "auth" => &["login", "logout", "status", "switch", "token"], "account" => &["list", "use", "add", "rename", "remove"], "models" => &["list", "show", "use"], - "keys" => &["list", "create", "show", "update", "use", "revoke"], + "keys" => &["list", "create", "show", "update", "audit", "revoke"], "byok" => &["list", "add", "rm", "test", "providers"], "presets" => &["list", "show", "create", "edit", "rm"], "mcp" => &["url", "add", "connections"], diff --git a/src/tui/state.rs b/src/tui/state.rs index 19ed9b1..12801ec 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -461,10 +461,6 @@ impl PaletteState { entry.action = format!("Switch account {agent}"); entry.detail = format!("for {agent}"); } - ConfigureKind::Key => { - entry.action = format!("Switch key {agent}"); - entry.detail = format!("for {agent}"); - } ConfigureKind::Exacto => { entry.action = format!("Toggle exacto {agent}"); entry.detail = @@ -554,13 +550,12 @@ fn launch_id_from_action(action: &str) -> Option { enum ConfigureKind { Model, Account, - Key, Exacto, Tools, MinContext, } -/// model / account / key / routing toggles — inline home-TUI switches. +/// model / account / routing toggles — inline home-TUI switches. fn configure_kind(action: &str, label: &str) -> Option { let a = action.to_ascii_lowercase(); let l = label.to_ascii_lowercase(); @@ -568,8 +563,6 @@ fn configure_kind(action: &str, label: &str) -> Option { Some(ConfigureKind::Model) } else if a.starts_with("switch account") || l.starts_with("account") { Some(ConfigureKind::Account) - } else if a.starts_with("switch key") || l.starts_with("key") { - Some(ConfigureKind::Key) } else if a.starts_with("toggle exacto") || l.starts_with("exacto") { Some(ConfigureKind::Exacto) } else if a.starts_with("toggle tools") || l.starts_with("tools") { diff --git a/tests/keys_audit.rs b/tests/keys_audit.rs new file mode 100644 index 0000000..7e7b540 --- /dev/null +++ b/tests/keys_audit.rs @@ -0,0 +1,225 @@ +//! Device login naming, the re-login revoke offer, `keys audit` and +//! `keys revoke ` against a local scripted server. Nothing touches +//! anyrouter.dev and nothing is revoked without a yes. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; + +const OLD_KEY: &str = "sk-ar-v1-oldoldoldoldoldoldoldoldoldoldaaaa"; +const NEW_KEY: &str = "sk-ar-v1-newnewnewnewnewnewnewnewnewnewbbbb"; + +#[derive(Debug)] +struct Seen { + method: String, + path: String, + body: String, +} + +/// Serve `replies` in order, one per connection. +fn serve(replies: Vec<(u16, String)>) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/api", listener.local_addr().unwrap()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for (status, body) in replies { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut len = 0usize; + loop { + let mut h = String::new(); + reader.read_line(&mut h).unwrap(); + if h.trim().is_empty() { + break; + } + if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut req_body = vec![0u8; len]; + reader.read_exact(&mut req_body).unwrap(); + let mut parts = line.split_whitespace(); + let seen = Seen { + method: parts.next().unwrap_or("").into(), + path: parts.next().unwrap_or("").into(), + body: String::from_utf8_lossy(&req_body).into_owned(), + }; + let resp = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(resp.as_bytes()).unwrap(); + tx.send(seen).unwrap(); + } + }); + (base, rx) +} + +fn home_with_key(tag: &str, key: &str) -> PathBuf { + let home = std::env::temp_dir().join(format!("anyr-keys-audit-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write( + home.join("config.yaml"), + format!("active_profile: default\nprofiles:\n default:\n api_key: {key}\n"), + ) + .unwrap(); + home +} + +fn run(home: &PathBuf, base: &str, args: &[&str]) -> (i32, String, String) { + let out = Command::new(env!("CARGO_BIN_EXE_anyr")) + .env("ANYR_NO_UPDATE", "1") + .env("NO_COLOR", "1") + .env("SSH_CONNECTION", "test") + .env_remove("ANYROUTER_API_KEY") + .env("ANYROUTER_HOME", home) + .args(args) + .args(["--base-url", base]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn row(name: &str, tail: &str, origin: Option<&str>, created: &str, used: Option<&str>) -> String { + serde_json::json!({ + "id": format!("key_{tail}"), + "hash": format!("hash{tail}{tail}"), + "name": name, + "label": format!("sk-ar-v1-...{tail}"), + "origin": origin, + "created_at": created, + "last_used_at": used, + "real_usage_monthly_requests": 0, + "disabled": false, + }) + .to_string() +} + +#[test] +fn device_relogin_names_the_key_and_only_hints_at_revoking_the_old_one() { + let keys = format!( + r#"{{"data":[{},{}]}}"#, + row( + "studio anyr 2026-10-27", + "bbbb", + Some("cli:cli"), + "2026-10-27T10:00:00Z", + None + ), + row( + "studio anyr 2026-10-01", + "aaaa", + Some("cli:cli"), + "2026-10-01T10:00:00Z", + Some("2026-10-26T10:00:00Z") + ), + ); + let (base, seen) = serve(vec![ + ( + 200, + r#"{"device_code":"dc","user_code":"ABCD-EFGH","verification_uri":"http://x/cli/device","interval":0,"expires_in":600}"#.into(), + ), + (200, format!(r#"{{"key":"{NEW_KEY}","user_id":"u1"}}"#)), + (200, r#"{"data":{"total_credits":1}}"#.into()), + (200, keys), + ]); + let home = home_with_key("relogin", OLD_KEY); + let (code, out, err) = run(&home, &base, &["login", "--device"]); + assert_eq!(code, 0, "{out}\n{err}"); + + let requests: Vec = seen.try_iter().collect(); + let start: serde_json::Value = serde_json::from_str(&requests[0].body).unwrap(); + let label = start["key_label"].as_str().unwrap(); + let (host, date) = label.rsplit_once(" anyr ").expect(label); + assert!(!host.is_empty() && date.len() == 10, "{label}"); + + // Scripted run: the old device key is named with a revoke command, and + // nothing is deleted. + assert!(err.contains("keys revoke aaaa"), "{err}"); + assert!( + requests.iter().all(|r| r.method != "DELETE"), + "{requests:?}" + ); + let all = format!("{out}{err}"); + assert!(!all.contains(NEW_KEY) && !all.contains(OLD_KEY), "{all}"); + let cfg = std::fs::read_to_string(home.join("config.yaml")).unwrap(); + assert!(cfg.contains(NEW_KEY), "new key saved"); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn keys_audit_lists_suggestions_but_never_the_key_in_use() { + // The current key was never used and is old: only "current" protects it. + let keys = format!( + r#"{{"data":[{},{},{}]}}"#, + row("this-machine", "bbbb", None, "2026-01-01T00:00:00Z", None), + row("ci-old", "aaaa", None, "2026-01-01T00:00:00Z", None), + row( + "busy", + "cccc", + None, + "2026-01-01T00:00:00Z", + Some("2099-01-01T00:00:00Z") + ), + ); + let (base, _seen) = serve(vec![(200, keys.clone()), (200, keys)]); + let home = home_with_key("audit", NEW_KEY); + let (code, out, err) = run(&home, &base, &["keys", "audit"]); + assert_eq!(code, 0, "{err}"); + assert!(out.contains("ci-old") && out.contains("…aaaa"), "{out}"); + assert!(out.contains("never used"), "{out}"); + assert!(!out.contains("this-machine"), "{out}"); + assert!(!out.contains("busy"), "{out}"); + assert!(!out.contains("sk-ar-v1-"), "{out}"); + + let (code, out, _) = run(&home, &base, &["keys", "audit", "--json"]); + assert_eq!(code, 0); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 1); + assert_eq!(v[0]["reason"], "never_used"); + assert_eq!(v[0]["last4"], "aaaa"); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn keys_use_is_gone_and_points_at_login() { + let home = home_with_key("use", NEW_KEY); + let (code, _, err) = run(&home, "http://127.0.0.1:9/api", &["keys", "use"]); + assert_ne!(code, 0); + assert!(err.contains("login"), "{err}"); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn keys_revoke_by_last4_deletes_that_hash_after_yes() { + let keys = format!( + r#"{{"data":[{}]}}"#, + row("ci-old", "aaaa", None, "2026-01-01T00:00:00Z", None) + ); + let (base, seen) = serve(vec![(200, keys), (200, "{}".into())]); + let home = home_with_key("revoke", NEW_KEY); + let (code, out, err) = run(&home, &base, &["keys", "revoke", "aaaa", "--yes"]); + assert_eq!(code, 0, "{err}"); + assert!(out.contains("…aaaa"), "{out}"); + let requests: Vec = seen.try_iter().collect(); + assert_eq!(requests[1].method, "DELETE"); + assert!( + requests[1].path.ends_with("/v1/keys/hashaaaaaaaa"), + "{requests:?}" + ); + let _ = std::fs::remove_dir_all(&home); +}