fix(gateway): gate internal command persistence mutations

This commit is contained in:
Peter Steinberger
2026-03-22 22:44:31 -07:00
parent 81445a9010
commit 09faed6bd8
10 changed files with 229 additions and 4 deletions

View File

@@ -106,4 +106,92 @@ describe("phone-control plugin", () => {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("blocks internal operator.write callers from mutating phone control", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-"));
try {
let config: Record<string, unknown> = {
gateway: {
nodes: {
allowCommands: [],
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
},
},
};
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
config = next;
});
let command: OpenClawPluginCommandDefinition | undefined;
registerPhoneControl.register(
createApi({
stateDir,
getConfig: () => config,
writeConfig: writeConfigFile,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
if (!command) {
throw new Error("phone-control plugin did not register its command");
}
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.write"],
});
expect(String(res?.text ?? "")).toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("allows internal operator.admin callers to mutate phone control", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-"));
try {
let config: Record<string, unknown> = {
gateway: {
nodes: {
allowCommands: [],
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
},
},
};
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
config = next;
});
let command: OpenClawPluginCommandDefinition | undefined;
registerPhoneControl.register(
createApi({
stateDir,
getConfig: () => config,
writeConfig: writeConfigFile,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
if (!command) {
throw new Error("phone-control plugin did not register its command");
}
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
expect(String(res?.text ?? "")).toContain("sms.send");
expect(writeConfigFile).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});

View File

@@ -358,6 +358,11 @@ export default definePluginEntry({
}
if (action === "disarm") {
if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
return {
text: "⚠️ /phone disarm requires operator.admin for internal gateway callers.",
};
}
const res = await disarmNow({
api,
stateDir,
@@ -375,6 +380,11 @@ export default definePluginEntry({
}
if (action === "arm") {
if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
return {
text: "⚠️ /phone arm requires operator.admin for internal gateway callers.",
};
}
const group = parseGroup(tokens[1]);
if (!group) {
return { text: `Usage: /phone arm <group> [duration]\nGroups: ${formatGroupList()}` };