Skip to content

Commit c688f94

Browse files
fix(browser): scope per-tab threads to one browser session
Tab ids are only unique within a browser session - Chrome reissues them from a low counter every launch - but gateway sessions persist. Keying a thread on the tab id alone therefore handed a brand-new tab the previous launch's conversation for that id: hydrateHistory rendered a stranger's thread and new turns continued it. That breaks the panel's central promise that two tabs never mix context, and it hits low-numbered tabs after every restart. Fold a per-launch nonce (kept in storage.session, alongside the generation map it already scopes) into the derived key, and refuse to derive a key without it rather than falling back to a colliding one. This trades resume-across-restart, which tab-id reuse had already made meaningless, for correctness.
1 parent 411a896 commit c688f94

5 files changed

Lines changed: 82 additions & 17 deletions

File tree

docs/tools/chrome-extension.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,12 @@ my details", "summarize this thread" — the agent drives that tab through the
125125
normal `browser` tool.
126126

127127
- **Per-tab conversations.** Each tab chats in its own gateway session
128-
(`…:thread:tab-<id>`), so two tabs never mix context. Reopening the panel on
129-
the same tab resumes its conversation; **New chat** starts a fresh one.
128+
(`…:thread:tab-<browser-session>-<id>`), so two tabs never mix context.
129+
Reopening the panel on the same tab resumes its conversation; **New chat**
130+
starts a fresh one. Threads are scoped to one browser session on purpose:
131+
Chrome reissues tab ids from a low counter every launch, so a restart starts
132+
each tab fresh rather than handing it whichever conversation last held that
133+
id.
130134
- **Allow the panel's origin — required once.** The panel is a Chrome
131135
extension page, so its WebSocket always sends an
132136
`Origin: chrome-extension://<extension-id>` header, and the Gateway checks any

extensions/browser/chrome-extension/modules/panel-core.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export function deriveTabSessionKey(
55
mainSessionKey: unknown,
66
tabId: unknown,
7+
browserSession: unknown,
78
generation?: number,
89
): string | null;
910

