Skip to content

dashboard: take the key out of the URL in a browser - #561

Merged
Cloto-dev merged 2 commits into
masterfrom
browser-cookie-session
Sep 9, 2026
Merged

dashboard: take the key out of the URL in a browser#561
Cloto-dev merged 2 commits into
masterfrom
browser-cookie-session

Conversation

@Cloto-dev

@Cloto-dev Cloto-dev commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Completes the "no key in the URL" condition of the browser sign-in line. #559
gave the kernel a session cookie and #560 let the edge mint one; this makes the
dashboard actually use it.

The three places the key reached a URL

Not one leak but a class, because these requests are started by the browser
rather than by script and a URL carries no headers:

  1. the event streamuseEventStream built ?token=;
  2. assetswithToken() behind <img src>, <audio> and the VRM model;
  3. the avatar popup?key=, read back by VrmViewerPage.

A URL is also a history entry, a Referer, and a line in every proxy log
between the browser and the origin. The cookie is attached by the browser
itself to same-origin requests, including the ones markup starts, so nothing
has to put a credential in a URL and nothing does.

Tauri keeps the query parameter, deliberately

The desktop shell serves the SPA from a custom protocol, so its origin is not
the API's: a SameSite=Strict cookie the kernel sets is never sent from it, and
removing ?token= would break the event stream and every avatar there. Every
switch in this PR is isTauri and the reason is always this one.

The switches live in withToken, urlToken and openVrmWindowInner — one per
channel — rather than at the dozen call sites, because one call site would
forget.

Ordering, which is the subtle part

restoreBrowserSession() runs in bootstrap() before React mounts. That timing
is load-bearing rather than an optimisation: a child's effect runs before its
provider's, so a mint started from a provider effect happens after the consumers
have already asked whether a credential exists — and their effects do not re-run,
because the key they depend on never changed. The stream would then never open at
all. Restoring before React removes the question instead of racing it; the
provider effect still covers a key entered later.

Ending the session is folded into forgetApiKey for a related reason: a browser
that has dropped the key while still holding a live admin cookie has not signed
out, it has only lost the ability to say so.

The CORS question this closes

x-agent-token stays out of allow_headers, and the second commit writes down
why so the next reader does not "fix" it. An agent token names one agent and
carries that agent's capability gate; the browser signs in as the operator and
its tool calls are resolved inside the kernel. Adding the header would not
unblock a flow — it would declare that a page may hold an agent's credential.
Nothing is blocked by its absence today: the only caller presenting one is a
server-side process, and CORS constrains browsers.

Verification

Local gates: biome check src/, tsc --noEmit, vitest run (107 tests, up from
84), npm run build, plus cargo fmt, scripts/lint-rust.sh and
check-docs-facts.py for the kernel comment. The dashboard test-count claim in
docs/DEVELOPMENT.md is updated.

Mutation testing — 12/12 mutants behaved as expected. Every switch and every
piece of wiring was deleted or inverted in turn against the committed baseline:

mutant verdict first named test that went red
no-op (self-test, must survive) SURVIVED
asset URLs carry the token again in a browser CAUGHT never put the key in a URL, whichever builder or binding is used
asset URL mode inverted (browser yes, Tauri no) CAUGHT append the cache-buster with "&" after the token
event stream puts the token back in the URL CAUGHT opens with a bare URL once the session cookie is in place
event stream stops waiting for the cookie CAUGHT does not open the stream before there is a credential for it
avatar popup carries ?key= again CAUGHT opens a popup with no credential in its URL
provider stops minting when the key changes CAUGHT mints a session when a key is entered
forgetting the key no longer ends the session CAUGHT ends the session when the key is forgotten
boot-time restore mints nothing CAUGHT mints from the stored key before anything else asks
a refusal is cached forever instead of retried CAUGHT retries after a refusal instead of caching the "no" forever
sign-out never reaches the kernel CAUGHT tells the kernel and forgets the record
the mint stops sending credentials CAUGHT asks the kernel once and reports that it was granted

The first run of that harness was invalid and the self-test is what said so.
It passed --reporter=basic, which vitest 4 no longer has — it tried to load it
as a custom reporter module and every run exited non-zero regardless of the
mutation, so all twelve reported CAUGHT including the no-op. The numbers above
are from the fixed harness, whose checker was first validated against a known-good
tree (exit 0) and a known-bad one (exit 1).

Not in this PR

  • The measurement against a real browser and a real deployment — access log,
    Referer, history. The assertions here are that no builder or hook produces
    a URL carrying the key, which is a property of the code; confirming what a
    browser then does with it needs the kernel reachable, and that waits on the
    headless Linux gate.
    (Edited after merge: an earlier version of this note said the browser-mode
    "Browser access is deprecated" banner now points the opposite way and should be
    replaced. That was wrong and is withdrawn — this work makes the browser route
    usable, it does not narrow what the desktop app is for, and the two do not
    conflict. The banner is left alone.)

Three places put the admin key in a URL, because the requests that need it are
started by the browser rather than by script and a URL carries no headers: the
event stream, the asset builders behind `<img>`/`<audio>`/the VRM model, and the
avatar popup's `?key=`. A URL is also a history entry, a `Referer`, and a line in
every proxy log between the browser and the origin.

The kernel has taken a session cookie since #559. This makes the browser use it.
The browser attaches it by itself to same-origin requests, including the ones
markup starts, so nothing has to put a credential in a URL and nothing does.

Tauri keeps `?token=` and `?key=`, and that is not an oversight: the desktop
shell serves the SPA from a custom protocol, so its origin is not the API's, a
SameSite cookie is never sent, and removing the query parameter would break the
event stream and every avatar there. Each switch is `isTauri` and the reason is
always this one. The switches sit in `withToken`, `urlToken` and
`openVrmWindowInner` rather than at the dozen call sites, because one call site
would forget.

`restoreBrowserSession` runs before React mounts, and the timing is the point: a
child's effect runs before its provider's, so a mint started from a provider
effect would happen after the consumers have already asked whether a credential
exists — and their effects do not re-run, because the key they depend on never
changed. Ending the session is folded into forgetting the key for the same
reason: a browser that has dropped the key while still holding a live admin
cookie has not signed out, it has only lost the ability to say so.
The CORS allowlist has no `x-agent-token`, and a reader hitting a CORS error
from a future browser-side tool call would reasonably read that as an omission
and add it. It is not: an agent token names one agent and carries that agent's
capability gate, while the browser signs in as the operator. Adding the header
would not unblock a flow, it would declare that a page may hold an agent's
credential.

Nothing is blocked by its absence today — the only caller presenting one is a
server-side process, and CORS constrains browsers.
@Cloto-dev
Cloto-dev merged commit bbe8c5c into master Sep 9, 2026
16 checks passed
@Cloto-dev
Cloto-dev deleted the browser-cookie-session branch September 9, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant