Skip to content

feat(ai): track login age, warn before a pool login ends, okena ai accounts - #324

Merged
matej21 merged 7 commits into
mainfrom
feat/ai-login-age
Nov 10, 2026
Merged

matej21 merged 7 commits into
mainfrom
feat/ai-login-age

Conversation

@matej21

@matej21 matej21 commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Problem

A Claude subscription login used by the AI proxy lasts about a month. When the provider rejects its refresh token, every proxied session ends its turn with "Please run /login" and nothing told the user beforehand. The token reply the proxy reads carries only the access token's expires_in, so the proxy had no idea how old a login was.

Change

  • Login and refresh times. accounts.json records per account logged_in_ms (set by an interactive login, kept by refreshes, reset by the next login) and refreshed_ms (last successful refresh, cleared by a new login). Both reach clients as new optional fields on AiAccount; an older proxy or file reads as None. Accounts logged in before this change have no login time until their next login.
  • Logs. proxy.log records each login (login account=…), each successful refresh (token_refresh ok account=… login_age_days=…, info), each rejected refresh (token_refresh rejected account=… login_age_days=…, warning) and a token refused right after a refresh (token rejected after refresh account=…, warning). Account ids only, never token values.
  • UI. Settings → AI Proxy shows the login age under each account (Logged in 12 days ago). From day 25 it reads Logged in 25 days ago, log in again soon and Log In Again appears next to the account. A rejected login reads Login rejected, log in again (was Needs login). The activity view and the usage-widget popover show Login rejected; the activity view also shows the age in warning colour from day 25. The shared rule is AiAccount::login_state in okena-core (ok / expiring / rejected, threshold AI_LOGIN_EXPIRING_DAYS = 25).
  • CLI. okena ai accounts [--json] prints the pool without credentials, for other local tools: id<TAB>label<TAB>email<TAB>login_epoch<TAB>refresh_epoch<TAB>state (Unix epoch seconds, empty if unknown; state ok, expiring or rejected). --json prints [{id, label, email, logged_in_ms, refreshed_ms, login_age_days, state}]. It uses the existing ai_proxy action through the daemon, like the settings panel.
  • Token reply fields. Once per proxy process and grant type, token_reply_fields grant=… fields=… logs the top-level names (never values) of the fields in the token endpoint reply.
  • Docs: docs/reference/ai-proxy.md (new Login age section), CLI skill reference.

Compatibility with #323 (Log In Again visibility): this PR only changes the condition under which the button shows (login_state != Ok instead of needs_login). #323's busy guard and re-login tracking sit inside that closure, and render_ai_proxy.rs merges cleanly. The only textual overlap is in crates/okena-ai/src/server.rs, where both PRs add a test at the same spot; whichever merges second keeps both.

Decisions

Decision (brief) Where Status
"Record per account the time of the interactive login (set on login, kept across refreshes, reset on a new login)" crates/okena-ai/src/accounts.rs (StoredAccount::logged_in_ms, finish_login, refresh) done
"log each successful refresh and each rejected one with the account id (never token values)" accounts.rs Accounts::refresh, mark_needs_login; test login_time_survives_refreshes_until_a_new_login asserts the lines and checks them for token values done
"Expose the login age per account where the UI already shows accounts (… in the existing locale)" render_ai_proxy.rs (login_age_text, English UI), ai_proxy_activity.rs done
"warn in the UI from day 25" okena-core AI_LOGIN_EXPIRING_DAYS, AiAccount::login_state; settings row + activity view done
"show a clear state when the refresh was rejected (pairs with the existing "Log In Again" action from #323 …; otherwise keep it compatible)" Login rejected, log in again; Log In Again shown for rejected and expiring accounts. #323 still open, overlap is one line done
"a CLI or management-socket query that prints, per account, the account id/email, login time, last successful refresh, and state (ok / expiring / rejected). Document it." okena ai accounts in crates/okena-cli; docs/reference/ai-proxy.md, skill.md done
"Once, log only the NAMES of the fields present in the token endpoint reply (not values)" crates/okena-ai/src/oauth.rs token_reply / fields_to_log (once per process and grant, top-level names) done; the live answer comes with the first refresh on a build that has this
"Separate spike … claude setup-token" report only, no code in this PR reported to the task owner

