Skip to content

fix(desktop): don't let stale .env token clobber desktop-injected session token - #76958

Open
andrexibiza wants to merge 8 commits into
NousResearch:mainfrom
andrexibiza:fix/desktop-env-session-token
Open

fix(desktop): don't let stale .env token clobber desktop-injected session token#76958
andrexibiza wants to merge 8 commits into
NousResearch:mainfrom
andrexibiza:fix/desktop-env-session-token

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Related #40680 #50737 #54034 #55790 #56974 #66120 #66223 #67305 #73599 #73628 #74563 #74603

What

Closes the entire bug class that locked Hermes Desktop out for over an hour: a stale HERMES_DASHBOARD_SESSION_TOKEN persisted in the user .env silently clobbers the fresh token the Electron app injects into the spawned hermes serve process. env_loader.py loads .env with override=True, so the stale value wins before web_server._SESSION_TOKEN resolves → every WS handshake is rejected (token_mismatch → HTTP 403) → the desktop retries the identical failing boot for hours with no actionable message.

One PR, five layers, all verified against current main:

Layer Change
1. env_loader HERMES_DASHBOARD_SESSION_TOKEN preservation now lives in _load_dotenv_with_fallback() itself, gated on HERMES_DESKTOP == '1'every override=True load path (user env, project env, managed env, hot-reload re-entry) preserves the Electron-injected token. Unmarked shell exports keep the documented .env-overrides-shell rule; no injected token → .env value still loads.
2. web_server diagnostics Token provenance logged at startup (source=injected|env|generated length=N — never the value). A loopback token_mismatch with an env-sourced server token now WARNs naming the exact fix (stale HERMES_DASHBOARD_SESSION_TOKEN in <home>/.env — remove the line or run hermes setup). The hint fires for injected too: under the desktop marker, a mismatch IS the clobber signature. Auth semantics untouched.
3. headless 404 The known-benign headless-serve 404 (web UI disabled) is recognized by isHeadlessServeResponse() and returns the spawn token with an info note instead of a boot error — killing the misleading could not read served dashboard token line that masked the real failure on every launch.
4. boot loop WS session-token rejections are bounded (3 consecutive, createTokenRejectionRetryGuard) — the silent hour-long retry loop stops and the existing failure overlay names the .env key + resolved path + fix. Non-token failures keep the existing retry/repair behavior.
5. regression harness Real hermes serve subprocess + temp HERMES_HOME with a stale .env token: injected token ACCEPTED, stale REJECTED. RED on origin/main (proven: fresh → 403, stale → gateway.ready), GREEN on this branch. The harness keeps the class closed on CI.

Dedup note — supersedes the token-preservation portion of #66120 (authored by @PabloTheThinker): #66120 adds the same save/restore intent gated on HERMES_DESKTOP=1, but (a) covers only the user-env load plus a re-apply — the project-env override=True path stays exposed; (b) ships zero regression tests (sweeper-flagged). This PR covers all load paths at the low-level loader and adds the missing tests. Credit for identifying the seam goes to @PabloTheThinker. If this merges first, #66120's env_loader hunk becomes redundant.

Related PRs (same bug class / same files — read before merging)

PR Relation Composition
#66120 — "preserve Desktop session token" (@PabloTheThinker) Superseded for the env_loader portion (see dedup note above). Its other work (session body-drop split) is unrelated and untouched. If this PR merges first, #66120's env_loader hunk becomes redundant and should be dropped.
#54034 — "persist session token across daemon restarts" (@Kewe63) Overlapping — changes the same _SESSION_TOKEN resolve line in hermes_cli/web_server.py in the opposite direction (persistence; sweeper keep_open salvageability=medium with an unresolved security-boundary decision). Composes: this PR's provenance classifier is a pure os.environ read that classifies whatever value lands in _SESSION_TOKEN (env-set → injected/env; file-only → generated), so it works identically with or without persistence. Both touch web_server.py — whitespace-only merge-order concern.
#74603 — "make backend serve-vs-dashboard resolution deterministic (#74563)" (@Ahmett101) Overlapping file — also edits apps/desktop/electron/main.ts, but the probe path (~1648 unwrapWindowsVenvHermesCommand, ~7599 spawnPoolBackend, ~7856 startHermes). This PR edits the failure-latch path (~8292/8628/8671/9742) plus backend-start-failure.ts, which #74603 does not touch. Composes — disjoint regions of the same file; the second to merge needs only a trivial rebase.
#73628 — "show banner when stale session token forces dashboard reload" (@iap) Complementary — the web-dashboard (SPA) side of the same stale-token class (web/src/lib/api.ts only); this PR is the desktop-spawn/backend side. No file overlap; both should land.
#67305 — "isolate internal websocket capabilities" (@StellarisW) Adjacent — also touches hermes_cli/web_server.py + tools/environments/local.py (WS capability isolation vs token diagnostics). Composes — different regions; verify at merge time.

Related issues: #40680 (this PR's Fixes target — stale-token poisoning across local/remote mode transitions; grouped facets #50737/#55790/#56974), #74563 (runtime resolution inconsistency behind #74603), #73599 (stale session token → silent dashboard reload), #66223 (dashboard loopback 401 with valid token).

Reproduction (current vs expected)

Current (pre-fix) on main: with a stale HERMES_DASHBOARD_SESSION_TOKEN=old-token line in ~/.hermes/.env:

  1. Launch Hermes Desktop (spawns hermes serve with a fresh injected token + HERMES_DESKTOP=1).
  2. env_loader.py loads .env with override=True → the stale value replaces the injected token before web_server._SESSION_TOKEN resolves.
  3. Desktop connects to /api/ws with its fresh token → hmac.compare_digest fails → HTTP 403 → boot fails → retry loop repeats for ~1h.
  4. Log evidence: console auth rejected reason=token_mismatch mode=loopback; desktop.log: could not read served dashboard token (Hermes backend): 404: Headless backend... then WebSocket (/api/ws) rejected the session token — repeated ~7×.

Expected (post-fix): the desktop-injected token always wins when HERMES_DESKTOP=1; the WS handshake succeeds; a stale .env line is inert. If a mismatch still occurs, the backend log says where its token came from, the boot loop stops after 3 attempts, and the failure names the exact file/line to fix.

How to test

# Layer 1 + 5: env-loader unit suite + the class regression harness
cd <checkout> && <venv-python> -m pytest tests/hermes_cli/test_env_loader.py tests/hermes_cli/test_desktop_session_token_class.py -q --no-header -p no:cacheprovider
#   env_loader: 24 passed (21 lane tests + base) · harness: RED on main, GREEN here

# Layer 2: provenance + hint
<venv-python> -m pytest tests/hermes_cli/test_web_server.py tests/hermes_cli/test_web_server_console_ws.py -q --no-header -p no:cacheprovider -k 'TokenMismatchHint or SessionTokenSource'
#   10 passed (hint fires for injected+env+generated, loopback-gated)

# Layers 3+4: desktop TS suites + typecheck
cd apps/desktop && npx vitest run electron/dashboard-token.test.ts electron/backend-start-failure.test.ts   # 30 passed
npx tsc -p tsconfig.electron.json --noEmit

# Cross-check probe (machine-readable; RED on origin/main, GREEN here):
<venv-python> C:/tmp/probe_token_class.py <checkout> <venv-python>
#   main:     fresh-token-accepted FAIL / stale-token-rejected FAIL (0/2)
#   this PR:  fresh-token-accepted PASS / stale-token-rejected PASS (2/2)

Platforms tested

  • Windows 11 (git-bash), Python 3.12 — env_loader 24/24 via scripts/run_tests.sh (CI-parity runner), web_server targeted suites green (10/10 new tests; the 34-runner baseline failures reproduce identically on pristine origin/main — proven by file-swap stash-test, zero overlap with this diff), vitest 30/30, tsc clean, git diff --check clean, scripts/check-windows-footguns.py clean on all changed files.
  • Changes are platform-neutral (pure os.environ save/restore; pure TS helper logic; harness spawns real subprocess).

Why this matters to users

