openclaw / src /cli /nodes-cli /register.pairing.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
8.87 kB
// 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<OperatorScope>([
"operator.pairing",
"operator.write",
"operator.admin",
]);
function normalizeNodePairApproveScopes(scopes: unknown): OperatorScope[] {
const normalized = new Set<OperatorScope>(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("<requestId>", "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("<requestId>", "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 <idOrNameOrIp>", "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 <idOrNameOrIp>", "Node id, name, or IP")
.requiredOption("--name <displayName>", "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 <displayName>.`,
);
}
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}`));
});
}),
);
}