mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
CLI: recover devices commands via local pairing fallback
This commit is contained in:
@@ -2,6 +2,14 @@ import { Command } from "commander";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn();
|
||||
const buildGatewayConnectionDetails = vi.fn(() => ({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "local loopback",
|
||||
message: "",
|
||||
}));
|
||||
const listDevicePairing = vi.fn();
|
||||
const approveDevicePairing = vi.fn();
|
||||
const summarizeDeviceTokens = vi.fn();
|
||||
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -11,12 +19,19 @@ const runtime = {
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
buildGatewayConnectionDetails,
|
||||
}));
|
||||
|
||||
vi.mock("./progress.js", () => ({
|
||||
withProgress,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/device-pairing.js", () => ({
|
||||
listDevicePairing,
|
||||
approveDevicePairing,
|
||||
summarizeDeviceTokens,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
@@ -216,8 +231,73 @@ describe("devices cli tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"));
|
||||
listDevicePairing.mockResolvedValueOnce({
|
||||
pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk", ts: 1 }],
|
||||
paired: [],
|
||||
});
|
||||
summarizeDeviceTokens.mockReturnValue(undefined);
|
||||
|
||||
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 () => {
|
||||
callGateway
|
||||
.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"))
|
||||
.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"));
|
||||
listDevicePairing.mockResolvedValueOnce({
|
||||
pending: [{ requestId: "req-latest", deviceId: "device-1", publicKey: "pk", ts: 2 }],
|
||||
paired: [],
|
||||
});
|
||||
approveDevicePairing.mockResolvedValueOnce({
|
||||
requestId: "req-latest",
|
||||
device: {
|
||||
deviceId: "device-1",
|
||||
publicKey: "pk",
|
||||
approvedAtMs: 1,
|
||||
createdAtMs: 1,
|
||||
},
|
||||
});
|
||||
summarizeDeviceTokens.mockReturnValue(undefined);
|
||||
|
||||
await runDevicesApprove(["--latest"]);
|
||||
|
||||
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest");
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
|
||||
});
|
||||
|
||||
it("does not use local fallback when an explicit --url is provided", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"));
|
||||
|
||||
await expect(
|
||||
runDevicesCommand(["list", "--json", "--url", "ws://127.0.0.1:18789"]),
|
||||
).rejects.toThrow("pairing required");
|
||||
expect(listDevicePairing).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
callGateway.mockReset();
|
||||
buildGatewayConnectionDetails.mockReset();
|
||||
buildGatewayConnectionDetails.mockReturnValue({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "local loopback",
|
||||
message: "",
|
||||
});
|
||||
listDevicePairing.mockReset();
|
||||
approveDevicePairing.mockReset();
|
||||
summarizeDeviceTokens.mockReset();
|
||||
withProgress.mockClear();
|
||||
runtime.log.mockReset();
|
||||
runtime.error.mockReset();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Command } from "commander";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
summarizeDeviceTokens,
|
||||
type PairedDevice as InfraPairedDevice,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
@@ -53,6 +60,8 @@ type DevicePairingList = {
|
||||
paired?: PairedDevice[];
|
||||
};
|
||||
|
||||
const FALLBACK_NOTICE = "Direct scope access failed; using local fallback.";
|
||||
|
||||
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
@@ -81,6 +90,84 @@ const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unk
|
||||
}),
|
||||
);
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean {
|
||||
const message = normalizeErrorMessage(error).toLowerCase();
|
||||
if (!message.includes("pairing required")) {
|
||||
return false;
|
||||
}
|
||||
if (typeof opts.url === "string" && opts.url.trim().length > 0) {
|
||||
// Explicit --url might point at a remote/tunneled gateway; never silently
|
||||
// switch to local pairing files in that case.
|
||||
return false;
|
||||
}
|
||||
const connection = buildGatewayConnectionDetails();
|
||||
if (connection.urlSource !== "local loopback") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return isLoopbackHost(new URL(connection.url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function redactLocalPairedDevice(device: InfraPairedDevice): PairedDevice {
|
||||
const { tokens, ...rest } = device;
|
||||
return {
|
||||
...(rest as unknown as PairedDevice),
|
||||
tokens: summarizeDeviceTokens(tokens) as DeviceTokenSummary[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function listPairingWithFallback(opts: DevicesRpcOpts): Promise<DevicePairingList> {
|
||||
try {
|
||||
return parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));
|
||||
} catch (error) {
|
||||
if (!shouldUseLocalPairingFallback(opts, error)) {
|
||||
throw error;
|
||||
}
|
||||
if (opts.json !== true) {
|
||||
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
|
||||
}
|
||||
const local = await listDevicePairing();
|
||||
return {
|
||||
pending: local.pending as PendingDevice[],
|
||||
paired: local.paired.map((device) => redactLocalPairedDevice(device)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function approvePairingWithFallback(
|
||||
opts: DevicesRpcOpts,
|
||||
requestId: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return await callGatewayCli("device.pair.approve", opts, { requestId });
|
||||
} catch (error) {
|
||||
if (!shouldUseLocalPairingFallback(opts, error)) {
|
||||
throw error;
|
||||
}
|
||||
if (opts.json !== true) {
|
||||
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
|
||||
}
|
||||
const approved = await approveDevicePairing(requestId);
|
||||
if (!approved) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestId,
|
||||
device: redactLocalPairedDevice(approved.device),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevicePairingList(value: unknown): DevicePairingList {
|
||||
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
return {
|
||||
@@ -131,8 +218,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.command("list")
|
||||
.description("List pending and paired devices")
|
||||
.action(async (opts: DevicesRpcOpts) => {
|
||||
const result = await callGatewayCli("device.pair.list", opts, {});
|
||||
const list = parseDevicePairingList(result);
|
||||
const list = await listPairingWithFallback(opts);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(list, null, 2));
|
||||
return;
|
||||
@@ -284,8 +370,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.action(async (requestId: string | undefined, opts: DevicesRpcOpts) => {
|
||||
let resolvedRequestId = requestId?.trim();
|
||||
if (!resolvedRequestId || opts.latest) {
|
||||
const listResult = await callGatewayCli("device.pair.list", opts, {});
|
||||
const latest = selectLatestPendingRequest(parseDevicePairingList(listResult).pending);
|
||||
const latest = selectLatestPendingRequest((await listPairingWithFallback(opts)).pending);
|
||||
resolvedRequestId = latest?.requestId?.trim();
|
||||
}
|
||||
if (!resolvedRequestId) {
|
||||
@@ -293,9 +378,12 @@ export function registerDevicesCli(program: Command) {
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const result = await callGatewayCli("device.pair.approve", opts, {
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
const result = await approvePairingWithFallback(opts, resolvedRequestId);
|
||||
if (!result) {
|
||||
defaultRuntime.error("unknown requestId");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user