Skip to content

fix: dashboard-auth session revocation, .env lost-update race, and WS reattach-race test gap - #77174

Closed
JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/dashboard-auth-races-and-lost-updates
Closed

fix: dashboard-auth session revocation, .env lost-update race, and WS reattach-race test gap#77174
JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/dashboard-auth-races-and-lost-updates

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes four confirmed bugs and one test-coverage gap found during a deep
race/auth/consistency investigation, each tracked as its own issue with
full root-cause analysis. Brings in an already-authored WS TOCTOU fix
that turned out to exist only on an unrelated, unmerged branch.

Closes #77186, #77187, #77190, #77191, #77192.

#77188 (RAH-03: credential lifecycle across
.env/auth.json/cache/config.yaml isn't transactional) is
intentionally not included here — larger blast radius, left open as
follow-up work.

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#00f0ff', 'mainBkg': '#0a0a16', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#ff007f', 'lineColor': '#00f0ff'}}}%%
graph TD
    A[🔒 revoke_session / rotate password] -->|bump + persist| B[⚡ Session Epoch Store]
    B -->|epoch claim checked| C{Token Verify}
    C -->|epoch mismatch| D[🚫 Rejected]
    C -->|epoch match| E[✅ Session Valid]

    F[💾 Two concurrent .env writers] -->|read snapshot| G[⚡ _file_lock cross-process]
    G -->|serialized read-modify-write| H[🚀 Both keys survive]

    I[🔑 model.save_key] -->|rebuild inventory| J[⚡ real authenticated value]
    J -->|found in payload| K[🚀 kept as computed]
    J -->|not found: fallback| L[🚀 forced True]

    M[🔌 WS disconnect teardown] -->|snapshot sids| N[⚡ _session_resume_lock]
    O[🔁 session.resume reattach] -->|rebind transport| N
    N -->|revalidate ownership| P[✅ live reconnect preserved]
Loading

Test plan

  • tests/plugins/dashboard_auth/test_basic_provider.py — 22 passed; 3 new RAH-01 tests each confirmed to fail against pre-fix code
  • tests/hermes_cli/test_config.py (env save/remove suite) — passes incl. new TestSaveEnvValueConcurrency, confirmed to reproduce the lost update 3/3 runs pre-fix
  • tests/test_tui_gateway_server.py -k "close_sessions_for_transport or model_save_key" — passes; new RAH-04/RAH-05 tests each confirmed to fail pre-fix
  • tests/plugins/dashboard_auth/, test_dashboard_auth_provider_base.py, test_dashboard_auth_plugin_hook.py, test_dashboard_auth_password_login.py, test_dashboard_admin_endpoints.py — 157 passed, no regressions
  • python -m py_compile on every edited file
  • Full tests/test_tui_gateway_server.py suite (large file, run separately — targeted subsets above cover every touched code path)

🤖 Generated with Claude Code

JoaoMarcos44 and others added 4 commits August 2, 2026 21:04
…d session.resume reattach

_close_sessions_for_transport() snapshotted sessions owned by the
disconnecting transport under _sessions_lock, released the lock, then
mutated each session (close or repoint to the detached sentinel)
without re-checking ownership. session.resume's warm-reuse path
(_reuse_live_payload -> _live_session_payload) rebinds
session["transport"] under _session_resume_lock independently, so a
reconnect landing in that window got silently undone: the old
transport's teardown either force-closed the just-reattached session
or stomped its new transport back to _detached_ws_transport, making a
live reconnect look orphaned and eligible for grace-reap.

Revalidate transport ownership under the same resume_lock ->
sessions_lock ordering already used by the orphan-reap timer,
immediately before claiming (close) or repointing (detach) each
session. Slow teardown work still runs after releasing both locks.

Fixes #HPA-01
…AH-01)

BasicAuthProvider's tokens are stateless HMAC blobs with no server-side
session record, so revoke_session() was a documented no-op and rotating
the password (with an explicit, restart-stable secret) never invalidated
tokens already issued — verify_session() only checked the signature and
expiry, never anything derived from the current credential.

Give every token an "epoch" claim checked at verification time. The epoch
is persisted (atomic JSON write) and keyed to a fingerprint of the
credential source material (not the salted hash, which changes every
restart even when the password doesn't): register() detects a fingerprint
change and bumps the epoch, so both an explicit revoke_session() and a
credential rotation across a restart invalidate every previously issued
token. Same fingerprint across a restart leaves the epoch untouched,
preserving the documented explicit-secret multi-worker/restart-survival
contract.

Direct construction (tests, or any future caller) without a
credential_fingerprint keeps the previous in-memory-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…race (RAH-02)

save_env_value()/remove_env_value() each read the whole .env file, mutate
an in-memory line list, and atomic-replace the file. atomic_replace()
rules out a torn/partial write, but two concurrent callers (two
CLI/desktop processes editing credentials at once) can both read the same
snapshot and each write their own version — the loser's change vanishes
silently, with no error and a perfectly valid resulting file.

Add a cross-process advisory lock (reusing auth.py's existing fcntl/
msvcrt _file_lock helper) around the read-transform-write cycle in both
functions. Verified the race reproduces reliably (3/3 runs) against the
unlocked code with a threading.Barrier-synchronized test, and is closed
by the lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…RAH-04); add real WS reattach-race regression test (RAH-05)

RAH-04: model.save_key rebuilt the provider inventory via
build_models_payload(picker_hints=True), which already computes a real
`authenticated` value per row, then unconditionally overwrote it with
`True` right after — even for the row it had just found in that payload.
The comment above the line said it was only meant to cover the synthetic
fallback (provider not found in the rebuilt inventory at all); the code
ran unconditionally instead, silently discarding the real computed value
whenever it happened to be False.

RAH-05: the existing WS disconnect/reconnect race regression test
constructed both sessions already pointing at new_transport, so they
never entered `owned_sids` (filtered by old_transport) and the revalidation
logic added by the TOCTOU fix was never exercised — the test passed
identically before and after that fix and proved nothing about it. Added
a test that starts the session on old_transport (so the snapshot captures
it) and injects the reattach strictly between the snapshot and the
per-sid claim under _session_resume_lock, matching the actual race
window. Confirmed it fails against the pre-fix implementation and passes
against the current one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Split into individual PRs, one per issue, for clearer review/merge: #77207 (RAH-01), #77208 (RAH-02), #77210 (RAH-04), #77206 (RAH-05, stacked on RAH-06), #77205 (RAH-06). Closing this bundled PR in favor of those.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(dashboard-auth): basic-auth session survives revoke_session() and password rotation (RAH-01)

2 participants