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, 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 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, 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 { 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)"); } const configuredAsk = normalizeExecAsk(execDefaults?.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; preparedCmdText: string; approvalPlan: ReturnType["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, command: params.preparedCmdText, commandArgv: params.approvalPlan.argv, 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["plan"]; nodeEnv: Record | 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 = { nodeId: params.nodeId, command: "system.run", params: { command: params.approvalPlan.argv, rawCommand: params.approvalPlan.rawCommand, 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).agentId = params.approvalPlan.agentId ?? params.fallbackAgentId; } if (params.approvalPlan.sessionKey) { (invokeParams.params as Record).sessionKey = params.approvalPlan.sessionKey; } (invokeParams.params as Record).approved = params.approvedByAsk; if (params.approvalDecision) { (invokeParams.params as Record).approvalDecision = params.approvalDecision; } if (params.approvedByAsk && params.approvalId) { (invokeParams.params as Record).runId = params.approvalId; } if (params.invokeTimeout !== undefined) { invokeParams.timeoutMs = params.invokeTimeout; } return invokeParams; } export function registerNodesInvokeCommands(nodes: Command) { nodesCallOpts( nodes .command("invoke") .description("Invoke a command on a paired node") .requiredOption("--node ", "Node id, name, or IP") .requiredOption("--command ", "Command (e.g. canvas.eval)") .option("--params ", "JSON object string for params", "{}") .option("--invoke-timeout ", "Node invoke timeout in ms (default 15000)", "15000") .option("--idempotency-key ", "Idempotency key (optional)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("invoke", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const command = String(opts.command ?? "").trim(); if (!nodeId || !command) { const { error } = getNodesTheme(); defaultRuntime.error(error("--node and --command required")); defaultRuntime.exit(1); return; } const params = JSON.parse(String(opts.params ?? "{}")) as unknown; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; const invokeParams: Record = { nodeId, command, params, idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()), }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { invokeParams.timeoutMs = timeoutMs; } const result = await callGatewayCli("node.invoke", opts, invokeParams); defaultRuntime.log(JSON.stringify(result, null, 2)); }); }), { timeoutMs: 30_000 }, ); nodesCallOpts( nodes .command("run") .description("Run a shell command on a node (mac only)") .option("--node ", "Node id, name, or IP") .option("--cwd ", "Working directory") .option( "--env ", "Environment override (repeatable)", (value: string, prev: string[] = []) => [...prev, value], ) .option("--raw ", "Run a raw shell command string (sh -lc / cmd.exe /c)") .option("--agent ", "Agent id (default: configured default agent)") .option("--ask ", "Exec ask mode (off|on-miss|always)") .option("--security ", "Exec security mode (deny|allowlist|full)") .option("--command-timeout ", "Command timeout (ms)") .option("--needs-screen-recording", "Require screen recording permission") .option("--invoke-timeout ", "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, preparedCmdText: preparedContext.prepared.cmdText, 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.log(JSON.stringify(result, null, 2)); return; } const payload = typeof result === "object" && result !== null ? (result as { payload?: Record }).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 }, ); }