// Node pairing commands: list, approve, reject, remove, and rename paired nodes. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { getTerminalTableWidth } from "../../../packages/terminal-core/src/table.js"; import type { OperatorScope } from "../../gateway/method-scopes.js"; import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callNodesGatewayCli, callNodePairApprovalGatewayCli, nodesCallOpts, resolveCliNodeId, } from "./rpc.js"; import type { NodesRpcOpts, PendingRequest } from "./types.js"; const DEFAULT_NODE_PAIR_APPROVE_SCOPES: OperatorScope[] = ["operator.pairing"]; const NODE_PAIR_APPROVE_SCOPE_SET = new Set([ "operator.pairing", "operator.write", "operator.admin", ]); function normalizeNodePairApproveScopes(scopes: unknown): OperatorScope[] { const normalized = new Set(DEFAULT_NODE_PAIR_APPROVE_SCOPES); if (!Array.isArray(scopes)) { return [...normalized]; } for (const scope of scopes) { if (typeof scope !== "string") { continue; } if (!NODE_PAIR_APPROVE_SCOPE_SET.has(scope as OperatorScope)) { continue; } normalized.add(scope as OperatorScope); } return [...normalized]; } async function resolveApproveScopesForRequest( opts: NodesRpcOpts, requestId: string, ): Promise<{ scopes: OperatorScope[] }> { let pending: PendingRequest[]; try { const result = await callNodePairApprovalGatewayCli( "node.pair.list", opts, {}, { scopes: DEFAULT_NODE_PAIR_APPROVE_SCOPES }, ); pending = parsePairingList(result).pending; } catch { return { scopes: [...DEFAULT_NODE_PAIR_APPROVE_SCOPES] }; } const pendingRequestIds = pending .map((request) => request.requestId) .filter((id): id is string => typeof id === "string" && id.length > 0); const request = pending.find((candidate) => candidate.requestId === requestId); if (!request) { throw new Error(buildUnknownNodePairRequestIdMessage(requestId, opts, pendingRequestIds)); } const declaredScopes = normalizeNodePairApproveScopes(request.requiredApproveScopes); if (declaredScopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) { return { scopes: declaredScopes }; } // Older pending requests only list requested commands; derive approval scopes from them. return { scopes: resolveNodePairApprovalScopes(request.commands) as OperatorScope[], }; } function isUnknownNodePairRequestIdError( error: unknown, ): error is Error & { gatewayCode: "INVALID_REQUEST" } { const requestError = error as (Error & { gatewayCode?: unknown }) | undefined; return ( requestError instanceof Error && requestError.name === "GatewayClientRequestError" && requestError.gatewayCode === "INVALID_REQUEST" && requestError.message === "unknown requestId" ); } function buildUnknownNodePairRequestIdMessage( requestId: string, opts: NodesRpcOpts, pendingRequestIds?: string[], ): string { const lines = [`Unknown node pairing requestId: ${requestId}`]; if (pendingRequestIds !== undefined) { if (pendingRequestIds.length > 0) { lines.push(`Pending requestIds: ${pendingRequestIds.join(", ")}`); } else { lines.push("No pending node pairing requests are currently visible."); } } lines.push(`Run ${formatCliCommand("openclaw nodes pending")} to inspect current requests.`); const connectionReminder = formatConnectionFlagReminder(opts); if (connectionReminder) { lines.push(connectionReminder); } return lines.join("\n"); } function rethrowUnknownNodePairRequestId( error: unknown, requestId: string, opts: NodesRpcOpts, ): never { if (!isUnknownNodePairRequestIdError(error)) { throw error; } // Reuse the gateway error so generic formatting does not append its raw cause. error.name = "Error"; error.message = buildUnknownNodePairRequestIdMessage(requestId, opts); throw error; } /** Register node pairing management commands. */ export function registerNodesPairingCommands(nodes: Command) { nodesCallOpts( nodes .command("pending") .description("List pending pairing requests") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("pending", async () => { const result = await callNodesGatewayCli("node.pair.list", opts, {}); const { pending } = parsePairingList(result); if (opts.json) { defaultRuntime.writeJson(pending); return; } if (pending.length === 0) { const { muted } = getNodesTheme(); defaultRuntime.log(muted("No pending pairing requests.")); return; } const { heading, warn, muted } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const rendered = renderPendingPairingRequestsTable({ pending, now, tableWidth, theme: { heading, warn, muted }, }); defaultRuntime.log(rendered.heading); defaultRuntime.log(rendered.table); }); }), ); nodesCallOpts( nodes .command("approve") .description("Approve a pending pairing request") .argument("", "Pending request id") .action(async (requestId: string, opts: NodesRpcOpts) => { await runNodesCommand("approve", async () => { const { scopes } = await resolveApproveScopesForRequest(opts, requestId); let result: unknown; try { result = await callNodePairApprovalGatewayCli( "node.pair.approve", opts, { requestId, }, { scopes, }, ); } catch (error) { rethrowUnknownNodePairRequestId(error, requestId, opts); } defaultRuntime.writeJson(result); }); }), ); nodesCallOpts( nodes .command("reject") .description("Reject a pending pairing request") .argument("", "Pending request id") .action(async (requestId: string, opts: NodesRpcOpts) => { await runNodesCommand("reject", async () => { let result: unknown; try { result = await callNodesGatewayCli("node.pair.reject", opts, { requestId, }); } catch (error) { rethrowUnknownNodePairRequestId(error, requestId, opts); } defaultRuntime.writeJson(result); }); }), ); nodesCallOpts( nodes .command("remove") .description("Remove a paired node entry") .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("remove", async () => { const nodeId = await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""); const result = await callNodesGatewayCli("node.pair.remove", opts, { nodeId }); if (opts.json) { defaultRuntime.writeJson(result); return; } const { warn } = getNodesTheme(); defaultRuntime.log(warn(`Removed paired node ${nodeId}`)); }); }), ); nodesCallOpts( nodes .command("rename") .description("Rename a paired node (display name override)") .requiredOption("--node ", "Node id, name, or IP") .requiredOption("--name ", "New display name") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("rename", async () => { const name = normalizeOptionalString(opts.name) ?? ""; if (!name) { throw new Error( `--name must not be empty. Run ${formatCliCommand("openclaw nodes list")} to see paired nodes, then rerun with --name .`, ); } const nodeId = await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""); const result = await callNodesGatewayCli("node.rename", opts, { nodeId, displayName: name, }); if (opts.json) { defaultRuntime.writeJson(result); return; } const { ok } = getNodesTheme(); defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`)); }); }), ); }