From cbdbb22c603e3df38093bb17cdfb8a399cf1b67f Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:41:26 -0700 Subject: [PATCH] fix(voice): require admin for voice set (#97874) --- extensions/talk-voice/index.test.ts | 69 ++++++++++++++++++++++++++--- extensions/talk-voice/index.ts | 23 ++++++---- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 1d98eac0ede..eafeacf28d3 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -50,13 +50,19 @@ function createHarness(initialConfig: Record) { return { command, runtime }; } -function createCommandContext(args: string, channel = "discord", gatewayClientScopes?: string[]) { +function createCommandContext( + args: string, + channel = "discord", + gatewayClientScopes?: string[], + senderIsOwner?: boolean, +) { return { args, channel, channelId: channel, isAuthorizedSender: true, gatewayClientScopes, + senderIsOwner, commandBody: args ? `/voice ${args}` : "/voice", config: {}, requestConversationBinding: vi.fn(), @@ -108,6 +114,12 @@ describe("talk-voice plugin", () => { }); }); + it("exposes owner status for mutating voice commands", () => { + const { command } = createHarness({}); + + expect(command.exposeSenderIsOwner).toBe(true); + }); + it("lists voices from the active provider", async () => { const { command, runtime } = createHarness({ talk: { @@ -307,17 +319,62 @@ describe("talk-voice plugin", () => { expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); - it("allows /voice set from non-gateway channels without operator.admin", async () => { - const { runtime, run } = createElevenlabsVoiceSetHarness("telegram"); + it.each(["telegram", "discord"])( + "rejects /voice set from %s channel without operator.admin", + async (channel) => { + const { runtime, run } = createElevenlabsVoiceSetHarness(channel); + const result = await run(); + + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); + }, + ); + + it("keeps read-only voice commands available without operator.admin", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const status = await command.handler(createCommandContext("status", "telegram")); + const list = await command.handler(createCommandContext("list", "telegram")); + + expect(status.text).toContain("Talk voice status:"); + expect(list.text).toContain("ElevenLabs voices: 1"); + expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); + }); + + it("allows /voice set when operator.admin is present on a non-webchat channel", async () => { + const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]); const result = await run(); expect(runtime.config.mutateConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); - it("allows /voice set when operator.admin is present on a non-webchat channel", async () => { - const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]); - const result = await run(); + it("allows /voice set from an owner non-gateway channel without scopes", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler( + createCommandContext("set Claudia", "telegram", undefined, true), + ); expect(runtime.config.mutateConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 1400b8b774a..4a89b4a3486 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -113,14 +113,15 @@ function asProviderBaseUrl(value: unknown): string | undefined { const TALK_ADMIN_SCOPE = "operator.admin"; -function requiresAdminToSetVoice( - channel: string, - gatewayClientScopes?: readonly string[], -): boolean { +function requiresAdminToSetVoice(params: { + senderIsOwner?: boolean; + gatewayClientScopes?: readonly string[]; +}): boolean { + const { senderIsOwner, gatewayClientScopes } = params; if (Array.isArray(gatewayClientScopes)) { return !gatewayClientScopes.includes(TALK_ADMIN_SCOPE); } - return channel === "webchat"; + return senderIsOwner !== true; } export default definePluginEntry({ @@ -135,6 +136,7 @@ export default definePluginEntry({ }, description: "List/set Talk provider voices (affects iOS Talk playback).", acceptsArgs: true, + exposeSenderIsOwner: true, handler: async (ctx) => { const commandLabel = resolveCommandLabel(ctx.channel); const args = ctx.args?.trim() ?? ""; @@ -187,9 +189,14 @@ export default definePluginEntry({ } if (action === "set") { - // Gateway callers can override messageChannel, so scope presence is - // the reliable signal for internal admin-only mutations. - if (requiresAdminToSetVoice(ctx.channel, ctx.gatewayClientScopes)) { + // Persistent Talk voice changes are gateway config writes, so the + // mutating subcommand requires explicit admin or owner authority. + if ( + requiresAdminToSetVoice({ + senderIsOwner: ctx.senderIsOwner, + gatewayClientScopes: ctx.gatewayClientScopes, + }) + ) { return { text: `⚠️ ${commandLabel} set requires operator.admin.` }; }