UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,100 lines (1,099 loc) 49.7 kB
import { w as isNodesMachineOutput } from "./argv-DNjh_yaF.js"; import "./src-vebZIeLe.js"; import { n as formatByteSize } from "./format-CgMRsTCK.js"; import { c as normalizeOptionalLowercaseString, l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty, p as normalizeStringifiedOptionalString } from "./string-coerce-CIXf7egm.js"; import { t as resolveCliArgvInvocation } from "./argv-invocation-C1WhAkJL.js"; import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { m as shortenHomePath, p as shortenHomeInString } from "./utils-P__uGsPB.js"; import { t as formatCliCommand } from "./command-format-C7YfyMTd.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { t as parseDurationMs } from "./parse-duration-CuuCHKpt.js"; import { n as sanitizeTerminalText } from "./safe-text-BGBqp1a4.js"; import { t as formatDocsLink } from "./links-ClIwBcy4.js"; import { r as theme } from "./theme-vjDs9tao.js"; import { g as randomIdempotencyKey } from "./call-BWR5pPgw.js"; import { n as parsePairingList, t as parseNodeList } from "./node-list-parse-Btt53zQJ.js"; import { t as resolveNodePairApprovalScopes } from "./node-pairing-authz-D6Dq4aG1.js"; import { a as cameraTempPath, c as resolveCameraClipTarget, d as writeCameraClipPayloadToFile, f as writeCameraPayloadToFile, l as resolveCameraSnapTargets, n as screenRecordTempPath, o as parseCameraClipPayload, s as parseCameraSnapPayload, t as parseScreenRecordPayload, u as writeBase64ToFile } from "./nodes-screen-CtfFgNGc.js"; import { n as formatTimeAgo } from "./format-relative-DiD6ocfu.js"; import { r as isNodeHostStats } from "./node-host-stats-BMvjvr90.js"; import { a as withConsoleLogsRoutedToStderrForJson } from "./json-output-mode-D0F07HLf.js"; import { n as renderTable, t as getTerminalTableWidth } from "./table-DzN9OC44.js"; import { t as formatHelpExamples } from "./help-format-CAcwboTs.js"; import { a as nodesCallOpts, c as parseOptionalNodePositiveInteger, d as resolveNodeDiagnosticsId, i as callNodesGatewayCli, l as resolveCliNode, n as callNodeDiagnosticsGatewayCli, o as parseOptionalNodeFiniteNumber, r as callNodePairApprovalGatewayCli, s as parseOptionalNodeNonNegativeInteger, t as buildNodeInvokeParams, u as resolveCliNodeId } from "./rpc-BOZ2ZIpe.js"; import { n as getNodesTheme, r as runNodesCommand, t as formatConnectionFlagReminder } from "./cli-utils-BgUGJxDZ.js"; import { n as setCommandJsonMode } from "./json-mode-jHy4Wzfh.js"; import { t as formatPairingApproveCommand } from "./pairing-command-format-DTINlK0l.js"; import { Option } from "commander"; //#region src/cli/nodes-cli/format.ts /** Format node permission maps as a stable `[permission=yes|no]` label. */ function formatPermissions(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const entries = Object.entries(raw).map(([key, value]) => [normalizeStringifiedOptionalString(key) ?? "", value === true]).filter(([key]) => key.length > 0).toSorted((a, b) => a[0].localeCompare(b[0])); if (entries.length === 0) return null; return `[${entries.map(([key, granted]) => `${key}=${granted ? "yes" : "no"}`).join(", ")}]`; } //#endregion //#region src/cli/nodes-cli/register.camera.ts const parseFacing = (value) => { const v = normalizeLowercaseStringOrEmpty(normalizeOptionalString(value) ?? ""); if (v === "front" || v === "back") return v; throw new Error(`invalid facing: ${value} (expected front|back)`); }; function getGatewayInvokePayload(raw) { return typeof raw === "object" && raw !== null ? raw.payload : void 0; } /** Register node camera list/snap/clip commands. */ function registerNodesCameraCommands(nodes) { const camera = nodes.command("camera").description("Capture camera media from a paired node"); nodesCallOpts(camera.command("list").description("List available cameras on a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => { await runNodesCommand("camera list", async () => { const nodeId = await resolveCliNodeId(opts, opts.node ?? ""); const raw = await callNodesGatewayCli("node.invoke", opts, buildNodeInvokeParams({ nodeId, command: "camera.list", params: {} })); const res = typeof raw === "object" && raw !== null ? raw : {}; const payload = typeof res.payload === "object" && res.payload !== null ? res.payload : {}; const devices = Array.isArray(payload.devices) ? payload.devices.filter((device) => typeof device === "object" && device !== null && !Array.isArray(device)) : []; if (opts.json) { defaultRuntime.writeJson(devices); return; } if (devices.length === 0) { const { muted } = getNodesTheme(); defaultRuntime.log(muted("No cameras reported.")); return; } const { heading, muted } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const rows = devices.map((device) => ({ Name: typeof device.name === "string" ? device.name : "Unknown Camera", Position: typeof device.position === "string" ? device.position : muted("unspecified"), ID: typeof device.id === "string" ? device.id : "" })); defaultRuntime.log(heading("Cameras")); defaultRuntime.log(renderTable({ width: tableWidth, columns: [ { key: "Name", header: "Name", minWidth: 14, flex: true }, { key: "Position", header: "Position", minWidth: 10 }, { key: "ID", header: "ID", minWidth: 10, flex: true } ], rows }).trimEnd()); }); }), { timeoutMs: 6e4 }); nodesCallOpts(camera.command("snap").description("Capture a photo from a node camera (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back|both>", "Camera facing").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--max-width <px>", "Max width in px (optional)").option("--quality <0-1>", "JPEG quality (optional; platform-specific default)").option("--delay-ms <ms>", "Delay before capture in ms (optional; platform-specific default)").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => { await runNodesCommand("camera snap", async () => { const facingOpt = normalizeLowercaseStringOrEmpty(normalizeOptionalString(opts.facing) ?? ""); const facing = facingOpt === "" ? void 0 : facingOpt === "both" || facingOpt === "front" || facingOpt === "back" ? facingOpt : (() => { throw new Error(`invalid facing: ${String(opts.facing)} (expected front|back|both)`); })(); const maxWidth = parseOptionalNodePositiveInteger(opts.maxWidth, "--max-width"); const quality = parseOptionalNodeFiniteNumber(opts.quality, "--quality", { minInclusive: 0, maxInclusive: 1 }); const delayMs = parseOptionalNodeNonNegativeInteger(opts.delayMs, "--delay-ms"); const deviceId = normalizeOptionalString(opts.deviceId); const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const node = await resolveCliNode(opts, normalizeOptionalString(opts.node) ?? ""); const nodeId = node.nodeId; if (deviceId && facing === "both" && node.platform?.toLowerCase() !== "linux") throw new Error("facing=both is not allowed when --device-id is set"); const targets = resolveCameraSnapTargets({ facing, platform: node.platform, deviceId }); const captures = []; for (const target of targets) { const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.snap", params: { ...target.requestFacing ? { facing: target.requestFacing } : {}, maxWidth: Number.isFinite(maxWidth) ? maxWidth : void 0, quality: Number.isFinite(quality) ? quality : void 0, format: "jpg", delayMs: Number.isFinite(delayMs) ? delayMs : void 0, deviceId: deviceId || void 0 }, timeoutMs }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const payload = parseCameraSnapPayload(getGatewayInvokePayload(raw), { expectedHost: node.remoteIp }); captures.push({ facing: target.artifactFacing, payload, filePath: cameraTempPath({ kind: "snap", facing: target.artifactFacing, ext: payload.format === "jpeg" ? "jpg" : payload.format }) }); } const results = []; for (const { facing: artifactFacing, payload, filePath } of captures) { await writeCameraPayloadToFile({ filePath, payload, expectedHost: node.remoteIp, invalidPayloadMessage: "invalid camera.snap payload" }); results.push({ facing: artifactFacing, path: filePath, width: payload.width, height: payload.height }); } if (opts.json) { defaultRuntime.writeJson({ files: results }); return; } defaultRuntime.log(results.map((r) => shortenHomePath(r.path)).join("\n")); }); }), { timeoutMs: 6e4 }); nodesCallOpts(camera.command("clip").description("Capture a short video clip from a node camera (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back>", "Camera facing", "front").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--duration <ms|10s|1m>", "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", "3000").option("--no-audio", "Disable audio capture").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 90000)", "90000").action(async (opts) => { await runNodesCommand("camera clip", async () => { const facing = parseFacing(opts.facing ?? "front"); const durationMs = parseDurationMs(opts.duration ?? "3000"); const includeAudio = opts.audio !== false; const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const deviceId = normalizeOptionalString(opts.deviceId); const node = await resolveCliNode(opts, normalizeOptionalString(opts.node) ?? ""); const nodeId = node.nodeId; const target = resolveCameraClipTarget({ facing, platform: node.platform }); const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.clip", params: { facing: target.requestFacing, durationMs: Number.isFinite(durationMs) ? durationMs : void 0, includeAudio, format: "mp4", deviceId: deviceId || void 0 }, timeoutMs }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const payload = parseCameraClipPayload(getGatewayInvokePayload(raw)); const filePath = await writeCameraClipPayloadToFile({ payload, facing: target.artifactFacing, expectedHost: node.remoteIp }); if (opts.json) { defaultRuntime.writeJson({ file: { facing: target.artifactFacing, path: filePath, durationMs: payload.durationMs, hasAudio: payload.hasAudio } }); return; } defaultRuntime.log(shortenHomePath(filePath)); }); }), { timeoutMs: 9e4 }); } //#endregion //#region src/cli/nodes-cli/register.invoke.ts const BLOCKED_NODE_INVOKE_COMMANDS = /* @__PURE__ */ new Set(["system.run", "system.run.prepare"]); function parseNodeInvokeParams(value = "{}") { try { return JSON.parse(value); } catch { throw new Error("--params must be valid JSON."); } } /** Register direct node command invocation. */ function registerNodesInvokeCommands(nodes) { nodesCallOpts(nodes.command("invoke").description("Invoke a command on a paired node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").requiredOption("--command <command>", "Command (e.g. canvas.navigate)").option("--params <json>", "JSON object string for params", "{}").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").option("--idempotency-key <key>", "Idempotency key (optional)").action(async (opts) => { await runNodesCommand("invoke", async () => { const nodeQuery = normalizeOptionalString(opts.node) ?? ""; const command = normalizeOptionalString(opts.command) ?? ""; if (!nodeQuery || !command) throw new Error("--node and --command required"); if (BLOCKED_NODE_INVOKE_COMMANDS.has(normalizeLowercaseStringOrEmpty(command))) throw new Error(`command "${command}" is reserved for shell execution; use the exec tool with host=node instead`); const params = parseNodeInvokeParams(opts.params); const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const invokeParams = { nodeId: await resolveCliNodeId(opts, nodeQuery), command, params, idempotencyKey: opts.idempotencyKey ?? randomIdempotencyKey() }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs; const result = await callNodesGatewayCli("node.invoke", opts, invokeParams); defaultRuntime.writeJson(result); }); }), { timeoutMs: 3e4 }); } //#endregion //#region src/cli/nodes-cli/register.location.ts /** Register node location lookup commands. */ function registerNodesLocationCommands(nodes) { const location = nodes.command("location").description("Fetch location from a paired node"); nodesCallOpts(location.command("get").description("Fetch the current location from a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--max-age <ms>", "Use cached location newer than this (ms)").option("--accuracy <coarse|balanced|precise>", "Desired accuracy (default: balanced/precise depending on node setting)").option("--location-timeout <ms>", "Location fix timeout (ms)", "10000").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => { await runNodesCommand("location get", async () => { const desiredAccuracyRaw = normalizeOptionalLowercaseString(opts.accuracy); const desiredAccuracy = desiredAccuracyRaw === "coarse" || desiredAccuracyRaw === "balanced" || desiredAccuracyRaw === "precise" ? desiredAccuracyRaw : void 0; if (opts.accuracy !== void 0 && desiredAccuracy === void 0) throw new Error("invalid --accuracy (use coarse|balanced|precise)"); const maxAgeMs = parseOptionalNodeNonNegativeInteger(opts.maxAge, "--max-age"); const timeoutMs = parseOptionalNodePositiveInteger(opts.locationTimeout, "--location-timeout"); const invokeTimeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const invokeParams = { nodeId: await resolveCliNodeId(opts, opts.node ?? ""), command: "location.get", params: { maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : void 0, desiredAccuracy, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0 }, idempotencyKey: randomIdempotencyKey() }; if (typeof invokeTimeoutMs === "number" && Number.isFinite(invokeTimeoutMs)) invokeParams.timeoutMs = invokeTimeoutMs; const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const res = typeof raw === "object" && raw !== null ? raw : {}; const payload = res.payload && typeof res.payload === "object" ? res.payload : {}; if (opts.json) { defaultRuntime.writeJson(payload); return; } const lat = payload.lat; const lon = payload.lon; const acc = payload.accuracyMeters; if (typeof lat === "number" && typeof lon === "number") { const accText = typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : ""; defaultRuntime.log(`${lat},${lon}${accText}`); return; } defaultRuntime.writeJson(payload, 0); }); }), { timeoutMs: 3e4 }); } //#endregion //#region src/cli/nodes-cli/register.notify.ts /** Register node notification command. */ function registerNodesNotifyCommand(nodes) { nodesCallOpts(nodes.command("notify").description("Send a local notification on a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--title <text>", "Notification title").option("--body <text>", "Notification body").option("--sound <name>", "Notification sound").addOption(new Option("--priority <passive|active|timeSensitive>", "Notification priority").choices([ "passive", "active", "timeSensitive" ])).addOption(new Option("--delivery <system|overlay|auto>", "Delivery mode").choices([ "system", "overlay", "auto" ]).default("system")).option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").action(async (opts) => { await runNodesCommand("notify", async () => { const title = normalizeOptionalString(opts.title) ?? ""; const body = normalizeOptionalString(opts.body) ?? ""; if (!title && !body) throw new Error("missing --title or --body"); const invokeTimeout = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const invokeParams = { nodeId: await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""), command: "system.notify", params: { title, body, sound: opts.sound, priority: opts.priority, delivery: opts.delivery }, idempotencyKey: opts.idempotencyKey ?? randomIdempotencyKey() }; if (typeof invokeTimeout === "number" && Number.isFinite(invokeTimeout)) invokeParams.timeoutMs = invokeTimeout; const result = await callNodesGatewayCli("node.invoke", opts, invokeParams); if (opts.json) { defaultRuntime.writeJson(result); return; } const { ok } = getNodesTheme(); defaultRuntime.log(ok("notify ok")); }); })); } //#endregion //#region src/cli/nodes-cli/pairing-render.ts /** Render pending pairing requests with sanitized labels and relative request age. */ function renderPendingPairingRequestsTable(params) { const { pending, now, tableWidth, theme } = params; const rows = pending.map((r) => { const nodeLabel = r.displayName?.trim() ? r.displayName.trim() : r.nodeId; return { Request: sanitizeTerminalText(r.requestId), Node: sanitizeTerminalText(nodeLabel), IP: sanitizeTerminalText(r.remoteIp ?? ""), Requested: typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown") }; }); return { heading: theme.heading("Pending"), table: renderTable({ width: tableWidth, columns: [ { key: "Request", header: "Request", minWidth: 8 }, { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Requested", header: "Requested", minWidth: 12 } ], rows }).trimEnd() }; } //#endregion //#region src/cli/nodes-cli/register.pairing.ts const DEFAULT_NODE_PAIR_APPROVE_SCOPES = ["operator.pairing"]; const NODE_PAIR_APPROVE_SCOPE_SET = /* @__PURE__ */ new Set([ "operator.pairing", "operator.write", "operator.admin" ]); function normalizeNodePairApproveScopes(scopes) { 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)) continue; normalized.add(scope); } return [...normalized]; } async function resolveApproveScopesForRequest(opts, requestId) { let pending; 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) => 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 }; return { scopes: resolveNodePairApprovalScopes(request.commands) }; } function isUnknownNodePairRequestIdError(error) { const requestError = error; return requestError instanceof Error && requestError.name === "GatewayClientRequestError" && requestError.gatewayCode === "INVALID_REQUEST" && requestError.message === "unknown requestId"; } function buildUnknownNodePairRequestIdMessage(requestId, opts, pendingRequestIds) { const lines = [`Unknown node pairing requestId: ${requestId}`]; if (pendingRequestIds !== void 0) { 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, requestId, opts) { if (!isUnknownNodePairRequestIdError(error)) throw error; error.name = "Error"; error.message = buildUnknownNodePairRequestIdMessage(requestId, opts); throw error; } /** Register node pairing management commands. */ function registerNodesPairingCommands(nodes) { nodesCallOpts(nodes.command("pending").description("List pending pairing requests").action(async (opts) => { 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 rendered = renderPendingPairingRequestsTable({ pending, now: Date.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, opts) => { await runNodesCommand("approve", async () => { const { scopes } = await resolveApproveScopesForRequest(opts, requestId); let result; 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, opts) => { await runNodesCommand("reject", async () => { let result; 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) => { 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) => { 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}`)); }); })); } //#endregion //#region src/cli/nodes-cli/register.push.ts /** Register the node push-test command. */ function registerNodesPushCommand(nodes) { nodesCallOpts(nodes.command("push").description("Send an APNs test push to an iOS node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--title <text>", "Push title", "OpenClaw").option("--body <text>", "Push body").option("--environment <sandbox|production>", "Override APNs environment").action(async (opts) => { await runNodesCommand("push", async () => { const environment = normalizeOptionalLowercaseString(opts.environment); if (opts.environment && environment !== "sandbox" && environment !== "production") throw new Error("invalid --environment (use sandbox|production)"); const nodeId = await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""); const params = { nodeId, title: normalizeOptionalString(opts.title) || "OpenClaw", body: normalizeOptionalString(opts.body) || `Push test for node ${nodeId}` }; if (environment) params.environment = environment; const result = await callNodesGatewayCli("push.test", opts, params); const parsed = typeof result === "object" && result !== null ? result : {}; const ok = parsed.ok === true; const status = typeof parsed.status === "number" ? parsed.status : 0; const reason = typeof parsed.reason === "string" ? normalizeOptionalString(parsed.reason) : void 0; const env = typeof parsed.environment === "string" ? normalizeOptionalString(parsed.environment) ?? "unknown" : "unknown"; if (opts.json) defaultRuntime.writeJson(result); else { const { ok: okLabel, error: errorLabel } = getNodesTheme(); const label = ok ? okLabel : errorLabel; defaultRuntime.log(label(`push.test status=${status} ok=${ok} env=${env}`)); if (reason) defaultRuntime.log(`reason: ${reason}`); } if (!ok) process.exitCode = 1; }); }), { timeoutMs: 25e3 }); } //#endregion //#region src/cli/nodes-cli/register.screen.ts /** Register node screen recording commands. */ function registerNodesScreenCommands(nodes) { const screen = nodes.command("screen").description("Capture screen recordings from a paired node"); nodesCallOpts(screen.command("record").description("Capture a short screen recording from a node (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--screen <index>", "Screen index (0 = primary)", "0").option("--duration <ms|10s>", "Clip duration (ms or 10s)", "10000").option("--fps <fps>", "Frames per second", "10").option("--no-audio", "Disable microphone audio capture").option("--out <path>", "Output path").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 120000)", "120000").action(async (opts) => { await runNodesCommand("screen record", async () => { const durationMs = parseDurationMs(opts.duration ?? ""); const screenIndex = parseOptionalNodeNonNegativeInteger(opts.screen ?? "0", "--screen"); const fps = parseOptionalNodeFiniteNumber(opts.fps ?? "10", "--fps", { minExclusive: 0 }); const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout"); const nodeId = await resolveCliNodeId(opts, opts.node ?? ""); const invokeParams = buildNodeInvokeParams({ nodeId, command: "screen.record", params: { durationMs: Number.isFinite(durationMs) ? durationMs : void 0, screenIndex: Number.isFinite(screenIndex) ? screenIndex : void 0, fps: Number.isFinite(fps) ? fps : void 0, format: "mp4", includeAudio: opts.audio !== false }, timeoutMs }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const parsed = parseScreenRecordPayload((typeof raw === "object" && raw !== null ? raw : {}).payload); const filePath = opts.out ?? screenRecordTempPath({ ext: parsed.format || "mp4" }); const written = await writeBase64ToFile(filePath, parsed.base64); if (opts.json) { defaultRuntime.writeJson({ file: { path: written.path, durationMs: parsed.durationMs, fps: parsed.fps, screenIndex: parsed.screenIndex, hasAudio: parsed.hasAudio } }); return; } defaultRuntime.log(shortenHomePath(written.path)); }); }), { timeoutMs: 18e4 }); } //#endregion //#region src/cli/nodes-cli/register.status.ts function formatNodeStatsBytes(bytes) { return formatByteSize(bytes, { style: "legacy-binary", maxUnit: "tera", separator: " ", fractionDigits: (value, unit) => value < 10 && unit !== "byte" ? 1 : 0 }); } function formatNodeHostStats(stats, connected, now) { if (!isNodeHostStats(stats)) return null; const totalMemory = formatNodeStatsBytes(stats.memoryTotalBytes); const usedMemory = formatNodeStatsBytes(stats.memoryTotalBytes - stats.memoryFreeBytes); const memoryUnit = totalMemory.slice(totalMemory.lastIndexOf(" ")); const usedLabel = usedMemory.endsWith(memoryUnit) ? usedMemory.slice(0, -memoryUnit.length) : usedMemory; const summary = [ stats.loadAverage ? `load ${stats.loadAverage[0].toFixed(1)}/${stats.cpuCount}` : null, `mem ${usedLabel}/${totalMemory}`, stats.diskAvailableBytes !== void 0 ? `disk ${formatNodeStatsBytes(stats.diskAvailableBytes)} free` : null ].filter(Boolean).join(" · "); return connected ? summary : `${summary} (last known ${formatTimeAgo(Math.max(0, now - stats.updatedAtMs))})`; } function formatVersionLabel(raw) { const trimmed = raw.trim(); if (!trimmed) return raw; if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("v")) return trimmed; return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed; } function resolveNodeVersions(node) { const core = normalizeOptionalString(node.coreVersion); const ui = normalizeOptionalString(node.uiVersion); if (core || ui) return { core, ui }; const legacy = node.version?.trim(); if (!legacy) return { core: void 0, ui: void 0 }; const platform = normalizeOptionalLowercaseString(node.platform) ?? ""; return platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows" ? { core: legacy, ui: void 0 } : { core: void 0, ui: legacy }; } function formatNodeVersions(node) { const { core, ui } = resolveNodeVersions(node); const parts = []; if (core) parts.push(`core ${formatVersionLabel(core)}`); if (ui) parts.push(`ui ${formatVersionLabel(ui)}`); return parts.length > 0 ? parts.join(" · ") : null; } function isWindowsNodePlatform(platform) { const normalized = normalizeOptionalLowercaseString(platform) ?? ""; return normalized === "win32" || normalized === "windows"; } function formatPathEnv(raw, platform) { if (typeof raw !== "string") return null; const trimmed = raw.trim(); if (!trimmed) return null; const delimiter = isWindowsNodePlatform(platform) ? ";" : ":"; const parts = trimmed.split(delimiter).filter(Boolean); const display = parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(delimiter)}${delimiter}…${delimiter}${parts.slice(-1)[0]}`; return shortenHomeInString(display); } function formatClientLabel(node) { const clientId = node.clientId?.trim(); const clientMode = node.clientMode?.trim(); if (clientId && clientMode) return `${clientId}/${clientMode}`; return clientId || clientMode || null; } function formatNodeTerminalLabel(node) { const label = node.displayName?.trim() ? node.displayName.trim() : node.nodeId; return sanitizeTerminalText(label); } function formatNodeTimeAgo(now, timestamp) { return typeof timestamp === "number" && Number.isFinite(timestamp) ? formatTimeAgo(Math.max(0, now - timestamp)) : null; } function formatNodeApprovalState(raw) { return raw === "approved" || raw === "pending-approval" || raw === "pending-reapproval" || raw === "unapproved" ? raw : null; } function formatApprovalStateLabel(state) { if (state === "pending-approval") return "approval pending"; if (state === "pending-reapproval") return "reapproval pending"; return state; } function isPendingApprovalState(state) { return state === "pending-approval" || state === "pending-reapproval"; } function parseSinceMs(raw, label) { if (raw === void 0) return; try { return parseDurationMs(raw); } catch (err) { throw new Error(`${label}: ${formatErrorMessage(err)}`, { cause: err }); } } function matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now) { if (connectedOnly && !node.connected) return false; const lastConnectedAtMs = node.lastConnectedAtMs ?? node.connectedAtMs; return sinceMs === void 0 || typeof lastConnectedAtMs === "number" && now - lastConnectedAtMs <= sinceMs; } function mergePairedNodeWithEffectiveNode(paired, effective) { return { ...paired, ...effective, createdAtMs: paired?.createdAtMs, lastConnectedAtMs: effective.lastConnectedAtMs ?? paired?.lastConnectedAtMs ?? effective.connectedAtMs, displayName: effective.displayName ?? paired?.displayName, platform: effective.platform ?? paired?.platform, version: effective.version ?? paired?.version, coreVersion: effective.coreVersion ?? paired?.coreVersion, uiVersion: effective.uiVersion ?? paired?.uiVersion, remoteIp: effective.remoteIp ?? paired?.remoteIp, permissions: effective.permissions ?? paired?.permissions, approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs }; } function mergePairedNodesWithEffectiveNodes(paired, effectiveNodes) { if (effectiveNodes === null) return paired; const pairedById = new Map(paired.map((node) => [node.nodeId, node])); const seen = /* @__PURE__ */ new Set(); const rows = []; for (const effective of effectiveNodes) { const pairedNode = pairedById.get(effective.nodeId); if (!pairedNode && effective.paired !== true) continue; seen.add(effective.nodeId); rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective)); } for (const node of paired) if (!seen.has(node.nodeId)) rows.push(node); return rows; } async function tryReadNodeList(opts) { try { return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})); } catch (error) { defaultRuntime.error(getNodesTheme().muted(`live node view unavailable (${formatErrorMessage(error)}); showing paired-only data`)); return null; } } /** Register node status, describe, and paired-node list commands. */ function registerNodesStatusCommands(nodes) { nodesCallOpts(nodes.command("status").description("List known nodes with connection status and capabilities").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => { await runNodesCommand("status", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = await callNodeDiagnosticsGatewayCli("node.list", opts, {}); const obj = typeof result === "object" && result !== null ? result : {}; const { ok, warn, muted } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const nodesLocal = parseNodeList(result); const filtered = nodesLocal.filter((node) => matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now)); if (opts.json) { const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); defaultRuntime.writeJson({ ...obj, ts, nodes: filtered }); return; } const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; const filteredLabel = filtered.length !== nodesLocal.length ? ` (of ${nodesLocal.length})` : ""; defaultRuntime.log(`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`); if (filtered.length === 0) return; const rows = filtered.map((n) => { const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); const pathEnv = formatPathEnv(n.pathEnv, n.platform); const client = formatClientLabel(n); const lastActive = formatNodeTimeAgo(now, n.lastActiveAtMs); const detailParts = [ client ? `client: ${client}` : null, n.deviceFamily ? `device: ${n.deviceFamily}` : null, n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, perms ? `perms: ${perms}` : null, versions, formatNodeHostStats(n.hostStats, Boolean(n.connected), now), pathEnv ? `path: ${pathEnv}` : null, lastActive ? `input: ${lastActive}${n.active ? " (active)" : ""}` : null ].filter(Boolean).map((part) => sanitizeTerminalText(String(part))); const caps = Array.isArray(n.caps) ? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", ")) : "?"; const paired = n.paired ? ok("paired") : warn("unpaired"); const connected = n.connected ? ok("connected") : muted("disconnected"); const approvalState = formatNodeApprovalState(n.approvalState); const approval = approvalState === "approved" ? ok("approved") : isPendingApprovalState(approvalState) ? warn(formatApprovalStateLabel(approvalState)) : approvalState === "unapproved" ? warn("unapproved") : null; const since = typeof n.connectedAtMs === "number" ? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})` : ""; return { Node: formatNodeTerminalLabel(n), ID: sanitizeTerminalText(n.nodeId), IP: sanitizeTerminalText(n.remoteIp ?? ""), Detail: detailParts.join(" · "), Status: `${paired} · ${connected}${since}${approval ? ` · ${approval}` : ""}`, Caps: caps }; }); defaultRuntime.log(renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "ID", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Detail", header: "Detail", minWidth: 18, flex: true }, { key: "Status", header: "Status", minWidth: 18 }, { key: "Caps", header: "Caps", minWidth: 12, flex: true } ], rows }).trimEnd()); for (const node of filtered) { const approvalState = formatNodeApprovalState(node.approvalState); const requestId = normalizeOptionalString(node.pendingRequestId); if (isPendingApprovalState(approvalState) && requestId) { const approveCommand = formatPairingApproveCommand("nodes", requestId, { timeout: opts.timeout }); const action = approvalState === "pending-reapproval" ? "Reapproval" : "Approval"; defaultRuntime.log(warn(`${action} pending for ${formatNodeTerminalLabel(node)}. Run ${sanitizeTerminalText(approveCommand)}`)); const connectionReminder = formatConnectionFlagReminder(opts); if (connectionReminder) defaultRuntime.log(warn(connectionReminder)); } } }); })); nodesCallOpts(nodes.command("describe").description("Describe a node (capabilities + supported invoke commands)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => { await runNodesCommand("describe", async () => { const nodeId = await resolveNodeDiagnosticsId(opts, opts.node ?? ""); const result = await callNodeDiagnosticsGatewayCli("node.describe", opts, { nodeId }); if (opts.json) { defaultRuntime.writeJson(result); return; } const obj = typeof result === "object" && result !== null ? result : {}; const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; const connected = Boolean(obj.connected); const paired = Boolean(obj.paired); const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).toSorted() : null; const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).toSorted() : []; const perms = formatPermissions(obj.permissions); const approvalState = formatNodeApprovalState(obj.approvalState); const pendingRequestId = normalizeOptionalString(obj.pendingRequestId); const pendingCaps = Array.isArray(obj.pendingDeclaredCaps) ? obj.pendingDeclaredCaps.map(String).filter(Boolean).toSorted() : null; const pendingCommands = Array.isArray(obj.pendingDeclaredCommands) ? obj.pendingDeclaredCommands.map(String).filter(Boolean).toSorted() : []; const pendingPerms = formatPermissions(obj.pendingDeclaredPermissions); const approveCommand = isPendingApprovalState(approvalState) && pendingRequestId ? formatPairingApproveCommand("nodes", pendingRequestId, { timeout: opts.timeout }) : null; const connectionReminder = approveCommand ? formatConnectionFlagReminder(opts) : null; const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const client = formatClientLabel(obj); const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null; const versions = formatNodeVersions(obj); const lastActive = formatNodeTimeAgo(Date.now(), obj.lastActiveAtMs); const stats = formatNodeHostStats(obj.hostStats, connected, Date.now()); const { heading, ok, warn, muted } = getNodesTheme(); const status = `${paired ? ok("paired") : warn("unpaired")} · ${connected ? ok("connected") : muted("disconnected")}`; const tableWidth = getTerminalTableWidth(); const rows = [ { Field: "ID", Value: sanitizeTerminalText(nodeId) }, displayName ? { Field: "Name", Value: sanitizeTerminalText(displayName) } : null, client ? { Field: "Client", Value: sanitizeTerminalText(client) } : null, ip ? { Field: "IP", Value: sanitizeTerminalText(ip) } : null, family ? { Field: "Device", Value: sanitizeTerminalText(family) } : null, model ? { Field: "Model", Value: sanitizeTerminalText(model) } : null, perms ? { Field: "Perms", Value: sanitizeTerminalText(perms) } : null, versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null, stats ? { Field: "Stats", Value: stats } : null, pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null, lastActive ? { Field: "Last input", Value: `${lastActive}${obj.active === true ? " (active node)" : ""}` } : null, { Field: "Status", Value: status }, approvalState ? { Field: "Approval", Value: formatApprovalStateLabel(approvalState) } : null, pendingRequestId ? { Field: "Pending request", Value: sanitizeTerminalText(pendingRequestId) } : null, pendingCaps ? { Field: "Pending caps", Value: sanitizeTerminalText(pendingCaps.join(", ")) } : null, pendingPerms ? { Field: "Pending perms", Value: sanitizeTerminalText(pendingPerms) } : null, approveCommand ? { Field: approvalState === "pending-reapproval" ? "Reapprove" : "Approve", Value: sanitizeTerminalText(approveCommand) } : null, approveCommand && connectionReminder ? { Field: "Connection reminder", Value: connectionReminder } : null, { Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" } ].filter(Boolean); defaultRuntime.log(heading("Node")); defaultRuntime.log(renderTable({ width: tableWidth, columns: [{ key: "Field", header: "Field", minWidth: 8 }, { key: "Value", header: "Value", minWidth: 24, flex: true }], rows }).trimEnd()); defaultRuntime.log(""); defaultRuntime.log(heading("Commands")); if (commands.length === 0) defaultRuntime.log(muted("- (none effective)")); else for (const c of commands) defaultRuntime.log(`- ${sanitizeTerminalText(c)}`); if (pendingCommands.length > 0) { defaultRuntime.log(""); defaultRuntime.log(heading("Pending commands")); for (const command of pendingCommands) defaultRuntime.log(`- ${sanitizeTerminalText(command)}`); } }); })); nodesCallOpts(nodes.command("list").description("List pending and paired nodes").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => { await runNodesCommand("list", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = await callNodesGatewayCli("node.pair.list", opts, {}); const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== void 0; const pendingRows = pending; const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, hasFilters ? parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})) : await tryReadNodeList(opts)); const filteredPaired = effectivePairedRows.filter((node) => matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now)); const filteredLabel = hasFilters && filteredPaired.length !== effectivePairedRows.length ? ` (of ${effectivePairedRows.length})` : ""; if (opts.json) { defaultRuntime.writeJson({ pending: pendingRows, paired: filteredPaired.map((row) => { const { token: _token, ...rest } = row; return rest; }) }); return; } defaultRuntime.log(`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`); if (pendingRows.length > 0) { const rendered = renderPendingPairingRequestsTable({ pending: pendingRows, now, tableWidth, theme: { heading, warn, muted } }); defaultRuntime.log(""); defaultRuntime.log(rendered.heading); defaultRuntime.log(rendered.table); } if (filteredPaired.length > 0) { const pairedTableRows = filteredPaired.map((n) => ({ Node: formatNodeTerminalLabel(n), Id: sanitizeTerminalText(n.nodeId), IP: sanitizeTerminalText(n.remoteIp ?? ""), LastConnect: formatNodeTimeAgo(now, n.lastConnectedAtMs ?? n.connectedAtMs) ?? muted("unknown") })); defaultRuntime.log(""); defaultRuntime.log(heading("Paired")); defaultRuntime.log(renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "Id", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "LastConnect", header: "Last Connect", minWidth: 14 } ], rows: pairedTableRows }).trimEnd()); } }); })); } //#endregion //#region src/cli/nodes-cli/register.ts /** Register the `nodes` command group and lazy plugin-provided node commands. */ async function registerNodesCli(program, argv = process.argv) { const nodes = program.command("nodes").description("Manage gateway-owned nodes (pairing, status, invoke, and media)").addHelpText("after", () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw nodes status", "List known nodes with live status."], ["openclaw nodes pending", "Show pending node pairing requests."], ["openclaw nodes remove --node <id|name|ip>", "Remove a stale paired node entry."], ["openclaw nodes invoke --node <id> --command system.which --params '{\"bins\":[\"uname\"]}'", "Invoke a node command directly."], ["openclaw nodes camera snap --node <id>", "Capture a photo from a node camera."] ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/nodes", "docs.openclaw.ai/cli/nodes")}\n`); registerNodesStatusCommands(nodes); registerNodesPairingCommands(nodes); registerNodesInvokeCommands(nodes); registerNodesNotifyCommand(nodes); registerNodesPushCommand(nodes); registerNodesCameraCommands(nodes); registerNodesScreenCommands(nodes); registerNodesLocationCommands(nodes); setCommandJsonMode(nodes, "output", ({ argv: commandArgv }) => isNodesMachineOutput(commandArgv)); if (!shouldRegisterNodesPluginCommands(nodes, argv)) return; const { registerPluginCliCommandsFromValidatedConfig } = await import("./cli-CSMlN8GD.js"); await withConsoleLogsRoutedToStderrForJson(argv, async () => await registerPluginCliCommandsFromValidatedConfig(program, void 0, void 0, { mode: "lazy", primary: "nodes" })); } /** Plugin node subcommands are only resolved when the invocation is not a built-in nodes command. */ function shouldRegisterNodesPluginCommands(nodes, argv) { const { commandPath } = resolveCliArgvInvocation([...argv]); if (commandPath[0] !== "nodes") return true; const requestedSubcommand = commandPath[1]; if (!requestedSubcommand) return true; return !new Set(nodes.commands.map((command) => command.name())).has(requestedSubcommand); } //#endregion export { registerNodesCli };