重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,20 @@
export {
approveDevicePairing,
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
listDevicePairing,
revokeDeviceBootstrapToken,
type DeviceBootstrapProfile,
} from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export {
resolveGatewayBindUrl,
resolveGatewayPort,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk/core";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
} from "openclaw/plugin-sdk/sandbox";
export { renderQrPngBase64 } from "./qr-image.js";

View file

@ -0,0 +1,782 @@
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 })),
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
resolveGatewayPort: vi.fn(() => 18789),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
}));
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: [] })),
renderQrPngBase64: pluginApiMocks.renderQrPngBase64,
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
runPluginCommandWithTimeout: vi.fn(),
};
});
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"];
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,
};
}
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 text = requireText(result);
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
profile: {
roles: ["node"],
scopes: [],
},
});
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
expect(text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)");
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("```");
});
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.renderQrPngBase64.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 () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
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: [makePendingPairingRequest()],
paired: [],
});
vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult());
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", {
callerScopes: ["operator.write", "operator.pairing"],
});
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
it("does not force an empty caller scope context for external approvals", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
paired: [],
});
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 () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
paired: [],
});
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 () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
paired: [],
});
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
status: "forbidden",
reason: "caller-missing-scope",
scope: "operator.admin",
});
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", {
callerScopes: ["operator.write", "operator.pairing"],
});
expect(result).toEqual({
text: "⚠️ This command requires operator.admin to approve this pairing request.",
});
});
it("preserves approvals for non-gateway command surfaces", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [makePendingPairingRequest()],
paired: [],
});
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)." });
});
});

View file

