Skip to content

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads - #44529

Merged
OutThisLife merged 6 commits into
mainfrom
bb/desktop-profile-fallout
Jun 12, 2026
Merged

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads#44529
OutThisLife merged 6 commits into
mainfrom
bb/desktop-profile-fallout

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Consolidates the remaining desktop multi-profile / launch-reliability fixes into one reviewable PR.

Closes #44185. Closes #43114. Supersedes #44516 (already closed) and #43720 via the same commit.

What does this PR do?

Four commits, contributor authorship preserved via cherry-pick:

  1. fix(desktop): use served dashboard token for websocket auth (@jeffrobodie-glitch, from Fix desktop WebSocket auth with served dashboard token #43720 / fix(desktop): use served dashboard token for websocket auth #44516) — after backend readiness, read the window.__HERMES_SESSION_TOKEN__ the dashboard actually serves and use it for renderer state + /api/ws URLs, falling back to the spawn-time token. Fixes the Windows failure where /api/status returned 200 but the WS handshake was rejected.
  2. fix(desktop): route profile session reads (@Evisolpxe, from [codex] fix desktop profile session reads #44185) — non-default-profile sessions 404'd because several desktop entry points (session picker, command palette, archived-sessions settings, artifacts scan, export) still used the legacy single-profile /api/sessions list or dropped the owning profile before reading messages. Now they use listAllProfileSessions and thread ?profile= through message reads/export, plus lineage-root-id matching when resolving/deleting/archiving rows. Rebased over main's newer applyRuntimeInfo / applyStoredSessionPreviewRuntimeInfo refactor.
  3. fix(desktop): refuse a foreign backend's session token after readiness (new) — hardens (1) so it cannot silently authenticate against a backend we didn't spawn (details below).
  4. fix(desktop): prevent backend port-squat boot loop and pickPort self-collision (@bionicbutterfly13 / Mani Saint-Victor, from fix(desktop): prevent backend port-squat boot loop and pickPort self-collision #43114) — two launch-reliability fixes: the hermes:bootstrap:reset ("Reload and retry") handler now awaits teardownPrimaryBackendAndWait() instead of leaking a live backend that squats PORT_FLOOR; and pickPort()'s probe-then-bind TOCTOU window is closed with an in-process PortPool reservation held until the child exits (released on every exit/error/throw path). Rebased over current main; package.json test line re-merged.

(3) and (4) are complementary halves of the same failure: (4) prevents orphaned/raced backends from squatting ports, (3) detects the survivor case and refuses to authenticate against it.

Why the hardening in (3)

The desktop's readiness probe is waitForHermes → GET /api/status, which is public (hermes_cli/web_server.py:1520, no _require_token). So readiness passes even when the port is served by a process we did not spawn — e.g. an orphaned dashboard that won the bind race while our child died on the conflict. Commit (1) alone would adopt that stranger's token and silently connect, possibly to the wrong profile.

The fix discriminates on child liveness: the desktop pins HERMES_DASHBOARD_SESSION_TOKEN on every spawn and the server honors it at import (web_server.py:185), so a live child always serves our token.

Served token Our child Verdict
matches spawn token any ours — proceed
differs alive ours, env pin lost across spawn (e.g. shell-wrapped CLI shim) — adopt served token, log
differs dead foreign backend squatting the port — fail boot loudly, refuse the token

Implemented as a pure isForeignBackendToken() in dashboard-token.cjs, applied at both local spawn sites (startHermes + spawnPoolBackend), with unit tests for all four rows. The foreign-backend throw paths are reservation-leak-free: the child's exit handler has already released the PortPool slot, and release() is idempotent.

Timeline of the fallout (for reviewers)

With #44510 + #44512 on main and this PR, the known desktop multi-profile fallout is closed out: per-profile backends boot, ports aren't leaked or raced, MCP tools load, WS auth is trustworthy (and refuses impostors), and cross-profile sessions list/read/resume/export correctly.

Validation

  • Premise checks against backend: /api/profiles/sessions exists (web_server.py:2539); /api/sessions/{id}/messages accepts ?profile= (web_server.py:6333); _SESSION_TOKEN honors the spawn env (web_server.py:185).
  • apps/desktop: npm run test:desktop:platforms165 passed / 1 skipped / 0 failed (includes 13 dashboard-token tests — 4 new — and the 8 port-pool tests).
  • apps/desktop: npm run test:ui -- src/hermes.test.ts → 3/3 (includes the new cross-profile message-read routing test).
  • Full npm run test:ui: 570 passed / 7 failed — the 7 failures reproduce identically on clean origin/main (verified in a detached worktree); all in files this PR does not touch.
  • tsc -p . --noEmit → clean.
  • Scoped ESLint on changed files → no new problems (1 error + 6 warnings pre-exist on main, verified against main's copies).
  • Post-merge integration verified by reading both spawn sites: PortPool releases fire on the same exit/error handlers my foreign-token check relies on; startHermes's nulled hermesProcess is treated as child-dead.

Credit

@jeffrobodie-glitch & @lEWFkRAD (#43720), @Evisolpxe (#44185), @bionicbutterfly13 / Mani Saint-Victor (#43114), @aj47 (#44478). Original reviews by @austinpickett (#43720) and @liuhao1024 (#43114).

Real-world repro (live backend, not mocks)

Booted the actual hermes_cli/web_server.py under uvicorn (temp HERMES_HOME, pinned HERMES_DASHBOARD_SESSION_TOKEN) and drove the production dashboard-token.cjs against it over real HTTP:

  1. Injection-format contract — the extraction regex parses the token out of the genuinely served index.html (the raw f-string injection at web_server.py:10458, which no unit test exercises).
  2. The bug, reproduced & refused — dead child + a live foreign backend answering on the port: pre-fix behavior silently adopted the squatter's token; adoptServedDashboardToken now throws …served by a process we did not spawn; refusing its session token.
  3. Fix desktop WebSocket auth with served dashboard token #43720 benign drift — live child serving a regenerated token (env pin lost): served token adopted, drift logged.
  4. Premise confirmed live/api/status answers with no token (readiness really is a false positive against foreign processes), and the API genuinely rejects the stale spawn token (401) while accepting the served one (200) on /api/sessions.

Also folded the duplicated resolve→check→throw dance at both spawn sites into one adoptServedDashboardToken() helper (childAlive is a thunk, sampled post-fetch) — cc726aa.

jeffrobodie-glitch and others added 3 commits June 11, 2026 18:07
(cherry picked from commit f8209f9)
(cherry picked from commit 72290f0)
The served-token fallback adopts whatever token the dashboard HTML
injects. That is correct when our own child regenerated the token (env
pin lost across a shell-wrapped spawn), but wrong when the readiness
probe answered from a process we did not spawn: /api/status is public,
so an orphaned dashboard squatting the port passes waitForHermes while
our child dies on the bind conflict. Silently adopting that process's
token would authenticate the renderer against a foreign backend,
possibly on the wrong profile.

Discriminate on child liveness: the desktop pins
HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always
serves our token. Served-token mismatch + dead child = foreign backend;
fail the boot loudly instead of connecting. Mismatch + live child keeps
the adopt-served-token salvage from #43720.
@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/desktop-profile-fallout vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10764 on HEAD, 10764 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5637 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

…collision

Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module:

1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting.

2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window.

PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script.

(cherry picked from commit d413394)
…al into one helper

Both spawn paths (startHermes, spawnPoolBackend) duplicated the same
resolve -> log-fallback -> foreign-check -> throw dance. Collapse it into
adoptServedDashboardToken(baseUrl, spawnToken, {childAlive, label}) in
dashboard-token.cjs; childAlive is a thunk so liveness is sampled after
the fetch. Drop the redundant backendPool.delete in the pool's throw
path (the child exit/error handlers already own pool eviction).

Validated end-to-end against a real web_server.py backend, not just
units: token-injection regex vs the actual served index.html, foreign
refusal (dead child + live squatter), benign drift adoption, and the
401-vs-200 token auth split on /api/sessions.
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have labels Jun 11, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator Author

How the repro was done (live backend, production code paths)

No mocks anywhere in the loop — the real hermes_cli/web_server.py on one side, the real apps/desktop/electron/dashboard-token.cjs on the other, talking over actual HTTP.

1. Boot a genuine backend acting as the "squatter":

# /tmp/hermes-e2e/serve.py
import os, sys
os.environ["HERMES_HOME"] = "/tmp/hermes-e2e/home"          # temp, isolated
os.environ["HERMES_WEB_DIST"] = "/tmp/hermes-e2e/dist"      # minimal index.html + empty assets/
os.environ["HERMES_DASHBOARD_SESSION_TOKEN"] = sys.argv[2]  # the squatter's pinned token
sys.path.insert(0, "<repo>")
from hermes_cli.web_server import start_server
start_server(host="127.0.0.1", port=int(sys.argv[1]), open_browser=False)
python /tmp/hermes-e2e/serve.py 9777 squatter-token-AAAA

This is the exact process shape of the bug: a live dashboard on a port the desktop thinks it owns, serving a token the desktop did not mint.

2. Drive the production electron module against it (node, requiring dashboard-token.cjs directly):

# Scenario Result
1 Extraction regex vs the genuinely served index.html — the injection is a raw f-string (web_server.py:10458), not json.dumps; no unit test covers this contract regex extracts squatter-token-AAAA
2 The bug: childAlive: () => false + foreign live backend — pre-fix behavior silently adopted the squatter's token adoptServedDashboardToken throws …served by a process we did not spawn; refusing its session token
3 #43720 benign drift: childAlive: () => true, spawn pin differs from served token (env pin lost in spawn) served token adopted, drift logged ✅
4 Happy path: pin survived, tokens match accepted regardless of liveness ✅
5 Auth consequence is real, not theoretical GET /api/sessions with the stale spawn token → 401; with the served token → 200

3. Premise independently confirmed live: curl http://127.0.0.1:9777/api/status with no token returns full status JSON — so waitForHermes's readiness probe genuinely cannot distinguish our child from a foreign process. That's the false-positive window the foreign-token check closes.

All 5 passed. Unit suites on top: 13/13 dashboard-token tests, 165 platform tests, scoped eslint clean.

Node >=18 / Electron 40 ship fetch; the hand-rolled http/https.request
plumbing buys nothing. AbortSignal.timeout replaces the socket timeout,
protocol guard and >=400 rejection semantics preserved. 13/13 unit
tests and the live web_server.py repro both green over the new
transport.

@ethernet8023 ethernet8023 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@OutThisLife
OutThisLife merged commit 880107a into main Jun 12, 2026
25 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-profile-fallout branch June 12, 2026 00:06
alt-glitch pushed a commit that referenced this pull request Jun 14, 2026
fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
AIalliAI pushed a commit to AIalliAI/Hermes that referenced this pull request Jun 14, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
T02200059 pushed a commit to T02200059/hermes-agent that referenced this pull request Jun 18, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants