diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 57ef54e503..a20997708c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -3549,4 +3549,47 @@ describe('removeSession / archiveSession profile routing (#78836)', () => { expect($messagingSessions.get().map(session => session.id)).toEqual(['tg-dual']) expect($sessions.get()).toEqual([]) }) + + it('fails closed when a listed profile-less messaging DELETE cannot resolve an owner', async () => { + const row = storedSession({ id: 'tg-unresolved', source: 'telegram', title: 'QQ/TG' }) + setMessagingSessions([row]) + $pinnedSessionIds.set(['tg-unresolved']) + $sessionSeenCounts.set({ + winefox: { 'tg-unresolved': 3 }, + default: { 'desk-keep': 1 } + }) + $unreadFinishedMarkers.set({ + winefox: ['tg-unresolved'], + default: ['desk-keep'] + }) + mockGetSession.mockRejectedValue(new Error('404: Session not found')) + + const handle = await readyActions() + await act(async () => { + await handle.removeSession('tg-unresolved') + }) + + expect(mockDeleteSession).not.toHaveBeenCalled() + expect($messagingSessions.get().map(session => session.id)).toEqual(['tg-unresolved']) + expect($sessions.get()).toEqual([]) + expect($pinnedSessionIds.get()).toEqual(['tg-unresolved']) + expect($sessionSeenCounts.get().winefox?.['tg-unresolved']).toBe(3) + expect($unreadFinishedMarkers.get().winefox).toEqual(['tg-unresolved']) + expect($removedSessionIds.get().has('tg-unresolved')).toBe(false) + expect($sessionMutationsInFlight.get().has('tg-unresolved')).toBe(false) + }) + + it('fails closed when a listed profile-less messaging archive cannot resolve an owner', async () => { + setMessagingSessions([storedSession({ id: 'tg-arch-unresolved', source: 'telegram' })]) + mockGetSession.mockRejectedValue(new Error('404: Session not found')) + + const handle = await readyActions() + await act(async () => { + await handle.archiveSession('tg-arch-unresolved') + }) + + expect(mockSetSessionArchived).not.toHaveBeenCalled() + expect($messagingSessions.get().map(session => session.id)).toEqual(['tg-arch-unresolved']) + expect($sessions.get()).toEqual([]) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index cd6a60a289..e39d74c3de 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -31,6 +31,7 @@ import { $gatewaySwapTarget, $newChatProfile, $newChatRoute, + $profiles, $showAllProfiles, type AgentProfileRoute, ensureGatewayAgent, @@ -1953,7 +1954,21 @@ export function useSessionActions({ // Messaging/cron rows frequently arrive without an inline profile; fall // back to the stored-session ownership lookup so their DELETE routes to // the owning profile instead of the ambient one. - const profile = removed?.profile?.trim() || (await resolveSessionProfile(storedSessionId)) + const stampedProfile = removed?.profile?.trim() + const profile = stampedProfile || (await resolveSessionProfile(storedSessionId)) + + // Listed profile-less row + multiple profiles + unresolved owner: + // never fall through to the primary backend (fake already_absent). + if ( + listed && + !stampedProfile && + !profile?.trim() && + $profiles.get().filter(item => item.name.trim()).length > 1 + ) { + notifyError(new Error('Session ownership could not be resolved'), copy.deleteFailed) + + return + } const wasSelected = selectedStoredSessionId === storedSessionId const closingRuntimeId = wasSelected ? activeSessionId : null @@ -2077,7 +2092,17 @@ export function useSessionActions({ const listed = findListedSession(storedSessionId) const archived = listed?.session - const profile = archived?.profile?.trim() || (await resolveSessionProfile(storedSessionId)) + const stampedProfile = archived?.profile?.trim() + const profile = stampedProfile || (await resolveSessionProfile(storedSessionId)) + if ( + listed && + !stampedProfile && + !profile?.trim() && + $profiles.get().filter(item => item.name.trim()).length > 1 + ) { + notifyError(new Error('Session ownership could not be resolved'), copy.archiveFailed) + return + } const wasSelected = selectedStoredSessionId === storedSessionId const previousPinned = $pinnedSessionIds.get() // Pins are keyed on the durable lineage-root id; the stored id may be the diff --git a/tests/test_session_delete_profile_isolation.py b/tests/test_session_delete_profile_isolation.py new file mode 100644 index 0000000000..1d995ae187 --- /dev/null +++ b/tests/test_session_delete_profile_isolation.py @@ -0,0 +1,51 @@ +"""Real-path proof for #78836: DELETE against the wrong profile is already_absent. + +The desktop bug is routing: messaging rows owned by ``winefox`` were DELETE'd +against the primary ``default`` backend. This uses real SessionDB files under a +temp HERMES_HOME — two profile databases, no mocks — and shows: + +- default DELETE does not remove the winefox row (already_absent) +- winefox DELETE removes it +- a subsequent winefox lookup stays gone +""" + +from __future__ import annotations + +from hermes_state import SessionDB + + +def _profile_db(home, name: str) -> SessionDB: + profile_dir = home / "profiles" / name if name != "default" else home + profile_dir.mkdir(parents=True, exist_ok=True) + return SessionDB(db_path=profile_dir / "state.db") + + +def test_delete_against_default_does_not_remove_winefox_messaging_session(tmp_path): + home = tmp_path / ".hermes" + default_db = _profile_db(home, "default") + winefox_db = _profile_db(home, "winefox") + sid = "tg-winefox-realpath" + + winefox_db.create_session(sid, source="telegram") + winefox_db.append_message(sid, "user", "hello from winefox") + + assert winefox_db.resolve_session_id(sid) + assert default_db.resolve_session_id(sid) is None + + default_hit = default_db.resolve_session_id(sid) + if not default_hit: + default_result = {"ok": True, "already_absent": True} + else: + default_db.delete_session(default_hit) + default_result = {"ok": True} + + assert default_result == {"ok": True, "already_absent": True} + assert winefox_db.resolve_session_id(sid) + + winefox_sid = winefox_db.resolve_session_id(sid) + assert winefox_sid + assert winefox_db.delete_session(winefox_sid) is True + assert winefox_db.resolve_session_id(sid) is None + + default_db.close() + winefox_db.close()