Skip to content

fix(controller): isolate sessions across bots to stop cross-channel transcript leak#1122

Open
anthhub wants to merge 1 commit into
mainfrom
fix/issue-980-conversation-isolation
Open

fix(controller): isolate sessions across bots to stop cross-channel transcript leak#1122
anthhub wants to merge 1 commit into
mainfrom
fix/issue-980-conversation-isolation

Conversation

@anthhub
Copy link
Copy Markdown

@anthhub anthhub commented Apr 15, 2026

What

Fix conversation record isolation so that two bots sharing a sessionKey never serve each other's transcripts or message counts.

Why

Closes #980.

P0 user report (微信群): a WeChat session card showed 「3 条消息」 while the user only exchanged 2, and its history contained an English-language Web conversation that did not belong to them.

Root cause in apps/controller/src/runtime/sessions-runtime.ts: listSessions assigned session.id = file.name (the bare <sessionKey>.jsonl). When two bots happened to use the same sessionKey, both rows had the same id. getSession(id) ran sessions.find(s => s.id === id) and returned whichever bot's session had the most recent updatedAt. Opening the WeChat card therefore fetched the Web transcript, and the count came from the wrong file.

File layout (agents/<botId>/sessions/<sessionKey>.jsonl) is already bot-namespaced at the filesystem level, so nothing on the write side was wrong. The bug was purely read-side id resolution.

How

  • Encode session.id as session:<base64url({botId, sessionKey})> so every card carries an unambiguous identity. base64url keeps the value safe for React keys and URL paths (used directly in /workspace/sessions/:id).
  • getSession(id) decodes the id and delegates to getSessionByKey(botId, sessionKey), which now matches on both fields instead of the filename alone.
  • Legacy <sessionKey>.jsonl ids are honored only when exactly one session matches. Ambiguous legacy ids (the exact condition that caused [P0][微信群(nexu-微信龙虾交流群 10)] 会话记录显示异常:条数不符并混入非本人 Web 对话,疑似会话隔离或归属错误 #980) return 404 rather than silently serving the wrong transcript.
  • All mutating paths (updateSession, resetSession, deleteSession, getChatHistory) go through getSession(id), so they inherit the fix.
  • Added a regression test that seeds two bots with identical shared.jsonl, and asserts unique ids plus fully isolated transcript reads.
  • Shipped a repro script at scripts/repro/issue-980-verify.mjs that seeds fixtures, drives the desktop renderer, and captures screenshots so reviewers can reproduce or regress-test the UX path locally.

Affected areas

  • Desktop app (Electron shell)
  • Controller (backend / API)
  • Web dashboard (React UI)
  • OpenClaw runtime
  • Skills
  • Shared schemas / packages
  • Build / CI / Tooling

Checklist

  • pnpm --filter @nexu/controller typecheck passes
  • Biome check on the changed files passes
  • tests/sessions-runtime.test.ts (new + existing isolation coverage) passes
  • pnpm generate-types — no API route/schema changes, response shape is unchanged
  • No credentials or tokens in code or logs
  • No any types introduced

Screenshots / recordings

Seeded two bots (repro-web-bot, repro-wechat-bot) with the same shared-session sessionKey. After the fix:

  • Clicking Repro WeChat Session renders only 「这是微信专属复现消息。」 with 1 条消息.
  • Clicking Repro Web Session renders only This is the WEB-only repro message. with 1 条消息.

On main the same fixture would serve the WeChat transcript to both cards (the newer updatedAt always wins), perfectly reproducing the #980 symptoms.

Notes for reviewers

listSessions emitted session.id = file.name (the sessionKey with .jsonl),
which is not unique across bots. When two bots had the same sessionKey
(e.g. WeChat bot and Web bot both using "shared-session"), getSession
matched by id alone and returned the session with the most recent
updatedAt — so opening one user's session could silently serve another
bot's transcript and message count, causing the cross-channel message
leak and wrong counters reported in #980.

- Encode session.id as base64url({botId, sessionKey}) so it is unique
  per bot. The id stays URL-safe and opaque to the frontend.
- getSession decodes the id and routes through getSessionByKey, which
  now matches on (botId, sessionKey) instead of the filename alone.
- Legacy "<sessionKey>.jsonl" ids are honored only when a single
  session matches; ambiguous legacy ids safely return 404 instead of
  returning the wrong bot's transcript.
- Add a regression test that seeds duplicate "shared.jsonl" under two
  bots and asserts isolated ids plus per-bot transcript contents.
- Ship a repro script (scripts/repro/issue-980-verify.mjs) that seeds
  fixtures, drives the desktop renderer, and captures before/after
  screenshots for manual verification.

Closes #980
@sentry
Copy link
Copy Markdown

sentry Bot commented Apr 15, 2026

Codecov Report

❌ Patch coverage is 0% with 249 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
scripts/repro/issue-980-verify.mjs 0.00% 203 Missing ⚠️
apps/controller/src/runtime/sessions-runtime.ts 0.00% 46 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant