Files
openclaw/src/agents/bash-tools.exec.ts

1690 lines
55 KiB
TypeScript
Raw Normal View History

import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { Type } from "@sinclair/typebox";
import crypto from "node:crypto";
import path from "node:path";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
2026-01-18 04:27:33 +00:00
import {
type ExecAsk,
type ExecHost,
type ExecSecurity,
type ExecApprovalsFile,
2026-01-18 04:27:33 +00:00
addAllowlistEntry,
evaluateShellAllowlist,
2026-01-18 04:27:33 +00:00
maxAsk,
minSecurity,
requiresExecApproval,
2026-01-21 21:44:28 +00:00
resolveSafeBins,
2026-01-18 04:27:33 +00:00
recordAllowlistUse,
resolveExecApprovals,
resolveExecApprovalsFromFile,
2026-01-18 04:27:33 +00:00
} from "../infra/exec-approvals.js";
2026-01-17 05:43:27 +00:00
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js";
2026-01-17 05:43:27 +00:00
import { enqueueSystemEvent } from "../infra/system-events.js";
2026-01-23 06:26:30 +00:00
import { logInfo, logWarn } from "../logger.js";
2026-01-25 06:37:35 +00:00
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
2026-01-17 04:57:04 +00:00
import {
2026-01-17 05:43:27 +00:00
type ProcessSession,
2026-01-17 04:57:04 +00:00
type SessionStdin,
addSession,
appendOutput,
2026-01-17 06:23:21 +00:00
createSessionSlug,
2026-01-17 04:57:04 +00:00
markBackgrounded,
markExited,
2026-01-17 05:43:27 +00:00
tail,
2026-01-17 04:57:04 +00:00
} from "./bash-process-registry.js";
import {
buildDockerExecArgs,
buildSandboxEnv,
chunkString,
clampWithDefault,
coerceEnv,
killSession,
readEnvInt,
resolveSandboxWorkdir,
resolveWorkdir,
truncateMiddle,
} from "./bash-tools.shared.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
2026-01-18 04:27:33 +00:00
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
// Security: Blocklist of environment variables that could alter execution flow
// or inject code when running on non-sandboxed hosts (Gateway/Node).
const DANGEROUS_HOST_ENV_VARS = new Set([
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"NODE_OPTIONS",
"NODE_PATH",
"PYTHONPATH",
"PYTHONHOME",
"RUBYLIB",
"PERL5LIB",
"BASH_ENV",
"ENV",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
]);
const DANGEROUS_HOST_ENV_PREFIXES = ["DYLD_", "LD_"];
// Centralized sanitization helper.
// Throws an error if dangerous variables or PATH modifications are detected on the host.
function validateHostEnv(env: Record<string, string>): void {
for (const key of Object.keys(env)) {
const upperKey = key.toUpperCase();
// 1. Block known dangerous variables (Fail Closed)
if (DANGEROUS_HOST_ENV_PREFIXES.some((prefix) => upperKey.startsWith(prefix))) {
throw new Error(
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
);
}
if (DANGEROUS_HOST_ENV_VARS.has(upperKey)) {
throw new Error(
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
);
}
// 2. Strictly block PATH modification on host
// Allowing custom PATH on the gateway/node can lead to binary hijacking.
if (upperKey === "PATH") {
throw new Error(
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
);
}
}
}
const DEFAULT_MAX_OUTPUT = clampWithDefault(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
200_000,
1_000,
200_000,
);
const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault(
2026-01-30 03:15:10 +01:00
readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"),
200_000,
2026-01-17 08:18:27 +00:00
1_000,
200_000,
2026-01-17 08:18:27 +00:00
);
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
2026-01-17 05:43:27 +00:00
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
2026-01-22 00:49:02 +00:00
const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130_000;
const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10_000;
const APPROVAL_SLUG_LENGTH = 8;
2026-01-17 04:57:04 +00:00
type PtyExitEvent = { exitCode: number; signal?: number };
type PtyListener<T> = (event: T) => void;
type PtyHandle = {
pid: number;
write: (data: string | Buffer) => void;
onData: (listener: PtyListener<string>) => void;
onExit: (listener: PtyListener<PtyExitEvent>) => void;
};
type PtySpawn = (
file: string,
args: string[] | string,
options: {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
},
) => PtyHandle;
type PtyModule = {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
type PtyModuleLoader = () => Promise<PtyModule>;
const loadPtyModuleDefault: PtyModuleLoader = async () =>
(await import("@lydell/node-pty")) as unknown as PtyModule;
let loadPtyModule: PtyModuleLoader = loadPtyModuleDefault;
export function setPtyModuleLoaderForTests(loader?: PtyModuleLoader): void {
loadPtyModule = loader ?? loadPtyModuleDefault;
}
2026-01-17 04:57:04 +00:00
2026-01-22 00:49:02 +00:00
type ExecProcessOutcome = {
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
aggregated: string;
timedOut: boolean;
reason?: string;
};
type ExecProcessHandle = {
session: ProcessSession;
startedAt: number;
pid?: number;
promise: Promise<ExecProcessOutcome>;
kill: () => void;
};
export type ExecToolDefaults = {
2026-01-18 04:27:33 +00:00
host?: ExecHost;
security?: ExecSecurity;
ask?: ExecAsk;
node?: string;
2026-01-19 00:35:39 +00:00
pathPrepend?: string[];
2026-01-21 21:44:28 +00:00
safeBins?: string[];
2026-01-18 04:27:33 +00:00
agentId?: string;
backgroundMs?: number;
timeoutSec?: number;
2026-01-22 00:49:02 +00:00
approvalRunningNoticeMs?: number;
sandbox?: BashSandboxConfig;
elevated?: ExecElevatedDefaults;
allowBackground?: boolean;
scopeKey?: string;
2026-01-17 05:43:27 +00:00
sessionKey?: string;
messageProvider?: string;
2026-01-17 05:43:27 +00:00
notifyOnExit?: boolean;
cwd?: string;
};
export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = {
enabled: boolean;
allowed: boolean;
2026-01-22 05:32:13 +00:00
defaultLevel: "on" | "off" | "ask" | "full";
};
const execSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }),
workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional(
Type.Number({
description: "Milliseconds to wait before backgrounding (default 10000)",
}),
),
background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
timeout: Type.Optional(
Type.Number({
description: "Timeout in seconds (optional, kills process on expiry)",
}),
),
2026-01-17 04:57:04 +00:00
pty: Type.Optional(
Type.Boolean({
2026-01-17 05:48:34 +00:00
description:
"Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
2026-01-17 04:57:04 +00:00
}),
),
elevated: Type.Optional(
Type.Boolean({
description: "Run on the host with elevated permissions (if allowed)",
}),
),
2026-01-18 04:27:33 +00:00
host: Type.Optional(
Type.String({
description: "Exec host (sandbox|gateway|node).",
}),
),
security: Type.Optional(
Type.String({
description: "Exec security mode (deny|allowlist|full).",
}),
),
ask: Type.Optional(
Type.String({
description: "Exec ask mode (off|on-miss|always).",
}),
),
node: Type.Optional(
Type.String({
description: "Node id/name for host=node.",
}),
),
});
export type ExecToolDetails =
| {
status: "running";
sessionId: string;
pid?: number;
startedAt: number;
cwd?: string;
tail?: string;
}
| {
status: "completed" | "failed";
exitCode: number | null;
durationMs: number;
aggregated: string;
cwd?: string;
2026-01-22 00:49:02 +00:00
}
| {
status: "approval-pending";
approvalId: string;
approvalSlug: string;
expiresAtMs: number;
host: ExecHost;
command: string;
cwd?: string;
nodeId?: string;
};
2026-01-18 04:27:33 +00:00
function normalizeExecHost(value?: string | null): ExecHost | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return null;
}
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 renderExecHostLabel(host: ExecHost) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
}
2026-01-17 05:43:27 +00:00
function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}
2026-01-19 00:35:39 +00:00
function normalizePathPrepend(entries?: string[]) {
if (!Array.isArray(entries)) {
return [];
}
2026-01-19 00:35:39 +00:00
const seen = new Set<string>();
const normalized: string[] = [];
for (const entry of entries) {
if (typeof entry !== "string") {
continue;
}
2026-01-19 00:35:39 +00:00
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
2026-01-19 00:35:39 +00:00
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
function mergePathPrepend(existing: string | undefined, prepend: string[]) {
if (prepend.length === 0) {
return existing;
}
2026-01-19 00:35:39 +00:00
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;
}
2026-01-19 00:35:39 +00:00
seen.add(part);
merged.push(part);
}
return merged.join(path.delimiter);
}
function applyPathPrepend(
env: Record<string, string>,
prepend: string[],
options?: { requireExisting?: boolean },
) {
if (prepend.length === 0) {
return;
}
if (options?.requireExisting && !env.PATH) {
return;
}
2026-01-19 00:35:39 +00:00
const merged = mergePathPrepend(env.PATH, prepend);
if (merged) {
env.PATH = merged;
}
2026-01-19 00:35:39 +00:00
}
function applyShellPath(env: Record<string, string>, shellPath?: string | null) {
if (!shellPath) {
return;
}
const entries = shellPath
.split(path.delimiter)
.map((part) => part.trim())
.filter(Boolean);
if (entries.length === 0) {
return;
}
const merged = mergePathPrepend(env.PATH, entries);
if (merged) {
env.PATH = merged;
}
}
2026-01-17 05:43:27 +00:00
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) {
return;
}
2026-01-17 05:43:27 +00:00
const sessionKey = session.sessionKey?.trim();
if (!sessionKey) {
return;
}
2026-01-17 05:43:27 +00:00
session.exitNotified = true;
const exitLabel = session.exitSignal
? `signal ${session.exitSignal}`
: `code ${session.exitCode ?? 0}`;
const output = normalizeNotifyOutput(
tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
const summary = output
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
enqueueSystemEvent(summary, { sessionKey });
requestHeartbeatNow({ reason: `exec:${session.id}:exit` });
}
2026-01-22 00:49:02 +00:00
function createApprovalSlug(id: string) {
return id.slice(0, APPROVAL_SLUG_LENGTH);
}
function resolveApprovalRunningNoticeMs(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;
}
if (value <= 0) {
return 0;
}
2026-01-22 00:49:02 +00:00
return Math.floor(value);
}
function emitExecSystemEvent(text: string, opts: { sessionKey?: string; contextKey?: string }) {
const sessionKey = opts.sessionKey?.trim();
if (!sessionKey) {
return;
}
2026-01-22 00:49:02 +00:00
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
requestHeartbeatNow({ reason: "exec-event" });
}
async function runExecProcess(opts: {
command: string;
workdir: string;
env: Record<string, string>;
sandbox?: BashSandboxConfig;
containerWorkdir?: string | null;
usePty: boolean;
warnings: string[];
maxOutput: number;
pendingMaxOutput: number;
notifyOnExit: boolean;
scopeKey?: string;
sessionKey?: string;
timeoutSec: number;
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
}): Promise<ExecProcessHandle> {
const startedAt = Date.now();
const sessionId = createSessionSlug();
let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null;
let stdin: SessionStdin | undefined;
if (opts.sandbox) {
2026-01-25 06:37:35 +00:00
const { child: spawned } = await spawnWithFallback({
argv: [
"docker",
...buildDockerExecArgs({
containerName: opts.sandbox.containerName,
command: opts.command,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
env: opts.env,
tty: opts.usePty,
}),
],
options: {
2026-01-22 00:49:02 +00:00
cwd: opts.workdir,
env: process.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
2026-01-25 06:37:35 +00:00
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
2026-01-22 00:49:02 +00:00
stdin = child.stdin;
} else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig();
2026-01-23 06:26:30 +00:00
try {
const ptyModule = await loadPtyModule();
2026-01-23 06:26:30 +00:00
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} catch (err) {
const errText = String(err);
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning);
2026-01-25 06:37:35 +00:00
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (fallbackErr, fallback) => {
const fallbackText = formatSpawnError(fallbackErr);
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
opts.warnings.push(fallbackWarning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
2026-01-23 06:26:30 +00:00
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
2026-01-25 06:37:35 +00:00
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
2026-01-22 00:49:02 +00:00
stdin = child.stdin;
}
const session = {
id: sessionId,
command: opts.command,
scopeKey: opts.scopeKey,
sessionKey: opts.sessionKey,
notifyOnExit: opts.notifyOnExit,
exitNotified: false,
child: child ?? undefined,
stdin,
pid: child?.pid ?? pty?.pid,
startedAt,
cwd: opts.workdir,
maxOutputChars: opts.maxOutput,
pendingMaxOutputChars: opts.pendingMaxOutput,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
exitCode: undefined as number | null | undefined,
exitSignal: undefined as NodeJS.Signals | number | null | undefined,
truncated: false,
backgrounded: false,
} satisfies ProcessSession;
addSession(session);
let settled = false;
let timeoutTimer: NodeJS.Timeout | null = null;
let timeoutFinalizeTimer: NodeJS.Timeout | null = null;
let timedOut = false;
const timeoutFinalizeMs = 1000;
let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null;
const settle = (outcome: ExecProcessOutcome) => {
if (settled) {
return;
}
2026-01-22 00:49:02 +00:00
settled = true;
resolveFn?.(outcome);
};
const finalizeTimeout = () => {
if (session.exited) {
return;
}
2026-01-22 00:49:02 +00:00
markExited(session, null, "SIGKILL", "failed");
maybeNotifyOnExit(session, "failed");
const aggregated = session.aggregated.trim();
const reason = `Command timed out after ${opts.timeoutSec} seconds`;
settle({
status: "failed",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: Date.now() - startedAt,
aggregated,
timedOut: true,
reason: aggregated ? `${aggregated}\n\n${reason}` : reason,
});
};
const onTimeout = () => {
timedOut = true;
killSession(session);
if (!timeoutFinalizeTimer) {
timeoutFinalizeTimer = setTimeout(() => {
finalizeTimeout();
}, timeoutFinalizeMs);
}
};
if (opts.timeoutSec > 0) {
timeoutTimer = setTimeout(() => {
onTimeout();
}, opts.timeoutSec * 1000);
}
const emitUpdate = () => {
if (!opts.onUpdate) {
return;
}
2026-01-22 00:49:02 +00:00
const tailText = session.tail || session.aggregated;
const warningText = opts.warnings.length ? `${opts.warnings.join("\n")}\n\n` : "";
opts.onUpdate({
content: [{ type: "text", text: warningText + (tailText || "") }],
details: {
status: "running",
sessionId,
pid: session.pid ?? undefined,
startedAt,
cwd: session.cwd,
tail: session.tail,
},
});
};
const handleStdout = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
};
const handleStderr = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
};
if (pty) {
const cursorResponse = buildCursorPositionResponse();
pty.onData((data) => {
const raw = data.toString();
const { cleaned, requests } = stripDsrRequests(raw);
if (requests > 0) {
for (let i = 0; i < requests; i += 1) {
pty.write(cursorResponse);
}
}
handleStdout(cleaned);
});
} else if (child) {
child.stdout.on("data", handleStdout);
child.stderr.on("data", handleStderr);
}
const promise = new Promise<ExecProcessOutcome>((resolve) => {
resolveFn = resolve;
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (timeoutFinalizeTimer) {
clearTimeout(timeoutFinalizeTimer);
}
2026-01-22 00:49:02 +00:00
const durationMs = Date.now() - startedAt;
const wasSignal = exitSignal != null;
const isSuccess = code === 0 && !wasSignal && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status);
maybeNotifyOnExit(session, status);
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (settled) {
return;
}
2026-01-22 00:49:02 +00:00
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const reason = timedOut
? `Command timed out after ${opts.timeoutSec} seconds`
: wasSignal && exitSignal
? `Command aborted by signal ${exitSignal}`
: code === null
? "Command aborted before exit code was captured"
: `Command exited with code ${code}`;
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
settle({
status: "failed",
exitCode: code ?? null,
exitSignal: exitSignal ?? null,
durationMs,
aggregated,
timedOut,
reason: message,
});
return;
}
settle({
status: "completed",
exitCode: code ?? 0,
exitSignal: exitSignal ?? null,
durationMs,
aggregated,
timedOut: false,
});
};
if (pty) {
pty.onExit((event) => {
const rawSignal = event.signal ?? null;
const normalizedSignal = rawSignal === 0 ? null : rawSignal;
handleExit(event.exitCode ?? null, normalizedSignal);
});
} else if (child) {
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (timeoutFinalizeTimer) {
clearTimeout(timeoutFinalizeTimer);
}
2026-01-22 00:49:02 +00:00
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
const aggregated = session.aggregated.trim();
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err);
settle({
status: "failed",
exitCode: null,
exitSignal: null,
durationMs: Date.now() - startedAt,
aggregated,
timedOut,
reason: message,
});
});
}
});
return {
session,
startedAt,
pid: session.pid ?? undefined,
promise,
kill: () => killSession(session),
};
}
export function createExecTool(
defaults?: ExecToolDefaults,
// oxlint-disable-next-line typescript/no-explicit-any
): AgentTool<any, ExecToolDetails> {
const defaultBackgroundMs = clampWithDefault(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
10_000,
10,
120_000,
);
const allowBackground = defaults?.allowBackground ?? true;
const defaultTimeoutSec =
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec
: 1800;
2026-01-19 00:35:39 +00:00
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
2026-01-21 21:44:28 +00:00
const safeBins = resolveSafeBins(defaults?.safeBins);
2026-01-17 05:43:27 +00:00
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
2026-01-22 00:49:02 +00:00
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
return {
name: "exec",
label: "exec",
description:
2026-01-17 04:57:04 +00:00
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. Use pty=true for TTY-required commands (terminal UIs, coding agents).",
parameters: execSchema,
execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as {
command: string;
workdir?: string;
env?: Record<string, string>;
yieldMs?: number;
background?: boolean;
timeout?: number;
2026-01-17 04:57:04 +00:00
pty?: boolean;
elevated?: boolean;
2026-01-18 04:27:33 +00:00
host?: string;
security?: string;
ask?: string;
node?: string;
};
if (!params.command) {
throw new Error("Provide a command to start.");
}
const maxOutput = DEFAULT_MAX_OUTPUT;
2026-01-17 08:18:27 +00:00
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
const warnings: string[] = [];
const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) {
warnings.push("Warning: background execution is disabled; running synchronously.");
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampWithDefault(
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
2026-01-22 05:32:13 +00:00
const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full"
? "full"
: elevatedDefaults?.defaultLevel === "ask"
? "ask"
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
2026-01-22 05:32:13 +00:00
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: effectiveDefaultMode;
2026-01-22 05:32:13 +00:00
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
const gates: string[] = [];
const contextParts: string[] = [];
const provider = defaults?.messageProvider?.trim();
const sessionKey = defaults?.sessionKey?.trim();
if (provider) {
contextParts.push(`provider=${provider}`);
}
if (sessionKey) {
contextParts.push(`session=${sessionKey}`);
}
if (!elevatedDefaults?.enabled) {
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
} else {
gates.push(
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)",
);
}
throw new Error(
[
`elevated is not available right now (runtime=${runtime}).`,
`Failing gates: ${gates.join(", ")}`,
contextParts.length > 0 ? `Context: ${contextParts.join(" ")}` : undefined,
"Fix-it keys:",
"- tools.elevated.enabled",
"- tools.elevated.allowFrom.<provider>",
"- agents.list[].tools.elevated.enabled",
"- agents.list[].tools.elevated.allowFrom.<provider>",
]
.filter(Boolean)
.join("\n"),
);
}
}
if (elevatedRequested) {
2026-01-22 00:49:02 +00:00
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
}
2026-01-18 04:27:33 +00:00
const configuredHost = defaults?.host ?? "sandbox";
const requestedHost = normalizeExecHost(params.host) ?? null;
let host: ExecHost = requestedHost ?? configuredHost;
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
throw new Error(
`exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
`configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
);
}
if (elevatedRequested) {
host = "gateway";
}
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
2026-01-18 04:27:33 +00:00
const requestedSecurity = normalizeExecSecurity(params.security);
2026-01-18 04:37:15 +00:00
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested && elevatedMode === "full") {
2026-01-18 04:27:33 +00:00
security = "full";
}
const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
2026-01-22 05:32:13 +00:00
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
2026-01-18 04:27:33 +00:00
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) {
const resolved = await resolveSandboxWorkdir({
workdir: rawWorkdir,
sandbox,
warnings,
});
workdir = resolved.hostWorkdir;
containerWorkdir = resolved.containerWorkdir;
} else {
workdir = resolveWorkdir(rawWorkdir, warnings);
}
const baseEnv = coerceEnv(process.env);
// Logic: Sandbox gets raw env. Host (gateway/node) must pass validation.
// We validate BEFORE merging to prevent any dangerous vars from entering the stream.
if (host !== "sandbox" && params.env) {
validateHostEnv(params.env);
}
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
const env = sandbox
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
paramsEnv: params.env,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
});
applyShellPath(env, shellPath);
}
2026-01-19 00:35:39 +00:00
applyPathPrepend(env, defaultPathPrepend);
2026-01-18 04:27:33 +00:00
if (host === "node") {
const approvals = resolveExecApprovals(agentId, { security, ask });
2026-01-19 04:50:07 +00:00
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
2026-01-18 04:27:33 +00:00
throw new Error("exec denied: host=node security=deny");
}
const boundNode = defaults?.node?.trim();
const requestedNode = params.node?.trim();
if (boundNode && requestedNode && boundNode !== requestedNode) {
throw new Error(`exec node not allowed (bound to ${boundNode})`);
}
const nodeQuery = boundNode || requestedNode;
const nodes = await listNodes({});
if (nodes.length === 0) {
throw new Error(
"exec host=node requires a paired node (none available). This requires a companion app or node host.",
2026-01-18 04:27:33 +00:00
);
}
let nodeId: string;
try {
nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery);
} catch (err) {
if (!nodeQuery && String(err).includes("node required")) {
throw new Error(
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).",
{ cause: err },
2026-01-18 04:27:33 +00:00
);
}
throw err;
}
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(
"exec host=node requires a node that supports system.run (companion app or node host).",
);
2026-01-18 04:27:33 +00:00
}
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
2026-01-19 00:35:39 +00:00
const nodeEnv = params.env ? { ...params.env } : undefined;
2026-01-19 00:35:39 +00:00
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}
const baseAllowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: [],
safeBins: new Set(),
cwd: workdir,
env,
2026-02-03 09:34:08 -08:00
platform: nodeInfo?.platform,
});
let analysisOk = baseAllowlistEval.analysisOk;
let allowlistSatisfied = false;
if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) {
try {
2026-01-31 16:46:45 +09:00
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
"exec.approvals.node.get",
{ timeoutMs: 10_000 },
{ nodeId },
);
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot.file
: undefined;
if (approvalsFile && typeof approvalsFile === "object") {
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId,
overrides: { security: "allowlist" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: resolved.allowlist,
safeBins: new Set(),
cwd: workdir,
env,
2026-02-03 09:34:08 -08:00
platform: nodeInfo?.platform,
});
allowlistSatisfied = allowlistEval.allowlistSatisfied;
analysisOk = allowlistEval.analysisOk;
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const requiresAsk = requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
});
2026-01-22 00:49:02 +00:00
const commandText = params.command;
const invokeTimeoutMs = Math.max(
10_000,
(typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec) * 1000 + 5_000,
);
const buildInvokeParams = (
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
) =>
({
nodeId,
command: "system.run",
params: {
command: argv,
rawCommand: params.command,
2026-01-19 11:32:15 +00:00
cwd: workdir,
2026-01-22 00:49:02 +00:00
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId,
2026-01-22 00:49:02 +00:00
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
runId: runId ?? undefined,
},
2026-01-22 00:49:02 +00:00
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
// Register the approval with expectFinal:false to get immediate confirmation.
// This ensures the approval ID is valid before we return.
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
try {
const registrationResult = await callGatewayTool<{
status?: string;
expiresAtMs?: number;
}>(
"exec.approval.request",
{ timeoutMs: 10_000 },
{
id: approvalId,
command: commandText,
cwd: workdir,
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath: undefined,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
twoPhase: true,
},
{ expectFinal: false },
);
if (registrationResult?.expiresAtMs) {
expiresAtMs = registrationResult.expiresAtMs;
}
} catch (err) {
// Registration failed - throw to caller
throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err });
}
// Fire-and-forget: wait for decision via waitDecision endpoint, then execute.
2026-01-22 00:49:02 +00:00
void (async () => {
let decision: string | null = null;
try {
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
const decisionResult = await callGatewayTool<{ decision?: string }>(
"exec.approval.waitDecision",
2026-01-22 00:49:02 +00:00
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
{ id: approvalId },
);
const decisionValue =
2026-01-22 00:49:02 +00:00
decisionResult && typeof decisionResult === "object"
? (decisionResult as { decision?: unknown }).decision
: undefined;
decision = typeof decisionValue === "string" ? decisionValue : null;
2026-01-22 00:49:02 +00:00
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
return;
}
let approvedByAsk = false;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason: string | null = null;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
// Defer allowlist enforcement to the node host.
} else {
deniedReason = "approval-timeout";
}
} else if (decision === "allow-once") {
2026-01-19 04:50:07 +00:00
approvedByAsk = true;
2026-01-20 12:03:18 +00:00
approvalDecision = "allow-once";
2026-01-22 00:49:02 +00:00
} else if (decision === "allow-always") {
approvedByAsk = true;
approvalDecision = "allow-always";
2026-01-19 04:50:07 +00:00
}
2026-01-22 00:49:02 +00:00
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
return;
}
let runningTimer: NodeJS.Timeout | null = null;
if (approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
}, approvalRunningNoticeMs);
}
try {
await callGatewayTool(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
);
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
} finally {
if (runningTimer) {
clearTimeout(runningTimer);
}
2026-01-22 00:49:02 +00:00
}
})();
return {
content: [
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: commandText,
cwd: workdir,
nodeId,
},
};
2026-01-19 04:50:07 +00:00
}
2026-01-22 00:49:02 +00:00
const startedAt = Date.now();
const raw = await callGatewayTool(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(false, null),
);
const payload =
raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined;
const payloadObj =
payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : "";
const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : "";
const errorText = typeof payloadObj.error === "string" ? payloadObj.error : "";
const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false;
const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null;
2026-01-18 04:27:33 +00:00
return {
content: [
{
type: "text",
text: stdout || stderr || errorText || "",
2026-01-18 04:27:33 +00:00
},
],
details: {
status: success ? "completed" : "failed",
exitCode,
2026-01-18 04:27:33 +00:00
durationMs: Date.now() - startedAt,
aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"),
2026-01-18 04:27:33 +00:00
cwd: workdir,
} satisfies ExecToolDetails,
};
}
2026-01-22 05:32:13 +00:00
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security, ask });
2026-01-18 04:27:33 +00:00
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=gateway security=deny");
}
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
safeBins,
cwd: workdir,
env,
2026-02-03 09:34:08 -08:00
platform: process.platform,
});
const allowlistMatches = allowlistEval.allowlistMatches;
const analysisOk = allowlistEval.analysisOk;
const allowlistSatisfied =
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
const requiresAsk = requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
});
2026-01-18 04:27:33 +00:00
if (requiresAsk) {
2026-01-22 00:49:02 +00:00
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
2026-01-22 00:49:02 +00:00
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command;
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
// Register the approval with expectFinal:false to get immediate confirmation.
// This ensures the approval ID is valid before we return.
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
try {
const registrationResult = await callGatewayTool<{
status?: string;
expiresAtMs?: number;
}>(
"exec.approval.request",
{ timeoutMs: 10_000 },
{
id: approvalId,
command: commandText,
cwd: workdir,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
twoPhase: true,
},
{ expectFinal: false },
);
if (registrationResult?.expiresAtMs) {
expiresAtMs = registrationResult.expiresAtMs;
}
} catch (err) {
// Registration failed - throw to caller
throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err });
}
// Fire-and-forget: wait for decision via waitDecision endpoint, then execute.
2026-01-22 00:49:02 +00:00
void (async () => {
let decision: string | null = null;
try {
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
const decisionResult = await callGatewayTool<{ decision?: string }>(
"exec.approval.waitDecision",
2026-01-22 00:49:02 +00:00
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
{ id: approvalId },
);
const decisionValue =
2026-01-22 00:49:02 +00:00
decisionResult && typeof decisionResult === "object"
? (decisionResult as { decision?: unknown }).decision
: undefined;
decision = typeof decisionValue === "string" ? decisionValue : null;
2026-01-22 00:49:02 +00:00
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
return;
}
let approvedByAsk = false;
let deniedReason: string | null = null;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
2026-01-22 00:49:02 +00:00
deniedReason = "approval-timeout (allowlist-miss)";
} else {
approvedByAsk = true;
}
} else {
deniedReason = "approval-timeout";
2026-01-18 04:27:33 +00:00
}
2026-01-22 00:49:02 +00:00
} else if (decision === "allow-once") {
approvedByAsk = true;
2026-01-22 00:49:02 +00:00
} else if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
for (const segment of allowlistEval.segments) {
2026-01-22 00:49:02 +00:00
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, agentId, pattern);
2026-01-22 00:49:02 +00:00
}
2026-01-21 21:44:28 +00:00
}
}
2026-01-18 04:27:33 +00:00
}
2026-01-22 00:49:02 +00:00
if (
hostSecurity === "allowlist" &&
(!analysisOk || !allowlistSatisfied) &&
2026-01-22 00:49:02 +00:00
!approvedByAsk
) {
deniedReason = deniedReason ?? "allowlist-miss";
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
return;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
2026-01-22 00:49:02 +00:00
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
2026-01-22 00:49:02 +00:00
match,
commandText,
resolvedPath ?? undefined,
);
}
}
let run: ExecProcessHandle | null = null;
try {
run = await runExecProcess({
command: commandText,
workdir,
env,
sandbox: undefined,
containerWorkdir: null,
usePty: params.pty === true && !sandbox,
warnings,
maxOutput,
pendingMaxOutput,
notifyOnExit: false,
scopeKey: defaults?.scopeKey,
sessionKey: notifySessionKey,
timeoutSec: effectiveTimeout,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, spawn-failed): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
return;
}
markBackgrounded(run.session);
let runningTimer: NodeJS.Timeout | null = null;
if (approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${commandText}`,
{ sessionKey: notifySessionKey, contextKey },
);
}, approvalRunningNoticeMs);
}
const outcome = await run.promise;
if (runningTimer) {
clearTimeout(runningTimer);
}
2026-01-22 00:49:02 +00:00
const output = normalizeNotifyOutput(
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`;
const summary = output
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
emitExecSystemEvent(summary, { sessionKey: notifySessionKey, contextKey });
})();
return {
content: [
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
2026-01-22 00:49:02 +00:00
"Approve to run; updates will arrive after completion.",
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: workdir,
},
};
}
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
throw new Error("exec denied: allowlist miss");
2026-01-18 04:27:33 +00:00
}
2026-01-21 21:44:28 +00:00
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
2026-01-21 21:44:28 +00:00
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
2026-01-21 21:44:28 +00:00
match,
params.command,
allowlistEval.segments[0]?.resolution?.resolvedPath,
2026-01-21 21:44:28 +00:00
);
}
2026-01-18 04:27:33 +00:00
}
}
2026-01-22 00:49:02 +00:00
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
2026-01-23 06:26:30 +00:00
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
2026-01-17 04:57:04 +00:00
const usePty = params.pty === true && !sandbox;
2026-01-22 00:49:02 +00:00
const run = await runExecProcess({
command: params.command,
2026-01-22 00:49:02 +00:00
workdir,
env,
sandbox,
containerWorkdir,
usePty,
warnings,
maxOutput,
pendingMaxOutput,
notifyOnExit,
scopeKey: defaults?.scopeKey,
2026-01-17 05:43:27 +00:00
sessionKey: notifySessionKey,
2026-01-22 00:49:02 +00:00
timeoutSec: effectiveTimeout,
onUpdate,
});
let yielded = false;
let yieldTimer: NodeJS.Timeout | null = null;
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
const onAbortSignal = () => {
if (yielded || run.session.backgrounded) {
return;
}
2026-01-22 00:49:02 +00:00
run.kill();
};
if (signal?.aborted) {
onAbortSignal();
} else if (signal) {
signal.addEventListener("abort", onAbortSignal, { once: true });
}
return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
2026-01-22 00:49:02 +00:00
const resolveRunning = () =>
resolve({
content: [
{
type: "text",
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a"
}). Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
},
2026-01-22 00:49:02 +00:00
],
details: {
status: "running",
sessionId: run.session.id,
pid: run.session.pid ?? undefined,
startedAt: run.startedAt,
cwd: run.session.cwd,
tail: run.session.tail,
},
});
const onYieldNow = () => {
if (yieldTimer) {
clearTimeout(yieldTimer);
}
if (yielded) {
return;
}
yielded = true;
2026-01-22 00:49:02 +00:00
markBackgrounded(run.session);
resolveRunning();
};
if (allowBackground && yieldWindow !== null) {
if (yieldWindow === 0) {
onYieldNow();
} else {
yieldTimer = setTimeout(() => {
if (yielded) {
return;
}
yielded = true;
2026-01-22 00:49:02 +00:00
markBackgrounded(run.session);
resolveRunning();
}, yieldWindow);
}
}
2026-01-22 00:49:02 +00:00
run.promise
.then((outcome) => {
if (yieldTimer) {
clearTimeout(yieldTimer);
}
if (yielded || run.session.backgrounded) {
return;
}
2026-01-22 00:49:02 +00:00
if (outcome.status === "failed") {
reject(new Error(outcome.reason ?? "Command failed."));
return;
}
resolve({
content: [
{
type: "text",
2026-01-23 06:26:30 +00:00
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
},
],
details: {
status: "completed",
2026-01-22 00:49:02 +00:00
exitCode: outcome.exitCode ?? 0,
durationMs: outcome.durationMs,
aggregated: outcome.aggregated,
cwd: run.session.cwd,
},
2026-01-22 00:49:02 +00:00
});
})
.catch((err) => {
if (yieldTimer) {
clearTimeout(yieldTimer);
}
if (yielded || run.session.backgrounded) {
return;
}
2026-01-22 00:49:02 +00:00
reject(err as Error);
2026-01-17 04:57:04 +00:00
});
});
},
};
}
export const execTool = createExecTool();