Before: any stale HERMES_DASHBOARD_SESSION_TOKEN left in .env (e.g. from a prior remote-gateway setup, #40680, or an old update) silently poisoned desktop startup — the app retried the identical failure for hours with a generic WebSocket error, the log pointed at a misleading headless-404 line, and the only remedy was manually deleting a line from a file most users never open. After: the desktop-injected per-launch token always wins, a mismatch tells you exactly which file and key to remove (or just run hermes setup), the retry loop stops after 3 attempts instead of an hour, headless launches log cleanly, and the CI harness makes the whole class un-regressable.

Fixes #40680

Part of #78914
Part of #78915

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tracing the desktop-to-gateway token path; current main still has the reported overwrite path.

Problems

  • hermes_cli/env_loader.py:494 treats every inherited HERMES_DASHBOARD_SESSION_TOKEN as Electron-injected. That changes the documented loader rule that a profile .env overrides stale shell exports (current main hermes_cli/env_loader.py:470). Electron already supplies an authoritative discriminator: both spawn paths set HERMES_DESKTOP='1' beside the fresh token (apps/desktop/electron/main.ts:8199-8202, :8492-8495).

Suggested changes

  • Preserve the token only when HERMES_DESKTOP == '1' before the dotenv load. Add the marker to the positive cases and cover an unmarked pre-existing token being overridden by .env.

Automated hermes-sweeper review.

Comment thread hermes_cli/env_loader.py Outdated
# clobber the injected token, breaking the desktop↔gateway WebSocket
# auth handshake. Save/restore it around the .env load so the injected
# value (if present) always wins.
_desktop_session_token = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This cannot distinguish the Electron spawn credential from an ordinary shell export. Current loader semantics explicitly let the profile .env override stale shell values (env_loader.py:470), while both Electron spawn paths set HERMES_DESKTOP='1' beside this token (apps/desktop/electron/main.ts:8199-8202, :8492-8495). Snapshot and restore only when that pre-load marker is "1", then add an unmarked-shell-token regression case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8476064508 — the desktop session-token save/restore is gated on HERMES_DESKTOP='1' (hermes_cli/env_loader.py:488-493), so unmarked shell exports keep the documented loader semantics and only the desktop-injected token path gets the preserve behavior. Verified at head 8476064508: 21/21 env_loader tests pass.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Agreed — the unconditional save/restore changed the documented loader rule for non-desktop runs. Fixed in 809e299aba.

Change: load_hermes_dotenv() now preserves the inherited HERMES_DASHBOARD_SESSION_TOKEN only when HERMES_DESKTOP == "1" — the marker Electron sets beside the fresh token on both spawn paths (apps/desktop/electron/main.ts:8199-8202 and :8492-8495). An unmarked shell-exported token keeps the existing semantics: the profile .env overrides it with override=True.

Tests (tests/hermes_cli/test_env_loader.py, 16 passed):

  • test_desktop_injected_session_token_survives_dotenv_loadHERMES_DESKTOP=1 + injected token → injected value survives a stale .env entry for the same key.
  • test_unmarked_inherited_token_is_overridden_by_dotenv — token exported without the marker → .env value wins, preserving the documented rule you cited.
  • test_session_token_from_dotenv_when_not_injected — unchanged: no injected token → .env value reaches os.environ (CLI/headless path).
  • test_desktop_token_preservation_does_not_affect_other_keys — blast radius still locked to the one key; other keys keep override=True.

Validation: scripts/run_tests.sh tests/hermes_cli/test_env_loader.py → 16/16 pass, no flaky section; git diff --check clean; python scripts/check-windows-footguns.py clean on both files.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users area/sessions Session lifecycle, resume, persistence, history labels Aug 2, 2026
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/config Config system, migrations, profiles labels Aug 2, 2026
…sion token

The desktop app injects HERMES_DASHBOARD_SESSION_TOKEN into the spawned
hermes serve process so it can authenticate its WebSocket probe. The
user .env may contain a stale persisted value for the same key, and
load_hermes_dotenv() loads it with override=True, silently replacing
the injected token. The gateway then rejects the desktop's WS
handshake with 403 ("websocket connection failed").

Save/restore the injected token around the .env load so the spawn-time
value wins when present. Other keys keep existing override semantics.
Review follow-up: unconditionally preserving an inherited
HERMES_DASHBOARD_SESSION_TOKEN changed the documented loader rule that
a profile .env overrides stale shell exports. Electron pairs the
injected token with HERMES_DESKTOP='1' on both spawn paths
(apps/desktop/electron/main.ts), so gate the save/restore on that
marker. An unmarked shell-exported token keeps the existing override
semantics.
@andrexibiza
andrexibiza force-pushed the fix/desktop-env-session-token branch from 809e299 to 8476064 Compare August 3, 2026 01:35
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Review receipt — comment from 2026-08-02 on hermes_cli/env_loader.py (session-token preservation):

Addressed in 8476064 — the HERMES_DASHBOARD_SESSION_TOKEN save/restore is now gated on HERMES_DESKTOP=='1' (both Electron spawn paths in apps/desktop/electron/main.ts set the marker beside the token), so an unmarked shell export keeps the documented '.env overrides stale shell exports' rule exactly as requested.

Regression coverage added per the review: test_desktop_injected_session_token_survives_dotenv_load (marked path) + test_session_token_from_dotenv_when_not_injected (unmarked path). 21/21 env_loader tests pass.

…paths

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
…atch

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
The provenance classifier labels the stale-.env clobber state 'injected'
(HERMES_DESKTOP=1 survives the override while the token is replaced), so
the old early-return suppressed the actionable hint on the exact lockout
scenario it was built for. A loopback mismatch with an env-sourced server
token IS the signature — a genuinely adopted injection would have matched
and never reached the hint. Fire for injected+env alike; 'generated'
keeps its restart wording.

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
… on WS rejection

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Consolidated class close — validation receipt for the full 5-layer PR (head f435f8b, 8 commits):

Sweeper point addressed: the original keep_open point (unconditional save/restore changed the documented .env-overrides-shell rule) is fixed at 8476064 — preservation is gated on HERMES_DESKTOP=='1', pinned by test_unmarked_inherited_token_is_overridden_by_dotenv (a shell-exported token WITHOUT the marker is still overridden by .env).

Layer validation (all on this head):

  • env_loader: 24/24 via scripts/run_tests.sh (21 lane tests + base); guard lives in _load_dotenv_with_fallback() so user/project/managed/hot-reload override=True paths are all covered.
  • web_server diagnostics: 10/10 new tests (provenance classifier + mismatch hint; hint fires for injected+env+generated sources, loopback-gated). The 34-failure run_tests.sh baseline on test_web_server.py reproduces IDENTICALLY on pristine origin/main (file-swap stash-test) — zero overlap with this diff.
  • dashboard-token.ts headless-404: vitest 18/18.
  • boot-loop bound (N=3) + .env hint: vitest 12/12 + tsc clean.
  • Class regression harness (real serve spawn, stale .env): RED on origin/main (fresh→403, stale→gateway.ready), GREEN on this head (fresh→accepted, stale→403) — probe evidence: probe_token_class.py 2/2 PASS.

git diff --check clean; check-windows-footguns clean; all commits DCO-signed with the exact noreply email.

Related-PR coordination (comments posted): #66120 (env_loader hunk superseded here — credit to @PabloTheThinker in the body; haptic fix + drag-drop split are theirs to land), #54034 (composes; security-boundary decision + tmp-file race fixes listed), #74603 (disjoint main.ts regions; 4 fix points listed).

@wolfyy970

Copy link
Copy Markdown

Reproduced this on current main (v0.20.5, 261a4efb) on macOS.

In my case it showed up through Desktop group bots rather than the primary boot screen. The same Hermes host is used locally and from another Mac over SSH. Three profile backends appeared in the room but stopped responding because each profile had a stale HERMES_DASHBOARD_SESSION_TOKEN in its .env.

/api/status returned 200 while /api/ws rejected the token with 403. desktop.log reported that the backend was HTTP-reachable but the WebSocket session token was rejected.

I tested the same precedence fix locally. With a stale token in a temporary profile .env, a different process token, and HERMES_DESKTOP=1, the backend returned HTTP 200 and accepted the process token over WebSocket. The non-Desktop .env precedence remains unchanged. The relevant test set passed: 109 tests.

This confirms the bug also affects pooled per-profile backends and that switching between local Desktop and SSH-connected Desktop is a real way to hit it.

andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 22, 2026
SHA-gated, fork-local executor for rebuilding PR NousResearch#76958 on exact upstream main. Remove after publication.
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 22, 2026
Use the fork’s path-gated push executor to rebuild and verify the complete PR NousResearch#76958 product delta on exact upstream main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop local mode can inherit stale dashboard token after prior remote gateway setup

4 participants