mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
refactor(nodes): remove nodes.run execution path
This commit is contained in:
@@ -9,17 +9,16 @@ import {
|
||||
writeCameraClipPayloadToFile,
|
||||
writeCameraPayloadToFile,
|
||||
} from "../../cli/nodes-camera.js";
|
||||
import { parseEnvPairs, parseTimeoutMs } from "../../cli/nodes-run.js";
|
||||
import {
|
||||
parseScreenRecordPayload,
|
||||
screenRecordTempPath,
|
||||
writeScreenRecordToFile,
|
||||
} from "../../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import { parseTimeoutMs } from "../../cli/parse-timeout.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { OperatorScope } from "../../gateway/method-scopes.js";
|
||||
import { NODE_SYSTEM_RUN_COMMANDS } from "../../infra/node-commands.js";
|
||||
import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js";
|
||||
import { imageMimeFromFormat } from "../../media/mime.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
@@ -28,7 +27,7 @@ import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||
import { sanitizeToolResultImages } from "../tool-images.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
|
||||
import { listNodes, resolveNode, resolveNodeId, resolveNodeIdFromList } from "./nodes-utils.js";
|
||||
import { resolveNode, resolveNodeId } from "./nodes-utils.js";
|
||||
|
||||
const NODES_TOOL_ACTIONS = [
|
||||
"status",
|
||||
@@ -49,7 +48,6 @@ const NODES_TOOL_ACTIONS = [
|
||||
"device_info",
|
||||
"device_permissions",
|
||||
"device_health",
|
||||
"run",
|
||||
"invoke",
|
||||
] as const;
|
||||
|
||||
@@ -64,6 +62,7 @@ const MEDIA_INVOKE_ACTIONS = {
|
||||
"photos.latest": "photos_latest",
|
||||
"screen.record": "screen_record",
|
||||
} as const;
|
||||
const BLOCKED_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]);
|
||||
const NODE_READ_ACTION_COMMANDS = {
|
||||
camera_list: "camera.list",
|
||||
notifications_list: "notifications.list",
|
||||
@@ -169,16 +168,10 @@ const NodesToolSchema = Type.Object({
|
||||
notificationAction: optionalStringEnum(NOTIFICATIONS_ACTIONS),
|
||||
notificationKey: Type.Optional(Type.String()),
|
||||
notificationReplyText: Type.Optional(Type.String()),
|
||||
// run
|
||||
command: Type.Optional(Type.Array(Type.String())),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
env: Type.Optional(Type.Array(Type.String())),
|
||||
commandTimeoutMs: Type.Optional(Type.Number()),
|
||||
invokeTimeoutMs: Type.Optional(Type.Number()),
|
||||
needsScreenRecording: Type.Optional(Type.Boolean()),
|
||||
// invoke
|
||||
invokeCommand: Type.Optional(Type.String()),
|
||||
invokeParamsJson: Type.Optional(Type.String()),
|
||||
invokeTimeoutMs: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
export function createNodesTool(options?: {
|
||||
@@ -191,11 +184,6 @@ export function createNodesTool(options?: {
|
||||
modelHasVision?: boolean;
|
||||
allowMediaInvokeCommands?: boolean;
|
||||
}): AnyAgentTool {
|
||||
const sessionKey = options?.agentSessionKey?.trim() || undefined;
|
||||
const turnSourceChannel = options?.agentChannel?.trim() || undefined;
|
||||
const turnSourceTo = options?.currentChannelId?.trim() || undefined;
|
||||
const turnSourceAccountId = options?.agentAccountId?.trim() || undefined;
|
||||
const turnSourceThreadId = options?.currentThreadTs;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
@@ -206,7 +194,7 @@ export function createNodesTool(options?: {
|
||||
name: "nodes",
|
||||
ownerOnly: true,
|
||||
description:
|
||||
"Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/run/invoke).",
|
||||
"Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/invoke).",
|
||||
parameters: NodesToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@@ -658,151 +646,16 @@ export function createNodesTool(options?: {
|
||||
});
|
||||
return jsonResult(payload);
|
||||
}
|
||||
case "run": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodes = await listNodes(gatewayOpts);
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(
|
||||
"system.run requires a paired companion app or node host (no nodes available).",
|
||||
);
|
||||
}
|
||||
const nodeId = resolveNodeIdFromList(nodes, node);
|
||||
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
|
||||
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
|
||||
? nodeInfo?.commands?.includes("system.run")
|
||||
: false;
|
||||
if (!supportsSystemRun) {
|
||||
throw new Error(
|
||||
"system.run requires a companion app or node host; the selected node does not support system.run.",
|
||||
);
|
||||
}
|
||||
const commandRaw = params.command;
|
||||
if (!commandRaw) {
|
||||
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
|
||||
}
|
||||
if (!Array.isArray(commandRaw)) {
|
||||
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
|
||||
}
|
||||
const command = commandRaw.map((c) => String(c));
|
||||
if (command.length === 0) {
|
||||
throw new Error("command must not be empty");
|
||||
}
|
||||
const cwd =
|
||||
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
|
||||
const env = parseEnvPairs(params.env);
|
||||
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
|
||||
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);
|
||||
const needsScreenRecording =
|
||||
typeof params.needsScreenRecording === "boolean"
|
||||
? params.needsScreenRecording
|
||||
: undefined;
|
||||
const prepareRaw = await callGatewayTool<{ payload?: unknown }>(
|
||||
"node.invoke",
|
||||
gatewayOpts,
|
||||
{
|
||||
nodeId,
|
||||
command: "system.run.prepare",
|
||||
params: {
|
||||
command,
|
||||
cwd,
|
||||
agentId,
|
||||
sessionKey,
|
||||
},
|
||||
timeoutMs: invokeTimeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
);
|
||||
const prepared = parsePreparedSystemRunPayload(prepareRaw?.payload);
|
||||
if (!prepared) {
|
||||
throw new Error("invalid system.run.prepare response");
|
||||
}
|
||||
const runParams = {
|
||||
command: prepared.plan.argv,
|
||||
rawCommand: prepared.plan.commandText,
|
||||
cwd: prepared.plan.cwd ?? cwd,
|
||||
env,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
needsScreenRecording,
|
||||
agentId: prepared.plan.agentId ?? agentId,
|
||||
sessionKey: prepared.plan.sessionKey ?? sessionKey,
|
||||
};
|
||||
|
||||
// First attempt without approval flags.
|
||||
try {
|
||||
const raw = await callGatewayTool<{ payload?: unknown }>("node.invoke", gatewayOpts, {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: runParams,
|
||||
timeoutMs: invokeTimeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
return jsonResult(raw?.payload ?? {});
|
||||
} catch (firstErr) {
|
||||
const msg = firstErr instanceof Error ? firstErr.message : String(firstErr);
|
||||
if (!msg.includes("SYSTEM_RUN_DENIED: approval required")) {
|
||||
throw firstErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Node requires approval – create a pending approval request on
|
||||
// the gateway and wait for the user to approve/deny via the UI.
|
||||
const APPROVAL_TIMEOUT_MS = 120_000;
|
||||
const approvalId = crypto.randomUUID();
|
||||
const approvalResult = await callGatewayTool(
|
||||
"exec.approval.request",
|
||||
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
|
||||
{
|
||||
id: approvalId,
|
||||
systemRunPlan: prepared.plan,
|
||||
cwd: prepared.plan.cwd ?? cwd,
|
||||
nodeId,
|
||||
host: "node",
|
||||
agentId: prepared.plan.agentId ?? agentId,
|
||||
sessionKey: prepared.plan.sessionKey ?? sessionKey,
|
||||
turnSourceChannel,
|
||||
turnSourceTo,
|
||||
turnSourceAccountId,
|
||||
turnSourceThreadId,
|
||||
timeoutMs: APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
const decisionRaw =
|
||||
approvalResult && typeof approvalResult === "object"
|
||||
? (approvalResult as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
const approvalDecision =
|
||||
decisionRaw === "allow-once" || decisionRaw === "allow-always" ? decisionRaw : null;
|
||||
|
||||
if (!approvalDecision) {
|
||||
if (decisionRaw === "deny") {
|
||||
throw new Error("exec denied: user denied");
|
||||
}
|
||||
if (decisionRaw === undefined || decisionRaw === null) {
|
||||
throw new Error("exec denied: approval timed out");
|
||||
}
|
||||
throw new Error("exec denied: invalid approval decision");
|
||||
}
|
||||
|
||||
// Retry with the approval decision.
|
||||
const raw = await callGatewayTool<{ payload?: unknown }>("node.invoke", gatewayOpts, {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
...runParams,
|
||||
runId: approvalId,
|
||||
approved: true,
|
||||
approvalDecision,
|
||||
},
|
||||
timeoutMs: invokeTimeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
return jsonResult(raw?.payload ?? {});
|
||||
}
|
||||
case "invoke": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
const invokeCommand = readStringParam(params, "invokeCommand", { required: true });
|
||||
const invokeCommandNormalized = invokeCommand.trim().toLowerCase();
|
||||
if (BLOCKED_INVOKE_COMMANDS.has(invokeCommandNormalized)) {
|
||||
throw new Error(
|
||||
`invokeCommand "${invokeCommand}" is reserved for shell execution; use exec with host=node instead`,
|
||||
);
|
||||
}
|
||||
const dedicatedAction =
|
||||
MEDIA_INVOKE_ACTIONS[invokeCommandNormalized as keyof typeof MEDIA_INVOKE_ACTIONS];
|
||||
if (dedicatedAction && !options?.allowMediaInvokeCommands) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { writeBase64ToFile } from "../nodes-camera.js";
|
||||
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
|
||||
import { parseTimeoutMs } from "../nodes-run.js";
|
||||
import { parseTimeoutMs } from "../parse-timeout.js";
|
||||
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
|
||||
@@ -1,301 +1,11 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import {
|
||||
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
|
||||
type ExecApprovalsFile,
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
loadExecApprovals,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeExecAsk,
|
||||
normalizeExecSecurity,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../../infra/exec-approvals.js";
|
||||
import { buildNodeShellCommand } from "../../infra/node-shell.js";
|
||||
import { applyPathPrepend } from "../../infra/path-prepend.js";
|
||||
import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { parseNodeList } from "./format.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
type NodesRunOpts = NodesRpcOpts & {
|
||||
node?: string;
|
||||
cwd?: string;
|
||||
env?: string[];
|
||||
commandTimeout?: string;
|
||||
needsScreenRecording?: boolean;
|
||||
invokeTimeout?: string;
|
||||
idempotencyKey?: string;
|
||||
agent?: string;
|
||||
ask?: string;
|
||||
security?: string;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
type ExecDefaults = {
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
};
|
||||
|
||||
function resolveExecDefaults(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
agentId: string | undefined,
|
||||
): ExecDefaults | undefined {
|
||||
const globalExec = cfg?.tools?.exec;
|
||||
if (!agentId) {
|
||||
return globalExec
|
||||
? {
|
||||
security: globalExec.security,
|
||||
ask: globalExec.ask,
|
||||
node: globalExec.node,
|
||||
pathPrepend: globalExec.pathPrepend,
|
||||
safeBins: globalExec.safeBins,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
const agentExec = resolveAgentConfig(cfg, agentId)?.tools?.exec;
|
||||
return {
|
||||
security: agentExec?.security ?? globalExec?.security,
|
||||
ask: agentExec?.ask ?? globalExec?.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveNodePlatform(opts: NodesRpcOpts, nodeId: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await callGatewayCli("node.list", opts, {});
|
||||
const nodes = parseNodeList(res);
|
||||
const match = nodes.find((node) => node.nodeId === nodeId);
|
||||
return typeof match?.platform === "string" ? match.platform : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requirePreparedRunPayload(payload: unknown) {
|
||||
const prepared = parsePreparedSystemRunPayload(payload);
|
||||
if (!prepared) {
|
||||
throw new Error("invalid system.run.prepare response");
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function resolveNodesRunPolicy(opts: NodesRunOpts, execDefaults: ExecDefaults | undefined) {
|
||||
const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist";
|
||||
const requestedSecurity = normalizeExecSecurity(opts.security);
|
||||
if (opts.security && !requestedSecurity) {
|
||||
throw new Error("invalid --security (use deny|allowlist|full)");
|
||||
}
|
||||
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.ask is unset.
|
||||
const configuredAsk =
|
||||
normalizeExecAsk(execDefaults?.ask) ?? loadExecApprovals().defaults?.ask ?? "on-miss";
|
||||
const requestedAsk = normalizeExecAsk(opts.ask);
|
||||
if (opts.ask && !requestedAsk) {
|
||||
throw new Error("invalid --ask (use off|on-miss|always)");
|
||||
}
|
||||
return {
|
||||
security: minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity),
|
||||
ask: maxAsk(configuredAsk, requestedAsk ?? configuredAsk),
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareNodesRunContext(params: {
|
||||
opts: NodesRunOpts;
|
||||
command: string[];
|
||||
raw: string;
|
||||
nodeId: string;
|
||||
agentId: string | undefined;
|
||||
execDefaults: ExecDefaults | undefined;
|
||||
}) {
|
||||
const env = parseEnvPairs(params.opts.env);
|
||||
const timeoutMs = parseTimeoutMs(params.opts.commandTimeout);
|
||||
const invokeTimeout = parseTimeoutMs(params.opts.invokeTimeout);
|
||||
|
||||
let argv = Array.isArray(params.command) ? params.command : [];
|
||||
let rawCommand: string | undefined;
|
||||
if (params.raw) {
|
||||
rawCommand = params.raw;
|
||||
const platform = await resolveNodePlatform(params.opts, params.nodeId);
|
||||
argv = buildNodeShellCommand(rawCommand, platform ?? undefined);
|
||||
}
|
||||
|
||||
const nodeEnv = env ? { ...env } : undefined;
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, params.execDefaults?.pathPrepend, { requireExisting: true });
|
||||
}
|
||||
|
||||
const prepareResponse = (await callGatewayCli("node.invoke", params.opts, {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.run.prepare",
|
||||
params: {
|
||||
command: argv,
|
||||
rawCommand,
|
||||
cwd: params.opts.cwd,
|
||||
agentId: params.agentId,
|
||||
},
|
||||
idempotencyKey: `prepare-${randomIdempotencyKey()}`,
|
||||
})) as { payload?: unknown } | null;
|
||||
|
||||
return {
|
||||
prepared: requirePreparedRunPayload(prepareResponse?.payload),
|
||||
nodeEnv,
|
||||
timeoutMs,
|
||||
invokeTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveNodeApprovals(params: {
|
||||
opts: NodesRunOpts;
|
||||
nodeId: string;
|
||||
agentId: string | undefined;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
}) {
|
||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", params.opts, {
|
||||
nodeId: params.nodeId,
|
||||
})) as {
|
||||
file?: unknown;
|
||||
} | null;
|
||||
const approvalsFile =
|
||||
approvalsSnapshot && typeof approvalsSnapshot === "object" ? approvalsSnapshot.file : undefined;
|
||||
if (!approvalsFile || typeof approvalsFile !== "object") {
|
||||
throw new Error("exec approvals unavailable");
|
||||
}
|
||||
const approvals = resolveExecApprovalsFromFile({
|
||||
file: approvalsFile as ExecApprovalsFile,
|
||||
agentId: params.agentId,
|
||||
overrides: { security: params.security, ask: params.ask },
|
||||
});
|
||||
return {
|
||||
approvals,
|
||||
hostSecurity: minSecurity(params.security, approvals.agent.security),
|
||||
hostAsk: maxAsk(params.ask, approvals.agent.ask),
|
||||
askFallback: approvals.agent.askFallback,
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeRequestNodesRunApproval(params: {
|
||||
opts: NodesRunOpts;
|
||||
nodeId: string;
|
||||
agentId: string | undefined;
|
||||
approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"];
|
||||
hostSecurity: ExecSecurity;
|
||||
hostAsk: ExecAsk;
|
||||
askFallback: ExecSecurity;
|
||||
}) {
|
||||
let approvedByAsk = false;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
let approvalId: string | null = null;
|
||||
const requiresAsk = params.hostAsk === "always" || params.hostAsk === "on-miss";
|
||||
if (!requiresAsk) {
|
||||
return { approvedByAsk, approvalDecision, approvalId };
|
||||
}
|
||||
|
||||
approvalId = crypto.randomUUID();
|
||||
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
|
||||
// Keep client transport alive while the approver decides.
|
||||
const transportTimeoutMs = Math.max(
|
||||
parseTimeoutMs(params.opts.timeout) ?? 0,
|
||||
approvalTimeoutMs + 10_000,
|
||||
);
|
||||
const decisionResult = (await callGatewayCli(
|
||||
"exec.approval.request",
|
||||
params.opts,
|
||||
{
|
||||
id: approvalId,
|
||||
systemRunPlan: params.approvalPlan,
|
||||
cwd: params.approvalPlan.cwd,
|
||||
nodeId: params.nodeId,
|
||||
host: "node",
|
||||
security: params.hostSecurity,
|
||||
ask: params.hostAsk,
|
||||
agentId: params.approvalPlan.agentId ?? params.agentId,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: params.approvalPlan.sessionKey ?? undefined,
|
||||
timeoutMs: approvalTimeoutMs,
|
||||
},
|
||||
{ transportTimeoutMs },
|
||||
)) as { decision?: string } | null;
|
||||
const decision =
|
||||
decisionResult && typeof decisionResult === "object" ? (decisionResult.decision ?? null) : null;
|
||||
if (decision === "deny") {
|
||||
throw new Error("exec denied: user denied");
|
||||
}
|
||||
if (!decision) {
|
||||
if (params.askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (params.askFallback !== "allowlist") {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
}
|
||||
if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-always";
|
||||
}
|
||||
return { approvedByAsk, approvalDecision, approvalId };
|
||||
}
|
||||
|
||||
function buildSystemRunInvokeParams(params: {
|
||||
nodeId: string;
|
||||
approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"];
|
||||
nodeEnv: Record<string, string> | undefined;
|
||||
timeoutMs: number | undefined;
|
||||
invokeTimeout: number | undefined;
|
||||
approvedByAsk: boolean;
|
||||
approvalDecision: "allow-once" | "allow-always" | null;
|
||||
approvalId: string | null;
|
||||
idempotencyKey: string | undefined;
|
||||
fallbackAgentId: string | undefined;
|
||||
needsScreenRecording: boolean;
|
||||
}) {
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: params.approvalPlan.argv,
|
||||
rawCommand: params.approvalPlan.commandText,
|
||||
cwd: params.approvalPlan.cwd,
|
||||
env: params.nodeEnv,
|
||||
timeoutMs: params.timeoutMs,
|
||||
needsScreenRecording: params.needsScreenRecording,
|
||||
},
|
||||
idempotencyKey: String(params.idempotencyKey ?? randomIdempotencyKey()),
|
||||
};
|
||||
if (params.approvalPlan.agentId ?? params.fallbackAgentId) {
|
||||
(invokeParams.params as Record<string, unknown>).agentId =
|
||||
params.approvalPlan.agentId ?? params.fallbackAgentId;
|
||||
}
|
||||
if (params.approvalPlan.sessionKey) {
|
||||
(invokeParams.params as Record<string, unknown>).sessionKey = params.approvalPlan.sessionKey;
|
||||
}
|
||||
(invokeParams.params as Record<string, unknown>).approved = params.approvedByAsk;
|
||||
if (params.approvalDecision) {
|
||||
(invokeParams.params as Record<string, unknown>).approvalDecision = params.approvalDecision;
|
||||
}
|
||||
if (params.approvedByAsk && params.approvalId) {
|
||||
(invokeParams.params as Record<string, unknown>).runId = params.approvalId;
|
||||
}
|
||||
if (params.invokeTimeout !== undefined) {
|
||||
invokeParams.timeoutMs = params.invokeTimeout;
|
||||
}
|
||||
return invokeParams;
|
||||
}
|
||||
const BLOCKED_NODE_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]);
|
||||
|
||||
export function registerNodesInvokeCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
@@ -317,6 +27,11 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (BLOCKED_NODE_INVOKE_COMMANDS.has(command.toLowerCase())) {
|
||||
throw new Error(
|
||||
`command "${command}" is reserved for shell execution; use the exec tool with host=node instead`,
|
||||
);
|
||||
}
|
||||
const params = JSON.parse(String(opts.params ?? "{}")) as unknown;
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
@@ -338,131 +53,4 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
}),
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("run")
|
||||
.description("Run a shell command on a node (mac only)")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--env <key=val>",
|
||||
"Environment override (repeatable)",
|
||||
(value: string, prev: string[] = []) => [...prev, value],
|
||||
)
|
||||
.option("--raw <command>", "Run a raw shell command string (sh -lc / cmd.exe /c)")
|
||||
.option("--agent <id>", "Agent id (default: configured default agent)")
|
||||
.option("--ask <mode>", "Exec ask mode (off|on-miss|always)")
|
||||
.option("--security <mode>", "Exec security mode (deny|allowlist|full)")
|
||||
.option("--command-timeout <ms>", "Command timeout (ms)")
|
||||
.option("--needs-screen-recording", "Require screen recording permission")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 30000)", "30000")
|
||||
.argument("[command...]", "Command and args")
|
||||
.action(async (command: string[], opts: NodesRunOpts) => {
|
||||
await runNodesCommand("run", async () => {
|
||||
const cfg = loadConfig();
|
||||
const agentId = opts.agent?.trim() || resolveDefaultAgentId(cfg);
|
||||
const execDefaults = resolveExecDefaults(cfg, agentId);
|
||||
const raw = typeof opts.raw === "string" ? opts.raw.trim() : "";
|
||||
if (raw && Array.isArray(command) && command.length > 0) {
|
||||
throw new Error("use --raw or argv, not both");
|
||||
}
|
||||
if (!raw && (!Array.isArray(command) || command.length === 0)) {
|
||||
throw new Error("command required");
|
||||
}
|
||||
|
||||
const nodeQuery = String(opts.node ?? "").trim() || execDefaults?.node?.trim() || "";
|
||||
if (!nodeQuery) {
|
||||
throw new Error("node required (set --node or tools.exec.node)");
|
||||
}
|
||||
const nodeId = await resolveNodeId(opts, nodeQuery);
|
||||
const preparedContext = await prepareNodesRunContext({
|
||||
opts,
|
||||
command,
|
||||
raw,
|
||||
nodeId,
|
||||
agentId,
|
||||
execDefaults,
|
||||
});
|
||||
const approvalPlan = preparedContext.prepared.plan;
|
||||
const policy = resolveNodesRunPolicy(opts, execDefaults);
|
||||
const approvals = await resolveNodeApprovals({
|
||||
opts,
|
||||
nodeId,
|
||||
agentId,
|
||||
security: policy.security,
|
||||
ask: policy.ask,
|
||||
});
|
||||
if (approvals.hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=node security=deny");
|
||||
}
|
||||
const approvalResult = await maybeRequestNodesRunApproval({
|
||||
opts,
|
||||
nodeId,
|
||||
agentId,
|
||||
approvalPlan,
|
||||
hostSecurity: approvals.hostSecurity,
|
||||
hostAsk: approvals.hostAsk,
|
||||
askFallback: approvals.askFallback,
|
||||
});
|
||||
const invokeParams = buildSystemRunInvokeParams({
|
||||
nodeId,
|
||||
approvalPlan,
|
||||
nodeEnv: preparedContext.nodeEnv,
|
||||
timeoutMs: preparedContext.timeoutMs,
|
||||
invokeTimeout: preparedContext.invokeTimeout,
|
||||
approvedByAsk: approvalResult.approvedByAsk,
|
||||
approvalDecision: approvalResult.approvalDecision,
|
||||
approvalId: approvalResult.approvalId,
|
||||
idempotencyKey: opts.idempotencyKey,
|
||||
fallbackAgentId: agentId,
|
||||
needsScreenRecording: opts.needsScreenRecording === true,
|
||||
});
|
||||
|
||||
const result = await callGatewayCli("node.invoke", opts, invokeParams);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { payload?: Record<string, unknown> }).payload
|
||||
: undefined;
|
||||
|
||||
const stdout = typeof payload?.stdout === "string" ? payload.stdout : "";
|
||||
const stderr = typeof payload?.stderr === "string" ? payload.stderr : "";
|
||||
const exitCode = typeof payload?.exitCode === "number" ? payload.exitCode : null;
|
||||
const timedOut = payload?.timedOut === true;
|
||||
const success = payload?.success === true;
|
||||
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
if (timedOut) {
|
||||
const { error } = getNodesTheme();
|
||||
defaultRuntime.error(error("run timed out"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
|
||||
if (hint) {
|
||||
const { warn } = getNodesTheme();
|
||||
defaultRuntime.error(warn(hint));
|
||||
}
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0 && !success) {
|
||||
const { error } = getNodesTheme();
|
||||
defaultRuntime.error(error(`run exit ${exitCode}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 35_000 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ export function registerNodesCli(program: Command) {
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw nodes status", "List known nodes with live status."],
|
||||
["openclaw nodes pairing pending", "Show pending node pairing requests."],
|
||||
['openclaw nodes run --node <id> --raw "uname -a"', "Run a shell command on a node."],
|
||||
[
|
||||
'openclaw nodes invoke --node <id> --command system.which --params \'{"name":"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`,
|
||||
);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { parseTimeoutMs } from "./parse-timeout.js";
|
||||
|
||||
export function parseEnvPairs(pairs: unknown): Record<string, string> | undefined {
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const pair of pairs) {
|
||||
if (typeof pair !== "string") {
|
||||
continue;
|
||||
}
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = pair.slice(0, idx).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
env[key] = pair.slice(idx + 1);
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
export { parseTimeoutMs };
|
||||
@@ -332,8 +332,8 @@ export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] |
|
||||
const labels = macNodes.map((node) => node.displayName ?? node.nodeId).filter(Boolean);
|
||||
const note =
|
||||
labels.length > 0
|
||||
? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via nodes.run on that node.`
|
||||
: "Remote macOS node available. Run macOS-only skills via nodes.run on that node.";
|
||||
? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via exec host=node on that node.`
|
||||
: "Remote macOS node available. Run macOS-only skills via exec host=node on that node.";
|
||||
return {
|
||||
platforms: ["darwin"],
|
||||
hasBin: (bin) => bins.has(bin),
|
||||
|
||||
Reference in New Issue
Block a user