Files
openclaw/src/cli/nodes-cli/rpc.ts
Jason (Json) 65b460f234 fix(nodes): surface pending reapproval diagnostics (#92547)
* fix(nodes): surface pending reapproval diagnostics

* fix(nodes): harden reapproval diagnostics

* fix(nodes): scope pending diagnostics

* fix(nodes): request pairing diagnostics in cli

* fix(nodes): reuse stored auth for diagnostics

* fix(nodes): preserve selected diagnostics credentials

* fix(nodes): prefer approved diagnostics auth

* fix(nodes): narrow diagnostics fallbacks

* fix(nodes): recover from stale diagnostics auth

* fix(gateway): preserve connect error narrowing

* fix(nodes): isolate privileged diagnostics auth

* fix(nodes): constrain privileged diagnostics auth

* fix(nodes): close diagnostics review gaps

* fix(nodes): guard reapproval cleanup races

* fix(nodes): defer stale pairing cleanup

* fix(nodes): preserve reapproval on hello failure

* test(nodes): await post-handshake reapproval cleanup

* test(nodes): avoid unbound websocket send capture

* fix(nodes): allow local auth-none diagnostics

* fix(nodes): preserve overlapping reapproval

* fix(nodes): preserve pending node metadata

* fix(nodes): keep connection age with status

* fix(nodes): preserve reapproval during reconnect races

* fix(nodes): serialize reapproval cleanup

* fix(nodes): bound reapproval reconnect races

* test(nodes): satisfy cleanup claim lint

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 17:40:18 -07:00

276 lines
9.3 KiB
TypeScript

// Gateway RPC helpers for node CLI commands, including lazy runtime loading and option parsing.
import { randomUUID } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import type { OperatorScope } from "../../gateway/method-scopes.js";
import {
parseStrictFiniteNumber,
parseStrictNonNegativeInteger,
parseStrictPositiveInteger,
} from "../../infra/parse-finite-number.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { resolveNodeFromNodeList } from "../../shared/node-resolve.js";
import { parseNodeList, parsePairingList } from "./format.js";
import type { NodeListNode, NodesRpcOpts } from "./types.js";
type NodesCliRpcRuntimeModule = typeof import("./rpc.runtime.js");
const nodesCliRpcRuntimeLoader = createLazyImportLoader<NodesCliRpcRuntimeModule>(
() => import("./rpc.runtime.js"),
);
async function loadNodesCliRpcRuntime(): Promise<NodesCliRpcRuntimeModule> {
return nodesCliRpcRuntimeLoader.load();
}
const STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES = new Set([
"AUTH_REQUIRED",
"AUTH_UNAUTHORIZED",
"AUTH_TOKEN_MISMATCH",
"AUTH_DEVICE_TOKEN_MISMATCH",
"AUTH_SCOPE_MISMATCH",
"PAIRING_REQUIRED",
]);
function readGatewayClientRequestDetailCode(value: unknown): string | null {
if (!(value instanceof Error) || value.name !== "GatewayClientRequestError") {
return null;
}
const details = (value as Error & { details?: unknown }).details;
if (!details || typeof details !== "object") {
return null;
}
const code = (details as { code?: unknown }).code;
return typeof code === "string" ? code : null;
}
function isDiagnosticsAuthFallbackError(value: unknown): value is Error {
if (
value instanceof Error &&
(value.name === "GatewayCredentialsRequiredError" ||
value.name === "GatewayStoredDeviceAuthUnavailableError" ||
value.name === "GatewayLocalBackendSharedAuthUnavailableError")
) {
return true;
}
const detailCode = readGatewayClientRequestDetailCode(value);
if (detailCode !== null && STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES.has(detailCode)) {
return true;
}
return (
value instanceof Error &&
value.name === "GatewayClientRequestError" &&
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
value.message.includes("missing scope: operator.read")
);
}
function isUnknownGatewayMethodError(value: unknown, method: string): value is Error {
return (
value instanceof Error &&
value.name === "GatewayClientRequestError" &&
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
value.message.includes(`unknown method: ${method}`)
);
}
/** Attach shared Gateway connection/json options to a node command. */
export const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
.option("--json", "Output JSON", false);
/** Call a Gateway method through the lazily loaded node CLI RPC runtime. */
export const callGatewayCli = async (
method: string,
opts: NodesRpcOpts,
params?: unknown,
callOpts?: {
scopes?: OperatorScope[];
transportTimeoutMs?: number;
useStoredDeviceAuth?: boolean;
requiredStoredDeviceAuthScopes?: OperatorScope[];
useLocalBackendSharedAuth?: boolean;
},
) => {
const runtime = await loadNodesCliRpcRuntime();
return await runtime.callGatewayCliRuntime(method, opts, params, callOpts);
};
/** Read node diagnostics with pairing details when authorized, otherwise keep read-only access. */
export const callNodeDiagnosticsGatewayCli = async (
method: "node.list" | "node.describe",
opts: NodesRpcOpts,
params?: unknown,
) => {
try {
return await callGatewayCli(method, opts, params, {
useStoredDeviceAuth: true,
requiredStoredDeviceAuthScopes: ["operator.read", "operator.pairing"],
});
} catch (error) {
if (!isDiagnosticsAuthFallbackError(error)) {
throw error;
}
}
try {
return await callGatewayCli(method, opts, params, {
scopes: ["operator.read", "operator.pairing"],
useLocalBackendSharedAuth: true,
});
} catch (error) {
if (!isDiagnosticsAuthFallbackError(error)) {
throw error;
}
}
return await callGatewayCli(method, opts, params);
};
/** Call pairing approval methods with explicit operator scopes. */
export const callNodePairApprovalGatewayCli = async (
method: "node.pair.list" | "node.pair.approve",
opts: NodesRpcOpts,
params: unknown,
callOpts: { scopes: OperatorScope[]; transportTimeoutMs?: number },
) => {
const runtime = await loadNodesCliRpcRuntime();
return await runtime.callNodePairApprovalGatewayCliRuntime(method, opts, params, callOpts);
};
/** Build a node.invoke payload with an idempotency key and optional timeout. */
export function buildNodeInvokeParams(params: {
nodeId: string;
command: string;
params?: Record<string, unknown>;
timeoutMs?: number;
idempotencyKey?: string;
}): Record<string, unknown> {
const invokeParams: Record<string, unknown> = {
nodeId: params.nodeId,
command: params.command,
params: params.params,
idempotencyKey: params.idempotencyKey ?? randomUUID(),
};
if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) {
invokeParams.timeoutMs = params.timeoutMs;
}
return invokeParams;
}
function hasOptionalValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
/** Parse an optional positive integer node CLI flag. */
export function parseOptionalNodePositiveInteger(value: unknown, flag: string): number | undefined {
if (!hasOptionalValue(value)) {
return undefined;
}
const parsed = parseStrictPositiveInteger(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
/** Parse an optional non-negative integer node CLI flag. */
export function parseOptionalNodeNonNegativeInteger(
value: unknown,
flag: string,
): number | undefined {
if (!hasOptionalValue(value)) {
return undefined;
}
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a non-negative integer.`);
}
return parsed;
}
/** Parse an optional finite number node CLI flag with optional bounds. */
export function parseOptionalNodeFiniteNumber(
value: unknown,
flag: string,
bounds?: {
minExclusive?: number;
minInclusive?: number;
maxInclusive?: number;
},
): number | undefined {
if (!hasOptionalValue(value)) {
return undefined;
}
const parsed = parseStrictFiniteNumber(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a finite number.`);
}
if (bounds?.minExclusive !== undefined && parsed <= bounds.minExclusive) {
throw new Error(`${flag} must be greater than ${bounds.minExclusive}.`);
}
if (bounds?.minInclusive !== undefined && parsed < bounds.minInclusive) {
throw new Error(`${flag} must be at least ${bounds.minInclusive}.`);
}
if (bounds?.maxInclusive !== undefined && parsed > bounds.maxInclusive) {
throw new Error(`${flag} must be at most ${bounds.maxInclusive}.`);
}
return parsed;
}
/** Return the local-development hint for known unsigned Peekaboo bridge authorization failures. */
export function unauthorizedHintForMessage(message: string): string | null {
const haystack = normalizeLowercaseStringOrEmpty(message);
if (
haystack.includes("unauthorizedclient") ||
haystack.includes("bridge client is not authorized") ||
haystack.includes("unsigned bridge clients are not allowed")
) {
return [
"peekaboo bridge rejected the client.",
"sign the peekaboo CLI (TeamID Y5PE65HELJ) or launch the host with",
"PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1 for local dev.",
].join(" ");
}
return null;
}
/** Resolve a node query to a node id via live node list or paired-node fallback. */
export async function resolveNodeId(opts: NodesRpcOpts, query: string) {
return (await resolveNode(opts, query)).nodeId;
}
/** Resolve a node through the pairing-aware diagnostics view when available. */
export async function resolveNodeDiagnosticsId(opts: NodesRpcOpts, query: string) {
try {
const res = await callNodeDiagnosticsGatewayCli("node.list", opts, {});
return resolveNodeFromNodeList(parseNodeList(res), query).nodeId;
} catch (error) {
if (!isUnknownGatewayMethodError(error, "node.list")) {
throw error;
}
return await resolveNodeId(opts, query);
}
}
/** Resolve a node query to the best available node record. */
export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<NodeListNode> {
let nodes: NodeListNode[];
try {
const res = await callGatewayCli("node.list", opts, {});
nodes = parseNodeList(res);
} catch {
const res = await callGatewayCli("node.pair.list", opts, {});
const { paired } = parsePairingList(res);
nodes = paired.map((n) => ({
nodeId: n.nodeId,
displayName: n.displayName,
platform: n.platform,
version: n.version,
remoteIp: n.remoteIp,
}));
}
return resolveNodeFromNodeList(nodes, query);
}