import fs from "node:fs/promises" ;
import os from "node:os" ;
import path from "node:path" ;
import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/core" ;
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" ;
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js" ;
import type { OpenClawPluginApi } from "./api.js" ;
import type { PendingPairingRequest } from "./notify.ts" ;
const pluginApiMocks = vi.hoisted(() => ({
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "boot-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
})),
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw==" ),
resolveGatewayPort: vi.fn(() => 18789 ),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests" )),
writeQrPngTempFile: vi.fn(async (_data: string, opts: { tmpRoot: string }) => {
const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-" ));
const filePath = path.join(dirPath, "pair-qr.png" );
await fs.writeFile(filePath, "fakepng" );
return { filePath, dirPath, mediaLocalRoots: [dirPath] };
}),
}));
vi.mock("./api.js" , () => {
return {
PAIRING_SETUP_BOOTSTRAP_PROFILE: {
roles: ["node" ],
scopes: [],
},
approveDevicePairing: vi.fn(),
clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens,
definePluginEntry: vi.fn((entry) => entry),
issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken,
listDevicePairing: vi.fn(async () => ({ pending: [] })),
renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl,
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
runPluginCommandWithTimeout: vi.fn(),
writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile,
};
});
vi.mock("./notify.js" , () => ({
armPairNotifyOnce: vi.fn(async () => false ),
formatPendingRequests: vi.fn(() => "No pending device pairing requests." ),
handleNotifyCommand: vi.fn(async () => ({ text: "notify" })),
registerPairingNotifierService: vi.fn(),
}));
import { approveDevicePairing, listDevicePairing } from "./api.js" ;
import registerDevicePair from "./index.js" ;
type ListedPendingPairingRequest = Awaited<ReturnType<typeof listDevicePairing>>["pending" ][number];
type ApproveDevicePairingResolved = Awaited<ReturnType<typeof approveDevicePairing>>;
type ApprovedPairingResult = Extract<
NonNullable<ApproveDevicePairingResolved>,
{ status: "approved" }
>;
type ApprovedPairingDevice = ApprovedPairingResult["device" ];
const INTERNAL_PAIRING_SCOPES = ["operator.write" , "operator.pairing" ];
function createApi(params?: {
runtime?: OpenClawPluginApi["runtime" ];
pluginConfig?: Record<string, unknown>;
registerCommand?: (command: OpenClawPluginCommandDefinition) => void ;
}): OpenClawPluginApi {
return createTestPluginApi({
id: "device-pair" ,
name: "device-pair" ,
source: "test" ,
config: {
gateway: {
auth: {
mode: "token" ,
token: "gateway-token" ,
},
},
},
pluginConfig: {
publicUrl: "ws://51.79.175.165:18789",
...params?.pluginConfig,
},
runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime" ],
registerCommand: params?.registerCommand,
});
}
function registerPairCommand(params?: {
runtime?: OpenClawPluginApi["runtime" ];
pluginConfig?: Record<string, unknown>;
}): OpenClawPluginCommandDefinition {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
...params,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
if (!command) {
throw new Error("device-pair plugin did not register its /pair command" );
}
return command;
}
function requireText(result: { text?: unknown } | null | undefined): string {
if (typeof result?.text !== "string" ) {
throw new Error("pair command did not return a text response" );
}
return result.text;
}
function createChannelRuntime(
runtimeKey: string,
sendKey: string,
sendMessage: (...args: unknown[]) => Promise<unknown>,
): OpenClawPluginApi["runtime" ] {
return {
channel: {
outbound: {
loadAdapter: async (channelId: string) =>
channelId === runtimeKey
? ({
sendText: async ({ to, text, ...opts }: Record<string, unknown>) =>
await sendMessage(to, text, opts),
sendMedia: async ({ to, text, ...opts }: Record<string, unknown>) =>
await sendMessage(to, text, opts),
} as const )
: undefined,
},
},
} as unknown as OpenClawPluginApi["runtime" ];
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
return {
channel: "webchat" ,
isAuthorizedSender: true ,
commandBody: "/pair qr" ,
args: "qr" ,
config: {},
requestConversationBinding: async () => ({
status: "error" ,
message: "unsupported" ,
}),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null ,
...params,
};
}
function makePendingPairingRequest(
overrides: Partial<ListedPendingPairingRequest> = {},
): ListedPendingPairingRequest {
return {
requestId: "req-1" ,
deviceId: "victim-phone" ,
publicKey: "victim-public-key" ,
displayName: "Victim Phone" ,
platform: "ios" ,
ts: Date.now(),
...overrides,
};
}
function makeApprovedPairingDevice(
overrides: Partial<ApprovedPairingDevice> = {},
): ApprovedPairingDevice {
return {
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(),
...overrides,
};
}
function makeApprovedPairingResult(
overrides: Omit<Partial<ApprovedPairingResult>, "device" > & {
device?: Partial<ApprovedPairingDevice>;
} = {},
): ApprovedPairingResult {
const { device, ...resultOverrides } = overrides;
return {
status: "approved" ,
requestId: "req-1" ,
device: makeApprovedPairingDevice(device),
...resultOverrides,
};
}
function mockPendingPairingList() {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
paired: [],
});
}
function createInternalApproveLatestContext() {
return createCommandContext({
channel: "webchat" ,
args: "approve latest" ,
commandBody: "/pair approve latest" ,
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
});
}
function expectApproveCalledWithInternalPairingScopes() {
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1" , {
callerScopes: INTERNAL_PAIRING_SCOPES,
});
}
describe("device-pair /pair qr" , () => {
beforeEach(async () => {
vi.clearAllMocks();
pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({
token: "boot-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
});
await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true });
});
afterEach(async () => {
await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true , force: true });
});
it("returns an inline QR image for webchat surfaces" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
gatewayClientScopes: ["operator.write" , "operator.pairing" ],
}),
);
const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean };
const text = requireText(result);
expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1 );
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
profile: {
roles: ["node" ],
scopes: [],
},
});
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:" );
expect(payload.mediaUrl).toBe("data:image/png;base64,ZmFrZXBuZw==" );
expect(payload.sensitiveMedia).toBe(true );
expect(text).toContain("- Security: single-use bootstrap token" );
expect(text).toContain("**Important:** Run `/pair cleanup` after pairing finishes." );
expect(text).toContain("If this QR code leaks, run `/pair cleanup` immediately." );
expect(text).not.toContain("![OpenClaw pairing QR]" );
});
it("rejects qr setup for internal gateway callers without operator.pairing" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "qr" ,
commandBody: "/pair qr" ,
gatewayClientScopes: ["operator.write" ],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
it("reissues the bootstrap token if webchat QR rendering fails before falling back" , async () => {
pluginApiMocks.issueDeviceBootstrapToken
.mockResolvedValueOnce({
token: "first-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
})
.mockResolvedValueOnce({
token: "second-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
});
pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed" ));
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
gatewayClientScopes: ["operator.write" , "operator.pairing" ],
}),
);
const text = requireText(result);
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
token: "first-token" ,
});
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2 );
expect(text).toContain(
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead." ,
);
expect(text).toContain("Pairing setup code generated." );
});
it.each([
{
label: "Telegram" ,
runtimeKey: "telegram" ,
sendKey: "sendMessageTelegram" ,
ctx: {
channel: "telegram" ,
senderId: "123" ,
accountId: "default" ,
messageThreadId: 271 ,
},
expectedTarget: "123" ,
expectedOpts: {
accountId: "default" ,
threadId: 271 ,
},
},
{
label: "Discord" ,
runtimeKey: "discord" ,
sendKey: "sendMessageDiscord" ,
ctx: {
channel: "discord" ,
senderId: "123" ,
accountId: "default" ,
},
expectedTarget: "user:123" ,
expectedOpts: {
accountId: "default" ,
},
},
{
label: "Slack" ,
runtimeKey: "slack" ,
sendKey: "sendMessageSlack" ,
ctx: {
channel: "slack" ,
senderId: "user:U123" ,
accountId: "default" ,
messageThreadId: "1234567890.000001" ,
},
expectedTarget: "user:U123" ,
expectedOpts: {
accountId: "default" ,
threadId: "1234567890.000001" ,
},
},
{
label: "Signal" ,
runtimeKey: "signal" ,
sendKey: "sendMessageSignal" ,
ctx: {
channel: "signal" ,
senderId: "signal:+15551234567" ,
accountId: "default" ,
},
expectedTarget: "signal:+15551234567" ,
expectedOpts: {
accountId: "default" ,
},
},
{
label: "iMessage" ,
runtimeKey: "imessage" ,
sendKey: "sendMessageIMessage" ,
ctx: {
channel: "imessage" ,
senderId: "+15551234567" ,
accountId: "default" ,
},
expectedTarget: "+15551234567" ,
expectedOpts: {
accountId: "default" ,
},
},
{
label: "WhatsApp" ,
runtimeKey: "whatsapp" ,
sendKey: "sendMessageWhatsApp" ,
ctx: {
channel: "whatsapp" ,
senderId: "+15551234567" ,
accountId: "default" ,
},
expectedTarget: "+15551234567" ,
expectedOpts: {
accountId: "default" ,
verbose: false ,
},
},
])("sends $label a real QR image attachment" , async (testCase) => {
let sentPng = "" ;
const sendMessage = vi.fn().mockImplementation(async (_target, _caption, opts) => {
if (opts?.mediaUrl) {
sentPng = await fs.readFile(opts.mediaUrl, "utf8" );
}
return { messageId: "1" };
});
const command = registerPairCommand({
runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage),
});
const result = await command.handler(createCommandContext(testCase.ctx));
const text = requireText(result);
expect(sendMessage).toHaveBeenCalledTimes(1 );
const [target, caption, opts] = sendMessage.mock.calls[0 ] as [
string,
string,
{
mediaUrl?: string;
mediaLocalRoots?: string[];
accountId?: string;
} & Record<string, unknown>,
];
expect(target).toBe(testCase.expectedTarget);
expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:" );
expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup." );
expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately." );
expect(opts.mediaUrl).toMatch(/pair-qr\.png$/);
expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]);
expect(opts).toMatchObject(testCase.expectedOpts);
expect(sentPng).toBe("fakepng" );
await expect(fs.access(opts.mediaUrl!)).rejects.toThrow();
expect(text).toContain("QR code sent above." );
expect(text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes." );
});
it("reissues the bootstrap token after QR delivery failure before falling back" , async () => {
pluginApiMocks.issueDeviceBootstrapToken
.mockResolvedValueOnce({
token: "first-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
})
.mockResolvedValueOnce({
token: "second-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
});
const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed" ));
const command = registerPairCommand({
runtime: createChannelRuntime("discord" , "sendMessageDiscord" , sendMessage),
});
const result = await command.handler(
createCommandContext({
channel: "discord" ,
senderId: "123" ,
}),
);
const text = requireText(result);
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
token: "first-token" ,
});
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2 );
expect(text).toContain("Pairing setup code generated." );
expect(text).toContain("If this code leaks or you are done, run /pair cleanup" );
});
it("falls back to the setup code instead of ASCII when the channel cannot send media" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "msteams" ,
senderId: "8:orgid:123" ,
}),
);
const text = requireText(result);
expect(text).toContain("QR image delivery is not available on this channel" );
expect(text).toContain("Setup code:" );
expect(text).toContain("IMPORTANT: After pairing finishes, run /pair cleanup." );
expect(text).not.toContain("```" );
});
it("supports invalidating unused setup codes" , async () => {
const command = registerPairCommand();
const result = await command?.handler(
createCommandContext({
channel: "telegram" ,
args: "cleanup" ,
commandBody: "/pair cleanup" ,
}),
);
expect(pluginApiMocks.clearDeviceBootstrapTokens).toHaveBeenCalledTimes(1 );
expect(result).toEqual({ text: "Invalidated 2 unused setup codes." });
});
it("rejects cleanup for internal gateway callers without operator.pairing" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "cleanup" ,
commandBody: "/pair cleanup" ,
gatewayClientScopes: ["operator.write" ],
}),
);
expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
it("fails closed for cleanup when internal gateway scopes are absent" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "cleanup" ,
commandBody: "/pair cleanup" ,
gatewayClientScopes: undefined,
}),
);
expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
});
describe("device-pair /pair default setup code" , () => {
beforeEach(() => {
vi.clearAllMocks();
pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({
token: "boot-token" ,
expiresAtMs: Date.now() + 10 * 60 _000 ,
});
});
it("rejects setup code issuance for internal gateway callers without operator.pairing" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "" ,
commandBody: "/pair" ,
gatewayClientScopes: ["operator.write" ],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
it("rejects unknown subcommands that fall back to setup code issuance without operator.pairing" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "foo" ,
commandBody: "/pair foo" ,
gatewayClientScopes: ["operator.write" ],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
it("fails closed for webchat setup code issuance when scopes are absent" , async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "" ,
commandBody: "/pair" ,
gatewayClientScopes: undefined,
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
});
describe("device-pair notify pending formatting" , () => {
it("includes role and scopes for pending requests" , async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import ("./notify.ts" )>("./notify.ts" );
const pending: PendingPairingRequest[] = [
{
requestId: "req-1" ,
deviceId: "device-1" ,
displayName: "dev one" ,
platform: "ios" ,
role: "operator" ,
scopes: ["operator.admin" , "operator.read" ],
remoteIp: "198.51.100.2" ,
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("Pending device pairing requests:" );
expect(text).toContain("name=dev one" );
expect(text).toContain("platform=ios" );
expect(text).toContain("role=operator" );
expect(text).toContain("scopes=operator.admin, operator.read" );
expect(text).toContain("ip=198.51.100.2" );
});
it("falls back to roles list and no scopes when role/scopes are absent" , async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import ("./notify.ts" )>("./notify.ts" );
const pending: PendingPairingRequest[] = [
{
requestId: "req-2" ,
deviceId: "device-2" ,
roles: ["node" , "operator" ],
scopes: [],
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("role=node, operator" );
expect(text).toContain("scopes=none" );
});
});
describe("device-pair /pair approve" , () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects internal gateway callers without operator.pairing" , async () => {
mockPendingPairingList();
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 () => {
mockPendingPairingList();
vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult());
const command = registerPairCommand();
const result = await command.handler(createInternalApproveLatestContext());
expectApproveCalledWithInternalPairingScopes();
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
it("does not force an empty caller scope context for external approvals" , async () => {
mockPendingPairingList();
vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult());
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "telegram" ,
args: "approve latest" ,
commandBody: "/pair approve latest" ,
gatewayClientScopes: undefined,
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1" );
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
it("fails closed for approvals when internal gateway scopes are absent" , async () => {
mockPendingPairingList();
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat" ,
args: "approve latest" ,
commandBody: "/pair approve latest" ,
gatewayClientScopes: undefined,
}),
);
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers." ,
});
});
it("rejects approvals that request scopes above the caller session" , async () => {
mockPendingPairingList();
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
status: "forbidden" ,
reason: "caller-missing-scope" ,
scope: "operator.admin" ,
});
const command = registerPairCommand();
const result = await command.handler(createInternalApproveLatestContext());
expectApproveCalledWithInternalPairingScopes();
expect(result).toEqual({
text: "⚠️ This command requires operator.admin to approve this pairing request." ,
});
});
it("preserves approvals for non-gateway command surfaces" , async () => {
mockPendingPairingList();
vi.mocked(approveDevicePairing).mockResolvedValueOnce(
makeApprovedPairingResult({
device: {
scopes: ["operator.admin" ],
approvedScopes: ["operator.admin" ],
tokens: {
operator: {
token: "token-1" ,
role: "operator" ,
scopes: ["operator.admin" ],
createdAtMs: Date.now(),
},
},
},
}),
);
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "telegram" ,
args: "approve latest" ,
commandBody: "/pair approve latest" ,
gatewayClientScopes: undefined,
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1" );
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.18 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland