From 5512607ad92769d87086cffc9d2fb5e6dc1cd2c1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:33:12 +0100 Subject: [PATCH 1/7] feat(ai): record each account's login and last refresh time A subscription login ends after about a month and the token reply does not say when. The pool now keeps the time of the interactive login (kept by refreshes, reset by a new login) and of the last successful refresh, sends both with the status, and logs every login, successful refresh and rejected refresh with the account id and login age. AiAccount::login_state derives ok / expiring (from day 25) / rejected for every client. Co-Authored-By: Claude Opus 5.5 --- crates/okena-agent-usage/src/pool.rs | 2 + crates/okena-agent-usage/src/pool_summary.rs | 2 + crates/okena-ai/src/accounts.rs | 44 +++++++- crates/okena-ai/src/server.rs | 73 +++++++++++++ crates/okena-core/src/ai_proxy.rs | 103 +++++++++++++++++++ crates/okena-daemon-core/src/ai_proxy.rs | 2 + crates/okena-daemon-core/src/usage_poll.rs | 2 + 7 files changed, 225 insertions(+), 3 deletions(-) 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..ad910c0c6 100644 --- a/crates/okena-agent-usage/src/pool_summary.rs +++ b/crates/okena-agent-usage/src/pool_summary.rs @@ -330,6 +330,8 @@ mod tests { seven_day: None, requests: 0, last_used_ms: None, + logged_in_ms: None, + refreshed_ms: None, } } diff --git a/crates/okena-ai/src/accounts.rs b/crates/okena-ai/src/accounts.rs index 5f784f34a..e9e7ccead 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}"); } @@ -958,6 +994,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/server.rs b/crates/okena-ai/src/server.rs index 6578b3222..d683acd04 100644 --- a/crates/okena-ai/src/server.rs +++ b/crates/okena-ai/src/server.rs @@ -353,6 +353,7 @@ mod tests { use okena_core::ai_proxy::AiActivityKind; 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; @@ -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,72 @@ 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 h = harness(Vec::new()).await; + let login = || async { + let status = h + .client + .command(&AiProxyCommand::StartLogin { label: "a".into() }) + .await + .unwrap(); + let finish = AiProxyCommand::FinishLogin { + login_id: status.logins[0].id.clone(), + code: "code".into(), + }; + h.client.command(&finish).await.unwrap().accounts[0].clone() + }; + let account = login().await; + let logged_in = account.logged_in_ms.expect("the login time is recorded"); + assert_eq!(account.refreshed_ms, None); + + h.fake + .replies + .lock() + .insert("login-access".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(logged_in), "a refresh keeps it"); + assert!(account.refreshed_ms.is_some_and(|at| at >= logged_in)); + 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"], logged_in); + + h.fake.reject_refresh.store(true, Ordering::SeqCst); + h.fake + .replies + .lock() + .insert("login-rt-refreshed".into(), vec![401]); + h.message("conv").await; + let status = h.client.command(&AiProxyCommand::Status).await.unwrap(); + let account = &status.accounts[0]; + assert_eq!( + account.login_state(crate::now_ms()), + okena_core::ai_proxy::AiLoginState::Rejected + ); + let id = &account.id; + let logs = LOG_MESSAGES.lock().join("\n"); + assert!( + logs.contains(&format!( + "token_refresh rejected account={id:?} login_age_days=Some(0)" + )), + "{logs}" + ); + assert!( + !logs.contains("login-rt"), + "no token value in the log: {logs}" + ); + + let account = login().await; + assert!(!account.needs_login); + assert!(account.logged_in_ms.is_some_and(|at| at >= logged_in)); + assert_eq!(account.refreshed_ms, None, "a new login starts over"); + } + #[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-core/src/ai_proxy.rs b/crates/okena-core/src/ai_proxy.rs index f8b678ec5..2aea6f4a8 100644 --- a/crates/okena-core/src/ai_proxy.rs +++ b/crates/okena-core/src/ai_proxy.rs @@ -366,6 +366,46 @@ 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, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +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 +436,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 +602,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, } } } From c1e1a398f672b988e59237e8f16406ba80cdaa80 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:33:12 +0100 Subject: [PATCH 2/7] feat(ai): show login age and warn before the login ends Settings shows how long ago each pool account was logged in, warns from day 25 with Log In Again next to the account, and names a rejected login as such. The activity view shows the same states. Co-Authored-By: Claude Opus 5.5 --- .../src/views/overlays/ai_proxy_activity.rs | 17 ++++++-- .../src/views/overlays/settings_panel/mod.rs | 2 +- .../settings_panel/render_ai_proxy.rs | 40 ++++++++++++++++--- 3 files changed, 49 insertions(+), 10 deletions(-) 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::*; From 244c674a31000e37ad262c4dcef1205fce58a9ef Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:33:20 +0100 Subject: [PATCH 3/7] feat(cli): okena ai accounts lists pool logins without credentials One line per account: id, label, email, login time, last refresh and login state, or a JSON array with --json, so local tools can watch for logins about to end. Co-Authored-By: Claude Opus 5.5 --- crates/okena-cli/src/commands.rs | 124 +++++++++++++++++++++++++++++++ crates/okena-cli/src/lib.rs | 9 ++- crates/okena-cli/src/parser.rs | 20 +++++ crates/okena-cli/src/skill.md | 2 + 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/crates/okena-cli/src/commands.rs b/crates/okena-cli/src/commands.rs index 7dbc44abb..696e91ea1 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), + }) +} + 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) From 92b0db3984cab1187b2c67a8c87d23abd9e68030 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:33:20 +0100 Subject: [PATCH 4/7] feat(ai): log the token reply's field names once per grant Names only, never values, to learn whether the provider states how long a login lasts. Co-Authored-By: Claude Opus 5.5 --- crates/okena-ai/src/oauth.rs | 68 ++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/okena-ai/src/oauth.rs b/crates/okena-ai/src/oauth.rs index 00f30cb5d..09a76e6b8 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,48 @@ 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 !fields_logged.swap(true, Ordering::Relaxed) { + log::info!( + "[ai-proxy] token_reply_fields grant={grant} fields={}", + field_names(&body) + ); + } + Ok(serde_json::from_value(body)?) +} + +/// Top-level field names and those of nested objects (`account.uuid`). +fn field_names(body: &serde_json::Value) -> String { + fn name(key: &str) -> String { + key.chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .take(64) + .collect() + } + let mut names = Vec::new(); + for (key, value) in body.as_object().into_iter().flatten() { + names.push(name(key)); + for nested in value + .as_object() + .into_iter() + .flat_map(|object| object.keys()) + { + names.push(format!("{}.{}", name(key), name(nested))); + } + } + names.join(",") +} + pub struct LoginResult { pub tokens: Tokens, pub account_uuid: Option, @@ -137,7 +180,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 +230,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 +446,21 @@ mod tests { assert_eq!(limits.seven_day.unwrap().utilization, 3.0); } + #[test] + fn reply_fields_are_logged_by_name_only() { + let names = field_names(&serde_json::json!({ + "access_token": "secret-access", + "expires_in": 3600, + "account": {"uuid": "secret-uuid", "email_address": "someone@example.com"}, + "bad\nkey": 1 + })); + assert_eq!( + names, + "access_token,account,account.email_address,account.uuid,badkey,expires_in" + ); + assert!(!names.contains("secret") && !names.contains('@')); + } + #[test] fn only_a_dead_grant_rejects_the_refresh_token() { let rejected = |status: u16, body: &str| { From 060cf85c0cc90c85a2fc850e4210b0b5976a949c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:33:20 +0100 Subject: [PATCH 5/7] docs(ai): login age, refresh logs and okena ai accounts Co-Authored-By: Claude Opus 5.5 --- docs/reference/ai-proxy.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/reference/ai-proxy.md b/docs/reference/ai-proxy.md index 767826de9..af94c376d 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,41 @@ 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. + +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 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). Logins log `login account="…"`. Once per proxy process +and grant type, `token_reply_fields grant=refresh_token fields=…` lists the +names (never values) of the fields the token endpoint returned. + +`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 From 7a6568238982fde36a7ca7bb6506eed564dae111 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 15:39:43 +0100 Subject: [PATCH 6/7] fix(ai): address review round 1 of the login-age change - Log a token refused right after a refresh, so every rejected state has an account line in proxy.log. - Log only top-level token reply field names, and test the once-per-grant gating. - The E2E test seeds an old login time, so it proves a new login resets it, and checks the info lines for token values too. - One source for login state names; the usage widget says Login rejected like the other views. - Docs: which refresh outcomes are logged, and the downgrade caveat. Co-Authored-By: Claude Opus 5.5 --- crates/okena-agent-usage/src/pool_summary.rs | 4 +- crates/okena-ai/src/accounts.rs | 4 + crates/okena-ai/src/oauth.rs | 57 ++++++------ crates/okena-ai/src/server.rs | 98 +++++++++++--------- crates/okena-cli/src/commands.rs | 2 +- crates/okena-core/src/ai_proxy.rs | 3 +- docs/reference/ai-proxy.md | 18 ++-- 7 files changed, 100 insertions(+), 86 deletions(-) diff --git a/crates/okena-agent-usage/src/pool_summary.rs b/crates/okena-agent-usage/src/pool_summary.rs index ad910c0c6..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"); @@ -409,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 e9e7ccead..1e895b129 100644 --- a/crates/okena-ai/src/accounts.rs +++ b/crates/okena-ai/src/accounts.rs @@ -667,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}"); } diff --git a/crates/okena-ai/src/oauth.rs b/crates/okena-ai/src/oauth.rs index 09a76e6b8..28398d8d5 100644 --- a/crates/okena-ai/src/oauth.rs +++ b/crates/okena-ai/src/oauth.rs @@ -113,35 +113,30 @@ fn token_reply( grant: &str, fields_logged: &AtomicBool, ) -> anyhow::Result { - if !fields_logged.swap(true, Ordering::Relaxed) { - log::info!( - "[ai-proxy] token_reply_fields grant={grant} fields={}", - field_names(&body) - ); + 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)?) } -/// Top-level field names and those of nested objects (`account.uuid`). -fn field_names(body: &serde_json::Value) -> String { - fn name(key: &str) -> String { - key.chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '_') - .take(64) - .collect() - } - let mut names = Vec::new(); - for (key, value) in body.as_object().into_iter().flatten() { - names.push(name(key)); - for nested in value - .as_object() - .into_iter() - .flat_map(|object| object.keys()) - { - names.push(format!("{}.{}", name(key), name(nested))); - } +/// 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; } - names.join(",") + let names: Vec = 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.join(",")) } pub struct LoginResult { @@ -447,18 +442,18 @@ mod tests { } #[test] - fn reply_fields_are_logged_by_name_only() { - let names = field_names(&serde_json::json!({ + 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 - })); - assert_eq!( - names, - "access_token,account,account.email_address,account.uuid,badkey,expires_in" - ); + }); + 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] diff --git a/crates/okena-ai/src/server.rs b/crates/okena-ai/src/server.rs index d683acd04..9335b7bc2 100644 --- a/crates/okena-ai/src/server.rs +++ b/crates/okena-ai/src/server.rs @@ -350,7 +350,7 @@ 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; @@ -362,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); }); } @@ -838,67 +838,79 @@ mod tests { #[tokio::test] async fn login_time_survives_refreshes_until_a_new_login() { init_test_logger(); - let h = harness(Vec::new()).await; - let login = || async { - let status = h - .client - .command(&AiProxyCommand::StartLogin { label: "a".into() }) - .await - .unwrap(); - let finish = AiProxyCommand::FinishLogin { - login_id: status.logins[0].id.clone(), - code: "code".into(), - }; - h.client.command(&finish).await.unwrap().accounts[0].clone() - }; - let account = login().await; - let logged_in = account.logged_in_ms.expect("the login time is recorded"); - assert_eq!(account.refreshed_ms, None); + 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("login-access".into(), vec![401]); + 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(logged_in), "a refresh keeps it"); - assert!(account.refreshed_ms.is_some_and(|at| at >= logged_in)); + 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"], logged_in); + 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("login-rt-refreshed".into(), vec![401]); + .insert("old-rt-refreshed".into(), vec![401]); h.message("conv").await; let status = h.client.command(&AiProxyCommand::Status).await.unwrap(); - let account = &status.accounts[0]; assert_eq!( - account.login_state(crate::now_ms()), - okena_core::ai_proxy::AiLoginState::Rejected - ); - let id = &account.id; - let logs = LOG_MESSAGES.lock().join("\n"); - assert!( - logs.contains(&format!( - "token_refresh rejected account={id:?} login_age_days=Some(0)" - )), - "{logs}" - ); - assert!( - !logs.contains("login-rt"), - "no token value in the log: {logs}" + status.accounts[0].login_state(crate::now_ms()), + AiLoginState::Rejected ); - let account = login().await; + 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 >= logged_in)); + 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] diff --git a/crates/okena-cli/src/commands.rs b/crates/okena-cli/src/commands.rs index 696e91ea1..4c1573a36 100644 --- a/crates/okena-cli/src/commands.rs +++ b/crates/okena-cli/src/commands.rs @@ -246,7 +246,7 @@ fn ai_account_json(account: &okena_core::ai_proxy::AiAccount, now_ms: i64) -> se "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), + "state": account.login_state(now_ms).as_str(), }) } diff --git a/crates/okena-core/src/ai_proxy.rs b/crates/okena-core/src/ai_proxy.rs index 2aea6f4a8..b5d1715db 100644 --- a/crates/okena-core/src/ai_proxy.rs +++ b/crates/okena-core/src/ai_proxy.rs @@ -388,8 +388,7 @@ pub fn login_age_days(logged_in_ms: i64, now_ms: i64) -> i64 { } /// Whether the provider still accepts an account's login. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AiLoginState { Ok, /// At least [`AI_LOGIN_EXPIRING_DAYS`] old: log in again before it is rejected. diff --git a/docs/reference/ai-proxy.md b/docs/reference/ai-proxy.md index af94c376d..2238c4b09 100644 --- a/docs/reference/ai-proxy.md +++ b/docs/reference/ai-proxy.md @@ -215,19 +215,23 @@ 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. +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 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). Logins log `login account="…"`. Once per proxy process -and grant type, `token_reply_fields grant=refresh_token fields=…` lists the -names (never values) of the fields the token endpoint returned. +`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, From 20b2228f5e540420ba1125fab584a3b176408be2 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 10 Nov 2026 16:03:39 +0100 Subject: [PATCH 7/7] fix(ai): sort the logged token reply field names The workspace build enables serde_json's preserve_order, so key order differed from a single-crate build and the test failed in CI. Co-Authored-By: Claude Opus 5.5 --- crates/okena-ai/src/oauth.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/okena-ai/src/oauth.rs b/crates/okena-ai/src/oauth.rs index 28398d8d5..e18cce362 100644 --- a/crates/okena-ai/src/oauth.rs +++ b/crates/okena-ai/src/oauth.rs @@ -125,7 +125,8 @@ fn fields_to_log(body: &serde_json::Value, fields_logged: &AtomicBool) -> Option if fields_logged.swap(true, Ordering::Relaxed) { return None; } - let names: Vec = body + // 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()) @@ -136,7 +137,7 @@ fn fields_to_log(body: &serde_json::Value, fields_logged: &AtomicBool) -> Option .collect() }) .collect(); - Some(names.join(",")) + Some(names.into_iter().collect::>().join(",")) } pub struct LoginResult {