extensions/browser/chrome-extension/modules/panel-core.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,31 @@
66
* Deterministic per-tab session key: each pinned tab converses in its own
77
* thread off the agent's main session, so reopening the panel on the same tab
88
* resumes the same conversation without any client-side storage of history.
9+
*
10+
* `browserSession` namespaces the key, and is required. Chrome restarts tab ids
11+
* from a low counter on every launch while gateway sessions persist, so a bare
12+
* tab id would hand a brand-new tab the previous session's conversation for that
13+
* id — the exact context bleed "the tab is the conversation" promises against.
14+
* Scoping to a per-launch nonce trades resume-across-restart, which tab-id reuse
15+
* had already made meaningless, for correctness.
16+
*
917
* `generation` supports "New chat" for write-scope clients: sessions.reset is
1018
* operator.admin-only and sessions.create adopts an existing key, so a fresh
1119
* thread is minted by bumping the suffix instead of resetting in place.
1220
*/
13-
export function deriveTabSessionKey(mainSessionKey, tabId, generation = 0) {
21+
export function deriveTabSessionKey(mainSessionKey, tabId, browserSession, generation = 0) {
1422
if (typeof mainSessionKey !== "string" || !mainSessionKey || typeof tabId !== "number") {
1523
return null;
1624
}
25+
// No nonce, no key: falling back to a bare tab id would silently reintroduce
26+
// the cross-restart collision this parameter exists to close.
27+
if (typeof browserSession !== "string" || !browserSession) {
28+
return null;
29+
}
1730
const threadIndex = mainSessionKey.indexOf(":thread:");
1831
const base = threadIndex === -1 ? mainSessionKey : mainSessionKey.slice(0, threadIndex);
1932
const generationSuffix = generation > 0 ? `-g${generation}` : "";
20-
return `${base}:thread:tab-${tabId}${generationSuffix}`;
33+
return `${base}:thread:tab-${browserSession}-${tabId}${generationSuffix}`;
2134
}
2235

2336
/**

extensions/browser/chrome-extension/modules/panel-core.test.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,49 @@ import {
1313
} from "./panel-core.js";
1414

1515
describe("deriveTabSessionKey", () => {
16+
const B = "b1c2d3e4f5a6";
17+
1618
it("threads the tab off the agent main key", () => {
17-
expect(deriveTabSessionKey("agent:main:main", 42)).toBe("agent:main:main:thread:tab-42");
19+
expect(deriveTabSessionKey("agent:main:main", 42, B)).toBe(
20+
`agent:main:main:thread:tab-${B}-42`,
21+
);
1822
});
1923

2024
it("strips an existing :thread: suffix before threading", () => {
21-
expect(deriveTabSessionKey("agent:main:main:thread:xyz", 7)).toBe(
22-
"agent:main:main:thread:tab-7",
25+
expect(deriveTabSessionKey("agent:main:main:thread:xyz", 7, B)).toBe(
26+
`agent:main:main:thread:tab-${B}-7`,
2327
);
2428
});
2529

2630
it("appends a generation suffix for fresh chats on the same tab", () => {
27-
expect(deriveTabSessionKey("agent:main:main", 7, 2)).toBe("agent:main:main:thread:tab-7-g2");
28-
expect(deriveTabSessionKey("agent:main:main", 7, 0)).toBe("agent:main:main:thread:tab-7");
31+
expect(deriveTabSessionKey("agent:main:main", 7, B, 2)).toBe(
32+
`agent:main:main:thread:tab-${B}-7-g2`,
33+
);
34+
expect(deriveTabSessionKey("agent:main:main", 7, B, 0)).toBe(
35+
`agent:main:main:thread:tab-${B}-7`,
36+
);
37+
});
38+
39+
it("gives the same tab id a different thread in a different browser session", () => {
40+
// Chrome restarts tab ids from a low counter each launch while gateway
41+
// sessions persist, so without this a new tab would resume a stranger's
42+
// conversation. Same id, same generation, different launch -> different key.
43+
const first = deriveTabSessionKey("agent:main:main", 1, "aaaaaaaaaaaa");
44+
const second = deriveTabSessionKey("agent:main:main", 1, "bbbbbbbbbbbb");
45+
expect(first).not.toBe(second);
46+
});
47+
48+
it("returns null rather than falling back to a bare tab id", () => {
49+
// A missing nonce must not silently produce a colliding key.
50+
expect(deriveTabSessionKey("agent:main:main", 7, "")).toBeNull();
51+
expect(deriveTabSessionKey("agent:main:main", 7, undefined)).toBeNull();
52+
expect(deriveTabSessionKey("agent:main:main", 7, null)).toBeNull();
2953
});
3054

3155
it("returns null without a main key or tab id", () => {
32-
expect(deriveTabSessionKey(null, 7)).toBeNull();
33-
expect(deriveTabSessionKey("", 7)).toBeNull();
34-
expect(deriveTabSessionKey("agent:main:main", undefined)).toBeNull();
56+
expect(deriveTabSessionKey(null, 7, B)).toBeNull();
57+
expect(deriveTabSessionKey("", 7, B)).toBeNull();
58+
expect(deriveTabSessionKey("agent:main:main", undefined, B)).toBeNull();
3559
});
3660
});
3761

extensions/browser/chrome-extension/sidepanel.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,9 +373,32 @@ async function handleHelloOk(payload) {
373373
// Per-tab session binding
374374
// ---------------------------------------------------------------------------
375375

376-
// Tab ids are per-browser-session, so the generation map lives in
377-
// chrome.storage.session: it survives panel reloads (same tab resumes the
378-
// same thread) and resets with the browser (when tab ids recycle anyway).
376+
// Chrome hands out tab ids from a low counter that restarts with the browser,
377+
// while gateway sessions persist — so a key built on the tab id alone would give
378+
// a brand-new tab the previous launch's conversation for that id. This nonce
379+
// namespaces every key to one browser session. It lives in storage.session, so
380+
// it survives panel reloads (the same tab keeps its thread) and dies with the
381+
// browser, exactly when the ids it disambiguates start being reused.
382+
let browserSessionId = null;
383+
384+
async function browserSession() {
385+
if (browserSessionId) {
386+
return browserSessionId;
387+
}
388+
const stored = await chrome.storage.session.get(["browserSessionId"]);
389+
if (typeof stored.browserSessionId === "string" && stored.browserSessionId) {
390+
browserSessionId = stored.browserSessionId;
391+
return browserSessionId;
392+
}
393+
const bytes = new Uint8Array(6);
394+
crypto.getRandomValues(bytes);
395+
browserSessionId = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
396+
await chrome.storage.session.set({ browserSessionId });
397+
return browserSessionId;
398+
}
399+
400+
// The generation map is scoped the same way: it survives panel reloads and
401+
// resets with the browser, alongside the nonce that namespaces the keys.
379402
async function tabGeneration(tabId) {
380403
const stored = await chrome.storage.session.get(["tabSessionGen"]);
381404
return stored.tabSessionGen?.[tabId] ?? 0;
@@ -394,7 +417,7 @@ async function bindTabSession() {
394417
return;
395418
}
396419
const generation = await tabGeneration(pinnedTabId);
397-
const key = deriveTabSessionKey(mainSessionKey, pinnedTabId, generation);
420+
const key = deriveTabSessionKey(mainSessionKey, pinnedTabId, await browserSession(), generation);
398421
if (!key) {
399422
return;
400423
}
@@ -695,7 +718,7 @@ async function onNewChat() {
695718

696719
async function startFreshThread() {
697720
const generation = await bumpTabGeneration(pinnedTabId);
698-
sessionKey = deriveTabSessionKey(mainSessionKey, pinnedTabId, generation);
721+
sessionKey = deriveTabSessionKey(mainSessionKey, pinnedTabId, await browserSession(), generation);
699722
// The bump is persisted and sessionKey has already moved, so show the new
700723
// thread BEFORE the gateway round trips rather than after. If one of them
701724
// throws, the pane still matches where sends will actually land and the

0 commit comments

Comments
 (0)