fix(desktop): fail closed when messaging DELETE cannot resolve an owner

A listed profile-less row in a multi-profile setup must not DELETE/archive against the primary backend. Unresolved ownership keeps the row, pins, and unread state and never calls the mutation.
This commit is contained in:
Jeremy
2026-08-18 20:03:48 -07:00
committed by Teknium
parent add24666d5
commit cab6c4f70b
3 changed files with 121 additions and 2 deletions

View File

@@ -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([])
})
})

View File

@@ -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

View File

@@ -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()