diff --git a/crates/okena-agent-usage/src/pool.rs b/crates/okena-agent-usage/src/pool.rs index d049e127e..76400ccd0 100644 --- a/crates/okena-agent-usage/src/pool.rs +++ b/crates/okena-agent-usage/src/pool.rs @@ -664,6 +664,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } diff --git a/crates/okena-agent-usage/src/pool_summary.rs b/crates/okena-agent-usage/src/pool_summary.rs index 6380793d7..d2e773cbc 100644 --- a/crates/okena-agent-usage/src/pool_summary.rs +++ b/crates/okena-agent-usage/src/pool_summary.rs @@ -190,7 +190,7 @@ fn blocked(account: &AiAccount, now_ms: i64) -> Option { }) }; if account.needs_login { - return untimed("Needs login"); + return untimed("Login rejected"); } if !account.enabled { return untimed("Disabled"); @@ -330,6 +330,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } @@ -407,7 +409,7 @@ mod tests { [ ("a", None), ("b", Some("Disabled")), - ("c", Some("Needs login")) + ("c", Some("Login rejected")) ] ); assert_eq!(summary.accounts[0].tier.as_deref(), Some("Max 5x")); diff --git a/crates/okena-ai/src/accounts.rs b/crates/okena-ai/src/accounts.rs index 5f784f34a..1e895b129 100644 --- a/crates/okena-ai/src/accounts.rs +++ b/crates/okena-ai/src/accounts.rs @@ -6,7 +6,7 @@ use crate::oauth::{self, Pkce, RefreshError, Tokens, Upstream}; use okena_agent_usage::history::{self as usage_history, LimitSample, Provider, UsageSample}; use okena_core::ai_proxy::{ AiAccount, AiAccountHistory, AiAccountRule, AiActivity, AiPendingLogin, AiProxyStatus, - AiRouting, AiRoutingPolicy, AiUsage, is_routable_project_id, write_private, + AiRouting, AiRoutingPolicy, AiUsage, is_routable_project_id, login_age_days, write_private, }; use parking_lot::Mutex; use reqwest::header::HeaderMap; @@ -34,6 +34,18 @@ struct StoredAccount { enabled: bool, needs_login: bool, tokens: Tokens, + /// The interactive login; a refresh keeps it, a new login resets it. + #[serde(default)] + logged_in_ms: Option, + #[serde(default)] + refreshed_ms: Option, +} + +impl StoredAccount { + fn login_age_days(&self, now: i64) -> Option { + self.logged_in_ms + .map(|logged_in| login_age_days(logged_in, now)) + } } #[derive(Default, Serialize, Deserialize)] @@ -253,6 +265,8 @@ impl Accounts { seven_day: account.runtime.seven_day.clone(), requests: account.runtime.requests, last_used_ms: account.runtime.last_used_ms, + logged_in_ms: account.stored.logged_in_ms, + refreshed_ms: account.stored.refreshed_ms, }) .collect(), logins: state @@ -346,6 +360,7 @@ impl Accounts { None => None, }; let mut state = self.state.lock(); + let now = now_ms(); state.logins.retain(|login| login.id != login_id); // Logging the same provider account in again replaces its credentials. let existing = result.account_uuid.as_ref().and_then(|uuid| { @@ -357,6 +372,12 @@ impl Accounts { if let Some(account) = existing { account.stored.tokens = result.tokens; account.stored.needs_login = false; + account.stored.logged_in_ms = Some(now); + account.stored.refreshed_ms = None; + log::info!( + "[ai-proxy] login account={:?} (logged in again)", + account.stored.id + ); account.stored.email = result.email.or(account.stored.email.take()); if let Some(plan) = plan { account.stored.plan = Some(plan.kind); @@ -365,9 +386,11 @@ impl Accounts { account.runtime.plan_checked = true; account.runtime.cooldown_until_ms = None; } else { + let id = okena_core::ai_proxy::random_token()?; + log::info!("[ai-proxy] login account={id:?} (new account)"); state.accounts.push(Account { stored: StoredAccount { - id: okena_core::ai_proxy::random_token()?, + id, label, email: result.email, account_uuid: result.account_uuid, @@ -376,6 +399,8 @@ impl Accounts { enabled: true, needs_login: false, tokens: result.tokens, + logged_in_ms: Some(now), + refreshed_ms: None, }, runtime: Runtime { plan_checked: true, @@ -588,8 +613,14 @@ impl Accounts { match oauth::refresh(&self.http, &self.upstream, tokens).await { Ok(refreshed) => { let mut state = self.state.lock(); + let now = now_ms(); let account = state.account(account_id)?; account.stored.tokens = refreshed.clone(); + account.stored.refreshed_ms = Some(now); + log::info!( + "[ai-proxy] token_refresh ok account={account_id:?} login_age_days={:?}", + account.stored.login_age_days(now) + ); // Keep serving from memory even if the write fails; the error is logged. if let Err(error) = self.persist(&state) { log::error!("[ai-proxy] failed to persist refreshed credentials: {error}"); @@ -602,7 +633,12 @@ impl Accounts { } Err(RefreshError::Rejected) => { let mut state = self.state.lock(); - state.account(account_id)?.stored.needs_login = true; + let account = state.account(account_id)?; + account.stored.needs_login = true; + log::warn!( + "[ai-proxy] token_refresh rejected account={account_id:?} login_age_days={:?}; the account needs a new login", + account.stored.login_age_days(now_ms()) + ); if let Err(error) = self.persist(&state) { log::error!("[ai-proxy] failed to persist account state: {error}"); } @@ -631,6 +667,10 @@ impl Accounts { let mut state = self.state.lock(); if let Ok(account) = state.account(account_id) { account.stored.needs_login = true; + log::warn!( + "[ai-proxy] token rejected after refresh account={account_id:?} login_age_days={:?}; the account needs a new login", + account.stored.login_age_days(now_ms()) + ); if let Err(error) = self.persist(&state) { log::error!("[ai-proxy] failed to persist account state: {error}"); } @@ -958,6 +998,8 @@ mod tests { refresh_token: format!("refresh-{id}"), expires_at_ms: i64::MAX / 2, }, + logged_in_ms: None, + refreshed_ms: None, }) .collect(), }; diff --git a/crates/okena-ai/src/oauth.rs b/crates/okena-ai/src/oauth.rs index 00f30cb5d..e18cce362 100644 --- a/crates/okena-ai/src/oauth.rs +++ b/crates/okena-ai/src/oauth.rs @@ -5,6 +5,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use okena_core::ai_proxy::AiUsage; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; @@ -101,6 +102,44 @@ struct TokenAccount { email_address: Option, } +/// The token reply states only the access token's lifetime. Once per process +/// and grant, log which fields it carries (names, never values), to learn +/// whether the provider ever states how long the login itself lasts. +static CODE_REPLY_FIELDS_LOGGED: AtomicBool = AtomicBool::new(false); +static REFRESH_REPLY_FIELDS_LOGGED: AtomicBool = AtomicBool::new(false); + +fn token_reply( + body: serde_json::Value, + grant: &str, + fields_logged: &AtomicBool, +) -> anyhow::Result { + if let Some(fields) = fields_to_log(&body, fields_logged) { + log::info!("[ai-proxy] token_reply_fields grant={grant} fields={fields}"); + } + Ok(serde_json::from_value(body)?) +} + +/// The reply's top-level field names, the first time only. Nested objects are +/// skipped: one keyed by ids would put the ids into the log. +fn fields_to_log(body: &serde_json::Value, fields_logged: &AtomicBool) -> Option { + if fields_logged.swap(true, Ordering::Relaxed) { + return None; + } + // Sorted: serde_json keeps insertion order when a crate enables `preserve_order`. + let names: std::collections::BTreeSet = body + .as_object() + .into_iter() + .flat_map(|object| object.keys()) + .map(|key| { + key.chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .take(64) + .collect() + }) + .collect(); + Some(names.into_iter().collect::>().join(",")) +} + pub struct LoginResult { pub tokens: Tokens, pub account_uuid: Option, @@ -137,7 +176,11 @@ pub async fn exchange_code( status.is_success(), "Token exchange failed ({status}); the code may be expired or already used" ); - let reply: TokenReply = response.json().await?; + let reply = token_reply( + response.json().await?, + "authorization_code", + &CODE_REPLY_FIELDS_LOGGED, + )?; let account = reply.account.as_ref(); Ok(LoginResult { account_uuid: account.and_then(|account| account.uuid.clone()), @@ -183,10 +226,12 @@ pub async fn refresh( "token refresh returned {status}" ))); } - let reply: TokenReply = response + let body = response .json() .await .map_err(|error| RefreshError::Transient(error.into()))?; + let reply = token_reply(body, "refresh_token", &REFRESH_REPLY_FIELDS_LOGGED) + .map_err(RefreshError::Transient)?; tokens_from( reply.access_token, reply.refresh_token, @@ -397,6 +442,21 @@ mod tests { assert_eq!(limits.seven_day.unwrap().utilization, 3.0); } + #[test] + fn reply_fields_are_logged_by_name_only_and_once() { + let logged = AtomicBool::new(false); + let reply = serde_json::json!({ + "access_token": "secret-access", + "expires_in": 3600, + "account": {"uuid": "secret-uuid", "email_address": "someone@example.com"}, + "bad\nkey": 1 + }); + let names = fields_to_log(&reply, &logged).unwrap(); + assert_eq!(names, "access_token,account,badkey,expires_in"); + assert!(!names.contains("secret") && !names.contains('@')); + assert_eq!(fields_to_log(&reply, &logged), None); + } + #[test] fn only_a_dead_grant_rejects_the_refresh_token() { let rejected = |status: u16, body: &str| { diff --git a/crates/okena-ai/src/server.rs b/crates/okena-ai/src/server.rs index 6578b3222..9335b7bc2 100644 --- a/crates/okena-ai/src/server.rs +++ b/crates/okena-ai/src/server.rs @@ -350,9 +350,10 @@ mod tests { use super::*; use axum::http::HeaderMap; use futures::StreamExt; - use okena_core::ai_proxy::AiActivityKind; + use okena_core::ai_proxy::{AiActivityKind, AiLoginState}; use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::atomic::AtomicBool; static LOG_MESSAGES: Mutex> = Mutex::new(Vec::new()); static TEST_LOGGER: TestLogger = TestLogger; @@ -361,7 +362,7 @@ mod tests { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { log::set_logger(&TEST_LOGGER).unwrap(); - log::set_max_level(log::LevelFilter::Warn); + log::set_max_level(log::LevelFilter::Info); }); } @@ -391,6 +392,8 @@ mod tests { error_bodies: Mutex>, error_headers: Mutex, token_delay: Mutex, + /// Answer refreshes like a provider that ended the login. + reject_refresh: AtomicBool, } async fn fake_upstream(fake: Arc) -> String { @@ -446,6 +449,10 @@ mod tests { let delay = *fake.token_delay.lock(); tokio::time::sleep(delay).await; fake.refreshes.fetch_add(1, Ordering::SeqCst); + if fake.reject_refresh.load(Ordering::SeqCst) { + let error = serde_json::json!({"error": "invalid_grant"}); + return (StatusCode::BAD_REQUEST, axum::Json(error)).into_response(); + } axum::Json(serde_json::json!({ "access_token": format!("{}-refreshed", body["refresh_token"].as_str().unwrap()), "expires_in": 3600, @@ -828,6 +835,84 @@ mod tests { assert_eq!(stored["accounts"].as_array().unwrap().len(), 1); } + #[tokio::test] + async fn login_time_survives_refreshes_until_a_new_login() { + init_test_logger(); + let mut logged_in_long_ago = account("old", "token-old", "uuid-a"); + logged_in_long_ago["logged_in_ms"] = 1_000.into(); + let h = harness(vec![logged_in_long_ago]).await; + let status = h.client.command(&AiProxyCommand::Status).await.unwrap(); + let account = &status.accounts[0]; + assert_eq!(account.logged_in_ms, Some(1_000)); + assert_eq!(account.login_state(crate::now_ms()), AiLoginState::Expiring); + + h.fake.replies.lock().insert("token-old".into(), vec![401]); + assert_eq!(h.message("conv").await.0, 200); + let status = h.client.command(&AiProxyCommand::Status).await.unwrap(); + let account = &status.accounts[0]; + assert_eq!(account.logged_in_ms, Some(1_000), "a refresh keeps it"); + let refreshed = account.refreshed_ms.expect("the refresh is recorded"); + let stored: serde_json::Value = serde_json::from_slice( + &std::fs::read(proxy_dir(h._dir.path()).join("accounts.json")).unwrap(), + ) + .unwrap(); + assert_eq!(stored["accounts"][0]["logged_in_ms"], 1_000); + assert_eq!(stored["accounts"][0]["refreshed_ms"], refreshed); + + h.fake.reject_refresh.store(true, Ordering::SeqCst); + h.fake + .replies + .lock() + .insert("old-rt-refreshed".into(), vec![401]); + h.message("conv").await; + let status = h.client.command(&AiProxyCommand::Status).await.unwrap(); + assert_eq!( + status.accounts[0].login_state(crate::now_ms()), + AiLoginState::Rejected + ); + + let started = h + .client + .command(&AiProxyCommand::StartLogin { + label: "old".into(), + }) + .await + .unwrap(); + let finish = AiProxyCommand::FinishLogin { + login_id: started.logins[0].id.clone(), + code: "code".into(), + }; + let status = h.client.command(&finish).await.unwrap(); + let account = &status.accounts[0]; + assert!(!account.needs_login); + assert!( + account.logged_in_ms.is_some_and(|at| at > 1_000), + "a new login resets it" + ); + assert_eq!(account.refreshed_ms, None, "a new login starts over"); + + let logs: Vec = LOG_MESSAGES + .lock() + .iter() + .filter(|line| line.contains("account=\"old\"")) + .cloned() + .collect(); + let logs = logs.join("\n"); + for expected in [ + "token_refresh ok account=\"old\" login_age_days=Some(", + "token_refresh rejected account=\"old\" login_age_days=Some(", + "login account=\"old\" (logged in again)", + ] { + assert!(logs.contains(expected), "{expected} missing in {logs}"); + } + for token in ["token-old", "old-rt", "login-access", "login-rt"] { + assert!( + !logs.contains(token), + "token value {token} in the log: {logs}" + ); + } + } + #[tokio::test] async fn all_accounts_limited_returns_the_provider_429() { let h = harness(vec![account("a", "token-a", "uuid-a")]).await; diff --git a/crates/okena-app/src/views/overlays/ai_proxy_activity.rs b/crates/okena-app/src/views/overlays/ai_proxy_activity.rs index 23d26698d..c5ce28422 100644 --- a/crates/okena-app/src/views/overlays/ai_proxy_activity.rs +++ b/crates/okena-app/src/views/overlays/ai_proxy_activity.rs @@ -7,14 +7,16 @@ use crate::remote::local::DaemonEndpoint; use crate::settings::settings_entity; use crate::theme::theme; use crate::ui::tokens::{ui_text, ui_text_ms, ui_text_sm}; -use crate::views::overlays::settings_panel::{format_remaining, send_ai_proxy_command}; +use crate::views::overlays::settings_panel::{ + format_remaining, login_age_text, send_ai_proxy_command, +}; use crate::workspace::state::Workspace; use gpui::prelude::*; use gpui::*; use gpui_component::{h_flex, v_flex}; use okena_core::ai_proxy::{ - AI_ACTIVITY_MINUTES, AiAccount, AiActivity, AiActivityEvent, AiActivityKind, AiProxyCommand, - AiProxyState, AiProxyStatus, AiRouteActivity, + AI_ACTIVITY_MINUTES, AiAccount, AiActivity, AiActivityEvent, AiActivityKind, AiLoginState, + AiProxyCommand, AiProxyState, AiProxyStatus, AiRouteActivity, }; use okena_core::theme::ThemeColors; use okena_transport::client::{LOCAL_DAEMON_CONNECTION_ID, make_prefixed_id}; @@ -407,7 +409,7 @@ fn route_rows(status: &AiProxyStatus) -> Vec> { fn account_state(account: &AiAccount, now: i64, t: &ThemeColors) -> (String, u32) { if account.needs_login { - return ("Needs login".to_owned(), t.error); + return ("Login rejected".to_owned(), t.error); } if !account.enabled { return ("Disabled".to_owned(), t.text_muted); @@ -421,6 +423,11 @@ fn account_state(account: &AiAccount, now: i64, t: &ThemeColors) -> (String, u32 t.warning, ); } + if account.login_state(now) == AiLoginState::Expiring + && let Some(days) = account.login_age_days(now) + { + return (login_age_text(days), t.warning); + } ("Ready".to_owned(), t.success) } @@ -573,6 +580,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/mod.rs b/crates/okena-app/src/views/overlays/settings_panel/mod.rs index 8ce4456fc..7d41ec1ff 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/mod.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/mod.rs @@ -22,7 +22,7 @@ mod board_columns; use categories::SettingsCategory; use components::opt_string; -pub(crate) use render_ai_proxy::{format_remaining, send_ai_proxy_command}; +pub(crate) use render_ai_proxy::{format_remaining, login_age_text, send_ai_proxy_command}; use crate::keybindings::Cancel; use crate::remote::auth::TokenInfo; diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs b/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs index 3e4aed6e6..c9961f694 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs @@ -9,7 +9,8 @@ use crate::views::components::simple_input::{SimpleInput, SimpleInputState}; use gpui::prelude::FluentBuilder; use gpui::*; use okena_core::ai_proxy::{ - AiAccount, AiAccountRule, AiProxyCommand, AiProxyState, AiProxyStatus, AiRoutingPolicy, AiUsage, + AiAccount, AiAccountRule, AiLoginState, AiProxyCommand, AiProxyState, AiProxyStatus, + AiRoutingPolicy, AiUsage, }; use okena_core::api::ActionRequest; use okena_transport::client::LOCAL_DAEMON_CONNECTION_ID; @@ -549,8 +550,9 @@ impl SettingsPanel { usage_text("5h", account.five_hour.as_ref(), now), usage_text("7d", account.seven_day.as_ref(), now), ]; - if account.needs_login { - details.insert(0, "Needs login".into()); + let login_state = account.login_state(now); + if login_state == AiLoginState::Rejected { + details.insert(0, "Login rejected, log in again".into()); } else if !account.enabled { details.insert(0, "Disabled".into()); } else if let Some(period) = account.exhausted_period(now) { @@ -562,6 +564,14 @@ impl SettingsPanel { ); } details.push(format!("{} requests", account.requests)); + if let Some(days) = account.login_age_days(now) { + let age = login_age_text(days); + if login_state == AiLoginState::Expiring { + details.insert(0, format!("{age}, log in again soon")); + } else { + details.push(age); + } + } let id = account.id.clone(); let enabled = account.enabled; @@ -579,7 +589,7 @@ impl SettingsPanel { div() .flex() .gap(px(6.0)) - .when(account.needs_login, |row| { + .when(login_state != AiLoginState::Ok, |row| { row.child( button(format!("ai-account-relogin-{id}"), "Log In Again", &t).on_click( cx.listener(move |this, _, _, cx| { @@ -833,6 +843,15 @@ fn usage_text(window: &str, usage: Option<&AiUsage>, now: i64) -> String { } } +/// How long ago an account was logged in, e.g. "Logged in 3 days ago". +pub(crate) fn login_age_text(days: i64) -> String { + match days { + 0 => "Logged in today".into(), + 1 => "Logged in yesterday".into(), + days => format!("Logged in {days} days ago"), + } +} + pub(crate) fn format_remaining(ms: i64) -> String { let minutes = (ms / 60_000).max(1); if minutes < 60 { @@ -846,7 +865,9 @@ pub(crate) fn format_remaining(ms: i64) -> String { #[cfg(test)] mod tests { - use super::{LOCAL_DAEMON_CONNECTION_ID, local_daemon_project_id, policy_summary}; + use super::{ + LOCAL_DAEMON_CONNECTION_ID, local_daemon_project_id, login_age_text, policy_summary, + }; use okena_core::ai_proxy::{AiAccount, AiAccountRule, AiRoutingPolicy}; use std::collections::BTreeMap; @@ -864,6 +885,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } @@ -877,6 +900,13 @@ mod tests { } } + #[test] + fn login_age_reads_in_days() { + assert_eq!(login_age_text(0), "Logged in today"); + assert_eq!(login_age_text(1), "Logged in yesterday"); + assert_eq!(login_age_text(26), "Logged in 26 days ago"); + } + #[test] fn summary_names_what_a_project_may_use() { use AiAccountRule::*; diff --git a/crates/okena-cli/src/commands.rs b/crates/okena-cli/src/commands.rs index 7dbc44abb..4c1573a36 100644 --- a/crates/okena-cli/src/commands.rs +++ b/crates/okena-cli/src/commands.rs @@ -167,6 +167,89 @@ pub fn cli_usage(json_mode: bool) -> i32 { 0 } +/// `okena ai accounts [--json]` +pub fn cli_ai_accounts(json_mode: bool) -> i32 { + let token = match ensure_token() { + Ok(t) => t, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + let request = okena_core::api::ActionRequest::AiProxy { + request: okena_core::ai_proxy::AiProxyCommand::Status, + }; + let body = match serde_json::to_string(&request) { + Ok(body) => body, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + let reply = match api_action(&token, &body) { + Ok(reply) => reply, + Err(e) => { + eprintln!("{e}"); + return action_error_exit_code(&e); + } + }; + let state: okena_core::ai_proxy::AiProxyState = match serde_json::from_str(&reply) { + Ok(state) => state, + Err(e) => { + eprintln!("Invalid AI proxy response: {e}"); + return 1; + } + }; + let Some(status) = state.status else { + eprintln!( + "AI proxy unavailable: {}", + state.failure.as_deref().unwrap_or("no status") + ); + return 1; + }; + let now_ms = (okena_agent_usage::now_epoch() * 1000.0) as i64; + if json_mode { + let accounts: Vec<_> = status + .accounts + .iter() + .map(|account| ai_account_json(account, now_ms)) + .collect(); + println!("{}", serde_json::Value::Array(accounts)); + } else { + for account in &status.accounts { + println!("{}", ai_account_line(account, now_ms)); + } + } + 0 +} + +fn ai_account_line(account: &okena_core::ai_proxy::AiAccount, now_ms: i64) -> String { + let seconds = |ms: Option| ms.map(|ms| (ms / 1000).to_string()).unwrap_or_default(); + // Labels are free text: keep each account on one line with its columns. + let field = |text: &str| text.replace(['\t', '\n', '\r'], " "); + format!( + "{}\t{}\t{}\t{}\t{}\t{}", + account.id, + field(&account.label), + field(account.email.as_deref().unwrap_or("")), + seconds(account.logged_in_ms), + seconds(account.refreshed_ms), + account.login_state(now_ms).as_str() + ) +} + +fn ai_account_json(account: &okena_core::ai_proxy::AiAccount, now_ms: i64) -> serde_json::Value { + serde_json::json!({ + "id": account.id, + "label": account.label, + "email": account.email, + "logged_in_ms": account.logged_in_ms, + "refreshed_ms": account.refreshed_ms, + "login_age_days": account.login_age_days(now_ms), + "state": account.login_state(now_ms).as_str(), + }) +} + fn usage_lines(usage: &okena_core::api::ApiUsage, now_ms: i64) -> Vec { let status_name = |status| { serde_json::to_value(status) @@ -2810,6 +2893,47 @@ mod tests { ); } + #[test] + fn ai_account_lines_carry_login_times_and_state() { + use super::{ai_account_json, ai_account_line}; + let now_ms = 40 * 86_400_000; + let account = okena_core::ai_proxy::AiAccount { + id: "acc1".into(), + label: "Work\tMax".into(), + email: Some("someone@example.com".into()), + plan: Some("max".into()), + rate_limit_tier: None, + enabled: true, + needs_login: false, + cooldown_until_ms: None, + five_hour: None, + seven_day: None, + requests: 3, + last_used_ms: None, + logged_in_ms: Some(now_ms - 26 * 86_400_000), + refreshed_ms: Some(now_ms - 1_500), + }; + assert_eq!( + ai_account_line(&account, now_ms), + "acc1\tWork Max\tsomeone@example.com\t1209600\t3455998\texpiring" + ); + let json = ai_account_json(&account, now_ms); + assert_eq!(json["state"], "expiring"); + assert_eq!(json["login_age_days"], 26); + + let unknown = okena_core::ai_proxy::AiAccount { + email: None, + logged_in_ms: None, + refreshed_ms: None, + needs_login: true, + ..account + }; + assert_eq!( + ai_account_line(&unknown, now_ms), + "acc1\tWork Max\t\t\t\trejected" + ); + } + #[test] fn usage_lines_one_per_window_or_status() { use okena_core::api::{ diff --git a/crates/okena-cli/src/lib.rs b/crates/okena-cli/src/lib.rs index d7765295a..8ae8d0d1b 100644 --- a/crates/okena-cli/src/lib.rs +++ b/crates/okena-cli/src/lib.rs @@ -11,9 +11,9 @@ use okena_core::process::is_process_alive; use okena_transport::client::{LocalEndpoint, RemoteConnectionConfig}; use okena_workspace::persistence::config_dir; use parser::{ - ClaudeCmd, ClaudeHookCmd, ClaudeRemoteCmd, Cli, Command, FolderCmd, PaletteCmd, ProjectCmd, - ServiceCmd, SettingsCmd, SkillCmd, StatusCmd, StatusColumnCmd, TermCmd, ThemeCmd, UpdateCmd, - WindowCmd, WorktreeCmd, + AiCmd, ClaudeCmd, ClaudeHookCmd, ClaudeRemoteCmd, Cli, Command, FolderCmd, PaletteCmd, + ProjectCmd, ServiceCmd, SettingsCmd, SkillCmd, StatusCmd, StatusColumnCmd, TermCmd, ThemeCmd, + UpdateCmd, WindowCmd, WorktreeCmd, }; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -237,6 +237,9 @@ fn dispatch(cli: Cli) -> i32 { } => commands::cli_service("restart", &name, project.as_deref(), json), }, Command::Usage { json } => commands::cli_usage(json), + Command::Ai { cmd } => match cmd { + AiCmd::Accounts { json } => commands::cli_ai_accounts(json), + }, Command::Whoami { json } => commands::cli_whoami(json), Command::Ls { json } => commands::cli_ls(json), Command::Window { cmd } => match cmd { diff --git a/crates/okena-cli/src/parser.rs b/crates/okena-cli/src/parser.rs index 21d328ebe..a5ae847b0 100644 --- a/crates/okena-cli/src/parser.rs +++ b/crates/okena-cli/src/parser.rs @@ -80,6 +80,11 @@ pub enum Command { #[arg(long)] json: bool, }, + /// The AI proxy's Claude account pool + Ai { + #[command(subcommand)] + cmd: AiCmd, + }, /// Identify the current terminal and project (uses $OKENA_TERMINAL_ID) Whoami { /// Output JSON instead of the default plain text @@ -540,6 +545,21 @@ pub enum SkillCmd { }, } +#[derive(Subcommand)] +pub enum AiCmd { + /// Login state of each pool account, without credentials + /// + /// One tab-separated line per account: id, label, email, login time, last + /// successful token refresh (Unix epoch seconds; empty if unknown) and + /// state: `ok`, `expiring` (login at least 25 days old: log in again soon) + /// or `rejected` (the provider no longer accepts the login). + Accounts { + /// Output a JSON array instead + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] pub enum ServiceCmd { /// Start a service diff --git a/crates/okena-cli/src/skill.md b/crates/okena-cli/src/skill.md index d614d4f26..e56b37e0b 100644 --- a/crates/okena-cli/src/skill.md +++ b/crates/okena-cli/src/skill.md @@ -17,6 +17,8 @@ description: Drive a running Okena terminal multiplexer from the `okena` CLI — - `okena term ls [project]` — terminals as `idnameproject`. - `okena usage` — Claude Code / Codex rate-limit usage on the daemon's machine (`providerstatuswindowpercentreset_epoch`; `--json` for raw). +- `okena ai accounts` — AI proxy pool logins without credentials + (`idlabelemaillogin_epochrefresh_epochok|expiring|rejected`; `--json`). ## Addressing (how to name things) diff --git a/crates/okena-core/src/ai_proxy.rs b/crates/okena-core/src/ai_proxy.rs index f8b678ec5..b5d1715db 100644 --- a/crates/okena-core/src/ai_proxy.rs +++ b/crates/okena-core/src/ai_proxy.rs @@ -366,6 +366,45 @@ pub struct AiAccount { pub seven_day: Option, pub requests: u64, pub last_used_ms: Option, + /// When the account was last logged in interactively; kept across token + /// refreshes. `None` for a login made before the proxy recorded it. + #[serde(default)] + pub logged_in_ms: Option, + /// The last successful token refresh since that login. + #[serde(default)] + pub refreshed_ms: Option, +} + +/// Login age from which the UI warns that the login will soon stop working. +/// The provider ends a subscription login after about a month and says nothing +/// about it in the token reply. +pub const AI_LOGIN_EXPIRING_DAYS: i64 = 25; + +const DAY_MS: i64 = 24 * 60 * 60 * 1000; + +/// Whole days between an interactive login and `now_ms`. +pub fn login_age_days(logged_in_ms: i64, now_ms: i64) -> i64 { + (now_ms - logged_in_ms).max(0) / DAY_MS +} + +/// Whether the provider still accepts an account's login. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AiLoginState { + Ok, + /// At least [`AI_LOGIN_EXPIRING_DAYS`] old: log in again before it is rejected. + Expiring, + /// The provider no longer accepts the login; only a new login helps. + Rejected, +} + +impl AiLoginState { + pub fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Expiring => "expiring", + Self::Rejected => "rejected", + } + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -396,6 +435,25 @@ impl AiAccount { .map(|_| period) }) } + + /// Whole days since the interactive login, if it is known. + pub fn login_age_days(&self, now_ms: i64) -> Option { + self.logged_in_ms + .map(|logged_in| login_age_days(logged_in, now_ms)) + } + + pub fn login_state(&self, now_ms: i64) -> AiLoginState { + if self.needs_login { + AiLoginState::Rejected + } else if self + .login_age_days(now_ms) + .is_some_and(|days| days >= AI_LOGIN_EXPIRING_DAYS) + { + AiLoginState::Expiring + } else { + AiLoginState::Ok + } + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -543,4 +601,48 @@ mod tests { serde_json::json!({"command": "set_account_enabled", "account_id": "a", "enabled": false}) ); } + + #[test] + fn login_state_warns_from_day_25_and_reports_a_rejected_login() { + let now = 100 * DAY_MS; + let account = |logged_in_days_ago: Option, needs_login: bool| AiAccount { + id: "a".into(), + label: "A".into(), + email: None, + plan: None, + rate_limit_tier: None, + enabled: true, + needs_login, + cooldown_until_ms: None, + five_hour: None, + seven_day: None, + requests: 0, + last_used_ms: None, + logged_in_ms: logged_in_days_ago.map(|days| now - days * DAY_MS - 1), + refreshed_ms: None, + }; + assert_eq!(account(Some(24), false).login_age_days(now), Some(24)); + assert_eq!(account(Some(24), false).login_state(now), AiLoginState::Ok); + assert_eq!( + account(Some(25), false).login_state(now), + AiLoginState::Expiring + ); + assert_eq!( + account(Some(3), true).login_state(now), + AiLoginState::Rejected + ); + assert_eq!(account(None, false).login_state(now), AiLoginState::Ok); + assert_eq!(account(None, false).login_age_days(now), None); + } + + #[test] + fn an_account_from_an_older_proxy_has_no_login_time() { + let account: AiAccount = serde_json::from_value(serde_json::json!({ + "id": "a", "label": "A", "email": null, "plan": null, "enabled": true, + "needs_login": false, "cooldown_until_ms": null, "five_hour": null, + "seven_day": null, "requests": 0, "last_used_ms": null + })) + .unwrap(); + assert_eq!((account.logged_in_ms, account.refreshed_ms), (None, None)); + } } diff --git a/crates/okena-daemon-core/src/ai_proxy.rs b/crates/okena-daemon-core/src/ai_proxy.rs index a2735d7de..4700af375 100644 --- a/crates/okena-daemon-core/src/ai_proxy.rs +++ b/crates/okena-daemon-core/src/ai_proxy.rs @@ -474,6 +474,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } diff --git a/crates/okena-daemon-core/src/usage_poll.rs b/crates/okena-daemon-core/src/usage_poll.rs index 0ee7ea24f..2f0e17ee0 100644 --- a/crates/okena-daemon-core/src/usage_poll.rs +++ b/crates/okena-daemon-core/src/usage_poll.rs @@ -807,6 +807,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } } diff --git a/docs/reference/ai-proxy.md b/docs/reference/ai-proxy.md index 767826de9..2238c4b09 100644 --- a/docs/reference/ai-proxy.md +++ b/docs/reference/ai-proxy.md @@ -108,7 +108,8 @@ covers accounts added later. falls back to the terminal's own login either; it gets a 400 instead. - **401**: the access token is refreshed once and the request retried; if the token endpoint answers `invalid_grant` (or an error without a code) the - account is marked *Needs login*. Other token endpoint errors are temporary. + account needs a new login (*Login rejected* in the UI, `rejected` in + `okena ai accounts`). Other token endpoint errors are temporary. A refresh runs to completion and is saved even when the request that started it is cancelled, because the provider rotates the refresh token. - A request is never retried after its response started streaming. @@ -206,6 +207,45 @@ schema carries them; storage, pruning and chart data live in `okena-agent-usage::history`, and `okena-usage` renders them without owning proxy persistence. +## Login age + +A subscription login lasts about a month; then the provider rejects its +refresh token. The token reply states only the access token's lifetime, so the +proxy records when each account was logged in (`logged_in_ms` in +`accounts.json`): set by **Log In** / **Log In Again**, kept by token +refreshes, reset by the next login. It also records the last successful +refresh (`refreshed_ms`, cleared by a new login). Accounts logged in before the +proxy recorded this have no login time until their next login; so do accounts +after a downgrade to such a proxy, which rewrites `accounts.json` without it. + +Settings → AI Proxy shows the age under each account (*Logged in 12 days ago*). +From day 25 the account reads *Logged in 25 days ago, log in again soon*, the +activity view shows the age in warning colour, and **Log In Again** appears +next to the account. A rejected login reads *Login rejected, log in again*. + +`proxy.log` records every successful and every rejected refresh with the +account id and login age, never a token value: `token_refresh ok account="…" +login_age_days=Some(3)` (info) and `token_refresh rejected account="…" +login_age_days=Some(30); the account needs a new login` (warning). A token the +provider refuses right after a refresh logs `token rejected after refresh +account="…"` (warning). Logins log `login account="…"`. Transient refresh +failures appear as `refresh_error` or `account … unusable`. Once per proxy +process and grant type, `token_reply_fields grant=refresh_token fields=…` lists +the top-level field names (never values) of the token endpoint's reply. + +`okena ai accounts` prints the same without credentials, for other local +tools: one tab-separated line per account with id, label, email, login time, +last successful refresh (Unix epoch seconds, empty if unknown) and state +(`ok`, `expiring` from day 25, `rejected`): + +``` +a1b2… Work Max someone@example.com 1762000000 1762700000 ok +``` + +`--json` prints an array of `{id, label, email, logged_in_ms, refreshed_ms, +login_age_days, state}`. The command goes through the daemon like the settings +panel, so it starts the proxy when it is not running. + ## Activity **AI Proxy Activity** (command palette; bind `ShowAiProxyActivity` in