Testing

  • cargo test -p okena-core -p okena-ai -p okena-cli (new: login_state_warns_from_day_25_and_reports_a_rejected_login, an_account_from_an_older_proxy_has_no_login_time, login_time_survives_refreshes_until_a_new_login (E2E against the fake provider: login → refresh → rejected refresh → new login), reply_fields_are_logged_by_name_only_and_once, ai_account_lines_carry_login_times_and_state, login_age_reads_in_days)
  • cargo test -p okena-agent-usage
  • cargo clippy -p okena-app -p okena-ai -p okena-cli -p okena-core -p okena-daemon-core -p okena-agent-usage --all-targets -- -D warnings

Status

  • Review round 1 (review agent, claude-opus-5-5, 3db05164): approve; 5 minor + 4 nit findings fixed in 89cf64cb, no second round
  • CI on 89cf64cb failed: reply_fields_are_logged_by_name_only_and_once. The workspace build turns on serde_json's preserve_order, so key order differed; the names are now sorted (20b2228f).
  • Rebased once onto main (8972f3ef, version bump only, no conflicts); CI green on 20b2228f
  • Merged as 05584787

🤖 Generated with Claude Code

@matej21

matej21 commented Nov 10, 2026

Copy link
Copy Markdown
Member Author

Review round 1 — review agent (claude-opus-5-5)

Commit reviewed: 3db05164 (diff 3a17d534..3db05164). Verdict: approve.

Every row of the decision table matches the code. The state transitions, refresh lock and persistence, serde back-compat (old file + new proxy, new proxy + old daemon), time math and CLI edge cases are correct. No new log line carries a secret.

# Severity Where Finding
1 minor crates/okena-ai/src/server.rs test logger The logger is capped at Warn, so the "no token value in the log" check never sees the new info lines (login, token_refresh ok, token_reply_fields).
2 minor server.rs login_time_survives_refreshes_until_a_new_login at >= logged_in also passes if a new login keeps the old login time, because both logins can land in the same millisecond.
3 minor accounts.rs mark_needs_login When a just-refreshed token still gets a 401, the account shows as rejected with no account= line in proxy.log to explain it.
4 minor okena-agent-usage/src/pool_summary.rs The usage-widget popover still says "Needs login" while Settings and the activity view say "Login rejected".
5 minor oauth.rs Nothing tests the once-per-grant gating. Nested field names could log ids if the provider ever keys a map by id.
6 nit PR description The #323 overlap is not in render_ai_proxy.rs, which merges cleanly. It is in server.rs: both PRs add a test at the same spot, which is trivial to resolve.
7 nit docs/reference/ai-proxy.md Says every refresh is logged, but transient failures produce no token_refresh line.
8 nit okena-core AiLoginState The state names come from two sources, as_str and serde snake_case, and Deserialize is unused.
9 nit docs A downgrade to an older proxy drops the login times; the docs don't say so.

@matej21

matej21 commented Nov 10, 2026

Copy link
Copy Markdown
Member Author

Response to review round 1

The round approved with only minor and nit findings, so all are fixed in 89cf64cb and there is no second round.

# Outcome
1 fixed in 89cf64c: the test logger captures Info. The test now asserts the token_refresh ok, token_refresh rejected and login … (logged in again) lines and checks all of them for token-old, old-rt, login-access and login-rt.
2 fixed in 89cf64c: the test seeds the account with logged_in_ms = 1000 (so it starts as expiring) and asserts the new login's time is strictly greater.
3 fixed in 89cf64c: mark_needs_login logs token rejected after refresh account=… login_age_days=… (warning).
4 fixed in 89cf64c: the usage-widget popover says Login rejected. Login age stays out of that widget: it lists only why the proxy skips an account now, and an expiring login is still served. Settings and the activity view show the age.
5 fixed in 89cf64c: fields_to_log logs top-level names only, so a map keyed by ids cannot leak them. reply_fields_are_logged_by_name_only_and_once tests the gating.
6 fixed: the PR description now places the #323 overlap correctly.
7 fixed in 89cf64c: the docs list which refresh outcomes are logged and where transient failures appear.
8 fixed in 89cf64c: dropped serde from AiLoginState; the CLI JSON uses as_str(), the single source for the names.
9 fixed in 89cf64c: the docs say a downgrade drops the login times until the next login.

Verified: cargo test -p okena-core -p okena-ai -p okena-cli -p okena-agent-usage pass; cargo clippy -p okena-app -p okena-ai -p okena-cli -p okena-core -p okena-daemon-core -p okena-agent-usage --all-targets -- -D warnings is clean.

matej21 and others added 7 commits November 10, 2026 16:04
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Names only, never values, to learn whether the provider states how long
a login lasts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@matej21
matej21 merged commit 0558478 into main Nov 10, 2026
14 checks passed
@matej21
matej21 deleted the feat/ai-login-age branch November 10, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant