mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix(talk-voice): enforce operator.admin scope on /voice set config writes (#54461)
* fix(talk-voice): enforce operator.admin scope on /voice set config writes * fix(talk-voice): align scope guard with phone-control pattern Use optional chaining (?.) instead of Array.isArray so webchat callers with undefined scopes are rejected, matching the established pattern in phone-control. Add test for webchat-with-no-scopes case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,12 +27,17 @@ function createHarness(config: Record<string, unknown>) {
|
||||
return { command, runtime };
|
||||
}
|
||||
|
||||
function createCommandContext(args: string, channel: string = "discord") {
|
||||
function createCommandContext(
|
||||
args: string,
|
||||
channel: string = "discord",
|
||||
gatewayClientScopes?: string[],
|
||||
) {
|
||||
return {
|
||||
args,
|
||||
channel,
|
||||
channelId: channel,
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes,
|
||||
commandBody: args ? `/voice ${args}` : "/voice",
|
||||
config: {},
|
||||
requestConversationBinding: vi.fn(),
|
||||
@@ -200,6 +205,87 @@ describe("talk-voice plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects /voice set from gateway client with only operator.write scope", 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", "webchat", ["operator.write"]),
|
||||
);
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from gateway client with operator.admin scope", 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", "webchat", ["operator.admin"]),
|
||||
);
|
||||
|
||||
expect(runtime.config.writeConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
// gatewayClientScopes omitted — simulates internal webchat session without scopes
|
||||
const result = await command.handler(createCommandContext("set Claudia", "webchat"));
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from non-gateway channels without scope check", 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"));
|
||||
|
||||
expect(runtime.config.writeConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
it("returns provider lookup errors cleanly", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
|
||||
@@ -164,6 +164,14 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "set") {
|
||||
// Persistent config writes require operator.admin for gateway clients.
|
||||
// Without this check, a caller with only operator.write could bypass the
|
||||
// admin-only config.patch RPC by reaching writeConfigFile indirectly
|
||||
// through chat.send → /voice set.
|
||||
if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
return { text: `⚠️ ${commandLabel} set requires operator.admin for gateway clients.` };
|
||||
}
|
||||
|
||||
const query = tokens.slice(1).join(" ").trim();
|
||||
if (!query) {
|
||||
return { text: `Usage: ${commandLabel} set <voiceId|name>` };
|
||||
|
||||
Reference in New Issue
Block a user