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 token for API/curl calls
header = "x-shunt-admin-token"
# env var holding admin credentials as name:token pairs (SEPARATE from
# [server.auth] client tokens)
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 backexport 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 tokens add upstream accounts. Configuration validation is
fail-closed — a present [server.admin] whose token source is unset, empty, or
malformed is a startup error, never a silently-open admin surface (identical
discipline to [server.auth]).
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.
[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 loopback HTTP only. 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 token/header edits 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 token → 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). - API/curl: send the admin token in the configured header
(
x-shunt-admin-token). Header-token callers carry no ambient cookie and are therefore CSRF-exempt. - 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. - 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 (an emptySHUNT_ADMIN_TOKENSfails closed at startup). Rejecting stale sessions on reload is tracked in #100.
| 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 managed-pool state |
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 |
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.
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 or non-HTTPS/non-loopback override is ignored with a warning (mirroring the
Claude completion flow) instead of silently, and the exchange POST uses the
redirect-hardened client so a permitted endpoint cannot 3xx the single-use code to
an unsafe plaintext 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. 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 row is the
one exception to the identity masking above: it carries the account uuid so
the table can tell an observation and a managed pool account holding the same
subscription apart from two genuinely different accounts. The value is already
returned unmasked by GET /admin/accounts 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 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.
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.
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, 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). AccountPool tracks no sticky flag or last-selected
timestamp, so the dashboard reports what is actually stored rather than inventing
it. 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 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.