mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 10:50:58 +00:00
Extensions: require admin for config write commands (#56002)
* Extensions: require admin for config write commands * Tests: cover phone control disarm auth
This commit is contained in:
@@ -110,7 +110,11 @@ describe("phone-control plugin", () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => {
|
||||
expect(command.name).toBe("phone");
|
||||
|
||||
const res = await command.handler(createCommandContext("arm writes 30s"));
|
||||
const res = await command.handler({
|
||||
...createCommandContext("arm writes 30s"),
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
const text = String(res?.text ?? "");
|
||||
const nodes = (
|
||||
getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }
|
||||
@@ -139,6 +143,30 @@ describe("phone-control plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks external channel callers without operator.admin from mutating phone control", async () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
const res = await command.handler({
|
||||
...createCommandContext("arm writes 30s"),
|
||||
channel: "telegram",
|
||||
});
|
||||
|
||||
expect(String(res?.text ?? "")).toContain("requires operator.admin");
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks external channel callers without operator.admin from disarming phone control", async () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
const res = await command.handler({
|
||||
...createCommandContext("disarm"),
|
||||
channel: "telegram",
|
||||
});
|
||||
|
||||
expect(String(res?.text ?? "")).toContain("requires operator.admin");
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows internal operator.admin callers to mutate phone control", async () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
const res = await command.handler({
|
||||
@@ -151,4 +179,23 @@ describe("phone-control plugin", () => {
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows external channel callers with operator.admin to disarm phone control", async () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
await command.handler({
|
||||
...createCommandContext("arm writes 30s"),
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const res = await command.handler({
|
||||
...createCommandContext("disarm"),
|
||||
channel: "telegram",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(String(res?.text ?? "")).toContain("disarmed");
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,9 +358,9 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "disarm") {
|
||||
if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
return {
|
||||
text: "⚠️ /phone disarm requires operator.admin for internal gateway callers.",
|
||||
text: "⚠️ /phone disarm requires operator.admin.",
|
||||
};
|
||||
}
|
||||
const res = await disarmNow({
|
||||
@@ -380,9 +380,9 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "arm") {
|
||||
if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
return {
|
||||
text: "⚠️ /phone arm requires operator.admin for internal gateway callers.",
|
||||
text: "⚠️ /phone arm requires operator.admin.",
|
||||
};
|
||||
}
|
||||
const group = parseGroup(tokens[1]);
|
||||
|
||||
@@ -179,7 +179,9 @@ describe("talk-voice plugin", () => {
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const result = await command.handler(createCommandContext("set Claudia"));
|
||||
const result = await command.handler(
|
||||
createCommandContext("set Claudia", "webchat", ["operator.admin"]),
|
||||
);
|
||||
|
||||
expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({
|
||||
talk: {
|
||||
@@ -209,7 +211,7 @@ describe("talk-voice plugin", () => {
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]);
|
||||
|
||||
await command.handler(createCommandContext("set Ava"));
|
||||
await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"]));
|
||||
|
||||
expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({
|
||||
talk: {
|
||||
@@ -247,10 +249,18 @@ describe("talk-voice plugin", () => {
|
||||
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from non-gateway channels without scope check", async () => {
|
||||
it("rejects /voice set from non-gateway channels without operator.admin", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.writeConfigFile).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.writeConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
@@ -164,12 +164,11 @@ 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.` };
|
||||
// Persistent config writes require operator.admin on every channel.
|
||||
// Without this check, external channel senders could bypass the
|
||||
// admin-only config.patch RPC by reaching writeConfigFile indirectly.
|
||||
if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
|
||||
return { text: `⚠️ ${commandLabel} set requires operator.admin.` };
|
||||
}
|
||||
|
||||
const query = tokens.slice(1).join(" ").trim();
|
||||
|
||||
Reference in New Issue
Block a user