Skip to content

fix(web): the console switcher carries no token and sits beside the chip (#815 review) - #816

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/profile-switcher-no-tokens
Sep 25, 2026
Merged

eaitbrahim merged 1 commit into
mainfrom
fix/profile-switcher-no-tokens

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Follow-up to #815 (merged, not yet released), from its review. Refs #814.

What was wrong in #815

  1. Session tokens reached the page. live_peers returned url_for(record), which is http://host:port/?token=…, for every running console, and /api/config carried the list into <option value>. So the paper console's page held the live console's session token, and its own. That breaks security.py ("HttpOnly and must never be written into the page"). On a tunnelled deployment it would also have shipped every local token to the remote viewer.
  2. The dropdown was always empty. el(tag, className, text) has no children form, so el("select", …, ...options) set the select's text to "[object HTMLOptionElement]" and dropped every option.
  3. A console with no record could name the wrong console. A console started from a terminal writes no record. It then got a select with no current entry, and once item 2 was fixed the browser would pre-select the first option, which is another console's name.
  4. Each console's mode was frozen at start-up. It went stale after a config edit, contradicting _auto_trade_mode's rule of re-reading the config on every request.
  5. It turned the session chip into a dropdown, which [web] Session identity: profile · mode · equity-state switcher chip and a no-CTA paper banner #704 explicitly forbids. It only got past test_neither_the_chip_nor_the_banner_builds_anything_clickable because select isn't on that test's token list.

The fix

  • No token on the wire. payload.profile_switcher sends {switchable, choices: [{label, href}], display}. Each href is a token-free /switch/<port> on the current console.
  • GET /switch/<port>. This goes through normal admission, then a locality check, then reads the peer's record at click time. It answers 303 to the peer's own ?token= hand-off, which is the route keel open already uses. No page script can read a redirect's Location header.
    • Refusals: no session gives 403; a remote deployment or non-loopback peer gives 403; an unknown, dead or malformed port gives 404; this console's own port redirects to /.
  • One locality rule. security.local_deployment is split out of gated_action_permitted, so the switcher offer and the switch route can't disagree about what "local" means.
  • Service worker. sw.js now sends /switch/ navigations to the network. Otherwise the cached shell would answer them and switching would silently do nothing.
  • A separate control. #console-switcher sits beside the chip. It's a select with a disabled "switch console…" placeholder, listing the other consoles.
  • Smaller fixes.
    • This console is listed from its own arguments, never from a record.
    • Labels carry no mode, and records no longer store one.
    • serve now calls api._profile_name instead of an inline copy of it.

Verification

  • tests/web/test_profile_switcher.py is rewritten (23 tests). The old version asserted the token was present in the URL.
  • Six mutants were each killed: spreading children into el, dropping the locality check, removing the service-worker guard, listing this console as a choice, offering switching on a remote deployment, and an extra payload field.
  • Full suite: 6711 passed, 3 skipped. ruff check, ruff format --check and mypy are clean.
  • Manual check in Chromium. I ran two detached consoles in a throwaway KEEL_HOME (keel-live and keel-paper) with the service worker controlling the page.
    • The chip shows the profile as plain text, and the switcher lists the other console.
    • Switching paper → live → paper works both ways: each lands authenticated (/api/config 200) with no token left in the URL.
    • /api/config contains no token=.
  • Known limit: a console started after the page loaded appears on the next reload. Following a stale entry returns a 404 that asks you to reload.

Note: main currently has a JS syntax error from #810 (a duplicate const note in render.js), so every console built from main is blank. It's fixed separately. For the manual check above I applied that rename locally; it is not in this PR.

🤖 Generated with Claude Code

…hip (#815 review)

#815 put every running console's `http://.../?token=` into /api/config
and into <option value>: the paper console's page held the live
console's session token, and its own, which security.py says must never
be written into the page. It also rendered an empty select
(`el()` has no children form), named a different console as this one
when this console had no record, showed a start-up mode that goes
stale, and made the session chip a dropdown, which #704 forbids.

- The page gets {label, href} per OTHER console; href is a token-free
  /switch/<port> on this console. server._switch reads the peer's
  record when followed and 303s into the peer's own ?token= hand-off,
  so only the browser's navigation carries the token (as `keel open`
  does). Local only: security.local_deployment, shared with
  gated_action_permitted, plus a loopback peer.
- sw.js declines /switch/ navigations to the network; answered from the
  cached shell they would silently do nothing.
- The switcher is its own control, #console-switcher, beside the chip.
  sessionChip is back to its pre-#815 code and still passes #704's
  no-interactive-node rule; the new control has a matching rule.
- This console is never read from a record; no mode in labels; the
  record no longer stores one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim eaitbrahim added the fix Bug fix (groups under Fixes) label Sep 24, 2026
@eaitbrahim
eaitbrahim merged commit 42aa56a into main Sep 25, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/profile-switcher-no-tokens branch September 25, 2026 06:26
eaitbrahim added a commit that referenced this pull request Sep 25, 2026
…e switcher with no tokens (#818)

MINOR: #810 and #814/#816 add console features. No schema change since
0.16.0, so a deployment needs no `keel migrate` step beyond the usual
idempotent run.

What lands:

  #807 -- a quote-increment cache record predating quote_increment is a
  MISS, not an "unknown".
  #810 -- the refused view says how to get the address it asks you to
  paste; #817 fixes its duplicate `const note`, which blanked the console.
  #813 (#812) -- the cycle line ends `vetoed=N (rail, ...)`, so a rail
  veto no longer reads as a silent drop.
  #815 + #816 (#814) -- a console switcher beside the session chip.
  /api/config carries no session token; /switch/<port> redirects into the
  chosen console's own hand-off, local deployments only.

Eight workspace versions and the inter-package `==` pins move together,
plus the refreshed uv.lock the release workflow checks first.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (groups under Fixes)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant