Skip to content

Commit c2b8ccd

Browse files
fix(gateway): deny session-scoped clients the broad sessions.subscribe
A browser-copilot / SESSION_SCOPED_EVENTS connection could call the legacy broad sessions.subscribe and join the flat sessionEventSubscribers set, which server-session-events unions into session.message / sessions.changed recipients. Those events are not in SESSION_SUBSCRIPTION_EVENTS, so they skip the per-tab opt-out filter, delivering every session's live transcript to a connection the per-tab model documents as isolated. Deny the broad path for session-scoped clients; they subscribe per session via sessions.messages.subscribe. Adds a regression test: browser-copilot broad sessions.subscribe is rejected and a foreign session's transcript is not delivered.
1 parent f136288 commit c2b8ccd

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

src/gateway/server-methods/sessions-subscriptions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
// Session and transcript event subscription handlers.
2+
import {
3+
GATEWAY_CLIENT_CAPS,
4+
hasGatewayClientCap,
5+
} from "../../../packages/gateway-protocol/src/client-info.js";
26
import {
37
ErrorCodes,
48
errorShape,
@@ -7,6 +11,7 @@ import {
711
} from "../../../packages/gateway-protocol/src/index.js";
812
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
913
import { normalizeAgentId } from "../../routing/session-key.js";
14+
import { isBrowserCopilotClient } from "../../utils/message-channel.js";
1015
import { canReviewOperatorApproval } from "../operator-approval-authorization.js";
1116
import { APPROVALS_SCOPE } from "../operator-scopes.js";
1217
import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js";
@@ -33,6 +38,25 @@ function resolveSessionMessageSubscriptionKey(params: {
3338

3439
export const sessionSubscriptionHandlers: GatewayRequestHandlers = {
3540
"sessions.subscribe": ({ client, context, respond }) => {
41+
// Session-scoped clients (browser copilot / SESSION_SCOPED_EVENTS) must not join the broad
42+
// sessions.changed/session.message fanout. The per-tab isolation invariant in
43+
// server-broadcast.ts (SESSION_SUBSCRIPTION_EVENTS) is enforced only on the per-session
44+
// subscription path; a scoped connection on the broad registry would receive every session's
45+
// live transcript. Such clients subscribe per session via sessions.messages.subscribe.
46+
if (
47+
isBrowserCopilotClient(client?.connect?.client) ||
48+
hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS)
49+
) {
50+
respond(
51+
false,
52+
undefined,
53+
errorShape(
54+
ErrorCodes.INVALID_REQUEST,
55+
"session-scoped clients must subscribe per session via sessions.messages.subscribe",
56+
),
57+
);
58+
return;
59+
}
3660
const connId = client?.connId?.trim();
3761
if (connId) {
3862
context.subscribeSessionEvents(connId);

src/gateway/session-message-events.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,81 @@ describe("session.message websocket events", () => {
305305
}
306306
});
307307

308+
test("denies browser-copilot the broad sessions.subscribe and blocks foreign session transcript delivery", async () => {
309+
const storePath = await createSessionStoreFile();
310+
await writeSessionStore({
311+
entries: {
312+
main: { sessionId: "sess-main", updatedAt: Date.now() },
313+
other: { sessionId: "sess-other", updatedAt: Date.now() },
314+
},
315+
storePath,
316+
});
317+
const copilotOrigin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
318+
const copilotClient = {
319+
id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT,
320+
version: "test",
321+
platform: "chrome",
322+
deviceFamily: "extension",
323+
mode: GATEWAY_CLIENT_MODES.UI,
324+
};
325+
const copilotIdentityPath = path.join(path.dirname(storePath), "copilot-repro-device.json");
326+
const scopedCaps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
327+
const pairingWs = await harness.openWs({ origin: copilotOrigin });
328+
const copilotWs = await harness.openWs({ origin: copilotOrigin });
329+
try {
330+
const pairedHello = await connectOk(pairingWs, {
331+
scopes: ["operator.read", "operator.write"],
332+
caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS],
333+
client: copilotClient,
334+
deviceIdentityPath: copilotIdentityPath,
335+
prePairDevice: true,
336+
browserOrigin: copilotOrigin,
337+
});
338+
const deviceToken = (pairedHello as { auth?: { deviceToken?: string } }).auth?.deviceToken;
339+
expect(deviceToken).toBeTruthy();
340+
pairingWs.close();
341+
342+
// The NEW "session-scoped / per-tab" principal connects (dedicated paired identity+origin).
343+
await connectOk(copilotWs, {
344+
scopes: ["operator.read", "operator.write"],
345+
caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS],
346+
client: copilotClient,
347+
deviceIdentityPath: copilotIdentityPath,
348+
deviceToken,
349+
skipDefaultAuth: true,
350+
});
351+
352+
// The legacy broad sessions.subscribe must be rejected for a session-scoped principal so it
353+
// cannot join the unscoped fanout that carries session.message/sessions.changed.
354+
const broad = await rpcReq(copilotWs, "sessions.subscribe");
355+
expect(broad.ok).toBe(false);
356+
expect(broad.error?.message ?? "").toContain("sessions.messages.subscribe");
357+
358+
// A DIFFERENT session's live transcript must NOT reach this connection.
359+
const foreignDelivery = onceMessage(
360+
copilotWs,
361+
(message) =>
362+
message.type === "event" &&
363+
message.event === "session.message" &&
364+
(message.payload as { sessionKey?: string } | undefined)?.sessionKey === "agent:main:other",
365+
1500,
366+
).then(
367+
() => true,
368+
() => false,
369+
);
370+
const appended = await appendAssistantMessageToSessionTranscript({
371+
sessionKey: "agent:main:other",
372+
text: "SECRET transcript that belongs to a different tab/session",
373+
storePath,
374+
});
375+
expect(appended.ok).toBe(true);
376+
await expect(foreignDelivery).resolves.toBe(false);
377+
} finally {
378+
pairingWs.close();
379+
copilotWs.close();
380+
}
381+
});
382+
308383
test("rejects client identity changes across a dedicated copilot pairing", async () => {
309384
const storePath = await createSessionStoreFile();
310385
const copilotOrigin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";

0 commit comments

Comments
 (0)