mirror of https://github.com/openclaw/openclaw.git
725 lines
24 KiB
TypeScript
725 lines
24 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { type ExecHost } from "../infra/exec-approvals.js";
|
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
|
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
|
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
|
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
|
import { scopedHeartbeatWakeOptions } from "../routing/session-key.js";
|
|
import type { ProcessSession } from "./bash-process-registry.js";
|
|
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
|
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
|
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js";
|
|
export {
|
|
normalizeExecAsk,
|
|
normalizeExecHost,
|
|
normalizeExecSecurity,
|
|
} from "../infra/exec-approvals.js";
|
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
|
import {
|
|
applyScriptPolicyOverride,
|
|
checkAccessPolicy,
|
|
resolveArgv0,
|
|
} from "../infra/access-policy.js";
|
|
import { isBwrapAvailable, wrapCommandWithBwrap } from "../infra/exec-sandbox-bwrap.js";
|
|
import {
|
|
generateSeatbeltProfile,
|
|
wrapCommandWithSeatbelt,
|
|
} from "../infra/exec-sandbox-seatbelt.js";
|
|
import { logWarn } from "../logger.js";
|
|
import type { ManagedRun } from "../process/supervisor/index.js";
|
|
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
|
import {
|
|
addSession,
|
|
appendOutput,
|
|
createSessionSlug,
|
|
markExited,
|
|
tail,
|
|
} from "./bash-process-registry.js";
|
|
import {
|
|
buildDockerExecArgs,
|
|
chunkString,
|
|
clampWithDefault,
|
|
readEnvInt,
|
|
} from "./bash-tools.shared.js";
|
|
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
|
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
|
|
|
// Sanitize inherited host env before merge so dangerous variables from process.env
|
|
// are not propagated into non-sandboxed executions.
|
|
export function sanitizeHostBaseEnv(env: Record<string, string>): Record<string, string> {
|
|
const sanitized: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(env)) {
|
|
const upperKey = key.toUpperCase();
|
|
if (upperKey === "PATH") {
|
|
sanitized[key] = value;
|
|
continue;
|
|
}
|
|
if (isDangerousHostEnvVarName(upperKey)) {
|
|
continue;
|
|
}
|
|
sanitized[key] = value;
|
|
}
|
|
return sanitized;
|
|
}
|
|
// Centralized sanitization helper.
|
|
// Throws an error if dangerous variables or PATH modifications are detected on the host.
|
|
export function validateHostEnv(env: Record<string, string>): void {
|
|
for (const key of Object.keys(env)) {
|
|
const upperKey = key.toUpperCase();
|
|
|
|
// 1. Block known dangerous variables (Fail Closed)
|
|
if (isDangerousHostEnvVarName(upperKey)) {
|
|
throw new Error(
|
|
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
|
|
);
|
|
}
|
|
|
|
// 2. Strictly block PATH modification on host
|
|
// Allowing custom PATH on the gateway/node can lead to binary hijacking.
|
|
if (upperKey === "PATH") {
|
|
throw new Error(
|
|
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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"),
|
|
30_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;
|
|
const DEFAULT_NOTIFY_SNIPPET_CHARS = 180;
|
|
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.",
|
|
}),
|
|
),
|
|
});
|
|
|
|
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 renderExecHostLabel(host: ExecHost) {
|
|
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
|
|
}
|
|
|
|
export function normalizeNotifyOutput(value: string) {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function compactNotifyOutput(value: string, maxChars = DEFAULT_NOTIFY_SNIPPET_CHARS) {
|
|
const normalized = normalizeNotifyOutput(value);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
if (normalized.length <= maxChars) {
|
|
return normalized;
|
|
}
|
|
const safe = Math.max(1, maxChars - 1);
|
|
return `${normalized.slice(0, safe)}…`;
|
|
}
|
|
|
|
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 pathKey = findPathKey(env);
|
|
const merged = mergePathPrepend(env[pathKey], entries);
|
|
if (merged) {
|
|
env[pathKey] = 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 = compactNotifyOutput(
|
|
tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
|
|
);
|
|
if (status === "completed" && !output && session.notifyOnExitEmptySuccess !== true) {
|
|
return;
|
|
}
|
|
const summary = output
|
|
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
|
|
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
|
|
enqueueSystemEvent(summary, { sessionKey });
|
|
requestHeartbeatNow(
|
|
scopedHeartbeatWakeOptions(sessionKey, { reason: `exec:${session.id}:exit` }),
|
|
);
|
|
}
|
|
|
|
export function createApprovalSlug(id: string) {
|
|
return id.slice(0, APPROVAL_SLUG_LENGTH);
|
|
}
|
|
|
|
export function buildApprovalPendingMessage(params: {
|
|
warningText?: string;
|
|
approvalSlug: string;
|
|
approvalId: string;
|
|
command: string;
|
|
cwd: string;
|
|
host: "gateway" | "node";
|
|
nodeId?: string;
|
|
}) {
|
|
let fence = "```";
|
|
while (params.command.includes(fence)) {
|
|
fence += "`";
|
|
}
|
|
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
|
|
const lines: string[] = [];
|
|
const warningText = params.warningText?.trim();
|
|
if (warningText) {
|
|
lines.push(warningText, "");
|
|
}
|
|
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
|
|
lines.push(`Host: ${params.host}`);
|
|
if (params.nodeId) {
|
|
lines.push(`Node: ${params.nodeId}`);
|
|
}
|
|
lines.push(`CWD: ${params.cwd}`);
|
|
lines.push("Command:");
|
|
lines.push(commandBlock);
|
|
lines.push("Mode: foreground (interactive approvals available).");
|
|
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
|
|
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
|
|
lines.push("If the short code is ambiguous, use the full id in /approve.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
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(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
|
|
}
|
|
|
|
// Warn once per process when OS-level exec enforcement is unavailable and
|
|
// access-policy permissions are configured — so operators know exec runs unconfined.
|
|
let _bwrapUnavailableWarned = false;
|
|
function _warnBwrapUnavailableOnce(): void {
|
|
if (_bwrapUnavailableWarned) {
|
|
return;
|
|
}
|
|
_bwrapUnavailableWarned = true;
|
|
console.error(
|
|
"[access-policy] WARNING: bwrap is not available on this Linux host — exec commands run unconfined. Install bubblewrap to enable OS-level exec enforcement.",
|
|
);
|
|
}
|
|
|
|
/** Reset the one-time bwrap-unavailable warning flag. Only for use in tests. */
|
|
export function _resetBwrapUnavailableWarnedForTest(): void {
|
|
_bwrapUnavailableWarned = false;
|
|
}
|
|
|
|
let _windowsUnconfiguredWarned = false;
|
|
function _warnWindowsUnconfiguredOnce(): void {
|
|
if (_windowsUnconfiguredWarned) {
|
|
return;
|
|
}
|
|
_windowsUnconfiguredWarned = true;
|
|
console.error(
|
|
"[access-policy] WARNING: OS-level exec enforcement is not supported on Windows — exec commands run unconfined even when access-policy permissions are configured.",
|
|
);
|
|
}
|
|
|
|
/** Reset the one-time Windows-unconfigured warning flag. Only for use in tests. */
|
|
export function _resetWindowsUnconfiguredWarnedForTest(): void {
|
|
_windowsUnconfiguredWarned = false;
|
|
}
|
|
|
|
export async function runExecProcess(opts: {
|
|
command: string;
|
|
// Execute this instead of `command` (which is kept for display/session/logging).
|
|
// Used to sanitize safeBins execution while preserving the original user input.
|
|
execCommand?: string;
|
|
workdir: string;
|
|
env: Record<string, string>;
|
|
sandbox?: BashSandboxConfig;
|
|
containerWorkdir?: string | null;
|
|
usePty: boolean;
|
|
warnings: string[];
|
|
maxOutput: number;
|
|
pendingMaxOutput: number;
|
|
notifyOnExit: boolean;
|
|
notifyOnExitEmptySuccess?: boolean;
|
|
scopeKey?: string;
|
|
sessionKey?: string;
|
|
timeoutSec: number | null;
|
|
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
|
|
/** When set, wrap the exec command with OS-level path enforcement. */
|
|
permissions?: AccessPolicyConfig;
|
|
}): Promise<ExecProcessHandle> {
|
|
const startedAt = Date.now();
|
|
const sessionId = createSessionSlug();
|
|
const baseCommand = opts.execCommand ?? opts.command;
|
|
|
|
// Apply access-policy enforcement when permissions are configured.
|
|
// Hash verification and tool-layer exec checks run unconditionally — container
|
|
// sandboxes (opts.sandbox) share the host filesystem via volume mounts so a
|
|
// tampered script is still reachable. Only OS-level wrapping (seatbelt/bwrap)
|
|
// is skipped when a container sandbox already provides filesystem isolation.
|
|
let execCommand = baseCommand;
|
|
if (opts.permissions) {
|
|
// Fall back to the first token rather than the full command string so that
|
|
// checkAccessPolicy matches against a path-like token instead of a multi-word
|
|
// string that never matches any absolute-path rule (and would pass unconditionally
|
|
// under a permissive default).
|
|
const argv0 =
|
|
resolveArgv0(baseCommand, opts.workdir) ?? baseCommand.trim().split(/\s+/)[0] ?? baseCommand;
|
|
const {
|
|
policy: effectivePermissions,
|
|
overrideRules,
|
|
hashMismatch,
|
|
} = applyScriptPolicyOverride(opts.permissions, argv0);
|
|
if (hashMismatch) {
|
|
throw new Error(`exec denied: script hash mismatch for ${argv0}`);
|
|
}
|
|
// Tool-layer exec path check — defense-in-depth for platforms where OS-level
|
|
// enforcement (seatbelt/bwrap) is unavailable (Linux without bwrap, Windows).
|
|
// Mirrors the checkAccessPolicy calls in read/write tools for consistency.
|
|
//
|
|
// deny[] always wins — checked unconditionally even for scripts{} entries.
|
|
// The hash check authorises the policy overlay (effectivePermissions), not exec
|
|
// itself. A script in both deny[] and scripts{} is a config error; the deny wins.
|
|
// For scripts{} entries not in deny[], we skip the broader rules/default check
|
|
// so a sha256-matched script doesn't also need an explicit exec rule in the base policy.
|
|
// Mirror the tilde-expansion logic in applyScriptPolicyOverride so that
|
|
// scripts keys written as "~/bin/deploy.sh" are matched even though argv0
|
|
// is always an absolute path after resolveArgv0.
|
|
const _scripts = opts.permissions.scripts ?? {};
|
|
const hasScriptOverride =
|
|
argv0 in _scripts ||
|
|
Object.keys(_scripts).some(
|
|
(k) => k.startsWith("~") && k.replace(/^~(?=$|[/\\])/, os.homedir()) === argv0,
|
|
);
|
|
// Use default:"rwx" so only actual deny-pattern hits produce "deny".
|
|
// Without a default, permAllows(undefined, "exec") → false → every path
|
|
// not matched by a deny pattern would be incorrectly blocked.
|
|
const denyVerdict = checkAccessPolicy(argv0, "exec", {
|
|
deny: opts.permissions.deny,
|
|
default: "rwx",
|
|
});
|
|
if (denyVerdict === "deny") {
|
|
throw new Error(`exec denied by access policy: ${argv0}`);
|
|
}
|
|
if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") {
|
|
throw new Error(`exec denied by access policy: ${argv0}`);
|
|
}
|
|
// OS-level sandbox wrapping — skip when a container sandbox already isolates the process.
|
|
if (!opts.sandbox) {
|
|
if (process.platform === "darwin") {
|
|
const profile = generateSeatbeltProfile(effectivePermissions, os.homedir(), overrideRules);
|
|
execCommand = wrapCommandWithSeatbelt(baseCommand, profile);
|
|
} else if (process.platform === "linux") {
|
|
if (await isBwrapAvailable()) {
|
|
// Pass overrideRules separately so they are emitted AFTER deny[] mounts,
|
|
// giving script-specific grants precedence over base deny entries — matching
|
|
// the Seatbelt path where scriptOverrideRules are emitted last in the profile.
|
|
execCommand = wrapCommandWithBwrap(
|
|
baseCommand,
|
|
effectivePermissions,
|
|
os.homedir(),
|
|
overrideRules,
|
|
);
|
|
} else {
|
|
_warnBwrapUnavailableOnce();
|
|
}
|
|
} else if (process.platform === "win32") {
|
|
_warnWindowsUnconfiguredOnce();
|
|
}
|
|
}
|
|
}
|
|
|
|
const supervisor = getProcessSupervisor();
|
|
const shellRuntimeEnv: Record<string, string> = {
|
|
...opts.env,
|
|
OPENCLAW_SHELL: "exec",
|
|
};
|
|
|
|
const session: ProcessSession = {
|
|
id: sessionId,
|
|
command: opts.command,
|
|
scopeKey: opts.scopeKey,
|
|
sessionKey: opts.sessionKey,
|
|
notifyOnExit: opts.notifyOnExit,
|
|
notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true,
|
|
exitNotified: false,
|
|
child: undefined,
|
|
stdin: undefined,
|
|
pid: undefined,
|
|
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,
|
|
};
|
|
addSession(session);
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
const timeoutMs =
|
|
typeof opts.timeoutSec === "number" && opts.timeoutSec > 0
|
|
? Math.floor(opts.timeoutSec * 1000)
|
|
: undefined;
|
|
|
|
const spawnSpec:
|
|
| {
|
|
mode: "child";
|
|
argv: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
stdinMode: "pipe-open" | "pipe-closed";
|
|
}
|
|
| {
|
|
mode: "pty";
|
|
ptyCommand: string;
|
|
childFallbackArgv: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
stdinMode: "pipe-open";
|
|
} = (() => {
|
|
if (opts.sandbox) {
|
|
return {
|
|
mode: "child" as const,
|
|
argv: [
|
|
"docker",
|
|
...buildDockerExecArgs({
|
|
containerName: opts.sandbox.containerName,
|
|
command: execCommand,
|
|
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
|
env: shellRuntimeEnv,
|
|
tty: opts.usePty,
|
|
}),
|
|
],
|
|
env: process.env,
|
|
stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const),
|
|
};
|
|
}
|
|
const { shell, args: shellArgs } = getShellConfig();
|
|
const childArgv = [shell, ...shellArgs, execCommand];
|
|
if (opts.usePty) {
|
|
return {
|
|
mode: "pty" as const,
|
|
ptyCommand: execCommand,
|
|
childFallbackArgv: childArgv,
|
|
env: shellRuntimeEnv,
|
|
stdinMode: "pipe-open" as const,
|
|
};
|
|
}
|
|
return {
|
|
mode: "child" as const,
|
|
argv: childArgv,
|
|
env: shellRuntimeEnv,
|
|
stdinMode: "pipe-closed" as const,
|
|
};
|
|
})();
|
|
|
|
let managedRun: ManagedRun | null = null;
|
|
let usingPty = spawnSpec.mode === "pty";
|
|
const cursorResponse = buildCursorPositionResponse();
|
|
|
|
const onSupervisorStdout = (chunk: string) => {
|
|
if (usingPty) {
|
|
const { cleaned, requests } = stripDsrRequests(chunk);
|
|
if (requests > 0 && managedRun?.stdin) {
|
|
for (let i = 0; i < requests; i += 1) {
|
|
managedRun.stdin.write(cursorResponse);
|
|
}
|
|
}
|
|
handleStdout(cleaned);
|
|
return;
|
|
}
|
|
handleStdout(chunk);
|
|
};
|
|
|
|
try {
|
|
const spawnBase = {
|
|
runId: sessionId,
|
|
sessionId: opts.sessionKey?.trim() || sessionId,
|
|
backendId: opts.sandbox ? "exec-sandbox" : "exec-host",
|
|
scopeKey: opts.scopeKey,
|
|
cwd: opts.workdir,
|
|
env: spawnSpec.env,
|
|
timeoutMs,
|
|
captureOutput: false,
|
|
onStdout: onSupervisorStdout,
|
|
onStderr: handleStderr,
|
|
};
|
|
managedRun =
|
|
spawnSpec.mode === "pty"
|
|
? await supervisor.spawn({
|
|
...spawnBase,
|
|
mode: "pty",
|
|
ptyCommand: spawnSpec.ptyCommand,
|
|
})
|
|
: await supervisor.spawn({
|
|
...spawnBase,
|
|
mode: "child",
|
|
argv: spawnSpec.argv,
|
|
stdinMode: spawnSpec.stdinMode,
|
|
});
|
|
} catch (err) {
|
|
if (spawnSpec.mode === "pty") {
|
|
const warning = `Warning: PTY spawn failed (${String(err)}); retrying without PTY for \`${opts.command}\`.`;
|
|
logWarn(
|
|
`exec: PTY spawn failed (${String(err)}); retrying without PTY for "${opts.command}".`,
|
|
);
|
|
opts.warnings.push(warning);
|
|
usingPty = false;
|
|
try {
|
|
managedRun = await supervisor.spawn({
|
|
runId: sessionId,
|
|
sessionId: opts.sessionKey?.trim() || sessionId,
|
|
backendId: "exec-host",
|
|
scopeKey: opts.scopeKey,
|
|
mode: "child",
|
|
argv: spawnSpec.childFallbackArgv,
|
|
cwd: opts.workdir,
|
|
env: spawnSpec.env,
|
|
stdinMode: "pipe-open",
|
|
timeoutMs,
|
|
captureOutput: false,
|
|
onStdout: handleStdout,
|
|
onStderr: handleStderr,
|
|
});
|
|
} catch (retryErr) {
|
|
markExited(session, null, null, "failed");
|
|
maybeNotifyOnExit(session, "failed");
|
|
throw retryErr;
|
|
}
|
|
} else {
|
|
markExited(session, null, null, "failed");
|
|
maybeNotifyOnExit(session, "failed");
|
|
throw err;
|
|
}
|
|
}
|
|
session.stdin = managedRun.stdin;
|
|
session.pid = managedRun.pid;
|
|
|
|
const promise = managedRun
|
|
.wait()
|
|
.then((exit): ExecProcessOutcome => {
|
|
const durationMs = Date.now() - startedAt;
|
|
const isNormalExit = exit.reason === "exit";
|
|
const exitCode = exit.exitCode ?? 0;
|
|
// Shell exit codes 126 (not executable) and 127 (command not found) are
|
|
// unrecoverable infrastructure failures that should surface as real errors
|
|
// rather than silently completing — e.g. `python: command not found`.
|
|
const isShellFailure = exitCode === 126 || exitCode === 127;
|
|
const status: "completed" | "failed" =
|
|
isNormalExit && !isShellFailure ? "completed" : "failed";
|
|
|
|
markExited(session, exit.exitCode, exit.exitSignal, status);
|
|
maybeNotifyOnExit(session, status);
|
|
if (!session.child && session.stdin) {
|
|
session.stdin.destroyed = true;
|
|
}
|
|
const aggregated = session.aggregated.trim();
|
|
if (status === "completed") {
|
|
const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : "";
|
|
return {
|
|
status: "completed",
|
|
exitCode,
|
|
exitSignal: exit.exitSignal,
|
|
durationMs,
|
|
aggregated: aggregated + exitMsg,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
const reason = isShellFailure
|
|
? exitCode === 127
|
|
? "Command not found"
|
|
: "Command not executable (permission denied)"
|
|
: exit.reason === "overall-timeout"
|
|
? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0
|
|
? `Command timed out after ${opts.timeoutSec} seconds. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300).`
|
|
: "Command timed out. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300)."
|
|
: exit.reason === "no-output-timeout"
|
|
? "Command timed out waiting for output"
|
|
: exit.exitSignal != null
|
|
? `Command aborted by signal ${exit.exitSignal}`
|
|
: "Command aborted before exit code was captured";
|
|
return {
|
|
status: "failed",
|
|
exitCode: exit.exitCode,
|
|
exitSignal: exit.exitSignal,
|
|
durationMs,
|
|
aggregated,
|
|
timedOut: exit.timedOut,
|
|
reason: aggregated ? `${aggregated}\n\n${reason}` : reason,
|
|
};
|
|
})
|
|
.catch((err): ExecProcessOutcome => {
|
|
markExited(session, null, null, "failed");
|
|
maybeNotifyOnExit(session, "failed");
|
|
const aggregated = session.aggregated.trim();
|
|
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err);
|
|
return {
|
|
status: "failed",
|
|
exitCode: null,
|
|
exitSignal: null,
|
|
durationMs: Date.now() - startedAt,
|
|
aggregated,
|
|
timedOut: false,
|
|
reason: message,
|
|
};
|
|
});
|
|
|
|
return {
|
|
session,
|
|
startedAt,
|
|
pid: session.pid ?? undefined,
|
|
promise,
|
|
kill: () => {
|
|
managedRun?.cancel("manual-cancel");
|
|
},
|
|
};
|
|
}
|