| 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("OPENCLAW_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<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 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 = { |
| 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<string>(); |
| 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<string>(); |
| for (const part of [...prepend, ...partsExisting]) { |
| if (seen.has(part)) { |
| continue; |
| } |
| seen.add(part); |
| merged.push(part); |
| } |
| return merged.join(path.delimiter); |
| } |
|
|
| function applyPathPrepend( |
| env: Record<string, string>, |
| prepend: string[], |
| 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<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; |
| } |
| } |
|
|
| 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<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) { |
| 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 { |
| |
| } |
| }, |
| }; |
| } 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<ExecProcessOutcome>((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, |
| |
| ): AgentTool<any, ExecToolDetails> { |
| 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); |
| |
| 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<string, string>; |
| 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.<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) { |
| 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).", |
| { cause: err }, |
| ); |
| } |
| 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<{ 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" }, |
| }); |
| |
| const allowlistEval = evaluateShellAllowlist({ |
| command: params.command, |
| allowlist: resolved.allowlist, |
| safeBins: new Set(), |
| cwd: workdir, |
| env, |
| }); |
| allowlistSatisfied = allowlistEval.allowlistSatisfied; |
| analysisOk = allowlistEval.analysisOk; |
| } |
| } catch { |
| |
| } |
| } |
| 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<string, unknown>; |
|
|
| 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<{ decision: string }>( |
| "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, |
| }, |
| ); |
| const decisionValue = |
| decisionResult && typeof decisionResult === "object" |
| ? (decisionResult as { decision?: unknown }).decision |
| : undefined; |
| decision = typeof decisionValue === "string" ? decisionValue : 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") { |
| |
| } 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), |
| ); |
| 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; |
| return { |
| content: [ |
| { |
| type: "text", |
| text: stdout || stderr || errorText || "", |
| }, |
| ], |
| details: { |
| status: success ? "completed" : "failed", |
| exitCode, |
| durationMs: Date.now() - startedAt, |
| aggregated: [stdout, stderr, errorText].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<{ decision: string }>( |
| "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, |
| }, |
| ); |
| const decisionValue = |
| decisionResult && typeof decisionResult === "object" |
| ? (decisionResult as { decision?: unknown }).decision |
| : undefined; |
| decision = typeof decisionValue === "string" ? decisionValue : 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<string>(); |
| 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<string>(); |
| 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; |
|
|
| |
| 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<AgentToolResult<ExecToolDetails>>((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(); |
|
|