mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
6e026c2fe3
* fix(gateway): bind auth limits to ingress attribution * fix(gateway): close remaining ingress auth gaps * fix(gateway): carry attribution into new ingress paths * fix(gateway): close ingress ownership gaps * fix(gateway): complete proxy ingress hardening * fix(gateway): stabilize managed Tailscale ingress * fix(gateway): make Tailscale cleanup ownership-safe Refuse reset-on-exit publication until Tailscale exposes an atomic owner-bound cleanup operation, and migrate legacy configs with Doctor. * fix(gateway): finish ingress ownership repair * fix(gateway): own managed Tailscale route lifetime Run managed Serve and Funnel routes as foreground claims tied to the Gateway lifecycle. Retire named Service config through Doctor because Tailscale Services cannot run in foreground mode. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * fix(gateway): align Tailscale consumers and build guards Remove the retired named-service config from Telegram Mini App URL resolution and register the lifecycle worker as an explicit production entry. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * fix(gateway): preserve retired Tailscale inputs Keep Funnel enabled when removing an ignored named-Service setting and accept the legacy positive reset flag as a no-op now that managed routes always follow Gateway lifetime. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * fix(gateway): preserve Tailscale route diagnostics Prefer the actionable foreground CLI failure captured during timeout cleanup, and cover the original delayed-failure ordering. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * fix(gateway): reconcile Tailscale ingress with main Preserve current ingress ownership contracts after the rebase, retire the obsolete device-auth migration check, validate route-owner IPC, and move Tailscale auth coverage onto the managed listener. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * fix(gateway): finish ingress rebase coverage Unify the rebased net imports and let module-reset WebSocket tests prepare attribution through the same fresh module instance as the handler. Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> * test(gateway): align run-loop server fixture --------- Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
811 lines
35 KiB
TypeScript
811 lines
35 KiB
TypeScript
// Device Pair tests cover index plugin behavior.
|
|
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 { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawPluginApi } from "./api.js";
|
|
|
|
const pluginApiMocks = vi.hoisted(() => ({
|
|
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
|
|
issueDeviceBootstrapToken: vi.fn(async () => ({
|
|
token: "boot-token",
|
|
expiresAtMs: Date.now() + 10 * 60_000,
|
|
})),
|
|
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
|
|
renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw=="),
|
|
resolveGatewayPort: vi.fn(() => 18789),
|
|
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
|
|
writeQrPngTempFile: vi.fn(async (dataValue: string, opts: { tmpRoot: string }) => {
|
|
const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-"));
|
|
const filePath = path.join(dirPath, "pair-qr.png");
|
|
await fs.writeFile(filePath, "fakepng");
|
|
return { filePath, dirPath, mediaLocalRoots: [dirPath] };
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./api.js", () => ({
|
|
PAIRING_SETUP_BOOTSTRAP_PROFILE: {
|
|
roles: ["node", "operator"],
|
|
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
|
},
|
|
approveDevicePairing: vi.fn(),
|
|
clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens,
|
|
definePluginEntry: vi.fn((entry) => entry),
|
|
issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken,
|
|
listDevicePairing: vi.fn(async () => ({ pending: [] })),
|
|
renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl,
|
|
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
|
|
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
|
|
resolveAdvertisedLanHost: vi.fn(async () => null),
|
|
resolveGatewayBindUrl: vi.fn(),
|
|
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
|
|
resolveTailnetHostWithRunner: vi.fn(),
|
|
runPluginCommandWithTimeout: vi.fn(),
|
|
writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile,
|
|
}));
|
|
|
|
vi.mock("./notify.js", () => ({
|
|
armPairNotifyOnce: vi.fn(async () => false),
|
|
formatPendingRequests: vi.fn(() => "No pending device pairing requests."),
|
|
handleNotifyCommand: vi.fn(async () => ({ text: "notify" })),
|
|
}));
|
|
|
|
import {
|
|
approveDevicePairing,
|
|
listDevicePairing,
|
|
resolveAdvertisedLanHost,
|
|
resolveGatewayBindUrl,
|
|
resolveTailnetHostWithRunner,
|
|
} 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 RegisterPairOptions = {
|
|
config?: OpenClawPluginApi["config"];
|
|
runtime?: OpenClawPluginApi["runtime"];
|
|
pluginConfig?: Record<string, unknown>;
|
|
};
|
|
|
|
const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"];
|
|
const INTERNAL_SETUP_SCOPES = [...INTERNAL_PAIRING_SCOPES, "operator.talk.secrets"];
|
|
const LIMITED_SETUP_REQUEST = {
|
|
profile: {
|
|
roles: ["node", "operator"],
|
|
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
|
},
|
|
};
|
|
const FULL_SETUP_REQUEST = {
|
|
profile: {
|
|
roles: ["node", "operator"],
|
|
scopes: [
|
|
"operator.admin",
|
|
"operator.approvals",
|
|
"operator.read",
|
|
"operator.talk.secrets",
|
|
"operator.write",
|
|
],
|
|
purpose: "mobile-full",
|
|
},
|
|
};
|
|
const PAIRING_REQUIRED = "⚠️ This command requires operator.pairing.";
|
|
const TALK_SECRETS_REQUIRED =
|
|
"⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.";
|
|
const SECURE_URL_REQUIRED = "Tailscale and public mobile pairing require a secure gateway URL";
|
|
// Tagged tables quote `$name`; a row toString preserves the exact existing test title via `%s`.
|
|
const exactTestTitle = (title: string) => () => title;
|
|
|
|
function createApi(
|
|
params: RegisterPairOptions & {
|
|
registerCommand?: (command: OpenClawPluginCommandDefinition) => void;
|
|
} = {},
|
|
): OpenClawPluginApi {
|
|
return createTestPluginApi({
|
|
id: "device-pair",
|
|
name: "device-pair",
|
|
source: "test",
|
|
config: params.config ?? {
|
|
gateway: { auth: { mode: "token", token: "gateway-token" } },
|
|
},
|
|
pluginConfig: {
|
|
publicUrl: "wss://gateway.example.test",
|
|
...params.pluginConfig,
|
|
},
|
|
runtime: (params.runtime ?? {}) as OpenClawPluginApi["runtime"],
|
|
registerCommand: params.registerCommand,
|
|
});
|
|
}
|
|
|
|
function registerPairCommand(params: RegisterPairOptions = {}): 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 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,
|
|
};
|
|
}
|
|
|
|
async function runPair(context: Partial<PluginCommandContext>, options: RegisterPairOptions = {}) {
|
|
return await registerPairCommand(options).handler(createCommandContext(context));
|
|
}
|
|
|
|
async function runDefaultSetup(
|
|
options: RegisterPairOptions = {},
|
|
context: Partial<PluginCommandContext> = {},
|
|
) {
|
|
return await runPair(
|
|
{
|
|
channel: "webchat",
|
|
args: "",
|
|
commandBody: "/pair",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
...context,
|
|
},
|
|
options,
|
|
);
|
|
}
|
|
|
|
async function expectSetupRejected(
|
|
options: RegisterPairOptions,
|
|
expectedText: string,
|
|
exact = false,
|
|
): Promise<void> {
|
|
const result = await runDefaultSetup(options);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
|
|
if (exact) {
|
|
expect(result).toEqual({ text: expectedText });
|
|
} else {
|
|
expect(requireText(result)).toContain(expectedText);
|
|
}
|
|
}
|
|
|
|
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 requireMediaUrl(opts: { mediaUrl?: string }): string {
|
|
if (!opts.mediaUrl) {
|
|
throw new Error("pair command did not send a media URL");
|
|
}
|
|
return opts.mediaUrl;
|
|
}
|
|
|
|
async function expectPathMissing(targetPath: string): Promise<void> {
|
|
let error: unknown;
|
|
try {
|
|
await fs.access(targetPath);
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
|
}
|
|
|
|
async function expectRejectedCommand(params: {
|
|
context: Partial<PluginCommandContext>;
|
|
untouched: unknown;
|
|
text: string;
|
|
}): Promise<void> {
|
|
const result = await runPair(params.context);
|
|
expect(params.untouched).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ text: params.text });
|
|
}
|
|
|
|
function createChannelRuntime(
|
|
channel: string,
|
|
sendMessage: (...args: unknown[]) => Promise<unknown>,
|
|
): OpenClawPluginApi["runtime"] {
|
|
return {
|
|
channel: {
|
|
outbound: {
|
|
loadAdapter: async (channelId: string) =>
|
|
channelId === channel
|
|
? {
|
|
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),
|
|
}
|
|
: undefined,
|
|
},
|
|
},
|
|
} as unknown as OpenClawPluginApi["runtime"];
|
|
}
|
|
|
|
function makePendingPairingRequest(): ListedPendingPairingRequest {
|
|
return {
|
|
requestId: "req-1",
|
|
deviceId: "victim-phone",
|
|
publicKey: "victim-public-key",
|
|
displayName: "Victim Phone",
|
|
platform: "ios",
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
|
|
function makeApprovedPairingResult(): ApprovedPairingResult {
|
|
return {
|
|
status: "approved",
|
|
requestId: "req-1",
|
|
device: {
|
|
deviceId: "victim-phone",
|
|
publicKey: "victim-public-key",
|
|
displayName: "Victim Phone",
|
|
platform: "ios",
|
|
createdAtMs: Date.now(),
|
|
approvedAtMs: Date.now(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeForbiddenPairingResult(): ApproveDevicePairingResolved {
|
|
return {
|
|
status: "forbidden",
|
|
reason: "caller-missing-scope",
|
|
scope: "operator.admin",
|
|
};
|
|
}
|
|
|
|
function mockPendingPairingList() {
|
|
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
|
pending: [makePendingPairingRequest()],
|
|
paired: [],
|
|
});
|
|
}
|
|
|
|
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 });
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("./api.js");
|
|
vi.doUnmock("./notify.js");
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("declares bare invocation client presentation without changing remote argument handling", () => {
|
|
const command = registerPairCommand();
|
|
expect(command.acceptsArgs).toBe(true);
|
|
expect(command.clientPresentation).toEqual({
|
|
when: "no-arguments",
|
|
action: { kind: "device-pairing" },
|
|
});
|
|
});
|
|
|
|
describe("device-pair /pair qr", () => {
|
|
it("returns an inline QR image for webchat surfaces", async () => {
|
|
const command = registerPairCommand();
|
|
expect(command.requiredScopes).toEqual(["operator.pairing"]);
|
|
const result = await command.handler(
|
|
createCommandContext({ channel: "webchat", gatewayClientScopes: ["operator.admin"] }),
|
|
);
|
|
const payload = result as {
|
|
text?: string;
|
|
mediaUrl?: string;
|
|
channelData?: Record<string, unknown>;
|
|
sensitiveMedia?: boolean;
|
|
};
|
|
const text = requireText(result);
|
|
|
|
expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(FULL_SETUP_REQUEST);
|
|
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
|
|
expect(payload.mediaUrl).toBeUndefined();
|
|
expect(payload.channelData?.openclawPairingQr).toEqual({
|
|
setupCode: expect.any(String),
|
|
expiresAtMs: expect.any(Number),
|
|
});
|
|
expect(payload.sensitiveMedia).toBe(true);
|
|
expect(text).toContain("- Security: single-use bootstrap token");
|
|
expect(text).toContain("**Important:** Run `/pair cleanup` after pairing finishes.");
|
|
expect(text).toContain("If this QR code leaks, run `/pair cleanup` immediately.");
|
|
expect(text).not.toContain("![OpenClaw pairing QR]");
|
|
});
|
|
|
|
it.each`
|
|
toString | context | text
|
|
${exactTestTitle("rejects qr setup for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("rejects qr setup for non-gateway command surfaces without pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("rejects qr setup for internal callers without Talk secret scope")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${TALK_SECRETS_REQUIRED}
|
|
`("%s", async ({ context, text }) => {
|
|
await expectRejectedCommand({
|
|
context: { ...context, args: "qr", commandBody: "/pair qr" },
|
|
untouched: pluginApiMocks.issueDeviceBootstrapToken,
|
|
text,
|
|
});
|
|
});
|
|
|
|
it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => {
|
|
pluginApiMocks.issueDeviceBootstrapToken
|
|
.mockResolvedValueOnce({ token: "first-token", expiresAtMs: Date.now() + 10 * 60_000 })
|
|
.mockResolvedValueOnce({ token: "second-token", expiresAtMs: Date.now() + 10 * 60_000 });
|
|
pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed"));
|
|
|
|
const text = requireText(
|
|
await runPair({ channel: "webchat", gatewayClientScopes: INTERNAL_SETUP_SCOPES }),
|
|
);
|
|
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`
|
|
toString | channel | context | target | opts
|
|
${exactTestTitle("sends Telegram a real QR image attachment")} | ${"telegram"} | ${{ senderId: "123", accountId: "default", messageThreadId: 271 }} | ${"123"} | ${{ accountId: "default", threadId: 271 }}
|
|
${exactTestTitle("sends Discord a real QR image attachment")} | ${"discord"} | ${{ senderId: "123", accountId: "default" }} | ${"user:123"} | ${{ accountId: "default" }}
|
|
${exactTestTitle("sends Slack a real QR image attachment")} | ${"slack"} | ${{ senderId: "user:U123", accountId: "default", messageThreadId: "1234567890.000001" }} | ${"user:U123"} | ${{ accountId: "default", threadId: "1234567890.000001" }}
|
|
${exactTestTitle("sends Signal a real QR image attachment")} | ${"signal"} | ${{ senderId: "signal:+15551234567", accountId: "default" }} | ${"signal:+15551234567"} | ${{ accountId: "default" }}
|
|
${exactTestTitle("sends iMessage a real QR image attachment")} | ${"imessage"} | ${{ senderId: "+15551234567", accountId: "default" }} | ${"+15551234567"} | ${{ accountId: "default" }}
|
|
${exactTestTitle("sends WhatsApp a real QR image attachment")} | ${"whatsapp"} | ${{ senderId: "+15551234567", accountId: "default" }} | ${"+15551234567"} | ${{ accountId: "default", verbose: false }}
|
|
`("%s", async ({ channel, context, target, opts }) => {
|
|
let sentPng = "";
|
|
const sendMessage = vi.fn().mockImplementation(async (_target, _caption, sendOpts) => {
|
|
if (sendOpts?.mediaUrl) {
|
|
sentPng = await fs.readFile(sendOpts.mediaUrl, "utf8");
|
|
}
|
|
return { messageId: "1" };
|
|
});
|
|
const result = await runPair(
|
|
{ channel, ...context, gatewayClientScopes: INTERNAL_SETUP_SCOPES },
|
|
{ runtime: createChannelRuntime(channel, sendMessage) },
|
|
);
|
|
const text = requireText(result);
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
const [actualTarget, caption, sendOpts] = sendMessage.mock.calls[0] as [
|
|
string,
|
|
string,
|
|
{ mediaUrl?: string; mediaLocalRoots?: string[]; accountId?: string } & Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
];
|
|
expect(actualTarget).toBe(target);
|
|
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.");
|
|
const mediaUrl = requireMediaUrl(sendOpts);
|
|
expect(mediaUrl).toMatch(/pair-qr\.png$/);
|
|
expect(sendOpts).toEqual({
|
|
cfg: { gateway: { auth: { mode: "token", token: "gateway-token" } } },
|
|
mediaUrl,
|
|
mediaLocalRoots: [path.dirname(mediaUrl)],
|
|
...opts,
|
|
});
|
|
expect(sentPng).toBe("fakepng");
|
|
await expectPathMissing(mediaUrl);
|
|
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 text = requireText(
|
|
await runPair(
|
|
{
|
|
channel: "discord",
|
|
senderId: "123",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
},
|
|
{ runtime: createChannelRuntime("discord", sendMessage) },
|
|
),
|
|
);
|
|
|
|
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 text = requireText(
|
|
await runPair({
|
|
channel: "msteams",
|
|
senderId: "8:orgid:123",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
}),
|
|
);
|
|
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.each(["toString", "constructor", "__proto__"])(
|
|
"requires QR channel sender %s to be an own entry",
|
|
async (channel) => {
|
|
const loadAdapter = vi.fn(async () => undefined);
|
|
const text = requireText(
|
|
await runPair(
|
|
{
|
|
channel,
|
|
senderId: "prototype-channel",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
},
|
|
{
|
|
runtime: {
|
|
channel: { outbound: { loadAdapter } },
|
|
} as unknown as OpenClawPluginApi["runtime"],
|
|
},
|
|
),
|
|
);
|
|
expect(pluginApiMocks.writeQrPngTempFile).not.toHaveBeenCalled();
|
|
expect(loadAdapter).not.toHaveBeenCalled();
|
|
expect(pluginApiMocks.revokeDeviceBootstrapToken).not.toHaveBeenCalled();
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain("QR image delivery is not available on this channel");
|
|
expect(text).toContain("Setup code:");
|
|
},
|
|
);
|
|
|
|
it("supports invalidating unused setup codes", async () => {
|
|
const result = await runPair({
|
|
channel: "telegram",
|
|
args: "cleanup",
|
|
commandBody: "/pair cleanup",
|
|
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
|
|
});
|
|
expect(pluginApiMocks.clearDeviceBootstrapTokens).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({ text: "Invalidated 2 unused setup codes." });
|
|
});
|
|
|
|
it.each`
|
|
toString | context | untouched
|
|
${exactTestTitle("rejects cleanup for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", args: "cleanup", commandBody: "/pair cleanup", gatewayClientScopes: ["operator.write"] }} | ${pluginApiMocks.clearDeviceBootstrapTokens}
|
|
${exactTestTitle("fails closed for cleanup when internal gateway scopes are absent")} | ${{ channel: "webchat", args: "cleanup", commandBody: "/pair cleanup", gatewayClientScopes: undefined }} | ${pluginApiMocks.clearDeviceBootstrapTokens}
|
|
${exactTestTitle("rejects status for non-gateway command surfaces without pairing scopes")} | ${{ channel: "telegram", args: "status", commandBody: "/pair status", gatewayClientScopes: undefined }} | ${listDevicePairing}
|
|
`("%s", async ({ context, untouched }) => {
|
|
await expectRejectedCommand({ context, untouched, text: PAIRING_REQUIRED });
|
|
});
|
|
});
|
|
|
|
describe("device-pair /pair default setup code", () => {
|
|
it.each`
|
|
toString | context | text
|
|
${exactTestTitle("rejects setup code issuance for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("rejects unknown subcommands that fall back to setup code issuance without operator.pairing")} | ${{ channel: "webchat", args: "foo", commandBody: "/pair foo", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("rejects setup code issuance for internal callers without Talk secret scope")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${TALK_SECRETS_REQUIRED}
|
|
${exactTestTitle("fails closed for webchat setup code issuance when scopes are absent")} | ${{ channel: "webchat", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("fails closed for non-gateway setup code issuance when scopes are absent")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED}
|
|
`("%s", async ({ context, text }) => {
|
|
await expectRejectedCommand({
|
|
context: { args: "", commandBody: "/pair", ...context },
|
|
untouched: pluginApiMocks.issueDeviceBootstrapToken,
|
|
text,
|
|
});
|
|
});
|
|
|
|
it("allows command owners to issue setup codes from non-gateway command surfaces", async () => {
|
|
const text = requireText(
|
|
await runPair({
|
|
channel: "telegram",
|
|
args: "",
|
|
commandBody: "/pair",
|
|
gatewayClientScopes: undefined,
|
|
senderIsOwner: true,
|
|
}),
|
|
);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(FULL_SETUP_REQUEST);
|
|
expect(text).toContain("Pairing setup code generated.");
|
|
});
|
|
|
|
it.each`
|
|
toString | options | context | expectedText
|
|
${exactTestTitle("normalizes secure bare publicUrl host ports before issuing setup codes")} | ${{ config: { gateway: { tls: { enabled: true }, auth: { mode: "token", token: "gateway-token" } } }, pluginConfig: { publicUrl: "gateway.example.test:18789/setup" } }} | ${{ gatewayClientScopes: ["operator.admin"] }} | ${"Gateway: wss://gateway.example.test:18789"}
|
|
${exactTestTitle("allows loopback cleartext setup urls")} | ${{ pluginConfig: { publicUrl: "ws://127.0.0.1:18789" } }} | ${undefined} | ${"Gateway: ws://127.0.0.1:18789"}
|
|
${exactTestTitle("allows mdns cleartext setup urls")} | ${{ pluginConfig: { publicUrl: "ws://openclaw.local:18789" } }} | ${undefined} | ${"Gateway: ws://openclaw.local:18789"}
|
|
`("%s", async ({ options, context, expectedText }) => {
|
|
const text = requireText(await runDefaultSetup(options, context));
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain(expectedText);
|
|
});
|
|
|
|
it.each([
|
|
"ws://[fc00::1]:18789",
|
|
"ws://[fd7a:115c:a1e0::1]:18789",
|
|
"ws://[fe80::1]:18789",
|
|
"ws://[febf::1]:18789",
|
|
])("allows IPv6 ULA and link-local cleartext setup url %s", async (publicUrl) => {
|
|
const text = requireText(await runDefaultSetup({ pluginConfig: { publicUrl } }));
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain(`Gateway: ${publicUrl}`);
|
|
});
|
|
|
|
it("uses Tailscale Serve MagicDNS as a secure setup url", async () => {
|
|
vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net");
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: {
|
|
tailscale: { mode: "serve" },
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain("Gateway: wss://gateway.tailnet.ts.net");
|
|
});
|
|
|
|
it("keeps secure setup limited for non-admin gateway callers", async () => {
|
|
const text = requireText(await runDefaultSetup());
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(LIMITED_SETUP_REQUEST);
|
|
expect(text).toContain("Access: limited");
|
|
expect(text).not.toContain("Plaintext ws:// was limited for safety");
|
|
});
|
|
|
|
it("allows private LAN cleartext setup urls", async () => {
|
|
const text = requireText(
|
|
await runDefaultSetup(
|
|
{ pluginConfig: { publicUrl: "ws://192.168.1.20:18789" } },
|
|
{ gatewayClientScopes: ["operator.admin"] },
|
|
),
|
|
);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(LIMITED_SETUP_REQUEST);
|
|
expect(text).toContain("Gateway: ws://192.168.1.20:18789");
|
|
expect(text).toContain("Access: limited");
|
|
expect(text).toContain("Plaintext ws:// was limited for safety");
|
|
});
|
|
|
|
it("uses the advertised LAN helper for bind-derived setup urls", async () => {
|
|
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("10.211.55.3");
|
|
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
|
|
url: `ws://${params.pickLanHost()}:18789`,
|
|
source: "gateway.bind=lan",
|
|
}));
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } },
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(resolveAdvertisedLanHost).toHaveBeenCalledTimes(1);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain("Gateway: ws://10.211.55.3:18789");
|
|
});
|
|
|
|
it("does not advertise legacy Tailscale Serve fallbacks for LAN setup urls", async () => {
|
|
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("192.168.139.3");
|
|
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
|
|
url: `ws://${params.pickLanHost()}:18789`,
|
|
source: "gateway.bind=lan",
|
|
}));
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } },
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(text).toContain("Gateway: ws://192.168.139.3:18789");
|
|
expect(text).not.toContain("Fallback:");
|
|
});
|
|
|
|
it("does not advertise a loopback Serve route for a custom bind", async () => {
|
|
vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({
|
|
url: "ws://192.168.139.3:18789",
|
|
source: "gateway.bind=custom",
|
|
});
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "192.168.139.3",
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(text).toContain("Gateway: ws://192.168.139.3:18789");
|
|
expect(text).not.toContain("Fallback:");
|
|
});
|
|
|
|
it.each(["ws://0.0.0.0:18789", "ws://[::]:18789"])(
|
|
"rejects unspecified cleartext setup url %s before issuing setup codes",
|
|
async (publicUrl) => {
|
|
await expectSetupRejected({ pluginConfig: { publicUrl } }, SECURE_URL_REQUIRED);
|
|
},
|
|
);
|
|
|
|
it("rejects public cleartext setup urls before issuing setup codes", async () => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl: "ws://gateway.example.test:18789" } },
|
|
SECURE_URL_REQUIRED,
|
|
);
|
|
});
|
|
|
|
it("rejects tailnet cleartext setup urls before issuing setup codes", async () => {
|
|
vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({
|
|
url: "ws://100.64.0.9:18789",
|
|
source: "gateway.bind=tailnet",
|
|
});
|
|
await expectSetupRejected(
|
|
{
|
|
config: {
|
|
gateway: {
|
|
bind: "tailnet",
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
},
|
|
"prefer gateway.tailscale.mode=serve",
|
|
);
|
|
});
|
|
|
|
it.each(["ws://[2001:db8::1]:18789", "ws://[fe7f::1]:18789", "ws://[fec0::1]:18789"])(
|
|
"rejects non-LAN IPv6 cleartext setup url %s before issuing setup codes",
|
|
async (publicUrl) => {
|
|
await expectSetupRejected({ pluginConfig: { publicUrl } }, SECURE_URL_REQUIRED);
|
|
},
|
|
);
|
|
|
|
it("rejects invalid bare publicUrl host ports", async () => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl: "localhost:notaport" } },
|
|
"Error: Configured publicUrl is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
|
|
await expectSetupRejected(
|
|
{
|
|
config: {
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "127.0.0.1",
|
|
remote: { url: "http://localhost:notaport" },
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
},
|
|
"Error: Configured gateway.remote.url is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"http://localhost:notaport",
|
|
"http:gateway.example.test",
|
|
"ws:gateway.example.test",
|
|
"http:/localhost:notaport",
|
|
"ftp:/gateway.example.test",
|
|
"mailto:foo@example.com",
|
|
"ws://user:pass@gateway.example.test:18789",
|
|
])("rejects invalid publicUrl %s before issuing setup codes", async (publicUrl) => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl } },
|
|
"Error: Configured publicUrl is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 text = formatPendingRequests([
|
|
{
|
|
requestId: "req-1",
|
|
deviceId: "device-1",
|
|
displayName: "dev one",
|
|
platform: "ios",
|
|
role: "operator",
|
|
scopes: ["operator.admin", "operator.read"],
|
|
remoteIp: "198.51.100.2",
|
|
},
|
|
]);
|
|
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 text = formatPendingRequests([
|
|
{ requestId: "req-2", deviceId: "device-2", roles: ["node", "operator"], scopes: [] },
|
|
]);
|
|
expect(text).toContain("role=node, operator");
|
|
expect(text).toContain("scopes=none");
|
|
});
|
|
});
|
|
|
|
describe("device-pair /pair approve", () => {
|
|
it.each`
|
|
toString | context | pending | approved | expectedCall | expectedText
|
|
${exactTestTitle("rejects internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${true} | ${undefined} | ${null} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("allows internal gateway callers with operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."}
|
|
${exactTestTitle("rejects non-gateway approvals without pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${false} | ${undefined} | ${null} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("allows command owners to approve from non-gateway command surfaces")} | ${{ channel: "telegram", gatewayClientScopes: undefined, senderIsOwner: true }} | ${true} | ${makeApprovedPairingResult} | ${["operator.pairing"]} | ${"✅ Paired Victim Phone (ios)."}
|
|
${exactTestTitle("preserves gateway caller scopes for command-owner approvals")} | ${{ channel: "telegram", gatewayClientScopes: INTERNAL_PAIRING_SCOPES, senderIsOwner: true }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."}
|
|
${exactTestTitle("fails closed for approvals when internal gateway scopes are absent")} | ${{ channel: "webchat", gatewayClientScopes: undefined }} | ${true} | ${undefined} | ${null} | ${PAIRING_REQUIRED}
|
|
${exactTestTitle("rejects approvals that request scopes above the caller session")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeForbiddenPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"⚠️ This command requires operator.admin to approve this pairing request."}
|
|
${exactTestTitle("approves from command surfaces that carry pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."}
|
|
`("%s", async ({ context, pending, approved, expectedCall, expectedText }) => {
|
|
if (pending) {
|
|
mockPendingPairingList();
|
|
}
|
|
if (approved) {
|
|
vi.mocked(approveDevicePairing).mockResolvedValueOnce(approved());
|
|
}
|
|
const result = await runPair({
|
|
...context,
|
|
args: "approve latest",
|
|
commandBody: "/pair approve latest",
|
|
});
|
|
if (expectedCall) {
|
|
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
|
|
callerScopes: expectedCall,
|
|
});
|
|
} else {
|
|
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
|
|
}
|
|
expect(result).toEqual({ text: expectedText });
|
|
});
|
|
});
|