Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,11 @@ def _session_visible_to_active_profile(session_profile, handler=None) -> bool:
def _request_session_visibility_exempt(method: str, path: str | None) -> bool:
if not path:
return False
if method == "GET" and path == "/api/session":
# Detail-load owns profile mismatch handling so the frontend can switch
# to the session's profile instead of treating a valid cross-profile
# deep link as a deleted/stale session.
return True
if method != "POST":
return False
# Import routes create/claim sessions before normal ownership exists, and
Expand Down Expand Up @@ -11579,7 +11584,12 @@ def handle_get(handler, parsed) -> bool:
s = get_session(sid, metadata_only=(not load_messages))
_session_profile = getattr(s, 'profile', None) or None
if not _session_visible_to_active_profile(_session_profile, handler):
return bad(handler, "Session not found", 404)
return j(handler, {
"error": "Session belongs to a different profile",
"code": "session_profile_mismatch",
"session_id": sid,
"profile": _session_profile,
}, status=409)
Comment on lines +11587 to +11592

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!

original_stream_id = getattr(s, "active_stream_id", None)
_clear_stale_stream_state(s)
cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
Expand Down Expand Up @@ -11906,7 +11916,12 @@ def handle_get(handler, parsed) -> bool:
cli_meta = _lookup_cli_session_metadata(sid)
_session_profile = (cli_meta or {}).get("profile") or None
if not _session_visible_to_active_profile(_session_profile, handler):
return bad(handler, "Session not found", 404)
return j(handler, {
"error": "Session belongs to a different profile",
"code": "session_profile_mismatch",
"session_id": sid,
"profile": _session_profile,
}, status=409)
synth, reason = _claim_or_synthesize_cli_session(sid, cli_meta=cli_meta or {})
if reason == "was_webui":
# Deleted WebUI session: 404 so the client self-heals
Expand Down
50 changes: 50 additions & 0 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,41 @@ function _rearmActiveSessionStream(){
if(activeSid) startSessionStream(activeSid);
}

function _sessionProfileMismatchFromError(e){
if(!e || e.status!==409 || !e.body) return null;
try{
const body=JSON.parse(e.body);
if(body && body.code==='session_profile_mismatch' && body.profile){
return {profile:String(body.profile), session_id:String(body.session_id||'')};
}
}catch(_){ }
return null;
}

async function _switchProfileForSessionLoad(profile){
const name=String(profile||'').trim();
if(!name) throw new Error('missing profile');
if(name===S.activeProfile) return;
if(typeof _invalidateSessionListRenders==='function') _invalidateSessionListRenders();
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(true);
if(typeof showSessionListSkeleton==='function') showSessionListSkeleton(name);
try{
const data=await api('/api/profile/switch',{method:'POST',body:JSON.stringify({name}),timeoutToast:false});
S.activeProfile=data.active||name;
S.activeProfileIsDefault=!!data.is_default;
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
if(data.default_model) window._defaultModel=data.default_model;
if(data.default_model_provider) window._activeProvider=data.default_model_provider;
if(typeof startGatewaySSE==='function') startGatewaySSE();
if(typeof syncTopbar==='function') syncTopbar();
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false);
if(typeof renderSessionList==='function') await renderSessionList();
}finally{
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false);
}
}

async function loadSession(sid){
const opts = arguments[1] || {};
if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){
Expand Down Expand Up @@ -1304,6 +1339,21 @@ async function loadSession(sid){
try {
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
} catch(e) {
const profileMismatch=_sessionProfileMismatchFromError(e);
if(profileMismatch && profileMismatch.profile && !opts.skipProfileResolve){
if (_loadingSessionId !== sid) {
_rearmActiveSessionStream();
return;
}
try{
if(typeof showToast==='function') showToast(`Switching to ${profileMismatch.profile} profile for this session…`,2200);
await _switchProfileForSessionLoad(profileMismatch.profile);
if (_loadingSessionId === sid) _loadingSessionId = null;
return loadSession(sid,{...opts,skipProfileResolve:true,force:true});
Comment on lines +1350 to +1352

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

}catch(switchErr){
e=switchErr;
}
}
const _msgInner = $('msgInner');
// Stale-load guard (Codex): a newer loadSession() may have started while this
// request was awaiting (e.g. the user clicked a healthy session during a
Expand Down
11 changes: 11 additions & 0 deletions tests/test_bugfix_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ def test_session_url_builder_strips_legacy_session_query_alias():
assert "current.searchParams.delete('session_id');" in helper


def test_cross_profile_session_deep_links_switch_profile_instead_of_self_healing():
routes = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
sessions = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")

assert '"code": "session_profile_mismatch"' in routes
assert 'if method == "GET" and path == "/api/session":' in routes
assert "function _sessionProfileMismatchFromError" in sessions
assert "_switchProfileForSessionLoad(profileMismatch.profile)" in sessions
assert "skipProfileResolve:true" in sessions


def test_service_worker_precaches_same_origin_vendor_shell_assets():
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")

Expand Down