fix(gateway): gate session.tool mirrors behind session subscriptions (#124732)

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.
This commit is contained in:
Peter Steinberger
2026-08-16 11:30:26 -07:00
committed by GitHub
parent b7fb951a94
commit 1bcec211d9
2 changed files with 36 additions and 0 deletions
@@ -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"]);
+4
View File
@@ -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 {