mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 05:41:36 +00:00
* 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>
276 lines
9.3 KiB
TypeScript
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);
|
|
}
|