Skip to content

fix: auto-switch profiles for session links - #5419

Closed
harcek wants to merge 1 commit into
nesquena:masterfrom
harcek:fix/cross-profile-session-links
Closed

harcek wants to merge 1 commit into
nesquena:masterfrom
harcek:fix/cross-profile-session-links

Conversation

@harcek

@harcek harcek commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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/session returns 404, 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/session now distinguishes valid-but-wrong-profile sessions from missing sessions.
  • Wrong-profile detail loads return structured 409 session_profile_mismatch with the owning profile name.
  • loadSession() catches that response, switches to the owning profile, and retries the session load once.
  • True missing/deleted sessions still use the existing 404 self-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 -q
    • 45 passed
  • Manual local smoke on a multi-profile install:
    • opening a deepresearcher session while active profile was default returned 409 session_profile_mismatch
    • after profile switch, the same session loaded successfully

Risks / 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.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes GET /api/session return a structured 409 session_profile_mismatch response (instead of the old 404) when the requested session belongs to a different profile, then teaches loadSession() in the frontend to auto-switch to the owning profile and retry the load once.

  • api/routes.py adds a visibility exemption for GET /api/session at the request-guard layer and emits the 409 JSON (including the owning profile name) in both the WebUI-sidecar and CLI-session paths.
  • static/sessions.js adds _sessionProfileMismatchFromError, _switchProfileForSessionLoad, and the retry branch inside the loadSession catch block, guarded by skipProfileResolve:true on the retry to prevent infinite recursion.
  • tests/test_bugfix_sweep.py adds a string-presence test confirming the key symbols were introduced.

Confidence Score: 3/5

The profile switch path introduces a race window that can clobber a concurrent session navigation; the fix is a single guard insertion but should be verified before merging.

The async profile switch (renderSessionList + network) creates a 1–3 second window. If the user clicks a different session during that window, the existing _loadingSessionId !== sid guard is checked before the switch starts but not after it completes. The unconditional loadSession(sid, {force:true}) retry then overwrites the newer navigation, leaving the user looking at the wrong session. All other callers in this file re-check _loadingSessionId after every await; this path is the only one that doesn't.

static/sessions.js — the retry path after _switchProfileForSessionLoad needs a stale-load guard before calling loadSession again.

Important Files Changed

Filename Overview
static/sessions.js Adds profile auto-switch on 409 response; missing stale-load guard after the async profile switch allows a concurrent navigation to be clobbered by the retry.
api/routes.py Adds visibility exemption for GET /api/session and returns 409 with profile name for cross-profile session loads; logic is well-scoped and matches the route-level guard pattern.
tests/test_bugfix_sweep.py Adds a string-presence test verifying that the key symbols from this change exist in source files; consistent with patterns already in this test file.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser as Browser (profile A)
    participant FE as sessions.js
    participant API as /api/session
    participant PS as /api/profile/switch

    Browser->>FE: loadSession(sid)
    FE->>API: "GET /api/session?session_id=sid"
    API-->>FE: "409 session_profile_mismatch {profile: "profileB"}"
    FE->>FE: _sessionProfileMismatchFromError(e)
    FE->>FE: "check _loadingSessionId === sid"
    FE->>PS: "POST /api/profile/switch {name: "profileB"}"
    PS-->>FE: "{active: "profileB", ...}"
    FE->>FE: "S.activeProfile = "profileB""
    FE->>FE: renderSessionList() [~1-3s]
    Note over FE: No stale-load guard here
    FE->>FE: "_loadingSessionId = null (if still === sid)"
    FE->>FE: "loadSession(sid, {skipProfileResolve:true, force:true})"
    FE->>API: "GET /api/session?session_id=sid"
    API-->>FE: "200 {session: {...}}"
    FE->>Browser: Render session content
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser as Browser (profile A)
    participant FE as sessions.js
    participant API as /api/session
    participant PS as /api/profile/switch

    Browser->>FE: loadSession(sid)
    FE->>API: "GET /api/session?session_id=sid"
    API-->>FE: "409 session_profile_mismatch {profile: "profileB"}"
    FE->>FE: _sessionProfileMismatchFromError(e)
    FE->>FE: "check _loadingSessionId === sid"
    FE->>PS: "POST /api/profile/switch {name: "profileB"}"
    PS-->>FE: "{active: "profileB", ...}"
    FE->>FE: "S.activeProfile = "profileB""
    FE->>FE: renderSessionList() [~1-3s]
    Note over FE: No stale-load guard here
    FE->>FE: "_loadingSessionId = null (if still === sid)"
    FE->>FE: "loadSession(sid, {skipProfileResolve:true, force:true})"
    FE->>API: "GET /api/session?session_id=sid"
    API-->>FE: "200 {session: {...}}"
    FE->>Browser: Render session content
Loading

Reviews (1): Last reviewed commit: "fix: auto-switch profiles for session li..." | Re-trigger Greptile

Comment thread static/sessions.js
Comment on lines +1350 to +1352
await _switchProfileForSessionLoad(profileMismatch.profile);
if (_loadingSessionId === sid) _loadingSessionId = null;
return loadSession(sid,{...opts,skipProfileResolve:true,force:true});

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.

