device-pair: align internal command checks

This commit is contained in:
Josh Lehman 2026-03-22 17:54:43 -07:00
parent a61e5d17f0
commit 3fe96c7b9e
No known key found for this signature in database
GPG Key ID: D141B425AC7F876B
5 changed files with 104 additions and 0 deletions

View File

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

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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 */