fix(bot-mode): protect ordinary sessions from hide sweep
This commit is contained in:
@@ -439,17 +439,50 @@ function fallbackSelectionAfterHide(name) {
|
||||
* setter is a no-op on already-hidden rows) and feature-detected: older
|
||||
* gateways lack session.set_hidden and simply keep the rows visible. */
|
||||
function hideOwnedBotSessions() {
|
||||
const canonical = Object.values($botMeta.get())
|
||||
.map(m => m && m.chat)
|
||||
.filter(Boolean)
|
||||
const canonical = Object.entries($botMeta.get())
|
||||
.map(([name, meta]) => ({ name, id: meta && meta.chat }))
|
||||
.filter(entry => Boolean(entry.id))
|
||||
const rooms = Object.values($groupChats.get())
|
||||
.flatMap(room => Object.values(room?.sessions || {}))
|
||||
.filter(sid => Boolean(sid) && sid !== true)
|
||||
const ids = [...new Set([...canonical, ...rooms])]
|
||||
|
||||
const known = Promise.all(
|
||||
ids.map(sid =>
|
||||
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
|
||||
// A stale local/server pointer must not be trusted merely because it looks
|
||||
// like a session id. Resolve every canonical pointer through the backend and
|
||||
// require the canonical Bot Chat title before the hide write. This is
|
||||
// deliberately fail-closed: an unavailable/old gateway may leave an old
|
||||
// Bot Chat visible, but it must never hide an unrelated user conversation.
|
||||
const verifiedCanonical = Promise.resolve()
|
||||
.then(() =>
|
||||
host.request('profiles.list', {
|
||||
include_sessions: true,
|
||||
preferred_session_ids: Object.fromEntries(canonical.map(entry => [entry.name, entry.id]))
|
||||
})
|
||||
)
|
||||
.then(res => {
|
||||
const profiles = Array.isArray(res?.profiles) ? res.profiles : []
|
||||
const valid = []
|
||||
|
||||
for (const entry of canonical) {
|
||||
const profile = profiles.find(item => item?.name === entry.name)
|
||||
const preferred = profile?.preferred_session
|
||||
const ids = [preferred?.id, preferred?.resolved_id, preferred?.session_id, preferred?.session_key]
|
||||
.filter(Boolean)
|
||||
.map(String)
|
||||
|
||||
if (String(preferred?.title || '').trim() === 'Bot Chat' && ids.includes(String(entry.id))) {
|
||||
valid.push(entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
.catch(() => [])
|
||||
|
||||
const known = verifiedCanonical.then(validCanonical =>
|
||||
Promise.all(
|
||||
[...new Set([...validCanonical, ...rooms])].map(sid =>
|
||||
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3363,19 +3396,28 @@ function createCanonicalChat(name) {
|
||||
*
|
||||
* Identity rules (hermes-agent#88200 — the row must open the session its
|
||||
* preview describes):
|
||||
* - grandfather: no pin + existing history adopts the previewed session
|
||||
* - grandfather: no pin + an existing Bot Chat adopts the previewed session
|
||||
* (`history`, the roster's last_session for this bot) instead of minting
|
||||
* a new empty chat;
|
||||
* a new empty chat. Ordinary user conversations are never adopted;
|
||||
* `last_session` is only a recency hint, not an ownership proof;
|
||||
* - a live pin is verified through the backend's precise preferred_session
|
||||
* resolver (hidden rows still resolve; compression lineages resolve to
|
||||
* the live tip) — never inferred from a paginated, hidden-excluding
|
||||
* session.list window, which misjudged real hidden pins as gone;
|
||||
* - transient lookup failures keep the pin: try the stored id as-is, and
|
||||
* only a rejected open enters recovery. */
|
||||
function isCanonicalBotChatHistory(history) {
|
||||
const rootTitle = String(history?.root_title || '').trim()
|
||||
const title = String(history?.title || '').trim()
|
||||
return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat')
|
||||
}
|
||||
|
||||
async function openBotCanonicalChat(name, pinned, history) {
|
||||
if (!pinned) {
|
||||
// Grandfather: adopt the conversation the row already previews.
|
||||
const adoptId = history?.id
|
||||
// Grandfather only an actual Bot Chat. `last_session` is merely the most
|
||||
// recent row for the profile; adopting it blindly can claim an unrelated
|
||||
// user conversation and the hide sweep would then hide that conversation.
|
||||
const adoptId = isCanonicalBotChatHistory(history) ? history.id : null
|
||||
if (adoptId && typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(name, adoptId, history)
|
||||
saveBotMeta(name, { chat: adoptId })
|
||||
@@ -3411,7 +3453,7 @@ async function openBotCanonicalChat(name, pinned, history) {
|
||||
return openStoredBotChat(name, pinned, history)
|
||||
}
|
||||
|
||||
if (preferred) {
|
||||
if (preferred && isCanonicalBotChatHistory(preferred)) {
|
||||
try {
|
||||
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
|
||||
return pinned
|
||||
@@ -3424,9 +3466,19 @@ async function openBotCanonicalChat(name, pinned, history) {
|
||||
}
|
||||
}
|
||||
|
||||
if (preferred) {
|
||||
// The stored pointer resolved to a real session, but not to Bot Mode's
|
||||
// plumbing session. Treat it as corrupted metadata rather than opening or
|
||||
// hiding the user's ordinary conversation.
|
||||
await saveBotMeta(name, { chat: null })
|
||||
return createCanonicalChat(name)
|
||||
}
|
||||
|
||||
// Definitively gone (db reset, or the lineage was rewritten past
|
||||
// recovery): re-anchor on the previewed session when there is one.
|
||||
const recoveryId = history?.id
|
||||
// A previewed row is safe to re-anchor only when it is Bot Mode plumbing.
|
||||
// Otherwise a stale pin must not steal the profile's ordinary latest chat.
|
||||
const recoveryId = isCanonicalBotChatHistory(history) ? history.id : null
|
||||
if (recoveryId && typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(name, recoveryId, history)
|
||||
saveBotMeta(name, { chat: recoveryId })
|
||||
|
||||
@@ -33,7 +33,7 @@ function loadOpenPath({ openSession, request }) {
|
||||
return { ...context.__open, saved, requests, host: context.host }
|
||||
}
|
||||
|
||||
const HISTORY = { id: 'hist-1', title: 'Weekly review', preview: 'history preview', last_active: 1000 }
|
||||
const HISTORY = { id: 'hist-1', title: 'Bot Chat', preview: 'history preview', last_active: 1000 }
|
||||
|
||||
// ── grandfather: no pin + existing history adopts the previewed session ────
|
||||
|
||||
@@ -51,6 +51,23 @@ test('grandfather: no pin + history opens and pins THAT session, no new chat', a
|
||||
'must not mint a new chat when the previewed session can be adopted')
|
||||
})
|
||||
|
||||
test('safety: no pin + ordinary latest history creates a Bot Chat instead of claiming it', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method =>
|
||||
method === 'session.create'
|
||||
? { stored_session_id: 'safe-bot-chat', session_id: 'safe-bot-chat-runtime' }
|
||||
: {}
|
||||
})
|
||||
|
||||
const ordinary = { ...HISTORY, id: 'ordinary-1', title: '生产调度会优化' }
|
||||
const result = await runtime.openBotCanonicalChat('ops', null, ordinary)
|
||||
|
||||
assert.equal(result, 'safe-bot-chat')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'safe-bot-chat' } }])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), true)
|
||||
})
|
||||
|
||||
test('grandfather: no pin + no history keeps the creation flow', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
@@ -122,6 +139,32 @@ test('pin: preferred_session present opens the resolved session and keeps the pi
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.list'), false)
|
||||
})
|
||||
|
||||
test('safety: a pinned ordinary session is rejected and replaced with a Bot Chat', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: { id: 'ordinary-3', resolved_id: 'ordinary-3', title: '生产调度会优化' }
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (method === 'session.create') return { stored_session_id: 'safe-pinned-chat', session_id: 'safe-pinned-runtime' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'ordinary-3', HISTORY)
|
||||
|
||||
assert.equal(result, 'safe-pinned-chat')
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'safe-pinned-chat' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('pin: compression-rotated pin opens the live tip, keeps the durable pin', async () => {
|
||||
const opened = []
|
||||
const runtime = loadOpenPath({
|
||||
@@ -132,7 +175,7 @@ test('pin: compression-rotated pin opens the live tip, keeps the durable pin', a
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'root-1', resolved_id: 'tip-9', title: 'Bot Chat',
|
||||
id: 'root-1', resolved_id: 'tip-9', root_title: 'Bot Chat', title: 'Bot Chat (continued)',
|
||||
preview: 'post-compression', started_at: 1, last_active: 9, message_count: 42
|
||||
}
|
||||
}]
|
||||
@@ -167,6 +210,27 @@ test('pin: definitively gone pin re-pins to the previewed session, not rows[0]',
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false)
|
||||
})
|
||||
|
||||
test('safety: a dead pin does not re-anchor on an ordinary latest session', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
if (method === 'session.create') return { stored_session_id: 'safe-replacement', session_id: 'safe-replacement-runtime' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const ordinary = { ...HISTORY, id: 'ordinary-2', title: '生产调度会优化' }
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', ordinary)
|
||||
|
||||
assert.equal(result, 'safe-replacement')
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'safe-replacement' } }
|
||||
])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), true)
|
||||
})
|
||||
|
||||
test('pin: gone pin + no history clears the pin and creates', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
|
||||
@@ -80,7 +80,7 @@ test('regression: only an actually-missing pin triggers recovery', async () => {
|
||||
|
||||
// Definitively gone, but the roster still previews a live session —
|
||||
// recovery re-anchors on THAT session instead of minting a new chat.
|
||||
const history = { id: 'hist-1', title: 'Weekly review', preview: 'p', last_active: 1 }
|
||||
const history = { id: 'hist-1', title: 'Bot Chat', preview: 'p', last_active: 1 }
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'dead-pin', history), 'hist-1')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }])
|
||||
})
|
||||
|
||||
@@ -59,6 +59,14 @@ test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', asy
|
||||
host: {
|
||||
request: async (method, params) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [
|
||||
{ name: 'alpha', preferred_session: { id: 'chat-a', title: 'Bot Chat' } },
|
||||
{ name: 'beta', preferred_session: { id: 'chat-b', title: 'Bot Chat' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
},
|
||||
@@ -75,9 +83,36 @@ test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', asy
|
||||
vm.runInNewContext(section, context, { filename: 'h.js' })
|
||||
await context.__h.hideOwnedBotSessions()
|
||||
|
||||
const ids = calls.map(c => c.params.session_id).sort()
|
||||
const ids = calls.filter(c => c.method === 'session.set_hidden').map(c => c.params.session_id).sort()
|
||||
assert.deepEqual(ids, ['chat-a', 'chat-b', 'room-core-a', 'room-core-b'])
|
||||
assert.ok(calls.every(c => c.method === 'session.set_hidden' && c.params.hidden === true))
|
||||
const hiddenCalls = calls.filter(c => c.method === 'session.set_hidden')
|
||||
assert.ok(hiddenCalls.every(c => c.params.hidden === true))
|
||||
})
|
||||
|
||||
test('safety: a stale canonical pointer to an ordinary session is not hidden', async () => {
|
||||
const start = source.indexOf('function hideOwnedBotSessions()')
|
||||
const end = source.indexOf('/** Fetch server-side avatars', start)
|
||||
const calls = []
|
||||
const context = {
|
||||
host: {
|
||||
request: async (method, params) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{ name: 'default', preferred_session: { id: 'ordinary-1', title: '生产调度会优化' } }]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
},
|
||||
$botMeta: { get: () => ({ default: { chat: 'ordinary-1' } }) },
|
||||
$groupChats: { get: () => ({}) }
|
||||
}
|
||||
const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions };\n')
|
||||
vm.runInNewContext(section, context, { filename: 'h-stale.js' })
|
||||
await context.__h.hideOwnedBotSessions()
|
||||
|
||||
assert.equal(calls.some(c => c.method === 'session.set_hidden'), false)
|
||||
})
|
||||
|
||||
test('sweepBotProfileSessions hides Bot-Mode-titled rows per roster bot, and only those', async () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Owen-narcissus
|
||||
@@ -90,6 +90,7 @@ def test_preferred_session_summarizes_pin_not_latest(home):
|
||||
pref = row["preferred_session"]
|
||||
assert pref["id"] == "pinned1"
|
||||
assert pref["resolved_id"] == "pinned1"
|
||||
assert pref["root_title"] == "Bot Chat"
|
||||
assert pref["title"] == "Bot Chat"
|
||||
assert "pinned chat content" in pref["preview"]
|
||||
# last_session keeps its own contract: the most recently active session.
|
||||
@@ -156,6 +157,8 @@ def test_preferred_session_resolves_compression_tip(home):
|
||||
# The pin keeps its durable identity; the summary comes from the live tip.
|
||||
assert pref["id"] == "root1"
|
||||
assert pref["resolved_id"] == "tip1"
|
||||
assert pref["root_title"] == "Bot Chat"
|
||||
assert pref["title"] == "Bot Chat (continued)"
|
||||
assert "post-compression content" in pref["preview"]
|
||||
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ def _(rid, params: dict) -> dict:
|
||||
return {
|
||||
"id": session_id,
|
||||
"resolved_id": tip,
|
||||
"root_title": row.get("title") or "",
|
||||
"title": tip_row.get("title") or "",
|
||||
"preview": preview,
|
||||
"started_at": tip_row.get("started_at") or row.get("started_at") or 0,
|
||||
|
||||
Reference in New Issue
Block a user