fix(bot-mode): protect ordinary sessions from hide sweep

This commit is contained in:
Owenz-creator
2026-08-19 17:19:57 +08:00
committed by Teknium
parent d604ba6585
commit 8408edcfb5
7 changed files with 174 additions and 18 deletions
+65 -13
View File
@@ -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"]
+1
View File
@@ -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,