@ -0,0 +1,798 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import {
clearDeviceBootstrapTokens,
definePluginEntry,
issueDeviceBootstrapToken,
listDevicePairing,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
renderQrPngBase64,
revokeDeviceBootstrapToken,
resolveGatewayBindUrl,
resolveGatewayPort,
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
resolveTailnetHostWithRunner,
type OpenClawPluginApi,
} from "./api.js";
import {
armPairNotifyOnce,
formatPendingRequests,
handleNotifyCommand,
registerPairingNotifierService,
} from "./notify.js";
import {
approvePendingPairingRequest,
selectPendingApprovalRequest,
} from "./pair-command-approve.js";
import {
buildMissingPairingScopeReply,
resolvePairingCommandAuthState,
} from "./pair-command-auth.js";
async function renderQrDataUrl(data: string): Promise<string> {
const pngBase64 = await renderQrPngBase64(data);
return `data:image/png;base64,${pngBase64}`;
}
async function writeQrPngTempFile(data: string): Promise<string> {
const pngBase64 = await renderQrPngBase64(data);
const tmpRoot = resolvePreferredOpenClawTmpDir();
const qrDir = await mkdtemp(path.join(tmpRoot, "device-pair-qr-"));
const filePath = path.join(qrDir, "pair-qr.png");
await writeFile(filePath, Buffer.from(pngBase64, "base64"));
return filePath;
}
function formatDurationMinutes(expiresAtMs: number): string {
const msRemaining = Math.max(0, expiresAtMs - Date.now());
const minutes = Math.max(1, Math.ceil(msRemaining / 60_000));
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
type DevicePairPluginConfig = {
publicUrl?: string;
};
type SetupPayload = {
url: string;
bootstrapToken: string;
expiresAtMs: number;
};
type ResolveUrlResult = {
url?: string;
source?: string;
error?: string;
};
type ResolveAuthLabelResult = {
label?: "token" | "password";
error?: string;
};
type QrCommandContext = {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
type QrChannelSender = {
createOpts: (params: {
ctx: QrCommandContext;
qrFilePath: string;
mediaLocalRoots: string[];
accountId?: string;
}) => Record<string, unknown>;
};
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
telegram: {
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(accountId ? { accountId } : {}),
}),
},
discord: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
slack: {
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(ctx.messageThreadId != null ? { threadId: String(ctx.messageThreadId) } : {}),
...(accountId ? { accountId } : {}),
}),
},
signal: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
imessage: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
whatsapp: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
verbose: false,
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
};
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = normalizeOptionalString(raw);
if (!candidate) {
return null;
}
const parsedUrl = parseNormalizedGatewayUrl(candidate);
if (parsedUrl) {
return parsedUrl;
}
const hostPort = normalizeOptionalString(candidate.split("/", 1)[0]) ?? "";
return hostPort ? `${schemeFallback}://${hostPort}` : null;
}
function parseNormalizedGatewayUrl(raw: string): string | null {
try {
const parsed = new URL(raw);
const scheme = parsed.protocol.slice(0, -1);
const normalizedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme;
if (!(normalizedScheme === "ws" || normalizedScheme === "wss")) {
return null;
}
if (!parsed.hostname) {
return null;
}
return `${normalizedScheme}://${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
return null;
}
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },
): "ws" | "wss" {
if (opts?.forceSecure) {
return "wss";
}
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function parseIPv4Octets(address: string): [number, number, number, number] | null {
const parts = address.split(".");
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return null;
}
return octets as [number, number, number, number];
}
function isPrivateIPv4(address: string): boolean {
const octets = parseIPv4Octets(address);
if (!octets) {
return false;
}
const [a, b] = octets;
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
return false;
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIPv4Octets(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
// Keep the numeric check for older Node runtimes that reported family as 4.
const isIpv4 = family === "IPv4" || (family as unknown) === 4;
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = normalizeOptionalString(entry.address) ?? "";
if (!address) {
continue;
}
if (predicate(address)) {
return address;
}
}
}
return null;
}
function pickLanIPv4(): string | null {
return pickMatchingIPv4(isPrivateIPv4);
}
function pickTailnetIPv4(): string | null {
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(): Promise<string | null> {
return await resolveTailnetHostWithRunner((argv, opts) =>
runPluginCommandWithTimeout({
argv,
timeoutMs: opts.timeoutMs,
}),
);
}
function resolveAuthLabel(cfg: OpenClawPluginApi["config"]): ResolveAuthLabelResult {
const mode = cfg.gateway?.auth?.mode;
const token =
pickFirstDefined([process.env.OPENCLAW_GATEWAY_TOKEN, cfg.gateway?.auth?.token]) ?? undefined;
const password =
pickFirstDefined([process.env.OPENCLAW_GATEWAY_PASSWORD, cfg.gateway?.auth?.password]) ??
undefined;
if (mode === "token" || mode === "password") {
return resolveRequiredAuthLabel(mode, { token, password });
}
if (token) {
return { label: "token" };
}
if (password) {
return { label: "password" };
}
return { error: "Gateway auth is not configured (no token or password)." };
}
function pickFirstDefined(candidates: Array<unknown>): string | null {
for (const value of candidates) {
const trimmed = normalizeOptionalString(value);
if (trimmed) {
return trimmed;
}
}
return null;
}
function resolveRequiredAuthLabel(
mode: "token" | "password",
values: { token?: string; password?: string },
): ResolveAuthLabelResult {
if (mode === "token") {
return values.token
? { label: "token" }
: { error: "Gateway auth is set to token, but no token is configured." };
}
return values.password
? { label: "password" }
: { error: "Gateway auth is set to password, but no password is configured." };
}
async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const cfg = api.config;
const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig;
const scheme = resolveScheme(cfg);
const port = resolveGatewayPort(cfg);
const configuredPublicUrl = normalizeOptionalString(pluginCfg.publicUrl);
if (configuredPublicUrl) {
const url = normalizeUrl(configuredPublicUrl, scheme);
if (url) {
return { url, source: "plugins.entries.device-pair.config.publicUrl" };
}
return { error: "Configured publicUrl is invalid." };
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost();
if (!host) {
return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
}
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
const remoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
if (remoteUrl) {
const url = normalizeUrl(remoteUrl, scheme);
if (url) {
return { url, source: "gateway.remote.url" };
}
}
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: pickTailnetIPv4,
pickLanHost: pickLanIPv4,
});
if (bindResult) {
return bindResult;
}
return {
error:
"Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.",
};
}
function encodeSetupCode(payload: SetupPayload): string {
const json = JSON.stringify(payload);
const base64 = Buffer.from(json, "utf8").toString("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function buildPairingFlowLines(stepTwo: string): string[] {
return [
"1) Open the iOS app → Settings → Gateway",
`2) ${stepTwo}`,
"3) Back here, run /pair approve",
"4) If this code leaks or you are done, run /pair cleanup",
];
}
function buildSecurityNoticeLines(params: {
kind: "setup code" | "QR code";
expiresAtMs: number;
markdown?: boolean;
}): string[] {
const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup";
const securityPrefix = params.markdown ? "- " : "";
const importantLine = params.markdown
? `**Important:** Run ${cleanupCommand} after pairing finishes.`
: `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`;
return [
`${securityPrefix}Security: single-use bootstrap token`,
`${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
"",
importantLine,
`If this ${params.kind} leaks, run ${cleanupCommand} immediately.`,
];
}
function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] {
return autoNotifyArmed
? [
"After scanning, wait here for the pairing request ping.",
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
"If the ping does not arrive, run `/pair approve latest` manually.",
]
: ["After scanning, run `/pair approve` to complete pairing."];
}
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs: payload.expiresAtMs,
}),
].join("\n");
}
function formatSetupInstructions(expiresAtMs: number): string {
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
"",
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs,
}),
].join("\n");
}
function buildQrInfoLines(params: {
payload: SetupPayload;
authLabel: string;
autoNotifyArmed: boolean;
expiresAtMs: number;
}): string[] {
return [
`Gateway: ${params.payload.url}`,
`Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
expiresAtMs: params.expiresAtMs,
}),
"",
...buildQrFollowUpLines(params.autoNotifyArmed),
"",
"If your camera still won’t lock on, run `/pair` for a pasteable setup code.",
];
}
function formatQrInfoMarkdown(params: {
payload: SetupPayload;
authLabel: string;
autoNotifyArmed: boolean;
expiresAtMs: number;
}): string {
return [
`- Gateway: ${params.payload.url}`,
`- Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
expiresAtMs: params.expiresAtMs,
markdown: true,
}),
"",
...buildQrFollowUpLines(params.autoNotifyArmed),
"",
"If your camera still won’t lock on, run `/pair` for a pasteable setup code.",
].join("\n");
}
function canSendQrPngToChannel(channel: string): boolean {
return channel in QR_CHANNEL_SENDERS;
}
function resolveQrReplyTarget(ctx: QrCommandContext): string {
if (ctx.channel === "discord") {
const senderId = normalizeOptionalString(ctx.senderId) ?? "";
if (senderId) {
return senderId.startsWith("user:") || senderId.startsWith("channel:")
? senderId
: `user:${senderId}`;
}
}
return (
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
""
);
}
const PAIR_SETUP_NON_ISSUING_ACTIONS = new Set([
"approve",
"cleanup",
"clear",
"notify",
"pending",
"revoke",
"status",
]);
function issuesPairSetupCode(action: string): boolean {
return !action || action === "qr" || !PAIR_SETUP_NON_ISSUING_ACTIONS.has(action);
}
async function issueSetupPayload(url: string): Promise<SetupPayload> {
const issuedBootstrap = await issueDeviceBootstrapToken({
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
return {
url,
bootstrapToken: issuedBootstrap.token,
expiresAtMs: issuedBootstrap.expiresAtMs,
};
}
async function sendQrPngToSupportedChannel(params: {
api: OpenClawPluginApi;
ctx: QrCommandContext;
target: string;
caption: string;
qrFilePath: string;
}): Promise<boolean> {
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
const accountId = normalizeOptionalString(params.ctx.accountId) || undefined;
const sender = QR_CHANNEL_SENDERS[params.ctx.channel];
if (!sender) {
return false;
}
const adapter = await params.api.runtime.channel.outbound.loadAdapter(params.ctx.channel);
const send = adapter?.sendMedia;
if (!send) {
return false;
}
await send({
cfg: params.api.config,
to: params.target,
text: params.caption,
...sender.createOpts({
ctx: params.ctx,
qrFilePath: params.qrFilePath,
mediaLocalRoots,
accountId,
}),
});
return true;
}
export default definePluginEntry({
id: "device-pair",
name: "Device Pair",
description: "QR/bootstrap pairing helpers for OpenClaw devices",
register(api: OpenClawPluginApi) {
registerPairingNotifierService(api);
api.registerCommand({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
handler: async (ctx) => {
const args = normalizeOptionalString(ctx.args) ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = normalizeLowercaseStringOrEmpty(tokens[0]);
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: undefined;
const authState = resolvePairingCommandAuthState({
channel: ctx.channel,
gatewayClientScopes,
});
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
}`,
);
if (action === "status" || action === "pending") {
const list = await listDevicePairing();
return { text: formatPendingRequests(list.pending) };
}
if (action === "notify") {
const notifyAction = normalizeLowercaseStringOrEmpty(tokens[1]) || "status";
return await handleNotifyCommand({
api,
ctx,
action: notifyAction,
});
}
if (action === "approve") {
if (authState.isMissingInternalPairingPrivilege) {
return buildMissingPairingScopeReply();
}
const list = await listDevicePairing();
const selected = selectPendingApprovalRequest({
pending: list.pending,
requested: normalizeOptionalString(tokens[1]),
});
if (selected.reply) {
return selected.reply;
}
const pending = selected.pending;
if (!pending) {
return { text: "Pairing request not found." };
}
return await approvePendingPairingRequest({
requestId: pending.requestId,
callerScopes: authState.approvalCallerScopes,
});
}
if (action === "cleanup" || action === "clear" || action === "revoke") {
if (authState.isMissingInternalPairingPrivilege) {
return buildMissingPairingScopeReply();
}
const cleared = await clearDeviceBootstrapTokens();
return {
text:
cleared.removed > 0
? `Invalidated ${cleared.removed} unused setup code${cleared.removed === 1 ? "" : "s"}.`
: "No unused setup codes were active.",
};
}
const authLabelResult = resolveAuthLabel(api.config);
if (authLabelResult.error) {
return { text: `Error: ${authLabelResult.error}` };
}
if (issuesPairSetupCode(action) && authState.isMissingInternalPairingPrivilege) {
return buildMissingPairingScopeReply();
}
const urlResult = await resolveGatewayUrl(api);
if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
}
const authLabel = authLabelResult.label ?? "auth";
if (action === "qr") {
const channel = ctx.channel;
const target = resolveQrReplyTarget(ctx);
let autoNotifyArmed = false;
if (channel === "telegram" && target) {
try {
autoNotifyArmed = await armPairNotifyOnce({ api, ctx });
} catch (err) {
api.logger.warn?.(
`device-pair: failed to arm one-shot pairing notify (${(err as Error)?.message ?? err})`,
);
}
}
let payload = await issueSetupPayload(urlResult.url);
let setupCode = encodeSetupCode(payload);
const infoLines = buildQrInfoLines({
payload,
authLabel,
autoNotifyArmed,
expiresAtMs: payload.expiresAtMs,
});
if (target && canSendQrPngToChannel(channel)) {
let qrFilePath: string | undefined;
try {
qrFilePath = await writeQrPngTempFile(setupCode);
const sent = await sendQrPngToSupportedChannel({
api,
ctx,
target,
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
"\n",
),
qrFilePath,
});
if (sent) {
return {
text:
`QR code sent above.\n` +
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
"IMPORTANT: Run /pair cleanup after pairing finishes.",
};
}
} catch (err) {
api.logger.warn?.(
`device-pair: QR image send failed channel=${channel}, falling back (${(err as Error)?.message ?? err})`,
);
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
payload = await issueSetupPayload(urlResult.url);
setupCode = encodeSetupCode(payload);
} finally {
if (qrFilePath) {
await rm(path.dirname(qrFilePath), { recursive: true, force: true }).catch(
() => {},
);
}
}
}
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
if (channel === "webchat") {
let qrDataUrl: string;
try {
qrDataUrl = await renderQrDataUrl(setupCode);
} catch (err) {
api.logger.warn?.(
`device-pair: webchat QR render failed, falling back (${(err as Error)?.message ?? err})`,
);
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
payload = await issueSetupPayload(urlResult.url);
return {
text:
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" +
formatSetupReply(payload, authLabel),
};
}
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
formatQrInfoMarkdown({
payload,
authLabel,
autoNotifyArmed,
expiresAtMs: payload.expiresAtMs,
}),
"",
`![OpenClaw pairing QR](${qrDataUrl})`,
].join("\n"),
};
}
return {
text:
"QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
formatSetupReply(payload, authLabel),
};
}
const channel = ctx.channel;
const target =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
const payload = await issueSetupPayload(urlResult.url);
if (channel === "telegram" && target) {
try {
const runtimeKeys = Object.keys(api.runtime ?? {});
const channelKeys = Object.keys(api.runtime?.channel ?? {});
api.logger.debug?.(
`device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${
channelKeys.join(",") || "none"
}`,
);
const adapter = await api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
throw new Error(
`telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join(
",",
)})`,
);
}
await send({
cfg: api.config,
to: target,
text: formatSetupInstructions(payload.expiresAtMs),
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(
`device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${
ctx.messageThreadId ?? "none"
}`,
);
return { text: encodeSetupCode(payload) };
} catch (err) {
api.logger.warn?.(
`device-pair: telegram split send failed, falling back to single message (${(err as Error)?.message ?? err})`,
);
}
}
return {
text: formatSetupReply(payload, authLabel),
};
},
});
},
});

View file

@ -0,0 +1,154 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
const listDevicePairingMock = vi.hoisted(() => vi.fn(async () => ({ pending: [] })));
vi.mock("./api.js", () => ({
listDevicePairing: listDevicePairingMock,
}));
import { handleNotifyCommand } from "./notify.js";
describe("device-pair notify persistence", () => {
let stateDir: string;
beforeEach(async () => {
vi.clearAllMocks();
listDevicePairingMock.mockResolvedValue({ pending: [] });
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "device-pair-notify-"));
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("matches persisted telegram thread ids across number and string roundtrips", async () => {
await fs.writeFile(
path.join(stateDir, "device-pair-notify.json"),
JSON.stringify(
{
subscribers: [
{
to: "chat-123",
accountId: "telegram-default",
messageThreadId: 271,
mode: "persistent",
addedAtMs: 1,
},
],
notifiedRequestIds: {},
},
null,
2,
),
"utf8",
);
const api = createTestPluginApi({
runtime: {
state: {
resolveStateDir: () => stateDir,
},
} as never,
});
const status = await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat-123",
accountId: "telegram-default",
messageThreadId: "271",
},
action: "status",
});
expect(status.text).toContain("Pair request notifications: enabled for this chat.");
expect(status.text).toContain("Mode: persistent");
await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat-123",
accountId: "telegram-default",
messageThreadId: "271",
},
action: "off",
});
const persisted = JSON.parse(
await fs.readFile(path.join(stateDir, "device-pair-notify.json"), "utf8"),
) as { subscribers: unknown[] };
expect(persisted.subscribers).toEqual([]);
});
it("does not remove a different persisted subscriber when notify fields contain pipes", async () => {
await fs.writeFile(
path.join(stateDir, "device-pair-notify.json"),
JSON.stringify(
{
subscribers: [
{
to: "chat|123",
accountId: "acct",
mode: "persistent",
addedAtMs: 1,
},
{
to: "chat",
accountId: "123|acct",
mode: "persistent",
addedAtMs: 2,
},
],
notifiedRequestIds: {},
},
null,
2,
),
"utf8",
);
const api = createTestPluginApi({
runtime: {
state: {
resolveStateDir: () => stateDir,
},
} as never,
});
await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat",
accountId: "123|acct",
},
action: "off",
});
const status = await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat",
accountId: "123|acct",
},
action: "status",
});
expect(status.text).toContain("Pair request notifications: disabled for this chat.");
const persisted = JSON.parse(
await fs.readFile(path.join(stateDir, "device-pair-notify.json"), "utf8"),
) as { subscribers: Array<{ to: string; accountId?: string }> };
expect(persisted.subscribers).toHaveLength(1);
expect(persisted.subscribers[0]).toMatchObject({
to: "chat|123",
accountId: "acct",
});
});
});

View file

@ -0,0 +1,520 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawPluginApi } from "./api.js";
import { listDevicePairing } from "./api.js";
const NOTIFY_STATE_FILE = "device-pair-notify.json";
const NOTIFY_POLL_INTERVAL_MS = 10_000;
const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
type NotifySubscription = {
to: string;
accountId?: string;
messageThreadId?: string | number;
mode: "persistent" | "once";
addedAtMs: number;
};
type NotifyStateFile = {
subscribers: NotifySubscription[];
notifiedRequestIds: Record<string, number>;
};
export type PendingPairingRequest = {
requestId: string;
deviceId: string;
displayName?: string;
platform?: string;
role?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
ts?: number;
};
function formatStringList(values?: readonly string[]): string {
if (!Array.isArray(values) || values.length === 0) {
return "none";
}
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
return normalized.length > 0 ? normalized.join(", ") : "none";
}
function formatRoleList(request: PendingPairingRequest): string {
const role = normalizeOptionalString(request.role);
if (role) {
return role;
}
return formatStringList(request.roles);
}
function formatScopeList(request: PendingPairingRequest): string {
return formatStringList(request.scopes);
}
export function formatPendingRequests(pending: PendingPairingRequest[]): string {
if (pending.length === 0) {
return "No pending device pairing requests.";
}
const lines: string[] = ["Pending device pairing requests:"];
for (const req of pending) {
const label = normalizeOptionalString(req.displayName) || req.deviceId;
const platform = normalizeOptionalString(req.platform);
const ip = normalizeOptionalString(req.remoteIp);
const parts = [
`- ${req.requestId}`,
label ? `name=${label}` : null,
platform ? `platform=${platform}` : null,
`role=${formatRoleList(req)}`,
`scopes=${formatScopeList(req)}`,
ip ? `ip=${ip}` : null,
].filter(Boolean);
lines.push(parts.join(" · "));
}
return lines.join("\n");
}
function resolveNotifyStatePath(stateDir: string): string {
return path.join(stateDir, NOTIFY_STATE_FILE);
}
function normalizeNotifyState(raw: unknown): NotifyStateFile {
const root = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const subscribersRaw = Array.isArray(root.subscribers) ? root.subscribers : [];
const notifiedRaw =
typeof root.notifiedRequestIds === "object" && root.notifiedRequestIds !== null
? (root.notifiedRequestIds as Record<string, unknown>)
: {};
const subscribers: NotifySubscription[] = [];
for (const item of subscribersRaw) {
if (typeof item !== "object" || item === null) {
continue;
}
const record = item as Record<string, unknown>;
const to = normalizeOptionalString(record.to) ?? "";
if (!to) {
continue;
}
const accountId = normalizeOptionalString(record.accountId) ?? undefined;
const messageThreadId =
typeof record.messageThreadId === "string"
? normalizeOptionalString(record.messageThreadId) || undefined
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
const mode = record.mode === "once" ? "once" : "persistent";
const addedAtMs =
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
? Math.trunc(record.addedAtMs)
: Date.now();
subscribers.push({
to,
accountId,
messageThreadId,
mode,
addedAtMs,
});
}
const notifiedRequestIds: Record<string, number> = {};
for (const [requestId, ts] of Object.entries(notifiedRaw)) {
const normalizedRequestId = normalizeOptionalString(requestId);
if (!normalizedRequestId) {
continue;
}
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
continue;
}
notifiedRequestIds[normalizedRequestId] = Math.trunc(ts);
}
return { subscribers, notifiedRequestIds };
}
async function readNotifyState(filePath: string): Promise<NotifyStateFile> {
try {
const content = await fs.readFile(filePath, "utf8");
return normalizeNotifyState(JSON.parse(content));
} catch {
return { subscribers: [], notifiedRequestIds: {} };
}
}
async function writeNotifyState(filePath: string, state: NotifyStateFile): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const content = JSON.stringify(state, null, 2);
await fs.writeFile(filePath, `${content}\n`, "utf8");
}
function notifySubscriberKey(subscriber: {
to: string;
accountId?: string;
messageThreadId?: string | number;
}): string {
return JSON.stringify([
subscriber.to,
subscriber.accountId ?? "",
normalizeNotifyThreadKey(subscriber.messageThreadId),
]);
}
function normalizeNotifyThreadKey(messageThreadId?: string | number): string {
if (typeof messageThreadId === "number" && Number.isFinite(messageThreadId)) {
return String(Math.trunc(messageThreadId));
}
if (typeof messageThreadId !== "string") {
return "";
}
const normalized = normalizeOptionalString(messageThreadId);
if (!normalized) {
return "";
}
if (!/^-?\d+$/u.test(normalized)) {
return normalized;
}
try {
return BigInt(normalized).toString();
} catch {
return normalized;
}
}
type NotifyTarget = {
to: string;
accountId?: string;
messageThreadId?: string | number;
};
function resolveNotifyTarget(ctx: {
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
}): NotifyTarget | null {
const to =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
if (!to) {
return null;
}
return {
to,
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
};
}
function upsertNotifySubscriber(
subscribers: NotifySubscription[],
target: NotifyTarget,
mode: NotifySubscription["mode"],
): boolean {
const key = notifySubscriberKey(target);
const index = subscribers.findIndex((entry) => notifySubscriberKey(entry) === key);
const next: NotifySubscription = {
...target,
mode,
addedAtMs: Date.now(),
};
if (index === -1) {
subscribers.push(next);
return true;
}
const existing = subscribers[index];
if (existing?.mode === mode) {
return false;
}
subscribers[index] = next;
return true;
}
function buildPairingRequestNotificationText(request: PendingPairingRequest): string {
const label = normalizeOptionalString(request.displayName) || request.deviceId;
const platform = normalizeOptionalString(request.platform);
const ip = normalizeOptionalString(request.remoteIp);
const role = formatRoleList(request);
const scopes = formatScopeList(request);
const lines = [
"📲 New device pairing request",
`ID: ${request.requestId}`,
`Name: ${label}`,
...(platform ? [`Platform: ${platform}`] : []),
`Role: ${role}`,
`Scopes: ${scopes}`,
...(ip ? [`IP: ${ip}`] : []),
"",
`Approve: /pair approve ${request.requestId}`,
"List pending: /pair pending",
];
return lines.join("\n");
}
function requestTimestampMs(request: PendingPairingRequest): number | null {
if (typeof request.ts !== "number" || !Number.isFinite(request.ts)) {
return null;
}
const ts = Math.trunc(request.ts);
return ts > 0 ? ts : null;
}
function shouldNotifySubscriberForRequest(
subscriber: NotifySubscription,
request: PendingPairingRequest,
): boolean {
if (subscriber.mode !== "once") {
return true;
}
const ts = requestTimestampMs(request);
// One-shot subscriptions should only notify for new requests created after arming.
if (ts == null) {
return false;
}
return ts >= subscriber.addedAtMs;
}
async function notifySubscriber(params: {
api: OpenClawPluginApi;
subscriber: NotifySubscription;
text: string;
}): Promise<boolean> {
const adapter = await params.api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
params.api.logger.warn(
"device-pair: telegram outbound adapter unavailable for pairing notifications",
);
return false;
}
try {
await send({
cfg: params.api.config,
to: params.subscriber.to,
text: params.text,
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
...(params.subscriber.messageThreadId != null
? { threadId: params.subscriber.messageThreadId }
: {}),
});
return true;
} catch (err) {
params.api.logger.warn(
`device-pair: failed to send pairing notification to ${params.subscriber.to}: ${formatErrorMessage(err)}`,
);
return false;
}
}
async function notifyPendingPairingRequests(params: {
api: OpenClawPluginApi;
statePath: string;
}): Promise<void> {
const state = await readNotifyState(params.statePath);
const pairing = await listDevicePairing();
const pending = pairing.pending as PendingPairingRequest[];
const now = Date.now();
const pendingIds = new Set(pending.map((entry) => entry.requestId));
let changed = false;
for (const [requestId, ts] of Object.entries(state.notifiedRequestIds)) {
if (!pendingIds.has(requestId) || now - ts > NOTIFY_MAX_SEEN_AGE_MS) {
delete state.notifiedRequestIds[requestId];
changed = true;
}
}
if (state.subscribers.length > 0) {
const oneShotDelivered = new Set<string>();
for (const request of pending) {
if (state.notifiedRequestIds[request.requestId]) {
continue;
}
const text = buildPairingRequestNotificationText(request);
let delivered = false;
for (const subscriber of state.subscribers) {
if (!shouldNotifySubscriberForRequest(subscriber, request)) {
continue;
}
const sent = await notifySubscriber({
api: params.api,
subscriber,
text,
});
delivered = delivered || sent;
if (sent && subscriber.mode === "once") {
oneShotDelivered.add(notifySubscriberKey(subscriber));
}
}
if (delivered) {
state.notifiedRequestIds[request.requestId] = now;
changed = true;
}
}
if (oneShotDelivered.size > 0) {
const initialCount = state.subscribers.length;
state.subscribers = state.subscribers.filter(
(subscriber) => !oneShotDelivered.has(notifySubscriberKey(subscriber)),
);
if (state.subscribers.length !== initialCount) {
changed = true;
}
}
}
if (changed) {
await writeNotifyState(params.statePath, state);
}
}
export async function armPairNotifyOnce(params: {
api: OpenClawPluginApi;
ctx: {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
}): Promise<boolean> {
if (params.ctx.channel !== "telegram") {
return false;
}
const target = resolveNotifyTarget(params.ctx);
if (!target) {
return false;
}
const stateDir = params.api.runtime.state.resolveStateDir();
const statePath = resolveNotifyStatePath(stateDir);
const state = await readNotifyState(statePath);
let changed = false;
if (upsertNotifySubscriber(state.subscribers, target, "once")) {
changed = true;
}
if (changed) {
await writeNotifyState(statePath, state);
}
return true;
}
export async function handleNotifyCommand(params: {
api: OpenClawPluginApi;
ctx: {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
action: string;
}): Promise<{ text: string }> {
if (params.ctx.channel !== "telegram") {
return { text: "Pairing notifications are currently supported only on Telegram." };
}
const target = resolveNotifyTarget(params.ctx);
if (!target) {
return { text: "Could not resolve Telegram target for this chat." };
}
const stateDir = params.api.runtime.state.resolveStateDir();
const statePath = resolveNotifyStatePath(stateDir);
const state = await readNotifyState(statePath);
const targetKey = notifySubscriberKey(target);
const current = state.subscribers.find((entry) => notifySubscriberKey(entry) === targetKey);
if (params.action === "on" || params.action === "enable") {
if (upsertNotifySubscriber(state.subscribers, target, "persistent")) {
await writeNotifyState(statePath, state);
}
return {
text:
"✅ Pair request notifications enabled for this Telegram chat.\n" +
"I will ping here when a new device pairing request arrives.",
};
}
if (params.action === "off" || params.action === "disable") {
const currentIndex = state.subscribers.findIndex(
(entry) => notifySubscriberKey(entry) === targetKey,
);
if (currentIndex !== -1) {
state.subscribers.splice(currentIndex, 1);
await writeNotifyState(statePath, state);
}
return { text: "✅ Pair request notifications disabled for this Telegram chat." };
}
if (params.action === "once" || params.action === "arm") {
await armPairNotifyOnce({
api: params.api,
ctx: params.ctx,
});
return {
text:
"✅ One-shot pairing notification armed for this Telegram chat.\n" +
"I will notify on the next new pairing request, then auto-disable.",
};
}
if (params.action === "status" || params.action === "") {
const pending = await listDevicePairing();
const enabled = Boolean(current);
const mode = current?.mode ?? "off";
return {
text: [
`Pair request notifications: ${enabled ? "enabled" : "disabled"} for this chat.`,
`Mode: ${mode}`,
`Subscribers: ${state.subscribers.length}`,
`Pending requests: ${pending.pending.length}`,
"",
"Use /pair notify on|off|once",
].join("\n"),
};
}
return { text: "Usage: /pair notify on|off|once|status" };
}
export function registerPairingNotifierService(api: OpenClawPluginApi): void {
let notifyInterval: ReturnType<typeof setInterval> | null = null;
api.registerService({
id: "device-pair-notifier",
start: async (ctx) => {
const statePath = resolveNotifyStatePath(ctx.stateDir);
const tick = async () => {
await notifyPendingPairingRequests({ api, statePath });
};
await tick().catch((err) => {
api.logger.warn(`device-pair: initial notify poll failed: ${formatErrorMessage(err)}`);
});
notifyInterval = setInterval(() => {
tick().catch((err) => {
api.logger.warn(`device-pair: notify poll failed: ${formatErrorMessage(err)}`);
});
}, NOTIFY_POLL_INTERVAL_MS);
notifyInterval.unref?.();
},
stop: async () => {
if (notifyInterval) {
clearInterval(notifyInterval);
notifyInterval = null;
}
},
});
}

View file

@ -0,0 +1,27 @@
{
"id": "device-pair",
"enabledByDefault": true,
"name": "Device Pairing",
"description": "Generate setup codes and approve device pairing requests.",
"commandAliases": [
{
"name": "pair",
"kind": "runtime-slash"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"publicUrl": {
"type": "string"
}
}
},
"uiHints": {
"publicUrl": {
"label": "Gateway URL",
"help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https)."
}
}
}

View file

@ -0,0 +1,78 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { approveDevicePairing, listDevicePairing } from "./api.js";
import { formatPendingRequests } from "./notify.js";
type PendingPairingEntry = Awaited<ReturnType<typeof listDevicePairing>>["pending"][number];
type ApprovePairingResult = Awaited<ReturnType<typeof approveDevicePairing>>;
type ApprovedPairingEntry = Exclude<ApprovePairingResult, null | { status: "forbidden" }>;
type ForbiddenPairingEntry = Extract<ApprovePairingResult, { status: "forbidden" }>;
function buildMultiplePendingApprovalReply(pending: PendingPairingEntry[]): { text: string } {
return {
text:
`${formatPendingRequests(pending)}\n\n` +
"Multiple pending requests found. Approve one explicitly:\n" +
"/pair approve <requestId>\n" +
"Or approve the most recent:\n" +
"/pair approve latest",
};
}
export function selectPendingApprovalRequest(params: {
pending: PendingPairingEntry[];
requested?: string;
}): { pending?: PendingPairingEntry; reply?: { text: string } } {
if (params.pending.length === 0) {
return { reply: { text: "No pending device pairing requests." } };
}
if (!params.requested) {
return params.pending.length === 1
? { pending: params.pending[0] }
: { reply: buildMultiplePendingApprovalReply(params.pending) };
}
if (normalizeLowercaseStringOrEmpty(params.requested) === "latest") {
return {
pending: [...params.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0],
};
}
return {
pending: params.pending.find((entry) => entry.requestId === params.requested),
reply: undefined,
};
}
function formatApprovedPairingReply(approved: ApprovedPairingEntry): { text: string } {
const label = normalizeOptionalString(approved.device.displayName) || approved.device.deviceId;
const platform = normalizeOptionalString(approved.device.platform);
const platformLabel = platform ? ` (${platform})` : "";
return { text: `✅ Paired ${label}${platformLabel}.` };
}
function formatForbiddenPairingRequirement(approved: ForbiddenPairingEntry): string {
return approved.scope ?? approved.role ?? "additional approval";
}
export async function approvePendingPairingRequest(params: {
requestId: string;
callerScopes?: readonly string[];
}): Promise<{ text: string }> {
const approved =
params.callerScopes === undefined
? await approveDevicePairing(params.requestId)
: await approveDevicePairing(params.requestId, { callerScopes: params.callerScopes });
if (!approved) {
return { text: "Pairing request not found." };
}
if (approved.status === "forbidden") {
return {
text: `⚠️ This command requires ${formatForbiddenPairingRequirement(approved)} to approve this pairing request.`,
};
}
return formatApprovedPairingReply(approved);
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolvePairingCommandAuthState } from "./pair-command-auth.js";
describe("device-pair pairing command auth", () => {
it("treats non-gateway channels as external approvals", () => {
expect(
resolvePairingCommandAuthState({
channel: "telegram",
gatewayClientScopes: undefined,
}),
).toEqual({
isInternalGatewayCaller: false,
isMissingInternalPairingPrivilege: false,
approvalCallerScopes: undefined,
});
});
it("fails closed for webchat when scopes are absent", () => {
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: undefined,
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingInternalPairingPrivilege: true,
approvalCallerScopes: [],
});
});
it("accepts pairing and admin scopes for internal callers", () => {
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingInternalPairingPrivilege: false,
approvalCallerScopes: ["operator.write", "operator.pairing"],
});
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingInternalPairingPrivilege: false,
approvalCallerScopes: ["operator.admin"],
});
});
});

View file

@ -0,0 +1,46 @@
type PairingCommandAuthParams = {
channel: string;
gatewayClientScopes?: readonly string[] | null;
};
export type PairingCommandAuthState = {
isInternalGatewayCaller: boolean;
isMissingInternalPairingPrivilege: boolean;
approvalCallerScopes?: readonly string[];
};
function isInternalGatewayPairingCaller(params: PairingCommandAuthParams): boolean {
return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes);
}
export function resolvePairingCommandAuthState(
params: PairingCommandAuthParams,
): PairingCommandAuthState {
const isInternalGatewayCaller = isInternalGatewayPairingCaller(params);
if (!isInternalGatewayCaller) {
return {
isInternalGatewayCaller,
isMissingInternalPairingPrivilege: false,
approvalCallerScopes: undefined,
};
}
const approvalCallerScopes = Array.isArray(params.gatewayClientScopes)
? params.gatewayClientScopes
: [];
const isMissingInternalPairingPrivilege =
!approvalCallerScopes.includes("operator.pairing") &&
!approvalCallerScopes.includes("operator.admin");
return {
isInternalGatewayCaller,
isMissingInternalPairingPrivilege,
approvalCallerScopes,
};
}
export function buildMissingPairingScopeReply(): { text: string } {
return {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
};
}

View file

@ -0,0 +1 @@
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";