diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 536b8ca4912..204e8c95100 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -43,6 +43,7 @@ vi.mock("./notify.js", () => ({ registerPairingNotifierService: vi.fn(), })); +import { approveDevicePairing, listDevicePairing } from "./api.js"; import registerDevicePair from "./index.js"; function createApi(params?: { @@ -371,3 +372,89 @@ describe("device-pair /pair qr", () => { expect(result).toEqual({ text: "Invalidated 2 unused setup codes." }); }); }); + +describe("device-pair /pair approve", () => { + it("rejects internal gateway callers without operator.pairing", async () => { + vi.mocked(listDevicePairing).mockResolvedValueOnce({ + pending: [ + { + requestId: "req-1", + deviceId: "victim-phone", + publicKey: "victim-public-key", + displayName: "Victim Phone", + platform: "ios", + ts: Date.now(), + }, + ], + paired: [], + }); + + const command = registerPairCommand(); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "approve latest", + commandBody: "/pair approve latest", + gatewayClientScopes: ["operator.write"], + }), + ); + + expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); + expect(result).toEqual({ + text: "⚠️ This command requires operator.pairing for internal gateway callers.", + }); + }); + + it("allows internal gateway callers with operator.pairing", async () => { + vi.mocked(listDevicePairing).mockResolvedValueOnce({ + pending: [ + { + requestId: "req-1", + deviceId: "victim-phone", + publicKey: "victim-public-key", + displayName: "Victim Phone", + platform: "ios", + ts: Date.now(), + }, + ], + paired: [], + }); + vi.mocked(approveDevicePairing).mockResolvedValueOnce({ + status: "approved", + requestId: "req-1", + device: { + deviceId: "victim-phone", + publicKey: "victim-public-key", + displayName: "Victim Phone", + platform: "ios", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { + operator: { + token: "token-1", + role: "operator", + scopes: ["operator.pairing"], + createdAtMs: Date.now(), + }, + }, + createdAtMs: Date.now(), + approvedAtMs: Date.now(), + }, + }); + + const command = registerPairCommand(); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "approve latest", + commandBody: "/pair approve latest", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1"); + expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); + }); +}); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7a416b2b56e..fcba3af972d 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -577,6 +577,9 @@ export default definePluginEntry({ const args = ctx.args?.trim() ?? ""; const tokens = args.split(/\s+/).filter(Boolean); const action = tokens[0]?.toLowerCase() ?? ""; + const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes) + ? ctx.gatewayClientScopes + : null; api.logger.info?.( `device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${ action || "new" @@ -598,6 +601,15 @@ export default definePluginEntry({ } if (action === "approve") { + if ( + gatewayClientScopes && + !gatewayClientScopes.includes("operator.pairing") && + !gatewayClientScopes.includes("operator.admin") + ) { + return { + text: "⚠️ This command requires operator.pairing for internal gateway callers.", + }; + } const requested = tokens[1]?.trim(); const list = await listDevicePairing(); if (list.pending.length === 0) { diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index d40bd87e315..56e66e37d2c 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -37,6 +37,7 @@ export const handlePluginCommand: CommandHandler = async ( channel: command.channel, channelId: command.channelId, isAuthorizedSender: command.isAuthorizedSender, + gatewayClientScopes: params.ctx.GatewayClientScopes, commandBody: command.commandBodyNormalized, config: cfg, from: command.from, diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index d22c05af54f..682586b6cac 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -184,6 +184,7 @@ export async function executePluginCommand(params: { channel: string; channelId?: PluginCommandContext["channelId"]; isAuthorizedSender: boolean; + gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"]; commandBody: string; config: OpenClawConfig; from?: PluginCommandContext["from"]; @@ -217,6 +218,7 @@ export async function executePluginCommand(params: { channel, channelId: params.channelId, isAuthorizedSender, + gatewayClientScopes: params.gatewayClientScopes, args: sanitizedArgs, commandBody, config, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a49e4e79aa8..918832d07e7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -971,6 +971,8 @@ export type PluginCommandContext = { channelId?: ChannelId; /** Whether the sender is on the allowlist */ isAuthorizedSender: boolean; + /** Gateway client scopes for internal control-plane callers */ + gatewayClientScopes?: string[]; /** Raw command arguments after the command name */ args?: string; /** The full normalized command body */