The stale-load guard (#3993 Codex race fix) adds an early at the top
of the loadSession catch block; the test helper anchored on the first
after the catch, which now truncated at the guard. Anchor the body
extraction past the guard (from ) so it captures the 404/non-404
self-heal body as before. Behavior unchanged.
currentSid is snapshotted before the awaited /api/session fetch; if the user
clicks a healthy session while a boot-time restore is in flight and that boot
load then fails non-404, _clearStuckSessionOnBoot(sid, null) would wipe the
healthy session's localStorage/URL. Guard the catch block on
_loadingSessionId !== sid (a newer load superseded this one) — re-arm the active
stream and bail before any self-heal/DOM mutation. Protects both the non-404 and
404 inline self-heal paths. + regression test.
Refined the Codex-CORE fix: the happy-path (already-stored session) guard rejects
only an explicit read_only flag — a stored messaging session already owns its
sidecar, so the messaging-fork risk is specific to the materialize FALLBACK
(which creates a sidecar) where the _is_messaging_session_record check stays.
Also completed test_issue1436's _stub_session MagicMock (read_only=False,
_loaded_metadata_only=False) — bare MagicMock auto-attrs were truthy, tripping
the new read-only guard + _ensure_full_session_before_mutation reload.
Codex caught two data-integrity gaps in the contributor's guard: (1) the
get_session() happy path returned a stored session without checking read_only/
messaging, so an already-imported read-only session could be mutated via
rename/update/move; (2) the fallback only checked cli_meta.read_only, but agent
rows normalize messaging sources WITHOUT setting read_only — materializing a
writable sidecar for a state.db-owned messaging session forks its title/state.
Now reject getattr(s,'read_only') OR _is_messaging_session_record on the happy
path, and cli_meta.read_only OR _is_messaging_session_record(cli_meta) in the
fallback. Replaced the messaging-stub test with 3 regression tests (stored
read-only, stored messaging, messaging cli_meta without read_only flag).
If startSessionStream(sid) runs while the tab is ALREADY hidden (session loaded/
restored in a background tab), it set _sessionStreamSessionId then returned at the
hidden-skip WITHOUT recording _sessionStreamHiddenSid — so the visibility handler
(which reopens only from the holder) never reattached, silently dropping
bg_task_complete/server_turn_started until the next explicit startSessionStream.
Set _sessionStreamHiddenSid = sid on the hidden-skip return. Adds a regression test.
#3996 closes the gateway + per-session SSE on hidden tabs to free HTTP/1.1
connection-pool slots (#3992). But the per-session-stream visibility hook
guarded its reopen on _sessionStreamSessionId, which stopSessionStream() nulls —
so the per-session SSE closed on hide but never reopened on re-show (regressing
live-stream reattach). Capture the id into a dedicated _sessionStreamHiddenSid
before the close and reopen from it on visibility return. Gateway hook (sessions.js)
was already correct (reopens unconditionally + re-guards internally).
On 401, api() redirects to /login and returns undefined. The browser
navigates away immediately, so this code rarely runs. But even if it
did, clearing localStorage on transient auth expiry is wrong — it wipes
the saved session id and sends users to empty state after re-login.
Keep the self-heal in the catch-block else branch for non-401, non-404
errors (400/403/500/network) which genuinely trap the user on recurring
boot failures. 401 has no such property because the redirect already
breaks the retry loop.
Greptile: 'Keep the if (!data) early-return clearing-free (its old
behavior), and only call _clearStuckSessionOnBoot() from the else
branch of the catch.'
(#4028 follow-up)
The api() function returns undefined on 401 (redirects to login) rather
than throwing. So the 401 path exits via the if(!data) guard at line ~988,
not through the catch block — the e.status===401 branch inside the catch
is dead code (#4028 follow-up).
Greptile flagged that currentSid===sid also fires on same-session
force-reloads (e.g. background poll). If the server returns a transient
500 or network error, currentSid===sid is true — the helper wipes
hermes-webui-session from localStorage and resets the URL, even though
the session still exists on the server.
Limit the self-heal to boot-time failures only (!currentSid), where the
stored session ID is definitely stale. When currentSid is set (already
viewing a session), a non-404 failure could be transient and wiping
localStorage is unnecessarily destructive.
The 404 inline self-heal (line 932) already uses this tighter guard,
so this brings the non-404 path into alignment.
When a session exists in Hermes Agent state.db but has no WebUI sidecar
(SESSION_DIR/{sid}.json), mutation routes (rename, move, update) would
return 404 "Session not found" despite the session appearing in the sidebar.
This mirrors the existing fallback in /api/session/archive:
- Try get_session() first (WebUI store)
- On KeyError, look up CLI metadata via _lookup_cli_session_metadata()
- For messaging/Claude Code (read_only): return 403 instead of silent 404
- For regular CLI sessions: import_cli_session() to materialize sidecar
- Preserve source_tag/raw_source/session_source/etc. for lineage
Routes updated:
- /api/session/rename
- /api/session/update (workspace switch)
- /api/session/move
Refs: #3746 (same class: session discovery vs mutation mismatch),
#3915 (session store empty but data exists in Agent store)
When loadSession() fails during boot with a non-404 error (401, 400,
500, network), the session ID stays stuck in localStorage and the URL,
causing repeated failures on every page refresh.
The 404 path already had inline self-heal (clears localStorage + URL).
This extends it to all error cases:
- Added _clearStuckSessionOnBoot() helper to consolidate the self-heal
logic for non-404 errors.
- On 401 redirect (api() returns undefined): clears the stuck session
ID and shows a more informative message.
- On other non-404 errors: clears the stuck session ID and shows a
better error message distinguishing auth failures from other errors.
Preserves the guard that prevents clearing localStorage when clicking
into a *different* dead session while already viewing a healthy one.
Add HERMES_WEBUI_COOKIE_NAME so multiple WebUI instances sharing a
hostname (different ports) can use distinct auth cookie names. Browsers
scope cookies by host, not host+port (RFC 6265), so same-host instances
otherwise trample each other's `hermes_session` cookie and log users out.
- Resolve the cookie name via _resolve_cookie_name(): env > default,
mirroring the existing _resolve_session_ttl() pattern.
- Keep `hermes_session` as the default for backwards compatibility.
- Validate against the RFC 6265 token grammar; fall back to the default
with a logged warning on empty or malformed values.
- Cover default, override, empty, invalid, and Set-Cookie paths in
tests/test_auth_sessions.py::TestCookieNameResolution.
#3964 [security] gate first-password bootstrap (_set_password on POST /api/settings
while auth disabled) to local clients — blocks remote unauth first-run ownership.
Uses request-start auth snapshot (auth_enabled_before), so no mid-request TOCTOU.
Self-rebased onto v0.51.357 (8-behind, 3-dot fidelity verified byte-identical).
#3970 (oauth single-flight) DROPPED from this stage: Codex+Opus both caught a
check-then-insert race — _pending_oauth_flow_for releases the lock before the
device-code request + flow insertion, so concurrent unauth starts still spawn
multiple workers (Codex empirically reproduced w/ 2 threads). Returned to author
w/ the atomic per-(provider,home) start-lock fix. Re-gating the (N-1) stage.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
Both reviewers noted the _preservedLiveTurn.dataset.sessionId stamp was a dead
write in the segment-swap path (only the segment enters the DOM there; the
rebuilt turn was already stamped at build time). Move the stamp into the two
whole-turn paths where the preserved turn actually enters the DOM. No behavior
change; removes the dead write.