From 1bcec211d9efa3e6f68dbf0cdf8826da1be94f7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 11:30:26 -0700 Subject: [PATCH] fix(gateway): gate session.tool mirrors behind session subscriptions (#124732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.tool mirrors the raw agent tool event (full args/result snapshots) onto session subscribers, but the event name was missing from SESSION_SUBSCRIPTION_EVENTS. Clients with the session-scoped-events cap (or browser copilot) that subscribed to one session's messages therefore received every session's tool payloads through the mirror — the exact data the registry gate suppresses on the raw agent event. session.observer was added to the set for precisely this mirror-event class; session.tool carries strictly more data. Root cause: the gate is an allowlist and the newer mirror event never joined it. One set entry; the payload already carries sessionKey so registry keying works unchanged, and unscoped Control UI clients keep full fanout. Regression: new board test drives the server-chat-style targeted fanout and asserts a scoped client subscribed to a different session receives nothing — fails pre-fix. --- src/gateway/server-broadcast.board.test.ts | 32 ++++++++++++++++++++++ src/gateway/server-broadcast.ts | 4 +++ 2 files changed, 36 insertions(+) diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index cba874db37bf..b6013475e67f 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -164,6 +164,38 @@ describe("collaboration event scope guards", () => { expect(unsubscribed.socket.events).toEqual([]); }); + it("suppresses session.tool mirrors for scoped clients without a matching subscription", () => { + const subscribed = makeClient("subscribed", "operator", ["operator.read"]); + const otherSession = makeClient("other-session", "operator", ["operator.read"]); + const unscoped = makeClient("unscoped", "operator", ["operator.read"]); + for (const entry of [subscribed, otherSession]) { + entry.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS]; + } + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + sessionMessageSubscribers.subscribe(subscribed.client.connId, "agent:main:main"); + sessionMessageSubscribers.subscribe(otherSession.client.connId, "agent:main:other"); + const { broadcastToConnIds } = createGatewayBroadcaster({ + clients: new Set([subscribed.client, otherSession.client, unscoped.client]), + sessionMessageSubscribers, + }); + + // Mirrors the server-chat session.tool fanout: targeted at all session + // subscribers, session identity carried in the payload. + broadcastToConnIds( + "session.tool", + { sessionKey: "agent:main:main", tool: { name: "exec", args: { command: "ls" } } }, + new Set(["subscribed", "other-session", "unscoped"]), + { dropIfSlow: true }, + ); + + expect(subscribed.socket.events).toEqual(["session.tool"]); + // The scoped client subscribed to a different session must not receive + // another session's tool args via the mirror event. + expect(otherSession.socket.events).toEqual([]); + // Unscoped Control UI clients keep full fanout. + expect(unscoped.socket.events).toEqual(["session.tool"]); + }); + it("delivers prepared global observer audiences exactly once", () => { const main = makeClient("main", "operator", ["operator.read"]); const legacy = makeClient("legacy", "operator", ["operator.read"]); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 12b81f22c684..98a880d874cc 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -103,6 +103,10 @@ const SESSION_SUBSCRIPTION_EVENTS = new Set([ "chat", "chat.side_result", "session.observer", + // Mirrors the raw agent tool event (full args/result snapshots) onto + // session subscribers; omitting it here would hand scoped clients the + // exact payload the registry gate suppresses on the `agent` event. + "session.tool", ]); function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string {