From 42ecb7b43bbbbc60c160c986414818f99e7a5d79 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 8 Aug 2026 15:09:24 +0800 Subject: [PATCH] fix(plugin-sdk): enforce Talk session scope --- docs/plugins/sdk-runtime.md | 4 +-- src/gateway/talk-plugin-session.test.ts | 36 +++++++++++++++++++++++++ src/gateway/talk-plugin-session.ts | 9 ++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 9de5dd75aceb..1f46aa6144dc 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -423,8 +423,8 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination. session.close(); ``` - This method is available to trusted plugin request routes that declare the - `gatewayMethodDispatch` contract. Output audio is 24 kHz mono PCM16. + This method is available to Gateway-authenticated plugin routes with Talk access that declare + the `gatewayMethodDispatch` contract. Output audio is 24 kHz mono PCM16. diff --git a/src/gateway/talk-plugin-session.test.ts b/src/gateway/talk-plugin-session.test.ts index 66c40c9d2808..2e589d250bde 100644 --- a/src/gateway/talk-plugin-session.test.ts +++ b/src/gateway/talk-plugin-session.test.ts @@ -29,6 +29,7 @@ describe("plugin Talk session", () => { mocks.scope.mockReturnValue({ pluginId: "avatar", gatewayMethodDispatchAllowed: true, + client: { connect: { scopes: ["operator.talk"] } }, context: { logGateway: { warn: mocks.warn } }, }); mocks.createSession.mockResolvedValue({ relaySessionId: "relay-1" }); @@ -136,6 +137,41 @@ describe("plugin Talk session", () => { }); }); + it("closes a session whose event callback fails during creation", async () => { + mocks.createSession.mockImplementationOnce(async (params) => { + params.eventSink({ relaySessionId: "relay-1", type: "ready" }); + return { relaySessionId: "relay-1" }; + }); + + await expect( + openPluginTalkSession({ + sessionKey: "agent:main:avatar", + onEvent: () => { + throw new Error("renderer gone"); + }, + }), + ).rejects.toThrow("renderer gone"); + + expect(mocks.stopSession).toHaveBeenCalledWith({ + relaySessionId: "relay-1", + connId: expect.stringMatching(/^plugin:avatar:/), + }); + }); + + it("rejects plugin routes without Talk access", async () => { + mocks.scope.mockReturnValue({ + pluginId: "avatar", + gatewayMethodDispatchAllowed: true, + client: { connect: { scopes: ["operator.read"] } }, + context: { logGateway: { warn: mocks.warn } }, + }); + + await expect( + openPluginTalkSession({ sessionKey: "agent:main:avatar", onEvent: vi.fn() }), + ).rejects.toThrow("authenticated plugin request with Talk access"); + expect(mocks.createSession).not.toHaveBeenCalled(); + }); + it("requires an entitled request scope and a selected agent session", async () => { mocks.scope.mockReturnValue(undefined); await expect( diff --git a/src/gateway/talk-plugin-session.ts b/src/gateway/talk-plugin-session.ts index 90116d2e8c1e..d4286d7779ac 100644 --- a/src/gateway/talk-plugin-session.ts +++ b/src/gateway/talk-plugin-session.ts @@ -7,6 +7,7 @@ import { type PluginTalkSession, type PluginTalkSessionEvent, } from "../talk/plugin-session.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import type { TalkRealtimeRelayEvent } from "./talk-realtime-relay-state.js"; import { cancelTalkRealtimeRelayTurn, @@ -24,6 +25,12 @@ function requirePluginTalkScope() { "Interactive Talk sessions require a plugin request route that declares the gatewayMethodDispatch contract.", ); } + const operatorScopes = scope.client?.connect.scopes ?? []; + if (!authorizeOperatorScopesForMethod("talk.session.create", operatorScopes).allowed) { + throw new Error( + "Interactive Talk sessions require an authenticated plugin request with Talk access.", + ); + } return { context: scope.context, pluginId: scope.pluginId }; } @@ -153,7 +160,7 @@ export async function openPluginTalkSession( }); lifecycle.relaySessionId = session.relaySessionId; if (deliveryError) { - stopTalkRealtimeRelaySession({ relaySessionId, connId: ownerId }); + stopTalkRealtimeRelaySession({ relaySessionId: session.relaySessionId, connId: ownerId }); throw deliveryError; }