mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 23:23:29 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
20
openclaw/extensions/device-pair/api.ts
Normal file
20
openclaw/extensions/device-pair/api.ts
Normal 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";
|
||||
782
openclaw/extensions/device-pair/index.test.ts
Normal file
782
openclaw/extensions/device-pair/index.test.ts
Normal 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("");
|
||||
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)." });
|
||||
});
|
||||
});
|
||||
798
openclaw/extensions/device-pair/index.ts
Normal file
798
openclaw/extensions/device-pair/index.ts
Normal 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,
|
||||
}),
|
||||
"",
|
||||
``,
|
||||
].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),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
154
openclaw/extensions/device-pair/notify.test.ts
Normal file
154
openclaw/extensions/device-pair/notify.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
520
openclaw/extensions/device-pair/notify.ts
Normal file
520
openclaw/extensions/device-pair/notify.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
27
openclaw/extensions/device-pair/openclaw.plugin.json
Normal file
27
openclaw/extensions/device-pair/openclaw.plugin.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
78
openclaw/extensions/device-pair/pair-command-approve.ts
Normal file
78
openclaw/extensions/device-pair/pair-command-approve.ts
Normal 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);
|
||||
}
|
||||
53
openclaw/extensions/device-pair/pair-command-auth.test.ts
Normal file
53
openclaw/extensions/device-pair/pair-command-auth.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
46
openclaw/extensions/device-pair/pair-command-auth.ts
Normal file
46
openclaw/extensions/device-pair/pair-command-auth.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
1
openclaw/extensions/device-pair/qr-image.ts
Normal file
1
openclaw/extensions/device-pair/qr-image.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
Loading…
Add table
Add a link
Reference in a new issue