P1 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.

Suggested change
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});

Comment thread api/routes.py
Comment on lines +11587 to +11592
return j(handler, {
"error": "Session belongs to a different profile",
"code": "session_profile_mismatch",
"session_id": sid,
"profile": _session_profile,
}, status=409)

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.

P2 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 nesquena-hermes 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.

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 409 envelope: 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.

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jul 2, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Reading api/routes.py:522-534 (the _request_session_visibility_exempt change) plus the two detail-load branches at routes.py:11584-11591 and routes.py:11916-11923, and the frontend recovery path in static/sessions.js:1179-1211 and 1342-1356, this is a clean, well-scoped fix. A few notes to confirm it holds together.

The exemption is safe because both GET /api/session branches still enforce the boundary

The new early-return in the guard makes GET /api/session bypass the generic _guard_request_session_visibility sweep (called at routes.py:11233 for all /api/ GETs):

if method == "GET" and path == "/api/session":
    # Detail-load owns profile mismatch handling ...
    return True

That would be a visibility hole if the detail handler didn't re-check. It does — both the primary branch (get_session_session_visible_to_active_profile) and the CLI/foreign fallback in the except KeyError block (cli_meta_session_visible_to_active_profile) still gate on the active profile before returning any payload; the PR just swaps their 404 for the structured 409. So no session data leaks; a wrong-profile load returns metadata-free {"code":"session_profile_mismatch","profile":...}. Good.

Frontend error contract matches

_sessionProfileMismatchFromError reads e.status===409 and e.body. That lines up with the api() helper in static/workspace.js:56-63, which attaches exactly those fields on a non-ok response:

const err=new Error(message);
err.status=res.status;
err.statusText=res.statusText;
err.body=text;

So the parse path is correct.

Boot deep-links are covered

Worth calling out for reviewers: the URL deep-link path on a cold load goes through loadSession() (static/boot.js:3174, await loadSession(saved, ...)), which is exactly where the 409 recovery was wired. The other cold-load /api/session caller, _savedSessionSidebarOnlyState (boot.js:105), swallows errors and returns null, so it won't mis-handle the new 409 — it just falls back as before. The cross-profile deep-link case is handled on both cold boot and in-app navigation.

One thing to double-check: the retry re-entrancy guard

In loadSession, after _switchProfileForSessionLoad succeeds you do if (_loadingSessionId === sid) _loadingSessionId = null; then return loadSession(sid,{...opts,skipProfileResolve:true,force:true}). The skipProfileResolve flag correctly prevents an infinite switch loop if the retried load still 409s (it can't switch twice). Confirm that the profile switch itself invalidating/re-rendering the list (renderSessionList() inside _switchProfileForSessionLoad) doesn't race the re-entrant loadSession for the same sid — the stale-load guard (_loadingSessionId !== sid) should cover it, but a test that switches profile then immediately clicks a third session mid-switch would be worth adding to test_bugfix_sweep.py alongside the existing string-presence assertions.

The test_bugfix_sweep.py additions are static source assertions (checking the strings exist), which is consistent with the rest of that file, though they don't exercise the actual 409→switch→retry flow. A functional test on the handler (assert a cross-profile GET returns 409 with the owning profile name) would harden the contract against future refactors of the guard.

Net: correct layering (server distinguishes wrong-profile from missing, frontend recovers), preserves the 404 self-heal for genuinely deleted sessions, and keeps mutation endpoints on the strict guard. Looks good to merge.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.915 🔀 — thanks @harcek!

Clicking a session:// link that belongs to another profile now switches to that profile and loads the session, instead of failing with "Session not available in web UI" (it used to look identical to a deleted session). Truly missing/deleted sessions still self-heal.

Gate found + I fixed 3 issues (Codex + Fable):

  • Codex CORE: added a post-await stale-load guard so a navigation during the profile switch can't hijack the UI back to the old session.
  • Fable Finding 1: only emit the 409 when the owning profile is known — a truly-missing/legacy None-profile session under a non-default active profile would otherwise get a useless profile:null 409 that skipped the 404 self-heal and re-armed the SSE stream against a dead session id. Fixed both detail branches + 2 regression tests.
  • Fable Finding 2: a failed profile-switch POST no longer strands the sidebar on the skeleton (now mirrors the Profile switch: polished loading skeletons (sidebar + workspace) — #4662 phase 1 #4671 canonical-switch recovery).

No foreign-profile transcript is ever returned — the 409 carries only {error, code, session_id, profile}. The info-disclosure delta (session-existence + owning-profile-name) is acceptable for the single-user/multi-profile model (profile names are already enumerable via /api/profiles).

Full gate green: Codex SAFE, Fable SAFE (after fixes), full suite clean. Authorship preserved via your commit + Co-authored-by.

franksong2702 pushed a commit to franksong2702/hermes-webui-fork that referenced this pull request Jul 7, 2026
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>
Gerkinfeltser pushed a commit to Gerkinfeltser/hermes-webui that referenced this pull request Jul 8, 2026
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>
govtech42 pushed a commit to forks-ai/hermes-webui that referenced this pull request Aug 5, 2026
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>
alai04 pushed a commit to alai04/hermes-webui that referenced this pull request Aug 31, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants