import crypto from "node:crypto"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { type ExecAsk, type ExecHost, type ExecSecurity, type ExecApprovalsFile, addAllowlistEntry, evaluateShellAllowlist, maxAsk, minSecurity, requiresExecApproval, resolveSafeBins, recordAllowlistUse, resolveExecApprovals, resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, } from "../infra/shell-env.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { logInfo, logWarn } from "../logger.js"; import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js"; import { type ProcessSession, type SessionStdin, addSession, appendOutput, createSessionSlug, markBackgrounded, markExited, tail, } from "./bash-process-registry.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { buildDockerExecArgs, buildSandboxEnv, chunkString, clampNumber, coerceEnv, killSession, readEnvInt, resolveSandboxWorkdir, resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; const DEFAULT_MAX_OUTPUT = clampNumber( readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000, ); const DEFAULT_PENDING_MAX_OUTPUT = clampNumber( readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000, ); const DEFAULT_PATH = process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const DEFAULT_NOTIFY_TAIL_CHARS = 400; 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; type PtyExitEvent = { exitCode: number; signal?: number }; type PtyListener = (event: T) => void; type PtyHandle = { pid: number; write: (data: string | Buffer) => void; onData: (listener: PtyListener) => void; onExit: (listener: PtyListener) => void; }; type PtySpawn = ( file: string, args: string[] | string, options: { name?: string; cols?: number; rows?: number; cwd?: string; env?: Record; }, ) => PtyHandle; 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; kill: () => void; }; export type ExecToolDefaults = { host?: ExecHost; security?: ExecSecurity; ask?: ExecAsk; node?: string; pathPrepend?: string[]; safeBins?: string[]; agentId?: string; backgroundMs?: number; timeoutSec?: number; approvalRunningNoticeMs?: number; sandbox?: BashSandboxConfig; elevated?: ExecElevatedDefaults; allowBackground?: boolean; scopeKey?: string; sessionKey?: string; messageProvider?: string; notifyOnExit?: boolean; cwd?: string; }; export type { BashSandboxConfig } from "./bash-tools.shared.js"; export type ExecElevatedDefaults = { enabled: boolean; allowed: boolean; 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)", }), ), pty: Type.Optional( Type.Boolean({ description: "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)", }), ), elevated: Type.Optional( Type.Boolean({ description: "Run on the host with elevated permissions (if allowed)", }), ), 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; } | { status: "approval-pending"; approvalId: string; approvalSlug: string; expiresAtMs: number; host: ExecHost; command: string; cwd?: string; nodeId?: string; }; 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"; } function normalizeNotifyOutput(value: string) { return value.replace(/\s+/g, " ").trim(); } function normalizePathPrepend(entries?: string[]) { if (!Array.isArray(entries)) return []; const seen = new Set(); const normalized: string[] = []; for (const entry of entries) { if (typeof entry !== "string") continue; const trimmed = entry.trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); normalized.push(trimmed); } return normalized; } 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(); 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, prepend: string[], options?: { requireExisting?: boolean }, ) { if (prepend.length === 0) return; if (options?.requireExisting && !env.PATH) return; const merged = mergePathPrepend(env.PATH, prepend); if (merged) env.PATH = merged; } function applyShellPath(env: Record, 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; } function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") { if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return; const sessionKey = session.sessionKey?.trim(); if (!sessionKey) return; 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` }); } 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; return Math.floor(value); } function emitExecSystemEvent(text: string, opts: { sessionKey?: string; contextKey?: string }) { const sessionKey = opts.sessionKey?.trim(); if (!sessionKey) return; enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey }); requestHeartbeatNow({ reason: "exec-event" }); } async function runExecProcess(opts: { command: string; workdir: string; env: Record; sandbox?: BashSandboxConfig; containerWorkdir?: string | null; usePty: boolean; warnings: string[]; maxOutput: number; pendingMaxOutput: number; notifyOnExit: boolean; scopeKey?: string; sessionKey?: string; timeoutSec: number; onUpdate?: (partialResult: AgentToolResult) => void; }): Promise { const startedAt = Date.now(); const sessionId = createSessionSlug(); let child: ChildProcessWithoutNullStreams | null = null; let pty: PtyHandle | null = null; let stdin: SessionStdin | undefined; if (opts.sandbox) { 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: { cwd: opts.workdir, env: process.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }, 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; stdin = child.stdin; } else if (opts.usePty) { const { shell, args: shellArgs } = getShellConfig(); try { const ptyModule = (await import("@lydell/node-pty")) as unknown as { spawn?: PtySpawn; default?: { spawn?: PtySpawn }; }; 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); 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: { cwd: opts.workdir, env: opts.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }, 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; 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; settled = true; resolveFn?.(outcome); }; const finalizeTimeout = () => { if (session.exited) return; 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; 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((resolve) => { resolveFn = resolve; const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => { if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer); 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; 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); 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, // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance. ): AgentTool { const defaultBackgroundMs = clampNumber( 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; const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend); const safeBins = resolveSafeBins(defaults?.safeBins); const notifyOnExit = defaults?.notifyOnExit !== false; const notifySessionKey = defaults?.sessionKey?.trim() || undefined; 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: "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; yieldMs?: number; background?: boolean; timeout?: number; pty?: boolean; elevated?: boolean; host?: string; security?: string; ask?: string; node?: string; }; if (!params.command) { throw new Error("Provide a command to start."); } const maxOutput = DEFAULT_MAX_OUTPUT; 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 : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000) : null; const elevatedDefaults = defaults?.elevated; const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed); const elevatedDefaultMode = elevatedDefaults?.defaultLevel === "full" ? "full" : elevatedDefaults?.defaultLevel === "ask" ? "ask" : elevatedDefaults?.defaultLevel === "on" ? "ask" : "off"; const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off"; const elevatedMode = typeof params.elevated === "boolean" ? params.elevated ? elevatedDefaultMode === "full" ? "full" : "ask" : "off" : effectiveDefaultMode; 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. / agents.list[].tools.elevated.allowFrom.)", ); } 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.", "- agents.list[].tools.elevated.enabled", "- agents.list[].tools.elevated.allowFrom.", ] .filter(Boolean) .join("\n"), ); } } if (elevatedRequested) { logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`); } 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"); const requestedSecurity = normalizeExecSecurity(params.security); let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); if (elevatedRequested && elevatedMode === "full") { security = "full"; } const configuredAsk = defaults?.ask ?? "on-miss"; const requestedAsk = normalizeExecAsk(params.ask); let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); const bypassApprovals = elevatedRequested && elevatedMode === "full"; if (bypassApprovals) { ask = "off"; } 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); 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); } applyPathPrepend(env, defaultPathPrepend); if (host === "node") { const approvals = resolveExecApprovals(agentId, { security, ask }); 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 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.", ); } 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).", ); } 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).", ); } const argv = buildNodeShellCommand(params.command, nodeInfo?.platform); const nodeEnv = params.env ? { ...params.env } : undefined; if (nodeEnv) { applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); } const baseAllowlistEval = evaluateShellAllowlist({ command: params.command, allowlist: [], safeBins: new Set(), cwd: workdir, env, }); let analysisOk = baseAllowlistEval.analysisOk; let allowlistSatisfied = false; if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) { try { const approvalsSnapshot = (await callGatewayTool( "exec.approvals.node.get", { timeoutMs: 10_000 }, { nodeId }, )) as { file?: unknown } | null; 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, }); 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, }); 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, cwd: workdir, env: nodeEnv, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, agentId, sessionKey: defaults?.sessionKey, approved: approvedByAsk, approvalDecision: approvalDecision ?? undefined, runId: runId ?? undefined, }, idempotencyKey: crypto.randomUUID(), }) satisfies Record; if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; void (async () => { let decision: string | null = null; try { const decisionResult = (await callGatewayTool( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, { id: approvalId, command: commandText, cwd: workdir, host: "node", security: hostSecurity, ask: hostAsk, agentId, resolvedPath: undefined, sessionKey: defaults?.sessionKey, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, }, )) as { decision?: string } | null; decision = decisionResult && typeof decisionResult === "object" ? (decisionResult.decision ?? null) : null; } 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") { approvedByAsk = true; approvalDecision = "allow-once"; } else if (decision === "allow-always") { approvedByAsk = true; approvalDecision = "allow-always"; } 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); } })(); 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, }, }; } const startedAt = Date.now(); const raw = (await callGatewayTool( "node.invoke", { timeoutMs: invokeTimeoutMs }, buildInvokeParams(false, null), )) as { payload?: { exitCode?: number; timedOut?: boolean; success?: boolean; stdout?: string; stderr?: string; error?: string | null; }; }; const payload = raw?.payload ?? {}; return { content: [ { type: "text", text: payload.stdout || payload.stderr || payload.error || "", }, ], details: { status: payload.success ? "completed" : "failed", exitCode: payload.exitCode ?? null, durationMs: Date.now() - startedAt, aggregated: [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"), cwd: workdir, } satisfies ExecToolDetails, }; } if (host === "gateway" && !bypassApprovals) { const approvals = resolveExecApprovals(agentId, { security, ask }); 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, }); 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, }); if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; 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` : ""; void (async () => { let decision: string | null = null; try { const decisionResult = (await callGatewayTool( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, { id: approvalId, command: commandText, cwd: workdir, host: "gateway", security: hostSecurity, ask: hostAsk, agentId, resolvedPath, sessionKey: defaults?.sessionKey, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, }, )) as { decision?: string } | null; decision = decisionResult && typeof decisionResult === "object" ? (decisionResult.decision ?? null) : null; } 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) { deniedReason = "approval-timeout (allowlist-miss)"; } else { approvedByAsk = true; } } else { deniedReason = "approval-timeout"; } } else if (decision === "allow-once") { approvedByAsk = true; } else if (decision === "allow-always") { approvedByAsk = true; if (hostSecurity === "allowlist") { for (const segment of allowlistEval.segments) { const pattern = segment.resolution?.resolvedPath ?? ""; if (pattern) { addAllowlistEntry(approvals.file, agentId, pattern); } } } } if ( hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied) && !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(); for (const match of allowlistMatches) { if (seen.has(match.pattern)) continue; seen.add(match.pattern); recordAllowlistUse( approvals.file, agentId, 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); 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}). ` + "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"); } if (allowlistMatches.length > 0) { const seen = new Set(); for (const match of allowlistMatches) { if (seen.has(match.pattern)) continue; seen.add(match.pattern); recordAllowlistUse( approvals.file, agentId, match, params.command, allowlistEval.segments[0]?.resolution?.resolvedPath, ); } } } const effectiveTimeout = typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const usePty = params.pty === true && !sandbox; const run = await runExecProcess({ command: params.command, workdir, env, sandbox, containerWorkdir, usePty, warnings, maxOutput, pendingMaxOutput, notifyOnExit, scopeKey: defaults?.scopeKey, sessionKey: notifySessionKey, 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; run.kill(); }; if (signal?.aborted) onAbortSignal(); else if (signal) { signal.addEventListener("abort", onAbortSignal, { once: true }); } return new Promise>((resolve, reject) => { 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.", }, ], 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; markBackgrounded(run.session); resolveRunning(); }; if (allowBackground && yieldWindow !== null) { if (yieldWindow === 0) { onYieldNow(); } else { yieldTimer = setTimeout(() => { if (yielded) return; yielded = true; markBackgrounded(run.session); resolveRunning(); }, yieldWindow); } } run.promise .then((outcome) => { if (yieldTimer) clearTimeout(yieldTimer); if (yielded || run.session.backgrounded) return; if (outcome.status === "failed") { reject(new Error(outcome.reason ?? "Command failed.")); return; } resolve({ content: [ { type: "text", text: `${getWarningText()}${outcome.aggregated || "(no output)"}`, }, ], details: { status: "completed", exitCode: outcome.exitCode ?? 0, durationMs: outcome.durationMs, aggregated: outcome.aggregated, cwd: run.session.cwd, }, }); }) .catch((err) => { if (yieldTimer) clearTimeout(yieldTimer); if (yielded || run.session.backgrounded) return; reject(err as Error); }); }); }, }; } export const execTool = createExecTool();