mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 00:00:42 +00:00
590 lines
18 KiB
TypeScript
590 lines
18 KiB
TypeScript
import { Command } from "commander";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerDevicesCli } from "./devices-cli.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
runtime: {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
writeJson: vi.fn(),
|
|
},
|
|
callGateway: vi.fn(),
|
|
buildGatewayConnectionDetails: vi.fn(() => ({
|
|
url: "ws://127.0.0.1:18789",
|
|
urlSource: "local loopback",
|
|
message: "",
|
|
})),
|
|
listDevicePairing: vi.fn(),
|
|
approveDevicePairing: vi.fn(),
|
|
summarizeDeviceTokens: vi.fn(),
|
|
withProgress: vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn()),
|
|
}));
|
|
|
|
const {
|
|
runtime,
|
|
callGateway,
|
|
buildGatewayConnectionDetails,
|
|
listDevicePairing,
|
|
approveDevicePairing,
|
|
summarizeDeviceTokens,
|
|
} = mocks;
|
|
|
|
vi.mock("../gateway/call.js", () => ({
|
|
callGateway: mocks.callGateway,
|
|
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
|
|
}));
|
|
|
|
vi.mock("./progress.js", () => ({
|
|
withProgress: mocks.withProgress,
|
|
}));
|
|
|
|
vi.mock("../infra/device-pairing.js", () => ({
|
|
listDevicePairing: mocks.listDevicePairing,
|
|
approveDevicePairing: mocks.approveDevicePairing,
|
|
summarizeDeviceTokens: mocks.summarizeDeviceTokens,
|
|
}));
|
|
|
|
vi.mock("../runtime.js", () => ({
|
|
defaultRuntime: mocks.runtime,
|
|
writeRuntimeJson: (
|
|
targetRuntime: { log: (...args: unknown[]) => void },
|
|
value: unknown,
|
|
space = 2,
|
|
) => targetRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
|
|
}));
|
|
|
|
async function runDevicesApprove(argv: string[]) {
|
|
await runDevicesCommand(["approve", ...argv]);
|
|
}
|
|
|
|
async function runDevicesCommand(argv: string[]) {
|
|
const program = new Command();
|
|
registerDevicesCli(program);
|
|
await program.parseAsync(["devices", ...argv], { from: "user" });
|
|
}
|
|
|
|
function readRuntimeCallText(call: unknown[] | undefined): string {
|
|
const value = call?.[0];
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function readRuntimeOutput(): string {
|
|
return runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n");
|
|
}
|
|
|
|
function pendingDevice(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
requestId: "req-1",
|
|
deviceId: "device-1",
|
|
displayName: "Device One",
|
|
role: "operator",
|
|
scopes: ["operator.admin"],
|
|
ts: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function pairedDevice(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
deviceId: "device-1",
|
|
displayName: "Device One",
|
|
roles: ["operator"],
|
|
scopes: ["operator.read"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockGatewayPairingList(
|
|
pendingOverrides: Record<string, unknown> = {},
|
|
pairedOverrides: Record<string, unknown> = {},
|
|
) {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [pendingDevice(pendingOverrides)],
|
|
paired: [pairedDevice(pairedOverrides)],
|
|
});
|
|
}
|
|
|
|
function rejectGatewayForLocalFallback(message = "gateway closed (1008): pairing required") {
|
|
callGateway.mockRejectedValueOnce(new Error(message));
|
|
}
|
|
|
|
function mockLocalPairingFallback(message?: string) {
|
|
rejectGatewayForLocalFallback(message);
|
|
listDevicePairing.mockResolvedValueOnce({
|
|
pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk", ts: 1 }],
|
|
paired: [],
|
|
});
|
|
summarizeDeviceTokens.mockReturnValue(undefined);
|
|
}
|
|
|
|
describe("devices cli approve", () => {
|
|
it("approves an explicit request id without listing", async () => {
|
|
callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
|
|
|
|
await runDevicesApprove(["req-123"]);
|
|
|
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "device.pair.approve",
|
|
params: { requestId: "req-123" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("prints selected details and exits when implicit approval is used", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [
|
|
{
|
|
requestId: "req-abc",
|
|
deviceId: "device-9",
|
|
displayName: "Device Nine",
|
|
role: "operator",
|
|
scopes: ["operator.admin"],
|
|
remoteIp: "10.0.0.9",
|
|
ts: 1000,
|
|
},
|
|
],
|
|
paired: [
|
|
{
|
|
deviceId: "device-9",
|
|
displayName: "Device Nine",
|
|
roles: ["operator"],
|
|
scopes: ["operator.read"],
|
|
},
|
|
],
|
|
});
|
|
|
|
await runDevicesApprove([]);
|
|
|
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.list" }),
|
|
);
|
|
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
|
|
expect(logOutput).toContain("req-abc");
|
|
expect(logOutput).toContain("Device Nine");
|
|
expect(logOutput).toContain("Approved: roles: operator; scopes: operator.read");
|
|
expect(logOutput).toContain("Requested scopes exceed the current approval");
|
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("openclaw devices approve req-abc"),
|
|
);
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
});
|
|
|
|
it("sanitizes preview ip output for implicit approval", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [
|
|
{
|
|
requestId: "req-abc",
|
|
deviceId: "device-9",
|
|
displayName: "Device Nine",
|
|
role: "operator",
|
|
scopes: ["operator.admin"],
|
|
remoteIp: "10.0.0.9\rspoof",
|
|
ts: 1000,
|
|
},
|
|
],
|
|
paired: [
|
|
{
|
|
deviceId: "device-9",
|
|
displayName: "Device Nine",
|
|
roles: ["operator"],
|
|
scopes: ["operator.read"],
|
|
},
|
|
],
|
|
});
|
|
|
|
await runDevicesApprove([]);
|
|
|
|
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
|
|
expect(logOutput).not.toContain("\r");
|
|
expect(logOutput).toContain("IP: 10.0.0.9spoof");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "id is omitted",
|
|
args: [] as string[],
|
|
pending: [
|
|
{ requestId: "req-1", ts: 1000 },
|
|
{ requestId: "req-2", ts: 2000 },
|
|
],
|
|
expectedRequestId: "req-2",
|
|
},
|
|
{
|
|
name: "--latest is passed",
|
|
args: ["req-old", "--latest"] as string[],
|
|
pending: [
|
|
{ requestId: "req-2", ts: 2000 },
|
|
{ requestId: "req-3", ts: 3000 },
|
|
],
|
|
expectedRequestId: "req-3",
|
|
},
|
|
])("previews latest pending request when $name", async ({ args, pending, expectedRequestId }) => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending,
|
|
});
|
|
|
|
await runDevicesApprove(args);
|
|
|
|
expect(callGateway).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ method: "device.pair.list" }),
|
|
);
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(`openclaw devices approve ${expectedRequestId}`),
|
|
);
|
|
});
|
|
|
|
it("falls back to device id when selected pending display name is blank", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [
|
|
{
|
|
requestId: "req-blank",
|
|
deviceId: "device-9",
|
|
displayName: " ",
|
|
ts: 1000,
|
|
},
|
|
],
|
|
});
|
|
|
|
await runDevicesApprove([]);
|
|
|
|
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
|
|
expect(logOutput).toContain("device-9");
|
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("openclaw devices approve req-blank"),
|
|
);
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
});
|
|
|
|
it("includes explicit gateway flags in the rerun approval command", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [{ requestId: "req-url", deviceId: "device-9", ts: 1000 }],
|
|
});
|
|
|
|
await runDevicesApprove([
|
|
"--latest",
|
|
"--url",
|
|
"ws://gateway.example:18789/openclaw?cluster=qa lab",
|
|
"--timeout",
|
|
"3000",
|
|
"--token",
|
|
"secret-token",
|
|
]);
|
|
|
|
const errorOutput = runtime.error.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
|
|
expect(errorOutput).toContain(
|
|
"openclaw devices approve req-url --url 'ws://gateway.example:18789/openclaw?cluster=qa lab' --timeout 3000",
|
|
);
|
|
expect(errorOutput).toContain("Reuse the same --token option when rerunning.");
|
|
expect(errorOutput).not.toContain("secret-token");
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
});
|
|
|
|
it("returns JSON for implicit approval preview in JSON mode", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [{ requestId: "req-json", deviceId: "device-json", ts: 1000 }],
|
|
paired: [],
|
|
});
|
|
|
|
await runDevicesApprove(["--latest", "--json", "--url", "ws://gateway.example:18789"]);
|
|
|
|
expect(runtime.log).not.toHaveBeenCalled();
|
|
expect(runtime.error).not.toHaveBeenCalled();
|
|
expect(runtime.writeJson).toHaveBeenCalledWith({
|
|
selected: { requestId: "req-json", deviceId: "device-json", ts: 1000 },
|
|
approvalState: {
|
|
kind: "new-pairing",
|
|
requested: { roles: [], scopes: [] },
|
|
approved: null,
|
|
},
|
|
approveCommand: "openclaw devices approve req-json --url ws://gateway.example:18789 --json",
|
|
requiresAuthFlags: {
|
|
token: false,
|
|
password: false,
|
|
},
|
|
});
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
});
|
|
|
|
it("prints an error and exits when no pending requests are available", async () => {
|
|
callGateway.mockResolvedValueOnce({ pending: [] });
|
|
|
|
await runDevicesApprove([]);
|
|
|
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.list" }),
|
|
);
|
|
expect(runtime.error).toHaveBeenCalledWith("No pending device pairing requests to approve");
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
expect(callGateway).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.approve" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("devices cli remove", () => {
|
|
it("removes a paired device by id", async () => {
|
|
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
|
|
|
|
await runDevicesCommand(["remove", "device-1"]);
|
|
|
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "device.pair.remove",
|
|
params: { deviceId: "device-1" },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("devices cli clear", () => {
|
|
it("requires --yes before clearing", async () => {
|
|
await runDevicesCommand(["clear"]);
|
|
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
expect(runtime.error).toHaveBeenCalledWith("Refusing to clear pairing table without --yes");
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("clears paired devices and optionally pending requests", async () => {
|
|
callGateway
|
|
.mockResolvedValueOnce({
|
|
paired: [{ deviceId: "device-1" }, { deviceId: "device-2" }],
|
|
pending: [{ requestId: "req-1" }],
|
|
})
|
|
.mockResolvedValueOnce({ deviceId: "device-1" })
|
|
.mockResolvedValueOnce({ deviceId: "device-2" })
|
|
.mockResolvedValueOnce({ requestId: "req-1", deviceId: "device-1" });
|
|
|
|
await runDevicesCommand(["clear", "--yes", "--pending"]);
|
|
|
|
expect(callGateway).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ method: "device.pair.list" }),
|
|
);
|
|
expect(callGateway).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-1" } }),
|
|
);
|
|
expect(callGateway).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-2" } }),
|
|
);
|
|
expect(callGateway).toHaveBeenNthCalledWith(
|
|
4,
|
|
expect.objectContaining({ method: "device.pair.reject", params: { requestId: "req-1" } }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("devices cli tokens", () => {
|
|
it.each([
|
|
{
|
|
label: "rotates a token for a device role",
|
|
argv: [
|
|
"rotate",
|
|
"--device",
|
|
"device-1",
|
|
"--role",
|
|
"main",
|
|
"--scope",
|
|
"messages:send",
|
|
"--scope",
|
|
"messages:read",
|
|
],
|
|
expectedCall: {
|
|
method: "device.token.rotate",
|
|
params: {
|
|
deviceId: "device-1",
|
|
role: "main",
|
|
scopes: ["messages:send", "messages:read"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
label: "revokes a token for a device role",
|
|
argv: ["revoke", "--device", "device-1", "--role", "main"],
|
|
expectedCall: {
|
|
method: "device.token.revoke",
|
|
params: {
|
|
deviceId: "device-1",
|
|
role: "main",
|
|
},
|
|
},
|
|
},
|
|
])("$label", async ({ argv, expectedCall }) => {
|
|
callGateway.mockResolvedValueOnce({ ok: true });
|
|
await runDevicesCommand(argv);
|
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall));
|
|
});
|
|
|
|
it("rejects blank device or role values", async () => {
|
|
await runDevicesCommand(["rotate", "--device", " ", "--role", "main"]);
|
|
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
expect(runtime.error).toHaveBeenCalledWith("--device and --role required");
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|
|
|
|
describe("devices cli local fallback", () => {
|
|
const fallbackNotice = "Direct scope access failed; using local fallback.";
|
|
|
|
it("falls back to local pairing list when gateway returns pairing required on loopback", async () => {
|
|
mockLocalPairingFallback();
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: "device.pair.list" }),
|
|
);
|
|
expect(listDevicePairing).toHaveBeenCalledTimes(1);
|
|
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
|
|
});
|
|
|
|
it("falls back to local approve when gateway returns pairing required on loopback", async () => {
|
|
rejectGatewayForLocalFallback();
|
|
approveDevicePairing.mockResolvedValueOnce({
|
|
requestId: "req-latest",
|
|
device: {
|
|
deviceId: "device-1",
|
|
publicKey: "pk",
|
|
approvedAtMs: 1,
|
|
createdAtMs: 1,
|
|
},
|
|
});
|
|
summarizeDeviceTokens.mockReturnValue(undefined);
|
|
|
|
await runDevicesApprove(["req-latest"]);
|
|
|
|
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest", {
|
|
callerScopes: ["operator.admin"],
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
|
|
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
|
|
});
|
|
|
|
it("falls back to local pairing list when gateway returns a scope upgrade message on loopback", async () => {
|
|
mockLocalPairingFallback("scope upgrade pending approval (requestId: req-123)");
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
expect(listDevicePairing).toHaveBeenCalledTimes(1);
|
|
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
|
|
});
|
|
|
|
it("does not use local fallback when an explicit --url is provided", async () => {
|
|
rejectGatewayForLocalFallback();
|
|
|
|
await expect(
|
|
runDevicesCommand(["list", "--json", "--url", "ws://127.0.0.1:18789"]),
|
|
).rejects.toThrow("pairing required");
|
|
expect(listDevicePairing).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("devices cli list", () => {
|
|
it("renders requested versus approved access for pending upgrades", async () => {
|
|
mockGatewayPairingList({ scopes: ["operator.admin", "operator.read"] });
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
const output = readRuntimeOutput();
|
|
expect(output).toContain("Requested");
|
|
expect(output).toContain("Approved");
|
|
expect(output).toContain("operator.write");
|
|
expect(output).toContain("operator.read");
|
|
expect(output).toContain("scope upgrade");
|
|
});
|
|
|
|
it("normalizes pending device ids before matching paired approvals", async () => {
|
|
mockGatewayPairingList({ deviceId: " device-1 " });
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
const output = readRuntimeOutput();
|
|
expect(output).toContain("scope upgrade");
|
|
expect(output).toContain("operator.read");
|
|
});
|
|
|
|
it("does not show upgrade context for key-mismatched pending requests", async () => {
|
|
mockGatewayPairingList({ publicKey: "new-key" }, { publicKey: "old-key" });
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
const output = readRuntimeOutput();
|
|
expect(output).toContain("new pairing");
|
|
expect(output).not.toContain("scope upgrade");
|
|
expect(output).not.toContain("roles: operator; scopes: operator.read");
|
|
});
|
|
|
|
it("sanitizes device-controlled terminal output", async () => {
|
|
callGateway.mockResolvedValueOnce({
|
|
pending: [
|
|
{
|
|
requestId: "req-1",
|
|
deviceId: "device-1",
|
|
displayName: "Bad\u001b[2J\nName",
|
|
role: "operator",
|
|
scopes: ["operator.admin"],
|
|
remoteIp: "10.0.0.9\rspoof",
|
|
ts: 1,
|
|
},
|
|
],
|
|
paired: [
|
|
{
|
|
deviceId: "device-1",
|
|
displayName: "Pair\u001b]8;;https://evil.example\u001b\\ed",
|
|
roles: ["operator"],
|
|
scopes: ["operator.read"],
|
|
remoteIp: "10.0.0.1\u007f",
|
|
},
|
|
],
|
|
});
|
|
|
|
await runDevicesCommand(["list"]);
|
|
|
|
const output = readRuntimeOutput();
|
|
expect(output).not.toContain("\u001b");
|
|
expect(output).not.toContain("\r");
|
|
expect(output).toContain("BadName");
|
|
expect(output).toContain("spoof");
|
|
expect(output).toContain("Paired");
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
runtime.exit.mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
buildGatewayConnectionDetails.mockReturnValue({
|
|
url: "ws://127.0.0.1:18789",
|
|
urlSource: "local loopback",
|
|
message: "",
|
|
});
|
|
listDevicePairing.mockResolvedValue({ pending: [], paired: [] });
|
|
approveDevicePairing.mockResolvedValue(undefined);
|
|
summarizeDeviceTokens.mockReturnValue(undefined);
|
|
});
|