fix(voice): require admin for voice set (#97874)

This commit is contained in:
Agustin Rivera
2026-06-29 15:41:26 -07:00
committed by GitHub
parent 825aafac57
commit cbdbb22c60
2 changed files with 78 additions and 14 deletions

View File

@@ -50,13 +50,19 @@ function createHarness(initialConfig: Record<string, unknown>) {
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");

View File

@@ -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.` };
}