diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index f4d4f9906951..f472e69a43b2 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -117,10 +117,36 @@ describe("GatewayClient", () => { ); } - test("uses a large maxPayload for node snapshots", () => { - const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); + function startGatewayClient(params: { url: string; tlsFingerprint?: string }) { + const client = new GatewayClient(params); client.start(); - const last = wsMockState.last as { url: unknown; opts: unknown } | null; + return wsMockState.last; + } + + function expectNoGatewayClientAgent(params: { url: string; tlsFingerprint?: string }) { + const last = startGatewayClient(params) as { opts: { agent?: unknown } } | null; + expect(last?.opts.agent).toBeUndefined(); + } + + function setCorporateNoProxy() { + process.env["NO_PROXY"] = "corp.example.com"; + process.env["no_proxy"] = "corp.example.com"; + } + + function setGatewayOnlyProxyEnv() { + process.env["HTTP_PROXY"] = "http://127.0.0.1:3128"; + process.env["HTTPS_PROXY"] = "http://127.0.0.1:3128"; + } + + function registerGatewayOnlyProxy() { + return registerActiveManagedProxyUrl(new URL("http://127.0.0.1:3128"), "gateway-only"); + } + + test("uses a large maxPayload for node snapshots", () => { + const last = startGatewayClient({ url: "ws://127.0.0.1:1" }) as { + url: unknown; + opts: unknown; + } | null; const opts = last?.opts as { maxPayload?: number } | undefined; expect(last?.url).toBe("ws://127.0.0.1:1"); @@ -128,52 +154,32 @@ describe("GatewayClient", () => { }); test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => { - const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); - client.start(); - const last = wsMockState.last as { opts: { agent?: unknown } } | null; - - expect(last?.opts.agent).toBeUndefined(); + expectNoGatewayClientAgent({ url: "ws://127.0.0.1:1" }); }); test("does not pass an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => { - const client = new GatewayClient({ url: "ws://[::1]:1" }); - client.start(); - const last = wsMockState.last as { opts: { agent?: unknown } } | null; - - expect(last?.opts.agent).toBeUndefined(); + expectNoGatewayClientAgent({ url: "ws://[::1]:1" }); }); test("does not pass an explicit direct agent for localhost hostnames", () => { - const client = new GatewayClient({ url: "ws://localhost:1" }); - client.start(); - const last = wsMockState.last as { opts: { agent?: unknown } } | null; - - expect(last?.opts.agent).toBeUndefined(); + expectNoGatewayClientAgent({ url: "ws://localhost:1" }); }); test("does not force a direct agent for remote Gateway WebSocket connections", () => { - const client = new GatewayClient({ + expectNoGatewayClientAgent({ url: "wss://gateway.example.com", tlsFingerprint: "SHA256:AA:BB", }); - client.start(); - const last = wsMockState.last as { opts: { agent?: unknown } } | null; - - expect(last?.opts.agent).toBeUndefined(); }); test("scopes Gateway loopback bypass to WebSocket connection setup without mutating NO_PROXY", () => { - process.env["NO_PROXY"] = "corp.example.com"; - process.env["no_proxy"] = "corp.example.com"; - const registration = registerActiveManagedProxyUrl( - new URL("http://127.0.0.1:3128"), - "gateway-only", - ); + setCorporateNoProxy(); + const registration = registerGatewayOnlyProxy(); try { - const client = new GatewayClient({ url: "ws://127.0.0.1:18789" }); - client.start(); - const last = wsMockState.last as { noProxyDuringConstruction: unknown } | null; + const last = startGatewayClient({ url: "ws://127.0.0.1:18789" }) as { + noProxyDuringConstruction: unknown; + } | null; expect(last?.noProxyDuringConstruction).toBe("corp.example.com"); expect(process.env["NO_PROXY"]).toBe("corp.example.com"); @@ -184,19 +190,12 @@ describe("GatewayClient", () => { }); test("scopes IPv6 loopback bypass during Gateway-only proxy mode connection setup", () => { - process.env["NO_PROXY"] = "corp.example.com"; - process.env["no_proxy"] = "corp.example.com"; - process.env["HTTP_PROXY"] = "http://127.0.0.1:3128"; - process.env["HTTPS_PROXY"] = "http://127.0.0.1:3128"; - const registration = registerActiveManagedProxyUrl( - new URL("http://127.0.0.1:3128"), - "gateway-only", - ); + setCorporateNoProxy(); + setGatewayOnlyProxyEnv(); + const registration = registerGatewayOnlyProxy(); try { - const client = new GatewayClient({ url: "ws://[::1]:18789" }); - client.start(); - const last = wsMockState.last as { + const last = startGatewayClient({ url: "ws://[::1]:18789" }) as { noProxyDuringConstruction: unknown; httpProxyDuringConstruction: unknown; httpsProxyDuringConstruction: unknown; @@ -306,6 +305,24 @@ function makeOperatorWsClient(connId: string, socket: TestSocket, scopes: string } as GatewayWsClient["connect"]); } +function makeOperatorWsClients( + entries: Array<{ connId: string; socket: TestSocket; scopes: string[] }>, +) { + return new Set( + entries.map(({ connId, socket, scopes }) => makeOperatorWsClient(connId, socket, scopes)), + ); +} + +function makeReadPairClients( + first: { connId: string; socket: TestSocket; scopes: string[] }, + readSocket: TestSocket, +) { + return makeOperatorWsClients([ + first, + { connId: "c-read", socket: readSocket, scopes: ["operator.read"] }, + ]); +} + function makeScopedBroadcastClients() { const pairingSocket = makeRecordingSocket(); const nodeSocket = makeRecordingSocket(); @@ -326,6 +343,42 @@ function makeScopedBroadcastClients() { return { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients }; } +function makeScopedBroadcastContext() { + const scoped = makeScopedBroadcastClients(); + return { + ...scoped, + ...createGatewayBroadcaster({ clients: scoped.clients }), + }; +} + +function sentEvents(socket: RecordingSocket) { + return socket.sent.map((frame) => frame.event); +} + +function expectSentEvents(socket: RecordingSocket, events: string[]) { + expect(sentEvents(socket)).toEqual(events); +} + +function sentEventSeq(socket: RecordingSocket) { + return socket.sent.map((frame) => [frame.event, frame.seq]); +} + +function chatPayload() { + return { sessionKey: "agent:main:main", message: "secret" }; +} + +function chatSideResultPayload() { + return { sessionKey: "agent:main:main", text: "tool output" }; +} + +function broadcastChatClassEvents( + broadcast: ReturnType["broadcast"], +) { + broadcast("chat", chatPayload()); + broadcast("agent", { type: "status", sessionKey: "agent:main:main" }); + broadcast("chat.side_result", chatSideResultPayload()); +} + describe("gateway broadcaster", () => { it("filters approval and pairing events by scope", () => { const approvalsSocket: TestSocket = { @@ -367,42 +420,25 @@ describe("gateway broadcaster", () => { }); it("requires operator.read for chat-class broadcast events", () => { - const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } = - makeScopedBroadcastClients(); + const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } = + makeScopedBroadcastContext(); - const { broadcast } = createGatewayBroadcaster({ clients }); - - broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }); - broadcast("agent", { type: "status", sessionKey: "agent:main:main" }); - broadcast("chat.side_result", { sessionKey: "agent:main:main", text: "tool output" }); + broadcastChatClassEvents(broadcast); expect(pairingSocket.send).not.toHaveBeenCalled(); expect(nodeSocket.send).not.toHaveBeenCalled(); expect(readSocket.send).toHaveBeenCalledTimes(3); expect(writeSocket.send).toHaveBeenCalledTimes(3); expect(adminSocket.send).toHaveBeenCalledTimes(3); - expect(readSocket.sent.map((frame) => frame.event)).toEqual([ - "chat", - "agent", - "chat.side_result", - ]); - expect(writeSocket.sent.map((frame) => frame.event)).toEqual([ - "chat", - "agent", - "chat.side_result", - ]); - expect(adminSocket.sent.map((frame) => frame.event)).toEqual([ - "chat", - "agent", - "chat.side_result", - ]); + const expectedEvents = ["chat", "agent", "chat.side_result"]; + expectSentEvents(readSocket, expectedEvents); + expectSentEvents(writeSocket, expectedEvents); + expectSentEvents(adminSocket, expectedEvents); }); it("allows plugin.* broadcast events for operator.write and operator.admin", () => { - const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } = - makeScopedBroadcastClients(); - - const { broadcast } = createGatewayBroadcaster({ clients }); + const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } = + makeScopedBroadcastContext(); broadcast("plugin.myplugin.custom", { data: "test" }); broadcast("plugin.otherplugin.state", { state: "updated" }); @@ -412,21 +448,14 @@ describe("gateway broadcaster", () => { expect(readSocket.send).not.toHaveBeenCalled(); expect(writeSocket.send).toHaveBeenCalledTimes(2); expect(adminSocket.send).toHaveBeenCalledTimes(2); - expect(writeSocket.sent.map((frame) => frame.event)).toEqual([ - "plugin.myplugin.custom", - "plugin.otherplugin.state", - ]); - expect(adminSocket.sent.map((frame) => frame.event)).toEqual([ - "plugin.myplugin.custom", - "plugin.otherplugin.state", - ]); + const expectedEvents = ["plugin.myplugin.custom", "plugin.otherplugin.state"]; + expectSentEvents(writeSocket, expectedEvents); + expectSentEvents(adminSocket, expectedEvents); }); it("defaults unknown events to deny and classifies remaining gateway broadcast events", () => { - const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } = - makeScopedBroadcastClients(); - - const { broadcast } = createGatewayBroadcaster({ clients }); + const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } = + makeScopedBroadcastContext(); broadcast("cron", { jobId: "job-1" }); broadcast("talk.mode", { enabled: true }); @@ -440,7 +469,7 @@ describe("gateway broadcaster", () => { broadcast("update.available", { updateAvailable: { version: "2026.4.20" } }); broadcast("unknown.future.event", { hidden: true }); - expect(pairingSocket.sent.map((frame) => frame.event)).toEqual([ + expectSentEvents(pairingSocket, [ "heartbeat", "presence", "health", @@ -448,7 +477,7 @@ describe("gateway broadcaster", () => { "shutdown", "update.available", ]); - expect(nodeSocket.sent.map((frame) => frame.event)).toEqual([ + expectSentEvents(nodeSocket, [ "voicewake.changed", "voicewake.routing.changed", "heartbeat", @@ -458,7 +487,7 @@ describe("gateway broadcaster", () => { "shutdown", "update.available", ]); - expect(readSocket.sent.map((frame) => frame.event)).toEqual([ + expectSentEvents(readSocket, [ "cron", "voicewake.changed", "voicewake.routing.changed", @@ -469,7 +498,7 @@ describe("gateway broadcaster", () => { "shutdown", "update.available", ]); - expect(writeSocket.sent.map((frame) => frame.event)).toEqual([ + const writeVisibleEvents = [ "cron", "talk.mode", "voicewake.changed", @@ -480,42 +509,32 @@ describe("gateway broadcaster", () => { "tick", "shutdown", "update.available", - ]); - expect(adminSocket.sent.map((frame) => frame.event)).toEqual([ - "cron", - "talk.mode", - "voicewake.changed", - "voicewake.routing.changed", - "heartbeat", - "presence", - "health", - "tick", - "shutdown", - "update.available", - ]); + ]; + expectSentEvents(writeSocket, writeVisibleEvents); + expectSentEvents(adminSocket, writeVisibleEvents); }); it("keeps event seq contiguous per receiving client when scoped events are filtered", () => { const pairingSocket = makeRecordingSocket(); const readSocket = makeRecordingSocket(); - const clients = new Set([ - makeOperatorWsClient("c-pairing", pairingSocket, ["operator.pairing"]), - makeOperatorWsClient("c-read", readSocket, ["operator.read"]), - ]); + const clients = makeReadPairClients( + { connId: "c-pairing", socket: pairingSocket, scopes: ["operator.pairing"] }, + readSocket, + ); const { broadcast } = createGatewayBroadcaster({ clients }); - broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }); + broadcast("chat", chatPayload()); broadcast("heartbeat", { ts: 1 }); - broadcast("chat.side_result", { sessionKey: "agent:main:main", text: "tool output" }); + broadcast("chat.side_result", chatSideResultPayload()); broadcast("tick", { ts: 2 }); - expect(pairingSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([ + expect(sentEventSeq(pairingSocket)).toEqual([ ["heartbeat", 1], ["tick", 2], ]); - expect(readSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([ + expect(sentEventSeq(readSocket)).toEqual([ ["chat", 1], ["heartbeat", 2], ["chat.side_result", 3], @@ -527,10 +546,10 @@ describe("gateway broadcaster", () => { const firstSocket = makeRecordingSocket(); const secondSocket = makeRecordingSocket(); const thirdSocket = makeRecordingSocket(); - const clients = new Set([ - makeOperatorWsClient("c-1", firstSocket, ["operator.read"]), - makeOperatorWsClient("c-2", secondSocket, ["operator.write"]), - makeOperatorWsClient("c-3", thirdSocket, ["operator.admin"]), + const clients = makeOperatorWsClients([ + { connId: "c-1", socket: firstSocket, scopes: ["operator.read"] }, + { connId: "c-2", socket: secondSocket, scopes: ["operator.write"] }, + { connId: "c-3", socket: thirdSocket, scopes: ["operator.admin"] }, ]); const payloadKeys: string[] = []; const payload = { @@ -560,21 +579,19 @@ describe("gateway broadcaster", () => { slowReadSocket.bufferedAmount = Number.MAX_SAFE_INTEGER; const readSocket = makeRecordingSocket(); - const clients = new Set([ - makeOperatorWsClient("c-slow-read", slowReadSocket, ["operator.read"]), - makeOperatorWsClient("c-read", readSocket, ["operator.read"]), - ]); + const clients = makeReadPairClients( + { connId: "c-slow-read", socket: slowReadSocket, scopes: ["operator.read"] }, + readSocket, + ); const { broadcast } = createGatewayBroadcaster({ clients }); - broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true }); + broadcast("chat", chatPayload(), { dropIfSlow: true }); slowReadSocket.bufferedAmount = 0; broadcast("heartbeat", { ts: 1 }); - expect(slowReadSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([ - ["heartbeat", 2], - ]); - expect(readSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([ + expect(sentEventSeq(slowReadSocket)).toEqual([["heartbeat", 2]]); + expect(sentEventSeq(readSocket)).toEqual([ ["chat", 1], ["heartbeat", 2], ]); @@ -593,7 +610,7 @@ describe("gateway broadcaster", () => { const { broadcast } = createGatewayBroadcaster({ clients }); - broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true }); + broadcast("chat", chatPayload(), { dropIfSlow: true }); broadcast("heartbeat", { ts: 1 }); const payloadEvent = events.find((event) => event.type === "payload.large"); @@ -755,6 +772,22 @@ describe("node subscription manager", () => { }); describe("resolveNodeCommandAllowlist", () => { + function expectAllowed(allow: { has: (cmd: string) => boolean }, commands: string[]) { + for (const cmd of commands) { + expect(allow.has(cmd)).toBe(true); + } + } + + function expectDenied(allow: { has: (cmd: string) => boolean }, commands: string[]) { + for (const cmd of commands) { + expect(allow.has(cmd)).toBe(false); + } + } + + function expectDangerousCommandsDenied(allow: { has: (cmd: string) => boolean }) { + expectDenied(allow, DEFAULT_DANGEROUS_NODE_COMMANDS); + } + it("includes iOS service commands by default", () => { const allow = resolveNodeCommandAllowlist( {}, @@ -764,18 +797,17 @@ describe("resolveNodeCommandAllowlist", () => { }, ); - expect(allow.has("device.info")).toBe(true); - expect(allow.has("device.status")).toBe(true); - expect(allow.has("system.notify")).toBe(true); - expect(allow.has("contacts.search")).toBe(true); - expect(allow.has("calendar.events")).toBe(true); - expect(allow.has("reminders.list")).toBe(true); - expect(allow.has("photos.latest")).toBe(true); - expect(allow.has("motion.activity")).toBe(true); - - for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) { - expect(allow.has(cmd)).toBe(false); - } + expectAllowed(allow, [ + "device.info", + "device.status", + "system.notify", + "contacts.search", + "calendar.events", + "reminders.list", + "photos.latest", + "motion.activity", + ]); + expectDangerousCommandsDenied(allow); }); it("includes Android notifications and device diagnostics commands by default", () => { @@ -787,13 +819,15 @@ describe("resolveNodeCommandAllowlist", () => { }, ); - expect(allow.has("notifications.list")).toBe(true); - expect(allow.has("notifications.actions")).toBe(true); - expect(allow.has("device.permissions")).toBe(true); - expect(allow.has("device.health")).toBe(true); - expect(allow.has("callLog.search")).toBe(true); - expect(allow.has("system.notify")).toBe(true); - expect(allow.has("sms.search")).toBe(false); + expectAllowed(allow, [ + "notifications.list", + "notifications.actions", + "device.permissions", + "device.health", + "callLog.search", + "system.notify", + ]); + expectDenied(allow, ["sms.search"]); }); it("treats sms.search as dangerous by default", () => { @@ -826,20 +860,18 @@ describe("resolveNodeCommandAllowlist", () => { }, ); - expect(allow.has("canvas.present")).toBe(false); - expect(allow.has("canvas.a2ui.pushJSONL")).toBe(false); - expect(allow.has("camera.list")).toBe(true); - expect(allow.has("location.get")).toBe(true); - expect(allow.has("device.info")).toBe(true); - expect(allow.has("device.status")).toBe(true); - expect(allow.has("screen.snapshot")).toBe(true); - expect(allow.has("system.run")).toBe(true); - expect(allow.has("system.which")).toBe(true); - expect(allow.has("system.notify")).toBe(true); - - for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) { - expect(allow.has(cmd)).toBe(false); - } + expectDenied(allow, ["canvas.present", "canvas.a2ui.pushJSONL"]); + expectAllowed(allow, [ + "camera.list", + "location.get", + "device.info", + "device.status", + "screen.snapshot", + "system.run", + "system.which", + "system.notify", + ]); + expectDangerousCommandsDenied(allow); }); it("can explicitly allow dangerous commands via allowCommands", () => { @@ -867,9 +899,8 @@ describe("resolveNodeCommandAllowlist", () => { }, ); - expect(allow.has("system.run")).toBe(false); - expect(allow.has("system.which")).toBe(false); - expect(allow.has("system.notify")).toBe(true); + expectDenied(allow, ["system.run", "system.which"]); + expectAllowed(allow, ["system.notify"]); }); it("normalizes dotted-I platform values to iOS classification", () => { @@ -881,9 +912,8 @@ describe("resolveNodeCommandAllowlist", () => { }, ); - expect(allow.has("system.run")).toBe(false); - expect(allow.has("system.which")).toBe(false); - expect(allow.has("device.info")).toBe(true); + expectDenied(allow, ["system.run", "system.which"]); + expectAllowed(allow, ["device.info"]); }); });