M9 adds an opt-in, admin-authenticated web surface to shunt so an operator can
provision upstream Claude and Codex/ChatGPT accounts from a browser and observe
account-pool health without shell access. It builds directly on M8
(m8-anthropic-multi-account.md): the store, the
per-request account resolution, and the AccountPool quota/cooldown state all
already exist — M9 only adds an HTTP surface over them.
The surface is deliberately co-designed to share its foundations (session auth,
server-rendered page + CSRF convention, a single-use pending-login store, one
[server.admin] opt-in) with the planned Claude-apps gateway-login milestone,
which is inbound (client → shunt authorization server) where this feature is
outbound (operator provisions shunt → Anthropic upstream accounts). M9 lands
first and stands alone; gateway login builds on the same session/page layer
rather than growing a second stack.
After M8, adding an account to a deployed shunt required SSH (or
docker exec) plus an interactive shunt login claude --name <n> flow.
Depending on --mode, that flow creates a refreshable full-OAuth login,
imports an existing Claude Code login, or creates a one-year setup token. The
router exposed only /, /health, /protocol, /v1/models, /routes, and
the two /v1/messages* endpoints. Pool health (per-account quota utilization,
cooldowns) was observable only through the x-shunt-account response header
and logs. M9 relocates directly provisionable OAuth/setup-token flows from a
TTY to a browser form and surfaces the pool state that already lives in memory.
- Claude and Codex provisioning. The Claude browser form offers a refreshable
full-scope OAuth flow and the inference-only, one-year PKCE setup token. The
Codex form always uses ChatGPT OAuth and stores a refreshable credential.
Claude uses its fixed hosted manual redirect. ChatGPT redirects to
http://localhost:1455/auth/callback; that localhost page is expected to fail in the operator's browser, which copies the full address-bar URL (or its<code>#<state>values) back to shunt. Importing an existing Claude Code or Codex credential file stays CLI-only because the source file lives on the host. - Usage observation and credential ownership are separate. The primary Accounts and usage view automatically discovers supported provider logins on the host: Claude Code, Codex CLI, Gemini CLI, Kimi Code, Grok CLI, and Cursor.app. It reads current access/session material only in memory and never refreshes, copies, or writes the source; Cursor.app's SQLite state is opened read-only. An expired source is reported as unavailable. Claude usage is cached for 60 seconds; Codex remains response-derived; the other integrations call their first-party read-only quota surfaces.
- Refresh rotation ownership. A managed full-OAuth account is refreshed and written
back by
ClaudeAuthStore. Because the provider can rotate the refresh token, its store file must have one active process owner; copying or sharing it across running hosts can invalidate another process. Setup-token accounts remain static and avoid this hazard. - Full CRUD. Add (provision), list (metadata only), remove (delete the store file), and replace (re-run the flow with the same name, which the store already supports).
- Read-only pool dashboard. A JSON endpoint plus a table, over the state
AccountPoolalready tracks. No new state collection.
A new [server.admin] block under [server]. Absent ⇒ no admin routes are
registered at all — the default HTTP surface is unchanged. Present ⇒ the routes
exist and authenticate every request.
[server.admin]
# header carrying the admin credential for API/curl calls; `x-api-key` is
# accepted alongside it on the admin and spend-limit routers only
header = "x-shunt-admin-token"
# env var holding admin credentials as name:token pairs (SEPARATE from
# [server.auth] client tokens). These are the WRITE tier
tokens_env = "SHUNT_ADMIN_TOKENS"
session_ttl_secs = 3600 # browser session lifetime after login
pending_ttl_secs = 600 # time to open the authorize URL and paste the code back
# hide_observed = false # (default) read host CLI/app logins for the usage table
# Per-credential keys with an id the audit trail records. The key must come
# from ${VAR} / ${file:...} / a SHUNT_* override — a literal is rejected at load
[[server.admin.write_keys]]
id = "terraform"
key = "${SHUNT_ADMIN_KEY_TERRAFORM}"
[[server.admin.read_keys]]
id = "reporting"
key = "${file:/run/secrets/shunt-reporting-key}"export SHUNT_ADMIN_TOKENS="ops:3f9c…" # comma-separated name:token pairsAdmin credentials reuse the inbound-auth token format
(m4-inbound-auth.md) and its constant-time compare, but
are a separate credential from [server.auth]: client tokens are handed to
devices; admin credentials add upstream accounts and administer spend limits.
Configuration validation is fail-closed — a present [server.admin] whose
three credential sources (tokens_env/tokens_file, write_keys, read_keys)
are all unset, empty, or malformed is a startup error, never a silently-open
admin surface (identical discipline to [server.auth]). An array-only
deployment, with tokens_env unset, boots.
The token source can be a file instead of the environment: tokens_file (a
path, ~ expanded) holds the same name:token pairs, one per line or
comma-separated. When tokens_env is non-empty it wins; otherwise the file is
read, and an unreadable configured file is a startup error. This exists so the
token does not have to live in the launch environment. shunt dashboard setup
automates the whole enablement: it writes a random admin:<token> to
~/.shunt/admin-token (owner-only, 0600, via the same atomic-write path as
credential writeback), records it as [server.admin].tokens_file, adds
[server.oauth_usage], and prints the dashboard URL. It is idempotent —
re-running reuses the token and appends no duplicate block — and leaves any
pre-existing [server.admin] untouched.
read < write, and write implies read:
-
write— full access. Thetokens_env/tokens_filename:tokenpairs are this tier (retained for compatibility; new deployments should prefer[[server.admin.write_keys]], which carries anid), as is everywrite_keysentry. -
read— passes everyGETon the admin surface and on the spend-limit API, and is refused with403 permission_erroron every mutation. It signs in to the dashboard as well:POST /admin/loginmints a session that records thereadtier, andrequire_writerefuses that session's mutations exactly as it refuses the header credential's.The tier reaching the session is what makes that safe. While a browser session carried full access unconditionally, minting one from a read key would have silently escalated it, which is why the login form answered
401until sessions recorded their minting privilege.
A credential's privilege is the maximum over every set it matches, not
whichever set happened to be scanned last. Array ids must be non-blank and
array keys at least 32 characters; ids and key values must each be unique
across all three sets, and a collision names the colliding ids without logging a
key value. A legacy tokens_env token shorter than 32 characters warns rather
than failing, because those tokens predate the rule. Array keys are
Secret values: a key written literally in the config file
is rejected at load and must come from ${VAR}, ${file:/abs/path}, or a
SHUNT_* override.
[server.admin.oidc] adds an allowlisted OIDC/SSO button to the token login page.
Admin tokens remain mandatory as the API/curl credential and fallback browser
login; OIDC is an additional browser path, not a replacement security boundary.
[server.admin.oidc]
public_url = "https://admin.example.com"
issuer = "https://accounts.example.com"
client_id = "shunt-admin"
client_secret_env = "SHUNT_ADMIN_OIDC_SECRET"
allowed_domains = ["example.com"]
# allowed_emails = ["operator@example.net"]
# scopes = ["openid", "email", "profile"]The registered redirect URI is
{public_url}/admin/oidc/callback. public_url must be a bare HTTPS origin
(loopback HTTP is allowed for local development). The issuer and optional endpoint
overrides use HTTPS, or HTTP on localhost/127.0.0.1 only (the loopback
hosts the login page's CSP form-action can name). Startup also fails closed for an empty
issuer/client id, missing or empty client-secret environment variable, or an empty
email/domain allowlist.
POST /admin/oidc/start is same-origin guarded and rate-limited, discovers or uses
the configured authorization endpoint, creates PKCE plus a short-lived single-use
state, and redirects to the provider. The callback consumes that state before any
exchange, obtains a verified-email identity through the provider's token and
UserInfo endpoints, and re-checks the current hot-reloaded allowlist before
minting the ordinary admin session cookie. Provider input and exchange failures
produce generic browser pages; secrets, codes, and tokens are never logged or
echoed. Success always redirects to the fixed /admin target.
The split mirrors how M4/M8 already separate hot-reloadable config from process-lifetime state:
RuntimeState.admin_auth: Option<Arc<AdminAuth>>— re-resolved on every reload, so admin credential/header edits (including the key arrays) hot-apply just like[server.auth].AppState.admin_stores: Arc<AdminStores>— the session, pending-login, and rate-limiter stores, created once inbuild_router(likeArc<AccountPool>) and threaded through the per-request snapshot so a reload never drops a live browser session.- Whether the
/admin*route tree is registered is decided once at boot from the initial config (a reload cannot add or drop routes, likeserver.bind). A reload that toggles the block on or off logs awarn!that it needs a restart; disabling it on an already-registered surface makes every admin route reject requests (admin_authbecomesNone).
-
Two credentials, never mixed. Admin auth is the
[server.admin]credential; it is never the[server.auth]client tokens. -
Browser: sign in at
/admin/loginwith an admin credential of either tier → an opaque session id in an in-memorySessionStore, set as cookieshunt_admin_session(HttpOnly,SameSite=Strict,Path=/admin). The cookie is markedSecureunless the request host is loopback, so local HTTP dev and tests work while any real deployment host gets a Secure cookie (reusing M8'shost_is_loopbackloopback carve-out). A session carries the tier of the credential that minted it — areadkey's session is refused on every mutation, exactly as its header credential is. -
API/curl: send the admin credential in the configured header (
x-shunt-admin-token) or inx-api-key; both slots are accepted, and the resolved privilege is the maximum over whichever slots matched. When both slots carry a different valid credential of the same tier, the configured header supplies the audit actor, because it is checked first. This merged acceptance is scoped to the admin and spend-limit routers — on inference routesx-api-keyis the caller's own Anthropic credential slot and an admin credential never authenticates there. Whatever these routers accept,auth::inbound::consumed_bystrips from the same slot before any upstream request, alongside the gateway JWT and the[server.auth]static token, so an admin credential is never relayed to a provider. Header callers carry no ambient cookie and are therefore CSRF-exempt. -
Tier check on mutations: every account-provisioning mutation calls
require_writebefore the CSRF check; a read credential gets403 permission_error("read-only admin credential cannot perform this action").POST /admin/logoutneeds no credential at all — it only clears a session — and is guarded by the same-origin check instead. -
CSRF on every cookie-authenticated JSON mutation: a per-session synchronizer token, presented as
x-csrf-token, plus a same-origin check (Sec-Fetch-Site, falling back to comparingOrigin's authority toHost). No CORS.POST /admin/logoutis a plain navigation form that cannot send the header, so it is guarded by the same-origin check plus theSameSite=Strictcookie instead of the synchronizer token. -
Same-origin on
POST /admin/login, for the same reason and checked before the rate limit, so a cross-site flood cannot spend the operator's login budget.SameSite=Strictdecides whether the browser sends an existing session cookie; it does not stop the browser storing theSet-Cookiea cross-site form submission gets back. Without this guard, anyone holding a valid credential could submit that form from their own page and replace a visitor's session with one of their choosing. That became worth guarding whenread_keysgained the ability to sign in: a read key is handed to someone deliberately given less privilege, and this was its one lever against a write operator — silently downgrading that dashboard to read-only until the operator signed in again. Scripted logins are unaffected: the check passes when neitherSec-Fetch-SitenorOriginis present. -
The OIDC callback redeems
statebefore it spends that budget. The callback is aGET, so another site can fire it with an<img>tag, and a same-origin check cannot apply to the identity provider's own redirect. A callback carrying no livestateis therefore refused before the rate limit is consulted; onlyPOST /admin/api/oidc/start, which is same-origin checked, mints one. Without[server.admin.oidc]the callback answers404. -
Pending-login store is in-memory only, single-use, and TTL-bound; each completion attempt is counted and the entry is discarded after a small cap. The 256-bit OAuth
statealready makes guessing infeasible. -
Rate-limit on the completion and login endpoints (a coarse global fixed window each) against code- and admin-token-guessing storms.
-
Secrets never leak: the verifier, authorization code, access token, and refresh token are never logged and never returned to the browser. The OAuth
stateis intentionally carried in the authorize URL and the opaque session id only in theHttpOnlysession cookie — both are protocol values the browser must receive, not bearer secrets. Account add/remove is audit-logged by name and provisioning mode only. -
Docs recommend binding the admin surface behind HTTPS / a tunnel, same as the shared-gateway guide.
-
Emergency token rotation: browser sessions are validated only against the in-memory session store, and the running process's environment is fixed — a config reload re-reads
SHUNT_ADMIN_TOKENSfrom the same startup environment, so it neither rotates the token nor drops issued sessions (those persist untilsession_ttl_secs, default 1h). If an admin token is compromised, replace it in the environment source (systemd unit,.env, …) and restart the process: the restart both loads the new token set and drops every session the old token minted. To disable the last admin credential, remove the[server.admin]block before restarting (with no key arrays configured, an emptySHUNT_ADMIN_TOKENSfails closed at startup). Rejecting stale sessions on reload is tracked in #100. A${file:...}-backed[[server.admin.write_keys]]/read_keysentry is the exception to the first half of this: its value is re-read on every config load, so overwriting the referenced file and triggering a reload does rotate that key without a restart. Sessions already minted still survive untilsession_ttl_secs.That last sentence reaches read keys too, which it could not while a read key was refused a session. A
read_keyslogin now mints a read-tier session, so rotating a compromised read key stops its header credential at the next reload while its cookie goes on reading the admin surface untilsession_ttl_secselapses — and that key carries no upper bound (AdminConfig::session_ttl_secsis a bareu64with a default, andConfig::validatedoes not range-check it), so a deployment that raised it for convenience widens the window by exactly as much. What survives is read-only —require_writerefuses that session's mutations, and it is strictly less than the full access a write-tier session already carried across the same window — but a deployment that hands read keys out widely because revocation looked immediate no longer has that property, and should restart rather than reload. #100 covers both tiers; neither is fixed by the session tier alone.
Pre-split record. The paths below are the ones M9 shipped. Every JSON and mutation route among them has since moved to
/admin/api/*; only/admin,/admin/login, and/admin/oidc/callbackstill answer on the paths written here./adminkept its path but not its answer: the server-rendered dashboard described below is deleted, and that path now serves the embedded SPA shell — unauthenticated, so the sign-in redirect happens client-side afterGET /admin/api/sessionanswers401, and in a build without--features uiit answers404instead. This table is deliberately left as the pre-split record rather than rewritten — seeadmin-ui-delivery.md(Decision 3, Decision 4, and Resolution 6) for why the namespace split and the SPA move happened, anddocs/reference/endpoints.mdfor the current paths and the before/after migration table.
| Method | Path | Purpose |
|---|---|---|
GET |
/admin |
Dashboard (HTML); redirects to /admin/login when not signed in |
GET,POST |
/admin/login |
Token or OIDC login form → session cookie |
POST |
/admin/oidc/start |
Start the optional same-origin OIDC/PKCE browser login |
GET |
/admin/oidc/callback |
Complete OIDC login, enforce the current allowlist, and mint a session |
POST |
/admin/logout |
Clear the session |
GET |
/admin/accounts |
JSON: Claude store metadata (name, kind, expiry, UUID — never the token) |
GET |
/admin/accounts/codex |
JSON: Codex store metadata (name, expiry, account ID — never the token) |
GET |
/admin/observed |
JSON: read-only observed Claude, Codex, Gemini, Kimi, Grok, and Cursor identity, state, and provider-native usage — never token material |
GET |
/admin/pool |
JSON: per-claude_oauth/chatgpt_oauth/antigravity_oauth managed-pool state; account objects may include an optional plan string (antigravity_oauth accounts additionally carry a quota_buckets array containing the Gemini and Claude+GPT shared 5h/weekly windows from the retrieveUserQuotaSummary poll) |
POST |
/admin/accounts/claude |
{name, mode} → start Claude provisioning (oauth or setup_token); omitted mode defaults to setup_token; returns {authorize_url} |
POST |
/admin/accounts/claude/{name}/complete |
{code} → finish; stores the Claude account |
POST |
/admin/accounts/claude/{name}/refresh |
Exercise an imported account's refresh grant now and report whether the login is still alive; returns the new expires_at and never token material. 400 for a setup_token account (no refresh grant exists) or a terminal verdict (invalid_grant, no stored refresh token, or a rotated pair that could not be persisted); 502 for a non-terminal failure |
DELETE |
/admin/accounts/claude/{name} |
Remove the Claude account's store file |
POST |
/admin/accounts/codex |
{name} → start ChatGPT OAuth; returns {authorize_url} |
POST |
/admin/accounts/codex/{name}/complete |
{code} with a full callback URL or <code>#<state> → finish and store the Codex account |
DELETE |
/admin/accounts/codex/{name} |
Remove the Codex account's store file |
Gateway-owned errors keep the Anthropic error shape (ShuntError); page routes
render minimal server-side HTML with inline CSS/JS and no external requests.
Every GET above is reachable with a read credential, as is
POST /admin/login — it mints a session at the tier of whichever credential
signed in. The seven account-provisioning routes (POST/DELETE under
/admin/accounts/...) require write. POST /admin/logout and the two OIDC
routes are login-flow plumbing and are guarded by the same-origin/state checks
rather than by tier.
The browser flow reuses the CLI OAuth/setup-token internals in
auth/claude/login.rs (generate_pkce, build_authorize_url,
exchange_code) and stores through claude_store. The upstream redirect URI is
fixed to platform.claude.com/oauth/code/callback for this remote/manual flow —
the CLI's full-OAuth mode can use a localhost callback, but that loopback would
return to the operator's browser host rather than a remote shunt server. The
operator therefore pastes <code>#<state> into the form for both web modes.
POST /admin/accounts/claude {name, mode}validates the name and mode, generates a PKCE verifier/challenge +state, stores a single-use pending login with its authoritative flow kind (TTLpending_ttl_secs), and returns the authorize URL (https://claude.com/cai/oauth/authorize).mode = "oauth"requests the full refreshable Claude scope;mode = "setup_token"requestsuser:inference. Omittingmodedefaults tosetup_tokenfor API backward compatibility, while the dashboard explicitly sendsoauthby default.- The operator opens the URL, signs in to the target Claude account, approves,
and pastes the resulting
<code>#<state>. POST /admin/accounts/claude/{name}/complete {code}verifiesstate(constant-time), exchanges the code at the token endpoint (honoringSHUNT_CLAUDE_TOKEN_URLfor tests), then dispatches by the server-stored pending kind. Setup-token mode requests the one-year expiry, requires an account UUID, and callsstore_setup_token. Full OAuth omits that expiry override, requires a non-empty refresh token, accepts an optional account UUID, computesexpiresAt, and callsstore_oauth_tokens. Both writes are atomic at0600; the pending entry is consumed. The completion request cannot switch modes.- The completion response reports whether the account is live immediately (a
claude_oauthprovider with an emptyaccountslist scans the store each request) or needs a name-only[[providers.<name>.accounts]]entry + reload.
Removal deletes the store file directly, path-guarded so a caller-supplied name can never escape the accounts directory. This is new writeback behavior over an operator-owned store file (issue-sanctioned) and touches no upstream state.
The Codex form reuses the shared PKCE generator but follows the Codex CLI OAuth
contract from m2-chatgpt-oauth.md: authorize at
https://auth.openai.com/oauth/authorize with the fixed
http://localhost:1455/auth/callback redirect, then exchange the code using an
application/x-www-form-urlencoded POST. The operator may paste either the full
redirect URL from the browser address bar or <code>#<state>. Completion checks
the pending state in constant time, requires a refresh token, derives the account
ID from the access-token JWT, and writes the verbatim auth.json shape at 0600.
SHUNT_CODEX_TOKEN_URL overrides the exchange endpoint for local tests; an
invalid override, or one that is neither loopback nor HTTPS to the built-in
endpoint's host, is ignored with a warning (mirroring the Claude completion flow)
instead of silently, and the exchange POST uses the redirect-hardened client, which
follows a 3xx only within the host it started on, so a permitted endpoint cannot
hand the single-use code to another host. No access, refresh, or ID token is returned or logged.
Like Claude, an empty-account chatgpt_oauth provider scans the Codex store and
makes the new account live on its next request. Explicit-account providers need a
name-only account entry and reload.
The dashboard is usage-first. GET /admin/observed discovers supported local
credentials on each request but keeps every token/session in a private,
non-serializable model. Set [server.admin].hide_observed = true to skip that
discovery: GET /admin/api/observed still authenticates and returns an empty
accounts list without reading any of those sources, and
GET /admin/api/session reports hide_observed so the SPA skips the read. The
Accounts and usage table stays, listing managed pool accounts alone — it is
also where their usage is shown, so removing it would hide the pool's numbers
along with the host's logins. Claude Code checks its configured/default credential file,
then macOS Keychain service Claude Code-credentials; Codex, Gemini, Kimi, and
Grok read their CLI credential stores; Cursor opens Cursor.app's state.vscdb
with SQLITE_OPEN_READ_ONLY. The endpoint masks account identity, labels
ownership as observed, and never invokes a refresh/writeback store. Provider
requests have a 15-second timeout. Claude reads /api/oauth/usage, with the
token-free snapshot cached process-wide for 60 seconds. The Claude and Codex
rows are the exception to the identity masking above: each carries its account
uuid — the Claude account uuid, or the ChatGPT account id — so the table can
tell an observation and a managed pool account holding the same subscription
apart from two genuinely different accounts. Both values are already returned
unmasked by GET /admin/api/accounts and GET /admin/api/accounts/codex to the
same authenticated caller, so this adds no disclosure the admin surface did not
already make. Gemini returns every
Code Assist model bucket, Kimi returns weekly and 5-hour windows, Grok returns
credit/product usage, and Cursor returns billing-cycle, Auto + Composer, and
named-model usage. Codex remains response-derived: both translated Messages
traffic and raw inbound Responses attach the default CLI account id to
x-codex-* quota capture without importing the credential into the managed
store. Before such traffic its row explicitly says it is waiting for traffic.
The Accounts and usage heading carries a refresh bar: when the table last
finished loading, the countdown to the next usage-poller pass (from the pool
response's usage_poll; hidden while the poller is off), and a Reload button.
While the tab is visible the page re-reads shunt's own state every 30 seconds:
GET /admin/api/pool and the account stores, with the usage table rebuilt from
the observations it already has. It never calls GET /admin/api/observed on that
timer, because that endpoint asks each provider's usage API about every login on
the host, uncached. On those timed reloads, a row that is both a pool account and
a host login keeps the numbers the last full load drew, except where the pool's
own window has changed since that load: the pool has then learned something the
observation predates. A cached waiting-for-traffic is dropped the same way once
the pool has seen the account's traffic. One timed reload runs at a time, so a
backend slower than the interval still lands its answers. A hidden tab skips the timer and catches up once when it is
shown again. When the last observation failed the timer leaves the error showing
instead of repainting the table without the logins. Reload repeats the full
page load, observations included. It is not the per-account Refresh, which
exercises that account's refresh grant, and neither starts a usage-poller pass early.
The usage table groups by provider: the provider is named once and its accounts
nest beneath it, folding managed pool accounts in alongside the read-only
observations rather than stranding them in the advanced section. An observation
and a managed account are coalesced into one row when their account uuid
matches — one subscription is one row, labelled with the managed account name,
with the observation's windows preferred because the pool only learns a window
from a response header it has actually received. Each identity table is matched
only against accounts of its own store's auth kind: Claude store uuids against
claude_oauth accounts, Codex store account ids against chatgpt_oauth
accounts. The Codex case is the common one on a developer machine, where
~/.codex/auth.json and a shunt login codex copy are the same ChatGPT
account and previously rendered as two rows with an identical weekly bar.
Coalescing is deliberately conservative: identity resolves to None whenever
CLAUDE_CONFIG_DIR or CLAUDE_CREDENTIALS is set, and an unidentified
observation never matches. Both Claude credential sources are profile-agnostic
(the fixed Keychain service name above; a hardcoded
$HOME/.claude/.credentials.json, unless CLAUDE_CREDENTIALS relocates it)
while oauthAccount.accountUuid is read from a config directory that
CLAUDE_CONFIG_DIR relocates, so with either variable set the recorded
identity can name a different account than the credential that was actually
read — observed against a live two-account pool, where it labelled an
exhausted account's usage with the other account's uuid. Declining to merge
costs a combined row; guessing costs a silently mis-attributed quota bar.
Closing that gap needs a token-scoped identity source, which does not exist
today: /api/oauth/usage is the only Claude endpoint read here, and
shuntAccountUuid is captured only at login/import time.
The known uuid is still attached even when the local token has expired: the
account id is read independently of token validity, so an expired observation
keeps matching its managed row instead of rendering as an unrelated duplicate.
Which state the coalesced row then displays follows a precedence, not a
blanket override: a managed operational state (disabled/cooling/
near-quota) is an actionable gateway-side fact and wins over a stale local
observation error, so a cooling account still shows its cooldown remediation
even though the client's last local check happened to see an expired token.
An observed error (expired/unavailable) still surfaces over a merely idle
managed state (available/unseen), which is the "Needs login" case this
matching was built for.
Within the managed states both tables run one ladder — disabled,
needs_relogin, !has_state, hard cap, used-up window, account-wide cooldown,
near_quota, Fable cap, Fable-only cooldown — so the same account cannot read
near quota in the managed-pool table while the Accounts table calls it
Cooling (issue #512). A used-up window gets its own exhausted state
("Exhausted", noted Quota used up · clears when the window resets), read from
the pool snapshot's exhausted flag: the 5-hour or weekly window was rejected or
reached 100% utilization. The rejection test is selection's own, so a stale
aggregate status counts only when no per-window status has been recorded, and
a Fable-only (7d_oi) rejection does not count. near_quota cannot carry that
on its own, since the pool also sets it at the switch threshold (98% by default)
while the account can still serve. Like a hard cap, it lasts until its window resets, so it
sits above the short cooldown. The cooldown is
tested before near_quota because it is the fact that all of the account's
traffic is gated right now, while near_quota warns about what is coming. When
both cooldowns are running the row's state is the account-wide one, and its note
names both deadlines (retries in 10m · Fable retries in 30m), the way the
managed-pool table's Cooldown column already lists both (issue #511).
Managed provisioning and store metadata remain available under a collapsed
Manage pool accounts (advanced) section. AccountPool::snapshot(provider, &[AccountConfig], model) returns a token-free,
serializable view per account: 5h/7d/7d_oi utilization + reset, unified status,
account-wide cooldown-seconds-remaining, Fable-only cooldown-seconds-remaining,
near_quota, a needs_relogin flag, and a derived available flag. The Fable-only cooldown counts
toward available only when model is a Fable model, so an account cooling on
its 7d_oi bucket still reports available to every other family. Because the
admin snapshot is taken with model = None, the dashboard carries the
Fable-only cooldown as its own cooling-fable row state ("Cooling (Fable)",
with a Fable retries in … note) rather than folding it into available — the
account is genuinely live for every non-Fable family, so neither "Live" nor a
plain "Cooling" describes it. It reads
the same entries map select_order reads, clears only already-past quota
buckets (as the next selection would), never mutates the round-robin cursor, and
never inserts entries for accounts the pool has not yet seen (reported as
has_state: false). Such a row still carries a needs_relogin the admin
refresh probe recorded by store name — see the side table below — because that
is the one thing that can be known about an account the pool holds no entry for.
Each row also carries the account's request counters since process start:
requests_attempted, always the sum of requests_succeeded (a 2xx status),
requests_failed (any other status, or no response headers at all) and
requests_cancelled (the send was dropped before it resolved: the client went
away or a deadline cut it), and mean_latency_ms, the mean time from
dispatch to response headers over the attempts that received headers (null
until one has). One attempt is one proxied HTTP dispatch, a same-account retry
included; control-plane calls on the account's credential (usage polls, model
discovery, catalog lookups, token refreshes) are not attempts. The adapters
record it where the account is already bound (CountAttempt in
src/accounts/request_stats.rs, whose drop guard records a cancelled send),
since the failover chain sees one result per provider, after any account
rotation inside it. The totals live on the account's health entry under the
existing entries lock and are never emitted to the metric sinks, which stay
free of account identity. Like the cooldowns they are memory-only: a reload or
store rescan keeps them, deleting the account through the admin API drops them,
and a restart resets them. Rows that resolve to the same account identity share
one set. They are reported on a has_state: false row too, because an attempt
that timed out before any headers leaves no other trace. Attempts over the Codex
WebSocket transport, served or failed, are not counted, since a WebSocket turn
has no per-turn response headers to classify; an HTTP fallback after a failed
WebSocket attempt is counted.
Each row also carries bound_sessions, the number of that provider's live
session bindings (ADR-0008) on the row's physical identity. A binding idle for
more than 24 hours is expired and is not counted. Bindings are keyed by provider
and session hash, with the physical account identity as the value, so aliases
that resolve to one identity share an account_ref, and each of their rows
reports that identity's one count. The count is never split
per alias and never summed. A consumer that totals bound sessions dedupes by
account_ref, as it would for the request counters. The count is scoped to the
provider table: a session bound under another provider on the same physical
account counts only in that provider's table, not here. The handler reads the binding
table once per request, under the binding table's own lock, never while it
holds entries. Only counts are returned, never a session hash or id. A
runtime PATCH /admin/api/pool of sort_by_reset leaves every binding in
place, because that override only reorders failover and sessionless traffic.
needs_relogin marks an account whose credential no operator-free retry can
revive, and both dashboard tables report it as needs re-login — the
primary Accounts table ("Needs re-login", with a "Re-add this account to sign in
again" note) and the collapsed managed-pool table's State column — ahead
of every cooldown state. It exists because the two are otherwise
indistinguishable on the dashboard: a cooldown expires after five minutes and
the account is selected again, so a permanently dead account shows the same
cooling a quota pause does, forever — one 401 (and, for an imported account,
one already-rejected refresh POST) every five minutes with nothing durable to
see.
The mark also records why it was set (ReloginCause, internal — the JSON
field stays a boolean). Only one decision reads it: whether a successful refresh
grant is enough to clear the mark. RefreshGrant means the grant itself failed,
so a later grant that succeeds disproves it and the admin probe may clear it.
ServedRequest means the provider rejected a bearer the account actually
presented — the grant was never the broken part, so a working grant proves
nothing and only a served response clears it.
It is set from exactly three places, all terminal by construction:
-
a 401 on a credential that carries no refresh grant at all — a
token_envtoken, or a setup token still inside its localexpiresAtthat the provider has revoked early (adapters/anthropic/mod.rs, theRefreshRetrybranch). Resolution succeeds because the stored token still looks valid, so the verdict can only come from the upstream 401, -
a 401 on the retry after a refresh that itself succeeded. A live grant yielding a bearer the API still rejects means the account is de-authorized upstream, not momentarily unlucky; the adapter already cools it for five minutes and rotates, so without the mark it cycles there forever reported as plain
cooling, and -
a refresh that cannot be retried into success, classified by
auth::claude::auth::is_terminal_refresh_failure. Three reasons carry the typedTerminalRefreshmarker, mirroring the one the gateway store already uses (auth/gateway/auth.rs) and the code-based classification in the Kimi store (auth/kimi/auth.rs):InvalidGrant— the provider returned the OAuthinvalid_grantcode.NoRefreshToken— the credential file carries no refresh token, so there is no grant left to send. A setup token past its localexpiresAtreaches its dead state through this path rather than the 401 above: resolution fails before any upstream request is sent, so theRefreshRetrybranch is never entered. The two setup-token bullets are the same credential at different times — revoked-before-expiry surfaces via the 401, expired surfaces at resolution — and an expired token takes this path whether or not it was also revoked, because nothing reaches the provider.WritebackFailed— the provider rotated the token but the new pair could not be persisted. The grant already consumed the refresh token on disk, so every later attempt replays a spent one. Attached only when the response actually carried a different refresh token: a provider that omits the field leaves the stored one live, and that writeback failure stays non-terminal.
A transient failure (5xx, network, timeout, an unparseable body) is deliberately not terminal: marking one would report a healthy account as dead after a momentary provider blip. Both the 401 → force-refresh path and credential resolution classify this way: once a dead account's access token expires — its steady state within hours — the refresh is rejected on read, before any upstream POST, and that path marks the account too (
auth::resolve_claude_account_classified).
The Codex pool (chatgpt_oauth) marks from the same three places, with its
own typed marker (auth::codex::auth::TerminalRefresh, carried on
ChatGptAuthError next to the logged detail), since #616: a token_env 401
and a still-401 retry set ServedRequest, and a terminal refresh — the
invalid_grant code from auth.openai.com, no refresh token stored, or a
rotated pair lost before writeback — sets RefreshGrant from both the
resolution path and the 401 → force-refresh path. Before that fix every Codex
refresh failure was one undifferentiated five-minute cooldown, so a dead Codex
account read as live on the dashboard between attempts, indefinitely.
It is cleared by proof the credential works again — but the proof has to match
the cause. A served response (mark_healthy_scoped) clears any mark, and so
does a re-login through POST /admin/accounts/claude/{name}/complete, which
replaces the credential outright. A successful
POST /admin/accounts/claude/{name}/refresh probe clears only a
RefreshGrant-caused mark.
A quota 429 clears the mark too, on both the initial and the post-refresh
attempt. The provider read the credential and then refused on quota, so the
bearer authenticated and any mark from an earlier 401 is stale; without this an
exhausted account would tell the operator to re-login until some later non-429
response. Narrowed to the quota-rejected 429 specifically — the one carrying an
anthropic-ratelimit-unified-*-status: rejected header, which is what
accounts::classify routes to Rotate. Two other responses reach the same
code and prove nothing: a 5xx, which can come from an edge before the
credential is ever read, and a headerless 429, a generic throttle with no
account-scoped quota verdict behind it. The initial arm can test the status
alone because it is entered only for Rotate; the post-refresh arm groups
Rotate | PauseSame | RefreshRetry, so it checks the classification too. Only
the mark is cleared — the cooldown still runs.
A successful refresh on the proxy path clears nothing on its own. The adapter deliberately waits for the retried request: a live grant proves the refresh token works, not that the account can serve inference, and the retry may still come back 401 — in which case the mark is set rather than cleared.
The mark follows the credential, not the provider row that tripped over it.
One store account activated by name in two [[providers.*]] tables gets two
health entries — resolve_pool_accounts leaves a name-only entry UUID-less, so
account_key keys it as UpstreamInline, which carries the upstream name — yet
both are backed by one credential file. Marking (and clearing) therefore fans
out across every entry for the same store account, exactly as the admin
re-login and probe paths already do. The fan-out is narrow on purpose: an
account carrying its own credentials path or token_env is a different
credential that merely shares a name, so a failure on it condemns nothing else.
An entry keyed by uuid (Verified) is reached only from an account that carries
that same uuid, because AccountKey keeps no name to match on there.
The fan-out reaches three things it would otherwise miss. It stamps observed
on the sibling, because snapshot reports an unobserved entry as unseen and
drops every field on it — a row select_order has picked but no response has
answered yet would carry the mark and still render clean. It runs on
mark_healthy_scoped as well, so a response served through any row clears the
mark on all of them rather than leaving a live credential condemned until each
row happens to serve traffic of its own. And on the success path it clears the
mark alone: cooldowns and storm-control ramps stay per-row, because this is a
signal and not a routing policy.
That success-path clear is gated on an any_needs_relogin flag, because it sits
on the path every served response takes and scanning the map there cost ~2x on
the account_pool_healthy_updates benchmark for a signal that is absent in the
steady state. The flag is conservative in one direction only — false means no
entry carries a mark, true may outlive the last one and cost a single extra
scan — and every mutation of it happens under the entries lock, so the recompute
can never store false over a mark that landed beside it.
The flag is deliberately independent of the cooldown and changes nothing
about routing, selection, or the cooldown clock — it adds a signal, it does not
add a policy. It is memory-only: the opt-in [server.pool] state_path
persister carries quota alone, so a restart clears the mark and the account's
next terminal failure re-establishes it.
POST /admin/accounts/claude/{name}/refresh is the operator's on-demand probe
for the same question. It is write-tier, CSRF-checked, and rate-limited through
the same complete_rate limiter as the completion route (it POSTs to the
provider's token endpoint). It refuses a setup_token account up front rather
than attempting a grant that cannot exist, and it always goes through
ClaudeAuthStore::force_refresh, never a hand-rolled grant: that store holds
the process-global REFRESH_LOCK across read → POST → atomic writeback, which
is what keeps the probe from racing the proxy's own refresh and stranding a
rotated refresh token. It does not additionally take the per-account
AccountPool::refresh_lock the proxy takes — that lock is keyed by an
AccountConfig from a provider table, and a store account may be reachable
through zero, one, or several provider entries, so there is no single right one
to pick; it is also unnecessary, because both of the proxy's critical sections
sit inside the same global lock. Success returns the new expires_at and
nothing else — no access token, no refresh token, and no provider body that
carried them. It clears a RefreshGrant-caused mark, but deliberately not a
ServedRequest-caused one: the probe never sends a request the account has to
serve, so its success is no evidence against a bearer the provider already
rejected. The response's needs_relogin is read back from the pool after that
clear, so a probe that succeeded against a still-dead account says so rather
than reporting a recovery /admin/pool would contradict. The dashboard reports only what the pool actually stores. A session's account
lives in the binding table (ADR-0008), which the pool view reports as a
per-identity bound_sessions count. The pool keeps no last-selected timestamp,
so none is shown. GET /admin/pool enumerates each claude_oauth and chatgpt_oauth
provider's accounts (its configured list, or the corresponding Claude/Codex store
scan for an empty list — the same resolution the adapters use). Codex successful
responses now populate the 5h/7d fields from x-codex-* rate-limit headers;
unsupported windows are ignored and 7d_oi remains None because Codex has no
analog. Since issue #195 this recorded state also feeds Codex account selection (see m10-codex-multi-account.md), in addition to the dashboard display.
The mark above lives on health entries, keyed by AccountKey. The admin routes
do not have one: POST /admin/accounts/claude/{name}/refresh knows a store
name and, when the credential file carries a shuntAccountUuid, a uuid. For
an account some provider table has selected that is enough — the setter matches
those against every entry's identity. An account the pool has never selected
has no entry at all, so the setter used to update nothing and /admin/pool kept
reporting the row unseen even though the operator had just been told the
credential is dead (issue #439). Inventing an entry is not an option: it would
mean synthesizing an AccountKey the selection path never produced.
So the verdict is also recorded in a second, key-free place: store_relogin, a
set of StoreAccountRef — (store_family, name, uuid) — on the pool. It is a
set rather than a
map of causes because only the admin probe writes there and its verdict is
always RefreshGrant — membership is the cause. snapshot consults it in
both branches: the unseen row reports it alone, and an observed row reports
its entry's mark or the set's. The entry is not authoritative, because the
setter cannot always reach it — it matches entries with StoreAccountRef::matches,
which is uuid-only on a Verified key, so a verdict recorded with no uuid never
stamps a row whose config carries a uuid (AccountConfig::uuid is a config key,
and the probe's own uuid read returns None both when the credential file has no
shuntAccountUuid and when the read failed). Reporting only the entry there made
/admin/pool answer needs_relogin: false while store_account_needs_relogin
called the same account dead. ORing is safe because every clear purges the set
too, so one served response on any row backed by that store account lifts the
display. has_state stays false on the unseen row, which is still true and
which both dashboard tables read after needs_relogin, so the row renders
needs re-login rather than unseen.
Every path that clears the mark purges the set as well — a re-login
(set_needs_relogin_for_store_account(.., false)), a successful probe
(clear_grant_relogin_for_store_account), the narrow clear
(clear_needs_relogin), a served response (mark_healthy_scoped), and account
deletion (forget_identity, reached from
DELETE /admin/accounts/claude/{name} through forget_pool_health_if_absent).
forget_identity takes the store name alongside the resolved identity for the
set purge alone (the entry, refresh-lock and membership matcher stays keyed by
identity): the admin call sites resolve identity = account_uuid(name).unwrap_or(name),
so a delete whose uuid read succeeds arrives with a uuid that equals neither
field of a verdict recorded when that read had failed, and the ref would survive
the delete to condemn a later re-add of the same name.
That purge is also gated differently from the rest of the deletion cleanup.
forget_pool_health_if_absent reaches forget_identity only once nothing in the
store family still references the identity and the store scan actually
answered, because the identity-keyed state is legitimately shared: two store
names can resolve to one Anthropic account, and the surviving alias's health must
outlive the delete. The deleted name's verdict must not, whatever the scan
said — the caller has already removed or re-provisioned that name. So the
name-keyed half runs first and unconditionally, through
forget_store_relogin_name(family, name), which takes the set alone and strips
on (family, name): wider than accept, and safe for the same reasons the other
strips are, since the name is unique within its family, a sibling alias's verdict
is a separate ref keyed by that alias's own name, and the next terminal probe
re-marks. forget_identity's own store_name arm stays in place — redundant on
that path now, but it is public API that has to remain correct on its own.
The served-response purge reproduces the fan-out in both directions. Its
narrowing: an account carrying its own credentials path or token_env is a
different credential that merely shares a name, so serving on it clears nothing
— and for the same reason neither snapshot branch condemns such a row either,
since nothing it could do would ever clear the mark. Its width: a
Verified-keyed account reaches its name-keyed siblings through the name, so
the purge strips on (family, name-or-uuid) rather than on the key's own
identity. Matching only
the key would strand a verdict recorded with no uuid — the credential file
carried no shuntAccountUuid when the probe ran, or the admin path's uuid read
failed into None — once the account is only ever reached through a uuid-keyed
row, and every name-keyed row would keep rendering "needs re-login" for an
account that is demonstrably serving.
Every read and clear of the set speaks (family, name-or-uuid) rather than ref
equality — the two clears that carry the admin routes' own (family, name, uuid), and the probe's needs_relogin read-back — so neither is ever narrower
than the set that recorded the verdict — the set matches an entry on the uuid
or the name, and an equality-removal would strip less than it accepts. The
reachable gap is a re-login under the same store name that signs a different
subscription in: the credential file now reports a new uuid, the recorded ref
still carries the old one, and the fresh account would stay condemned on every
name-keyed row. A store name is unique within its family — it is the
credential file's name — so widening to it cannot reach another account's
verdict. The uuid arm is guarded on the ref actually having one, or a clear
carrying no uuid would match every uuid-less ref in the family whatever its name.
The accept side also matches through the account rather than through the
AccountKey — snapshot holds an AccountConfig, and a Verified key carries
a uuid and no name, so keying off it would be uuid-only. That matters because the
probe records whatever claude_store::account_uuid returned, which is None
both when the credential file carries no shuntAccountUuid and when that read
failed (unwrap_or(None)), while AccountConfig::uuid is a config key an
operator can set on the provider entry directly — so the two need not agree even
without a race, and matching through the key alone would silently never show a
verdict the probe did record. matches stays the predicate for the three paths
that match a pool entry, where the key really is all there is.
But accept is narrower than the strip, not identical to it, because the two
fail in opposite directions. Over-stripping only drops a verdict the account's
next terminal failure re-establishes; over-accepting renders "needs re-login"
against a credential the verdict is not about, and nothing on that row can lift
it. So store_relogin_ref_condemns takes the uuid as the stronger identity
whenever both sides carry one — same uuid, same credential; different uuids,
different credentials that merely share a store name — and falls back to the
name only when at most one side has a uuid. The is_some guard on that uuid arm
is what stops None == None from reading as agreement between two unrelated
uuid-less accounts. Every pair accept admits, the strip also strips, so the clear
stays at least as wide as the display.
Two ordering rules make this safe. The entries lock is always acquired first
and store_relogin second, never the reverse — most sites that touch the set
already hold the entries lock, so nesting is the usual pattern; forget_identity
takes the set alone, after releasing entries, and forget_store_relogin_name
takes nothing else at all. And any_needs_relogin is raised unconditionally by the setter rather than from
inside the entry loop (a never-selected account matches no entry), while
mark_healthy_scoped folds !store_relogin.is_empty() into its recomputed value
after purging. Without that fold the flag would drop to false while a
never-selected account's verdict is still live, and the gated scan — the only
thing that ever clears it — would never run again.
The Claude store table in that section reports a derived status rather than
the raw claudeAiOauth.expiresAt it used to render. That timestamp is the
~8-hour access-token deadline and means opposite things per credential kind,
so showing it verbatim made healthy accounts read as broken. An imported
account carries a refresh token and shunt renews it in-band (single-flight
writeback in auth/claude/auth.rs, triggered five minutes before expiry), so a
past timestamp there is routine and needs no operator action — the row reads
"Auto-refreshes", with the raw timestamp preserved on hover. A setup_token
account has no refresh token at all (one-year lifetime), so the same timestamp
is genuinely actionable: with more than the refresh buffer left, the row reads
"Valid until" followed by that date; inside that buffer — or past the date, or
with no date at all — it reads "Expired" under the expired danger style with a
Setup token cannot refresh · re-login required note. Only the setup-token kind
can reach that state.
The buffer is the same five minutes routing itself applies, and the dashboard
takes it from that constant rather than copying the number.
Tokens::is_valid_at accepts a credential only while
expiresAt > now + EXPIRY_BUFFER, where EXPIRY_BUFFER is auth/claude/auth.rs's
own five minutes — auth/shared.rs declares a same-named constant that drives
the generic JWT helper, not this path. A setup token has no refresh token to
recover with, so inside that window a routed request already fails on the
no-refresh-token path; comparing against the bare deadline here would have the
dashboard call a credential usable for the last five minutes of its life while
routing rejects it, the window in which the operator most needs the warning.
GET /admin/api/session serves the constant's millisecond value as
expiry_buffer_ms, so the two cannot drift apart. (The server-rendered
dashboard this replaced substituted it into its own script instead; a served
value and a substituted one are equally drift-proof, which is why the move
changed nothing here.)
The Codex store table alongside it carries the same status column, but
unconditionally: that store has no non-refreshable kind at all. Both writers
into it reject a missing or empty refresh token (import_auth and
store_chatgpt_tokens in auth/codex/store.rs) and there is no setup-token
analog, so shunt owns renewal for every row (single-flight refresh and atomic
writeback in auth/codex/auth.rs, five minutes before the access token's JWT
exp, via the shared EXPIRY_BUFFER). The expiry this column used to print
was therefore never actionable — it simply made every healthy Codex account
read as broken within the hour, directly beneath a Claude row saying
"Auto-refreshes" for the identical situation. Every Codex row now reads
"Auto-refreshes" too, with the raw timestamp on hover.
Each store row in both tables also carries a Re-login action beside Remove.
It adds no endpoint: re-provisioning is already the ordinary flow run under an
existing name (POST /admin/accounts/{claude,codex} → paste →
.../complete). Neither start route carries a duplicate-name guard, and both
completions capture the pre-store identity, overwrite the account in place, and
hand the old and new identities to cleanup_reprovisioned_pool_health, so a
reprovision that changes the upstream identity does not strand the replaced
one's health entry. The
button therefore only primes the existing add form — it fills in the account
name, clears any half-finished flow (including the currentName /
currentCodexName handle the completion POST interpolates into its URL, so a
code cannot be completed against the wrong account), and scrolls the form into
view. The Claude button additionally preselects the login method matching that
row's current kind, since re-provisioning under the other mode would silently
convert the account between refreshable and inference-only; the Codex form has
no such choice, ChatGPT OAuth being the only way into that store.
Clearing the form is not sufficient on its own. A start or completion request
already in flight writes its result back when it lands, restoring the flow that
was just cleared. The late start is the damaging one: it reopens the previous
account's authorize step and restores that account's handle while the name field
already reads the newly picked one, so following the reopened link stores the
freshly authorized credential under the old account's name — silently
overwriting a different pool account. A late completion is milder, blanking the
just-primed name and reporting success for the wrong account. Each request
therefore captures a per-form flow epoch (claudeFlowEpoch / codexFlowEpoch,
bumped by a re-login and by every new start or completion) and discards its own
response once superseded. Claude and Codex count separately, so re-priming one
form never discards the other's live flow.
Re-priming is not the only way a stale authorization step survives. A second
Start on a form whose step is already open used to leave the first link
clickable, and its Complete button posts to the name captured for that flow —
and because a completion bumps the epoch itself, that click also strands the
start now in flight, whose response arrives superseded and is dropped. start
therefore clears the authorization state before it bumps the epoch, so the link
on screen always belongs to the login the operator last asked for (issue #513).
The login-method radios are disabled for the same span: the mode is fixed in the
server's pending entry when the request is issued, so a live radio would let the
form read one method while the pending login is the other. The lock is keyed on
starting || authorizeUrl, not on authorizeUrl alone — start nulls
authorizeUrl before it sends, so the narrower key would reopen the radios for
exactly the length of the request that had already captured the mode. A
superseded start does not release the flag; prime and each newer start set it
as they take over, so it always belongs to whichever call owns the current epoch.
A disabled attribute states no reason, so the group carries a role="status"
note naming the lock — the form's own live region is empty at exactly that
moment, because start clears it and a successful start never sets it. Pressing
Start again does not release the lock (the replacement start re-arms it
before any render), so the note names the two things that do: completing the
flow, or reloading the page.
Clearing at issue time costs the operator something when the replacement start
then fails: the page has thrown away the only handle to a pending login the
server may well still hold. Restoring the flow is not the repair — reinstating authorizeUrl and the name
handle puts the page back into exactly the state #513 removed, and making that
coherent means restoring the name field too, stomping the edit the operator is
about to correct. Each failure message names what was closed instead, so the
operator restarts rather than hunting for a link that is gone (issue #531). Both
of start's failure paths carry it, because the clear runs before the request:
a refusal appends the notice to the server's own reason, and an unanswered start
says it in place of the "no authorization step opened" line, which speaks only
for the step that failed to open.
What the server still holds differs by path, which is why the notice speaks only
for the page. A refused start never reaches PendingStore::start —
add_account calls it only after every failure return — so the earlier pending
login is untouched and lives out its pending_ttl_secs. An unanswered one may
have reached the server all the same, and PendingStore::start is a keyed insert
that replaces the entry, so a retry under the same account name has already
invalidated the login the closed step pointed at. Neither is reachable from the
page, which is what the notice says and all it says.
The notice is said only when a step was in fact closed — a failed start that
closed nothing has nothing to report, and saying it regardless would teach an
operator to read past the sentence on the one occasion it is true. Reading
authorizeUrl alone does not decide that, because the closure and the message
that reports it can be separated: a start closes the step synchronously, and its
own response can then be dropped by the epoch guard when a newer start supersedes
it — while that newer start reads an authorizeUrl the older one already nulled.
The Start button is not disabled while a start is in flight, unlike Complete —
which is disabled for its own request, and carries the 120-second
AbortController bound described below precisely so that being disabled cannot
close it for the life of the page. start has no such bound. So a second Start
during the first is an ordinary double click, not a race. A
closedStepUnreported ref carries the fact across the gap and is released by
whichever message reports it. The predicate defers on a step whose code is
already submitted (completingNow): a completion leaves authorizeUrl non-null
until it succeeds and clears it only after the epoch guard, so a Start clicked
mid-completion supersedes that completion — suppressing its confirmation while
onStored has already stored the account. Claiming a lost step there would send
the operator to re-provision an account already in the table. Deferring is not
declining, though: when that superseded completion comes back a definite failure,
nothing was stored, the step it was spending is closed, and its own error is
suppressed by the epoch the newer start took — so complete reports the closure
itself on that branch. Which way it reports depends on what the superseding start
has managed to do by then, and both orders are ordinary: the refusal is a local
validation while the completion is an upstream round trip, so it usually lands
first. If that verdict is already on screen, the completion amends it in place;
if the start is still in flight, the ref carries the fact to the message it will
write. Only the start's own verdict is amended, tracked by shownStartFailure —
#addmsg is shared with the row actions, which report through report, and a
closed-step note appended to a failed refresh would name a start the operator did
not make. Nothing is reported at all when that start has opened a step of its
own (stepOpen, mirroring authorizeUrl for the same reason): the operator is
not stranded, and arming the carry there would strand the fact instead, since
completing the new step consumes it silently and the notice would surface later
on a failure that closed nothing. The two unknown completion outcomes — an unreadable
answer, an abandoned request — record nothing, because neither can say the
account was not stored and onStored has already re-read the table, which is
where that question is answered. Nor does a completion superseded by prime:
re-login discards the half-finished flow at the operator's own request, so the
next start closes nothing and must not say it did. The wider predicate the radio lock uses
(starting || authorizeUrl !== null) would reach the same case, but it fires just
as readily on two chained starts that never opened a step at all, and would then
name a step the operator never saw.
The epoch orders starts, where the later click is the live one, and must not
be extended to order two completions of the same flow: a completion consumes the
pending login, so there the first click is the one that stores the
credential and a second finds the entry already consumed and fails. Superseding
by epoch would silence the successful response and surface the failed one,
reporting an error over an account that was in fact stored. Each completion
handler therefore refuses re-entry while its own request is in flight
(claudeCompleting / codexCompleting), disabling the button for the duration.
That marker is deliberately not released by a newer start, tempting as that is
for a page whose Complete button is closed. PendingStore::attempt does not
consume the entry and complete_account removes it only after the store, so a
start issued during an in-flight exchange replaces the entry and lets a second
completion pass its own state check — both exchanges then reach the store in an
order nothing constrains, and the older one landing last leaves the account
holding the superseded credential.
The page's marker cannot be what keeps that sequence out of reach, because it
only binds one page: a second tab, a second operator, or a direct API call is
subject to none of it, and the abort bound below releases the marker while the
server may still be exchanging. So the ordering is server-side. Each completion
holds PendingStore::lock_completion for its whole attempt → exchange → store
→ remove sequence, keyed by the pending key, so a second completion waits and
then finds the entry already consumed and fails closed with "start again"
(issue #440). The marker stays as what it always was locally — a refusal that
costs no round-trip — rather than the thing that orders the mutations.
The lock is bounded by the exchange it holds. COMPLETION_EXCHANGE_TIMEOUT caps
that upstream exchange at 30 seconds, so a hung provider releases the lock with a
502 instead of parking every later completion for that key behind it for as long
as the connection stays open. The pending entry survives the timeout — the attempt
still counts against MAX_PENDING_ATTEMPTS — but the authorization code may
already be spent upstream, so the recovery is a fresh start rather than re-posting
the same code.
start deliberately does not take that lock: blocking a start behind an
in-flight exchange would stall the operator for up to the completion's own
timeout, and two racing starts already fail closed on the state check, which
is why ordering them is tracked separately rather than here.
Nothing else may release the marker, so the completion request carries its own
120-second AbortController bound, cleared in a finally: a connection that
never settles must not close the button for the life of the page. Whether the
account was stored is unknown once a completion is abandoned, so that path
refreshes the tables and says so, rather than reporting a failure the server may
not agree with.
The marker is a per-page-load convenience, not an enforceable lock: it is a
let in the inline script, so reloading the dashboard clears it and permits the
same retry the bound does. Being page-local it also cannot see a second tab or a
direct API call. Its job is only to keep one page's own two clicks from racing;
ordering concurrent completions is the server's, through the per-pending-key
lock described above.
Both are deliberately confined to the managed store tables: the observed rows in the top-level Accounts and usage table are unchanged, since those credentials are owned and refreshed by the provider client itself and shunt never invokes a refresh/writeback store for them.
The optional plan field is derived from credential data already held by
shunt: Claude reads claudeAiOauth.subscriptionType, and Codex reads the
chatgpt_plan_type claim from its stored JWT. Whenever an imported Claude
credential's on-disk access token is still valid, the request also makes a
bounded GET /api/oauth/profile backfill and caches the result — this runs
even for an account whose file already carries a subscription type, since
that value alone carries no multiplier detail and the profile lookup can
refine it toward a more precise one, while never discarding it if the lookup
is coarser or fails; this backfill only ever reads a token already on disk,
it never refreshes and never writes back. An account whose on-disk token has
already expired keeps whatever plan (or lack of one) it already had until a
later view, once normal traffic elsewhere refreshes it; an idle account with
no traffic and no usage polling can stay at that same value indefinitely
while its on-disk token stays expired. Setup-token and token_env accounts
are not backfilled. A missing, failed, or unrecognized lookup leaves the
existing plan (or its absence) unaffected.
Budget exhaustion never erases a file-derived plan: the file-read phase is
guaranteed its own min_slice floor above the shared deadline, so an earlier
provider's stalled backfill can never starve this cheap local read. Even in
the pathological case where the file phase does not finish within that
floor, any plan already cached from an earlier resolution still appears in
the response.
Plans, tokens, and credential identities are tracked positionally against
the resolved account list, never keyed by display name.
resolve_pool_accounts appends the configured accounts after any scoped store
entries without deduplicating names, so one provider can legitimately resolve
two distinct accounts that share a label; a name-keyed map collapses them,
which would both show one account's subscription on the other and — because
the same map supplies the bearer token — probe one account with the other's
credential.
The profile cache is keyed by the credential's own identity where one exists.
An account whose credential file carries the shuntAccountUuid shunt's import
stamps in is keyed by that value: it survives a token refresh and changes when
the account is re-provisioned, so a resolved plan is held for 24 hours. The
file's uuid outranks a uuid carried in config, because the cached value is
the plan of whoever that file's token authenticates as, and the config field
never selects the file — the credential path is derived from credentials or
the account name. A config uuid, or the process-lifetime inline-identity memo
that fills it in when config omits it, can therefore still name the previous
occupant of a re-provisioned path; keying off it would file the new account's
plan under the old account's identity and serve the old plan back for the full
day. A config uuid is used only when the file yielded none. Only when no uuid
exists anywhere does the key fall back to the account's name, which is stable
but not unique over time — the same name may later belong to a different
account. Nothing in production
clears this cache, so that fallback caps its entries at 10 minutes instead,
which is also the retry interval for a failed lookup.
Two further rules follow from that fallback being a name rather than an identity. A name key shared by two accounts within one resolved list is ambiguous, not merely imprecise: the shared cache cannot separate them, so neither account reads or writes it and both are resolved fresh. And because a uuid-less account's identity lives only in its credential file, the uuid last seen at each credential path is memoized, so a resolution whose file phase timed out can still reconstruct the key and serve an already-cached plan. That memo is keyed by path rather than name for the same reason as everything else on this path: two accounts a name cannot separate still have distinct paths, and when they share a path they are reading one file and so share one identity.
That memo is consulted only when the file phase produced no result at all. A completed read is authoritative even when it finds no uuid — the account holding that path today has no identity, whatever an earlier one had — and such a read also clears the remembered value, so a later timeout cannot resurrect it. Every way a read can fail to produce a uuid clears it, not just the parses-but-carries-none case: a missing, unreadable, or unparsable file drops the entry too. The bias is deliberate. Clearing costs at most a fallback to the name key and its 10-minute ceiling, while keeping a no-longer-evidenced uuid risks serving the path's previous occupant's plan for the full exact-identity day.
More than the path memo goes stale on a failed read, so such an account does
not use the shared cache at all for that pass. Every identity still available
to it names a previous holder: account.uuid is filled by
resolve_pool_accounts from the very file that just failed to read and is
memoized for the process lifetime, so it outlives that credential; and the
name fallback is stable but not unique over time. There is nothing to write
either, since a failed read yields no token. So the plan is omitted for that
pass and reappears once the credential reads again — an honest gap rather
than another account's subscription.
A file that parses and merely carries no shuntAccountUuid is not a failed
read and none of this applies to it: that is an ordinary hand-placed
credential, keyed as it always was.
The batched read walks its candidates in order, so it can record one account's failure and then be abandoned mid-list when a later credential stalls past the deadline. The per-account failures are therefore tracked outside the result the timeout discards — a failure the read already observed is knowledge the request keeps, and dropping it would hand that account back the cache access this rule exists to withhold.
A failure also has to outlive the request that saw it, for the same reason
one step further out. Per-request failure state says nothing about the next
request, so a later one that times out would carry none and fall back to the
still-memoized uuid — restoring precisely the identity the failed read
refuted. The path memo therefore records a read failure rather than merely
dropping what it knew, and an account whose path is remembered that way stays
out of the cache on a timed-out pass too. Only a pass that reads the file
again can clear it: a timeout is not evidence that the path became readable.
The batched credential read is single-flight, process-wide. A read holds its
permit until it genuinely finishes, not until the request waiting on it gives
up, because a spawn_blocking task cannot be cancelled once started. A
credential file on a hung network or FUSE mount therefore leaks one blocking
worker rather than one per /admin/pool request: later requests fail to
acquire the permit, skip the file phase, and fall back to their cached plans.
The gateway-login milestone (Claude Code /login against shunt) is inbound and
separate, but should reuse rather than duplicate:
- the browser/admin session-auth layer — the
/deviceapproval page needs an authenticated human, the same session mechanism as/admin; - the server-rendered page + CSRF convention;
- the
[server.admin]opt-in surface — the gateway-login block can nest beside it; - the single-use, TTL-bound pending store — the device-flow "pending
authorization" is the same shape (
session::PendingStoreis written generically for this reuse).
- Unit: session/pending TTL + single-use + attempt cap, rate limiter, CSRF
accept/reject, constant-time admin auth, cookie
Secureloopback carve-out,AccountPool::snapshot,claude_store::list_account_meta/remove_account. - Integration (
tests/admin_surface.rs): the routes are absent without the block (404); API requires auth (401); setup-token mode keeps the legacy omitted-mode behavior and one-year exchange; full Claude OAuth requests the full scope, omits the expiry override, and persists a refreshable account; ChatGPT OAuth carries the Codex CLI authorize parameters, accepts both callback paste forms, uses a form-encoded exchange, persists verbatim auth.json, and appears in the pool; malformed or unknown modes and invalid account names fail without storing a file; missing refresh tokens fail closed; list/pool/response payloads never expose token material; cookie mutations without a CSRF token are rejected (403); fail-closed startup without the tokens env.