feat: make nodes run exec-style

This commit is contained in:
Peter Steinberger
2026-01-21 20:24:12 +00:00
parent 6492e90c1b
commit 0d3b8f6ac3
5 changed files with 338 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- Nodes: run always uses exec approvals + defaults, with raw shell mode and ask/security overrides. https://docs.clawd.bot/cli/nodes
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.

View File

@@ -36,4 +36,19 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`).
```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...>
clawdbot nodes run --raw "git status"
clawdbot nodes run --agent main --node <id|name|ip> --raw "git status"
```
### Exec-style defaults
`nodes run` mirrors the models exec behavior (defaults + approvals):
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
- `--node` can be omitted when `tools.exec.node` is set.
Flags:
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.

View File

@@ -27,6 +27,25 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
},
};
}
if (opts.method === "exec.approvals.get") {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash",
file: {
version: 1,
defaults: {
security: "allowlist",
ask: "on-miss",
askFallback: "deny",
},
agents: {},
},
};
}
if (opts.method === "exec.approval.request") {
return { decision: "allow-once" };
}
return { ok: true };
});
@@ -51,6 +70,10 @@ vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({}),
}));
describe("nodes-cli coverage", () => {
it("lists nodes via node.list", async () => {
runtimeLogs.length = 0;
@@ -112,10 +135,43 @@ describe("nodes-cli coverage", () => {
env: { FOO: "bar" },
timeoutMs: 1200,
needsScreenRecording: true,
agentId: "main",
approved: true,
approvalDecision: "allow-once",
});
expect(invoke?.params?.timeoutMs).toBe(5000);
});
it("invokes system.run with raw command", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
randomIdempotencyKey.mockClear();
const { registerNodesCli } = await import("./nodes-cli.js");
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
["nodes", "run", "--agent", "main", "--node", "mac-1", "--raw", "echo hi"],
{ from: "user" },
);
const invoke = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0];
expect(invoke).toBeTruthy();
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
expect(invoke?.params?.command).toBe("system.run");
expect(invoke?.params?.params).toMatchObject({
command: ["/bin/sh", "-lc", "echo hi"],
rawCommand: "echo hi",
agentId: "main",
approved: true,
approvalDecision: "allow-once",
});
});
it("invokes system.notify with provided fields", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;

View File

@@ -1,10 +1,122 @@
import path from "node:path";
import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.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";
import { loadConfig } from "../../config/config.js";
import { resolveAgentConfig, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
maxAsk,
minSecurity,
resolveExecApprovalsFromFile,
} from "../../infra/exec-approvals.js";
import { buildNodeShellCommand } from "../../infra/node-shell.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[];
};
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return null;
}
function normalizeExecAsk(value?: string | null): ExecAsk | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return null;
}
function mergePathPrepend(existing: string | undefined, prepend: string[]) {
if (prepend.length === 0) return existing;
const partsExisting = (existing ?? "")
.split(path.delimiter)
.map((part) => part.trim())
.filter(Boolean);
const merged: string[] = [];
const seen = new Set<string>();
for (const part of [...prepend, ...partsExisting]) {
if (seen.has(part)) continue;
seen.add(part);
merged.push(part);
}
return merged.join(path.delimiter);
}
function applyPathPrepend(
env: Record<string, string>,
prepend: string[] | undefined,
options?: { requireExisting?: boolean },
) {
if (!Array.isArray(prepend) || prepend.length === 0) return;
if (options?.requireExisting && !env.PATH) return;
const merged = mergePathPrepend(env.PATH, prepend);
if (merged) env.PATH = merged;
}
function resolveExecDefaults(
cfg: ReturnType<typeof loadConfig> | undefined,
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,
}
: 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,
};
}
async function resolveNodePlatform(opts: NodesRpcOpts, nodeId: string): Promise<string | null> {
try {
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
const nodes = parseNodeList(res);
const match = nodes.find((node) => node.nodeId === nodeId);
return typeof match?.platform === "string" ? match.platform : null;
} catch {
return null;
}
}
export function registerNodesInvokeCommands(nodes: Command) {
nodesCallOpts(
@@ -52,39 +164,157 @@ export function registerNodesInvokeCommands(nodes: Command) {
nodes
.command("run")
.description("Run a shell command on a node (mac only)")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.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: NodesRpcOpts) => {
.argument("[command...]", "Command and args")
.action(async (command: string[], opts: NodesRunOpts) => {
await runNodesCommand("run", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
if (!Array.isArray(command) || command.length === 0) {
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 env = parseEnvPairs(opts.env);
const timeoutMs = parseTimeoutMs(opts.commandTimeout);
const invokeTimeout = parseTimeoutMs(opts.invokeTimeout);
let argv = Array.isArray(command) ? command : [];
let rawCommand: string | undefined;
if (raw) {
rawCommand = raw;
const platform = await resolveNodePlatform(opts, nodeId);
argv = buildNodeShellCommand(rawCommand, platform ?? undefined);
}
const nodeEnv = env ? { ...env } : undefined;
if (nodeEnv) {
applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true });
}
let approvedByAsk = false;
let approvalDecision: "allow-once" | "allow-always" | null = null;
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)");
}
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) 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,
overrides: { security: "allowlist" },
});
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=node security=deny");
}
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
if (requiresAsk) {
const decisionResult = (await callGatewayCli("exec.approval.request", opts, {
command: rawCommand ?? argv.join(" "),
cwd: opts.cwd,
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath: null,
sessionKey: null,
timeoutMs: 120_000,
})) 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 (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
// defer allowlist enforcement to node host
} else {
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";
}
}
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.run",
params: {
command,
command: argv,
cwd: opts.cwd,
env,
env: nodeEnv,
timeoutMs,
needsScreenRecording: opts.needsScreenRecording === true,
},
idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()),
};
if (agentId) {
(invokeParams.params as Record<string, unknown>).agentId = agentId;
}
if (rawCommand) {
(invokeParams.params as Record<string, unknown>).rawCommand = rawCommand;
}
(invokeParams.params as Record<string, unknown>).approved = approvedByAsk;
if (approvalDecision) {
(invokeParams.params as Record<string, unknown>).approvalDecision = approvalDecision;
}
if (invokeTimeout !== undefined) {
invokeParams.timeoutMs = invokeTimeout;
}

View File

@@ -210,14 +210,33 @@ export function resolveExecApprovals(
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
const file = ensureExecApprovals();
return resolveExecApprovalsFromFile({
file,
agentId,
overrides,
path: resolveExecApprovalsPath(),
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
token: file.socket?.token ?? "",
});
}
export function resolveExecApprovalsFromFile(params: {
file: ExecApprovalsFile;
agentId?: string;
overrides?: ExecApprovalsDefaultOverrides;
path?: string;
socketPath?: string;
token?: string;
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = agentId ?? "default";
const agentKey = params.agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY;
const fallbackAsk = overrides?.ask ?? DEFAULT_ASK;
const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
const fallbackAutoAllowSkills = overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS;
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK;
const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
const fallbackAutoAllowSkills = params.overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS;
const resolvedDefaults: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask, fallbackAsk),
@@ -246,9 +265,11 @@ export function resolveExecApprovals(
...(Array.isArray(agent.allowlist) ? agent.allowlist : []),
];
return {
path: resolveExecApprovalsPath(),
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
token: file.socket?.token ?? "",
path: params.path ?? resolveExecApprovalsPath(),
socketPath: expandHome(
params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(),
),
token: params.token ?? file.socket?.token ?? "",
defaults: resolvedDefaults,
agent: resolvedAgent,
allowlist,