| import type { AgentToolResult } from "@mariozechner/pi-agent-core"; |
| import type { ChildProcessWithoutNullStreams } from "node:child_process"; |
| import { Type } from "@sinclair/typebox"; |
| import path from "node:path"; |
| import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; |
| import type { ProcessSession, SessionStdin } from "./bash-process-registry.js"; |
| import type { ExecToolDetails } from "./bash-tools.exec.js"; |
| import type { BashSandboxConfig } from "./bash-tools.shared.js"; |
| import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; |
| import { enqueueSystemEvent } from "../infra/system-events.js"; |
| import { logWarn } from "../logger.js"; |
| import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js"; |
| import { |
| addSession, |
| appendOutput, |
| createSessionSlug, |
| markExited, |
| tail, |
| } from "./bash-process-registry.js"; |
| import { |
| buildDockerExecArgs, |
| chunkString, |
| clampWithDefault, |
| killSession, |
| readEnvInt, |
| } from "./bash-tools.shared.js"; |
| import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; |
| import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; |
|
|
| |
| |
| 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_"]; |
|
|
| |
| |
| export function validateHostEnv(env: Record<string, string>): void { |
| for (const key of Object.keys(env)) { |
| const upperKey = key.toUpperCase(); |
|
|
| |
| 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.`, |
| ); |
| } |
|
|
| |
| |
| if (upperKey === "PATH") { |
| throw new Error( |
| "Security Violation: Custom 'PATH' variable is forbidden during host execution.", |
| ); |
| } |
| } |
| } |
| export const DEFAULT_MAX_OUTPUT = clampWithDefault( |
| readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), |
| 200_000, |
| 1_000, |
| 200_000, |
| ); |
| export const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault( |
| readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"), |
| 200_000, |
| 1_000, |
| 200_000, |
| ); |
| export const DEFAULT_PATH = |
| process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| export const DEFAULT_NOTIFY_TAIL_CHARS = 400; |
| export const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000; |
| export const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130_000; |
| const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10_000; |
| const APPROVAL_SLUG_LENGTH = 8; |
|
|
| export 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.", |
| }), |
| ), |
| }); |
|
|
| 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; |
|
|
| export type ExecProcessOutcome = { |
| status: "completed" | "failed"; |
| exitCode: number | null; |
| exitSignal: NodeJS.Signals | number | null; |
| durationMs: number; |
| aggregated: string; |
| timedOut: boolean; |
| reason?: string; |
| }; |
|
|
| export type ExecProcessHandle = { |
| session: ProcessSession; |
| startedAt: number; |
| pid?: number; |
| promise: Promise<ExecProcessOutcome>; |
| kill: () => void; |
| }; |
|
|
| export function normalizeExecHost(value?: string | null): ExecHost | null { |
| const normalized = value?.trim().toLowerCase(); |
| if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { |
| return normalized; |
| } |
| return null; |
| } |
|
|
| export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { |
| const normalized = value?.trim().toLowerCase(); |
| if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { |
| return normalized; |
| } |
| return null; |
| } |
|
|
| export 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; |
| } |
|
|
| export function renderExecHostLabel(host: ExecHost) { |
| return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; |
| } |
|
|
| export function normalizeNotifyOutput(value: string) { |
| return value.replace(/\s+/g, " ").trim(); |
| } |
|
|
| export 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); |
| } |
|
|
| export 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; |
| } |
| } |
|
|
| export 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` }); |
| } |
|
|
| export function createApprovalSlug(id: string) { |
| return id.slice(0, APPROVAL_SLUG_LENGTH); |
| } |
|
|
| export 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); |
| } |
|
|
| export 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" }); |
| } |
|
|
| export 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), |
| }; |
| } |
|
|