feat: make nodes run exec-style
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 model’s 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user