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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
51
tests/test_session_delete_profile_isolation.py
Normal file
51
tests/test_session_delete_profile_isolation.py
Normal 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()
|
||||
Reference in New Issue
Block a user