Conversation
| await _switchProfileForSessionLoad(profileMismatch.profile); | ||
| if (_loadingSessionId === sid) _loadingSessionId = null; | ||
| return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); |
There was a problem hiding this comment.
Missing stale-load guard after profile switch
_switchProfileForSessionLoad awaits both a network call and renderSessionList(), which can take 1–3 seconds. If the user navigates to a different session during that window, _loadingSessionId becomes the new session ID. The if (_loadingSessionId === sid) check below correctly skips the null assignment, but the unconditional loadSession(sid, {force:true}) call still runs — and the very first line of loadSession sets _loadingSessionId = sid, overwriting the in-flight navigation the user actually requested. The same stale-load guard used everywhere else in this function should be repeated after the await.
| await _switchProfileForSessionLoad(profileMismatch.profile); | |
| if (_loadingSessionId === sid) _loadingSessionId = null; | |
| return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); | |
| await _switchProfileForSessionLoad(profileMismatch.profile); | |
| if (_loadingSessionId !== sid) { | |
| _rearmActiveSessionStream(); | |
| return; | |
| } | |
| _loadingSessionId = null; | |
| return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); |
| return j(handler, { | ||
| "error": "Session belongs to a different profile", | ||
| "code": "session_profile_mismatch", | ||
| "session_id": sid, | ||
| "profile": _session_profile, | ||
| }, status=409) |
There was a problem hiding this comment.
Profile name exposed in 409 body
The "profile": _session_profile field lets any authenticated session probe session IDs it doesn't own and discover the names of other profiles. In the cross-profile auto-switch flow the frontend only needs to receive the profile name when code == "session_profile_mismatch", so the exposure is intentional — but worth confirming this is acceptable given your threat model. The same pattern is repeated at line 11919.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks for this — the core idea (turn a valid cross-profile deep link into a profile-switch instead of a misleading "session not available") is a real UX improvement, and the data boundary is preserved (the new 409 returns only an error envelope; I confirmed no foreign-profile transcript/messages are served). One objective blocker before this can land, plus two framing notes for a maintainer decision.
⛔ Blocker: this breaks 4 existing regression tests on the profile-isolation boundary
tests/test_issue1611_session_profile_filtering.py was written specifically for the cross-profile GET /api/session boundary, and it hard-asserts the old 404 behavior. This PR changes that path from 404 to 409 (via j(...)) but never reconciles that suite, so 4 tests now fail:
tests/test_issue1611_session_profile_filtering.py
test_get_session_rejects_session_from_inactive_profile (:312)
test_get_session_rejects_metadata_only_session_from_inactive_profile (:337)
test_get_session_rejects_cookieless_session_from_inactive_profile (:362)
test_get_session_rejects_cli_session_from_inactive_profile (:390)
Each asserts captured["bad"]["status"] == 404 and "json" not in captured ("foreign-profile transcript must not be returned"). Post-change the response goes through j(...) at status 409, so bad is never called and captured["json"] is now present → both assertions fail. Reproduced on your head (0bfbb03): 4 failed, 3 passed.
Fix-spec
Update those 4 tests in tests/test_issue1611_session_profile_filtering.py to the new contract while keeping the original intent (no foreign-profile transcript leaks):
- Expect the
409envelope:status == 409,data["code"] == "session_profile_mismatch",data["profile"]is the owning profile name. - Keep the isolation assertion: verify the response body contains only the error envelope (
error/code/session_id/profile) and no session transcript/content/title keys — that's the boundary the suite exists to protect, and it still holds, so the assertion should be reworded, not dropped.
Note (not blocking): the new test in this PR is source-grep only
test_bugfix_sweep.py::test_cross_profile_session_deep_links_switch_profile_instead_of_self_healing only read_text()s the two files and asserts literal substrings exist. It would still pass if the status code were wrong or the retry-loop guard were broken. A behavioral test (the 409 envelope end-to-end, and the frontend one-shot switch-and-retry) would give real coverage — worth adding alongside the test_issue1611 fixups.
Note for a maintainer call: information-disclosure delta
Previously a cross-profile session and a truly-missing session were both 404 (indistinguishable). The 409 now makes /api/session a session-existence oracle and reveals the owning profile name for a given session id. Mitigating context: profile names are already fully enumerable by any authenticated WebUI user (GET /api/profiles lists them; POST /api/profile/switch accepts any existing profile with no per-user ACL), so the profile name isn't new privileged info — but the existence-confirmation + session_id → owning-profile binding is new. Flagging so a maintainer can confirm that's acceptable for the profile model; not a defect on its own.
Happy to re-review as soon as the test_issue1611 suite is reconciled. Nice fix otherwise.
|
Reading The exemption is safe because both GET
|
Release: auto-switch profiles for cross-profile session links (#5419)
|
Shipped in v0.51.915 🔀 — thanks @harcek! Clicking a Gate found + I fixed 3 issues (Codex + Fable):
No foreign-profile transcript is ever returned — the 409 carries only Full gate green: Codex SAFE, Fable SAFE (after fixes), full suite clean. Authorship preserved via your commit + Co-authored-by. |
A valid session:// deep link to a session owned by a DIFFERENT Hermes profile used to look identical to a deleted session (404 -> frontend self-heals away). Now GET /api/session returns a structured 409 session_profile_mismatch envelope (error/code/session_id/profile ONLY, no transcript) ONLY when the owning profile is KNOWN, and loadSession() catches it, switches to the owning profile, and retries once. Truly missing/deleted or legacy None-profile sessions keep the 404 self-heal. Gate fixes applied (Codex + Fable): - Codex CORE: added a post-await stale-load guard after _switchProfileForSessionLoad so a navigation during the switch can't hijack the UI back to the old session. - Fable Finding 1: only emit 409 when _session_profile is truthy; a None-profile (missing/legacy) session under a non-default active profile now keeps 404 instead of a useless profile=null 409 (which skipped self-heal + spun the SSE reconnect against a dead sid). Both detail branches. + 2 regression tests. - Fable Finding 2: _switchProfileForSessionLoad now clears the sidebar skeleton + re-renders from cache on switch-POST failure (mirrors the nesquena#4671 canonical-switch catch), then rethrows, so a failed switch can't strand the sidebar on the skeleton. Reconciled tests/test_issue1611_session_profile_filtering.py (4 tests) to the 409 contract while preserving the no-leak boundary assertion. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
A valid session:// deep link to a session owned by a DIFFERENT Hermes profile used to look identical to a deleted session (404 -> frontend self-heals away). Now GET /api/session returns a structured 409 session_profile_mismatch envelope (error/code/session_id/profile ONLY, no transcript) ONLY when the owning profile is KNOWN, and loadSession() catches it, switches to the owning profile, and retries once. Truly missing/deleted or legacy None-profile sessions keep the 404 self-heal. Gate fixes applied (Codex + Fable): - Codex CORE: added a post-await stale-load guard after _switchProfileForSessionLoad so a navigation during the switch can't hijack the UI back to the old session. - Fable Finding 1: only emit 409 when _session_profile is truthy; a None-profile (missing/legacy) session under a non-default active profile now keeps 404 instead of a useless profile=null 409 (which skipped self-heal + spun the SSE reconnect against a dead sid). Both detail branches. + 2 regression tests. - Fable Finding 2: _switchProfileForSessionLoad now clears the sidebar skeleton + re-renders from cache on switch-POST failure (mirrors the nesquena#4671 canonical-switch catch), then rethrows, so a failed switch can't strand the sidebar on the skeleton. Reconciled tests/test_issue1611_session_profile_filtering.py (4 tests) to the 409 contract while preserving the no-leak boundary assertion. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Claude Code sessions listed in the sidebar rendered "Session not available in web UI." when clicked under a named (non-root) profile. Root cause: get_claude_code_sessions() scans ~/.claude/projects and stamps profile=None on every row, because those JSONL transcripts belong to no Hermes profile. /api/sessions lists them regardless of active profile, but the GET /api/session detail load ran them through _session_visible_to_active_profile, whose _profiles_match coerces None -> 'default'. With hermes_profile=feng-family active, the gate 404'd before _claim_or_synthesize_cli_session ever ran, so the frontend hit its 404 branch and painted the empty-state message. POST /api/session/import_cli correctly returned 200 with an inline read-only payload (Claude Code sessions are read_only by design and never get a sidecar), which is why no file appeared in webui/sessions/ -- Session.save() was never supposed to run and was not silently failing. Exempt profile-less claude_code rows from the detail-load profile gate via _is_profile_agnostic_foreign_session(). Profile-tagged foreign rows keep the nesquena#5419 409 cross-profile contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code sessions listed in the sidebar rendered "Session not available in web UI." when clicked under a named (non-root) profile. Root cause: get_claude_code_sessions() scans ~/.claude/projects and stamps profile=None on every row, because those JSONL transcripts belong to no Hermes profile. /api/sessions lists them regardless of active profile, but the GET /api/session detail load ran them through _session_visible_to_active_profile, whose _profiles_match coerces None -> 'default'. With hermes_profile=feng-family active, the gate 404'd before _claim_or_synthesize_cli_session ever ran, so the frontend hit its 404 branch and painted the empty-state message. POST /api/session/import_cli correctly returned 200 with an inline read-only payload (Claude Code sessions are read_only by design and never get a sidecar), which is why no file appeared in webui/sessions/ -- Session.save() was never supposed to run and was not silently failing. Exempt profile-less claude_code rows from the detail-load profile gate via _is_profile_agnostic_foreign_session(). Profile-tagged foreign rows keep the nesquena#5419 409 cross-profile contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thinking Path
A valid
session://...deep link can point at a session owned by a different Hermes profile than the currently-active browser profile. Today that looks identical to a deleted/missing session:/api/sessionreturns404, the frontend self-heals the route/localStorage, and the user sees “Session not available in web UI” or falls back to another session.What Changed
GET /api/sessionnow distinguishes valid-but-wrong-profile sessions from missing sessions.409 session_profile_mismatchwith the owning profile name.loadSession()catches that response, switches to the owning profile, and retries the session load once.404self-heal path.Why It Matters
Cross-profile session links should open the real conversation, not imply data loss. This preserves the existing profile isolation boundary while making direct links and
session://links usable across profiles.Verification
./scripts/test.sh tests/test_bugfix_sweep.py tests/test_issue803.py -q45 passeddeepresearchersession while active profile wasdefaultreturned409 session_profile_mismatchRisks / Follow-ups
Low risk: the change is scoped to session detail loads. Mutation endpoints still use the existing request profile guard. Missing/deleted sessions keep returning
404.Model Used
AI-assisted by Hermes Agent using OpenAI Codex
gpt-5.5.