Files
openclaw/src/agents/bash-tools.exec.ts
jianxing zhang 3b3191ab3a fix(exec): skip gateway cwd injection for remote node host
When exec runs with host=node and no explicit cwd is provided, the
gateway was injecting its own process.cwd() as the default working
directory. In cross-platform setups (e.g. Linux gateway + Windows node),
this gateway-local path does not exist on the node, causing
"SYSTEM_RUN_DENIED: approval requires an existing canonical cwd".

This change detects when no explicit workdir was provided (neither via
the tool call params.workdir nor via agent defaults.cwd) and passes
undefined instead of the gateway cwd. This lets the remote node use its
own default working directory.

Changes:
- bash-tools.exec.ts: Track whether workdir was explicitly provided;
  when host=node and no explicit workdir, pass undefined instead of
  gateway process.cwd()
- bash-tools.exec-host-node.ts: Accept workdir as string | undefined;
  only send cwd to system.run.prepare when defined
- bash-tools.exec-approval-request.ts: Accept workdir as
  string | undefined in HostExecApprovalParams

Fixes #58934

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 02:04:26 +09:00

1639 lines
51 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js";
import {
type ExecHost,
loadExecApprovals,
maxAsk,
minSecurity,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js";
import { logInfo } from "../logger.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { splitShellArgs } from "../utils/shell-argv.js";
import { markBackgrounded } from "./bash-process-registry.js";
import { processGatewayAllowlist } from "./bash-tools.exec-host-gateway.js";
import { executeNodeHostCommand } from "./bash-tools.exec-host-node.js";
import {
DEFAULT_MAX_OUTPUT,
DEFAULT_PATH,
DEFAULT_PENDING_MAX_OUTPUT,
type ExecProcessOutcome,
applyPathPrepend,
applyShellPath,
normalizeExecAsk,
normalizeExecSecurity,
normalizeExecTarget,
normalizePathPrepend,
resolveExecTarget,
resolveApprovalRunningNoticeMs,
runExecProcess,
execSchema,
} from "./bash-tools.exec-runtime.js";
import type {
ExecElevatedDefaults,
ExecToolDefaults,
ExecToolDetails,
} from "./bash-tools.exec-types.js";
import {
buildSandboxEnv,
clampWithDefault,
coerceEnv,
readEnvInt,
resolveSandboxWorkdir,
resolveWorkdir,
truncateMiddle,
} from "./bash-tools.shared.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { failedTextResult, textResult } from "./tools/common.js";
export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type {
ExecElevatedDefaults,
ExecToolDefaults,
ExecToolDetails,
} from "./bash-tools.exec-types.js";
function buildExecForegroundResult(params: {
outcome: ExecProcessOutcome;
cwd?: string;
warningText?: string;
}): AgentToolResult<ExecToolDetails> {
const warningText = params.warningText?.trim() ? `${params.warningText}\n\n` : "";
if (params.outcome.status === "failed") {
return failedTextResult(`${warningText}${params.outcome.reason}`, {
status: "failed",
exitCode: params.outcome.exitCode ?? null,
durationMs: params.outcome.durationMs,
aggregated: params.outcome.aggregated,
timedOut: params.outcome.timedOut,
cwd: params.cwd,
});
}
return textResult(`${warningText}${params.outcome.aggregated || "(no output)"}`, {
status: "completed",
exitCode: params.outcome.exitCode,
durationMs: params.outcome.durationMs,
aggregated: params.outcome.aggregated,
cwd: params.cwd,
});
}
const PREFLIGHT_ENV_OPTIONS_WITH_VALUES = new Set([
"-C",
"-S",
"-u",
"--argv0",
"--block-signal",
"--chdir",
"--default-signal",
"--ignore-signal",
"--split-string",
"--unset",
]);
function isShellEnvAssignmentToken(token: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token);
}
function isEnvExecutableToken(token: string | undefined): boolean {
if (!token) {
return false;
}
const base = token.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
return normalizedBase === "env";
}
function stripPreflightEnvPrefix(argv: string[]): string[] {
if (argv.length === 0) {
return argv;
}
let idx = 0;
while (idx < argv.length && isShellEnvAssignmentToken(argv[idx])) {
idx += 1;
}
if (!isEnvExecutableToken(argv[idx])) {
return argv;
}
idx += 1;
while (idx < argv.length) {
const token = argv[idx];
if (token === "--") {
idx += 1;
break;
}
if (isShellEnvAssignmentToken(token)) {
idx += 1;
continue;
}
if (!token.startsWith("-") || token === "-") {
break;
}
idx += 1;
const option = token.split("=", 1)[0];
if (
PREFLIGHT_ENV_OPTIONS_WITH_VALUES.has(option) &&
!token.includes("=") &&
idx < argv.length
) {
idx += 1;
}
}
return argv.slice(idx);
}
function extractScriptTargetFromCommand(
command: string,
): { kind: "python"; relOrAbsPaths: string[] } | { kind: "node"; relOrAbsPaths: string[] } | null {
const raw = command.trim();
const splitShellArgsPreservingBackslashes = (value: string): string[] | null => {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
};
const shouldUseWindowsPathTokenizer =
process.platform === "win32" &&
/(?:^|[\s"'`])(?:[A-Za-z]:\\|\\\\|[^\s"'`|&;()<>]+\\[^\s"'`|&;()<>]+)/.test(raw);
const candidateArgv = shouldUseWindowsPathTokenizer
? [splitShellArgsPreservingBackslashes(raw)]
: [splitShellArgs(raw)];
const findFirstPythonScriptArg = (tokens: string[]): string | null => {
const optionsWithSeparateValue = new Set(["-W", "-X", "-Q", "--check-hash-based-pycs"]);
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === "--") {
const next = tokens[i + 1];
return next?.toLowerCase().endsWith(".py") ? next : null;
}
if (token === "-") {
return null;
}
if (token === "-c" || token === "-m") {
return null;
}
if ((token.startsWith("-c") || token.startsWith("-m")) && token.length > 2) {
return null;
}
if (optionsWithSeparateValue.has(token)) {
i += 1;
continue;
}
if (token.startsWith("-")) {
continue;
}
return token.toLowerCase().endsWith(".py") ? token : null;
}
return null;
};
const findNodeScriptArgs = (tokens: string[]): string[] => {
const optionsWithSeparateValue = new Set(["-r", "--require", "--import"]);
const preloadScripts: string[] = [];
let entryScript: string | null = null;
let hasInlineEvalOrPrint = false;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === "--") {
if (!hasInlineEvalOrPrint && !entryScript) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
entryScript = next;
}
}
break;
}
if (
token === "-e" ||
token === "-p" ||
token === "--eval" ||
token === "--print" ||
token.startsWith("--eval=") ||
token.startsWith("--print=") ||
((token.startsWith("-e") || token.startsWith("-p")) && token.length > 2)
) {
hasInlineEvalOrPrint = true;
if (token === "-e" || token === "-p" || token === "--eval" || token === "--print") {
i += 1;
}
continue;
}
if (optionsWithSeparateValue.has(token)) {
const next = tokens[i + 1];
if (next?.toLowerCase().endsWith(".js")) {
preloadScripts.push(next);
}
i += 1;
continue;
}
if (
(token.startsWith("-r") && token.length > 2) ||
token.startsWith("--require=") ||
token.startsWith("--import=")
) {
const inlineValue = token.startsWith("-r")
? token.slice(2)
: token.slice(token.indexOf("=") + 1);
if (inlineValue.toLowerCase().endsWith(".js")) {
preloadScripts.push(inlineValue);
}
continue;
}
if (token.startsWith("-")) {
continue;
}
if (!hasInlineEvalOrPrint && !entryScript && token.toLowerCase().endsWith(".js")) {
entryScript = token;
}
break;
}
const targets = [...preloadScripts];
if (entryScript) {
targets.push(entryScript);
}
return targets;
};
const extractTargetFromArgv = (
argv: string[] | null,
):
| { kind: "python"; relOrAbsPaths: string[] }
| { kind: "node"; relOrAbsPaths: string[] }
| null => {
if (!argv || argv.length === 0) {
return null;
}
let commandIdx = 0;
while (commandIdx < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])) {
commandIdx += 1;
}
const executable = argv[commandIdx]?.toLowerCase();
if (!executable) {
return null;
}
const args = argv.slice(commandIdx + 1);
if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) {
const script = findFirstPythonScriptArg(args);
if (script) {
return { kind: "python", relOrAbsPaths: [script] };
}
return null;
}
if (executable === "node") {
const scripts = findNodeScriptArgs(args);
if (scripts.length > 0) {
return { kind: "node", relOrAbsPaths: scripts };
}
return null;
}
return null;
};
for (const argv of candidateArgv) {
const attempts = [argv, argv ? stripPreflightEnvPrefix(argv) : null];
for (const attempt of attempts) {
const target = extractTargetFromArgv(attempt);
if (target) {
return target;
}
}
}
return null;
}
function extractUnquotedShellText(raw: string): string | null {
let out = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
if (!inSingle && !inDouble) {
// Preserve escapes outside quotes so downstream heuristics can distinguish
// escaped literals (e.g. `\|`) from executable shell operators.
out += `\\${ch}`;
}
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
}
continue;
}
if (inDouble) {
const next = raw[i + 1];
if (ch === "\\" && next && /[\\'"$`\n\r]/.test(next)) {
i += 1;
continue;
}
if (ch === '"') {
inDouble = false;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
out += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
return out;
}
function analyzeInterpreterHeuristicsFromUnquoted(raw: string): {
hasPython: boolean;
hasNode: boolean;
hasComplexSyntax: boolean;
hasProcessSubstitution: boolean;
hasScriptHint: boolean;
} {
const hasPython =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*python(?:3(?:\.\d+)?)?(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasNode =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*node(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasProcessSubstitution = /(?<!\\)<\(|(?<!\\)>\(/u.test(raw);
const hasComplexSyntax =
/(?<!\\)\|/u.test(raw) ||
/(?<!\\)&&/u.test(raw) ||
/(?<!\\)\|\|/u.test(raw) ||
/(?<!\\);/u.test(raw) ||
raw.includes("\n") ||
raw.includes("\r") ||
/(?<!\\)\$\(/u.test(raw) ||
/(?<!\\)`/u.test(raw) ||
hasProcessSubstitution;
const hasScriptHint = /(?:^|[\s|&;()<>])[^"'`\s|&;()<>]+\.(?:py|js)(?=$|[\s|&;()<>])/i.test(raw);
return { hasPython, hasNode, hasComplexSyntax, hasProcessSubstitution, hasScriptHint };
}
function extractShellWrappedCommandPayload(
executable: string | undefined,
args: string[],
): string | null {
if (!executable) {
return null;
}
const executableBase = executable.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
const normalizedExecutable = executableBase.endsWith(".exe")
? executableBase.slice(0, -4)
: executableBase;
if (!/^(?:bash|dash|fish|ksh|sh|zsh)$/i.test(normalizedExecutable)) {
return null;
}
const shortOptionsWithSeparateValue = new Set(["-O", "-o"]);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--") {
return null;
}
if (arg === "-c") {
return args[i + 1] ?? null;
}
if (/^-[A-Za-z]+$/u.test(arg)) {
if (arg.includes("c")) {
return args[i + 1] ?? null;
}
if (shortOptionsWithSeparateValue.has(arg)) {
i += 1;
}
continue;
}
if (/^--[A-Za-z0-9][A-Za-z0-9-]*(?:=.*)?$/u.test(arg)) {
if (!arg.includes("=")) {
const next = args[i + 1];
if (next && next !== "--" && !next.startsWith("-")) {
i += 1;
}
}
continue;
}
return null;
}
return null;
}
function shouldFailClosedInterpreterPreflight(command: string): {
hasInterpreterInvocation: boolean;
hasComplexSyntax: boolean;
hasProcessSubstitution: boolean;
hasInterpreterSegmentScriptHint: boolean;
hasInterpreterPipelineScriptHint: boolean;
isDirectInterpreterCommand: boolean;
} {
const raw = command.trim();
const rawArgv = splitShellArgs(raw);
const argv = rawArgv ? stripPreflightEnvPrefix(rawArgv) : null;
let commandIdx = 0;
while (
argv &&
commandIdx < argv.length &&
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])
) {
commandIdx += 1;
}
const directExecutable = argv?.[commandIdx]?.toLowerCase();
const args = argv ? argv.slice(commandIdx + 1) : [];
const isDirectPythonExecutable = Boolean(
directExecutable && /^python(?:3(?:\.\d+)?)?$/i.test(directExecutable),
);
const isDirectNodeExecutable = directExecutable === "node";
const isDirectInterpreterCommand = isDirectPythonExecutable || isDirectNodeExecutable;
const unquotedRaw = extractUnquotedShellText(raw) ?? raw;
const topLevel = analyzeInterpreterHeuristicsFromUnquoted(unquotedRaw);
const shellWrappedPayload = extractShellWrappedCommandPayload(directExecutable, args);
const nestedUnquoted = shellWrappedPayload
? (extractUnquotedShellText(shellWrappedPayload) ?? shellWrappedPayload)
: "";
const nested = shellWrappedPayload
? analyzeInterpreterHeuristicsFromUnquoted(nestedUnquoted)
: {
hasPython: false,
hasNode: false,
hasComplexSyntax: false,
hasProcessSubstitution: false,
hasScriptHint: false,
};
const splitShellSegmentsOutsideQuotes = (
rawText: string,
params: { splitPipes: boolean },
): string[] => {
const segments: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushSegment = () => {
if (buf.trim().length > 0) {
segments.push(buf);
}
buf = "";
};
for (let i = 0; i < rawText.length; i += 1) {
const ch = rawText[i];
const next = rawText[i + 1];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
buf += ch;
escaped = true;
continue;
}
if (inSingle) {
buf += ch;
if (ch === "'") {
inSingle = false;
}
continue;
}
if (inDouble) {
buf += ch;
if (ch === '"') {
inDouble = false;
}
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "\n" || ch === "\r") {
pushSegment();
continue;
}
if (ch === ";") {
pushSegment();
continue;
}
if (ch === "&" && next === "&") {
pushSegment();
i += 1;
continue;
}
if (ch === "|" && next === "|") {
pushSegment();
i += 1;
continue;
}
if (params.splitPipes && ch === "|") {
pushSegment();
continue;
}
buf += ch;
}
pushSegment();
return segments;
};
const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => {
const segment = extractUnquotedShellText(rawSegment) ?? rawSegment;
return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test(
segment,
);
};
const isScriptExecutingInterpreterCommand = (rawCommand: string): boolean => {
const argv = splitShellArgs(rawCommand.trim());
if (!argv || argv.length === 0) {
return false;
}
const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(argv[0] ?? "")
? argv.slice(1)
: argv;
if (withoutLeadingKeyword.length === 0) {
return false;
}
const normalizedArgv = stripPreflightEnvPrefix(withoutLeadingKeyword);
let commandIdx = 0;
while (
commandIdx < normalizedArgv.length &&
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(normalizedArgv[commandIdx] ?? "")
) {
commandIdx += 1;
}
const executable = normalizedArgv[commandIdx]?.toLowerCase();
if (!executable) {
return false;
}
const args = normalizedArgv.slice(commandIdx + 1);
if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) {
const pythonInfoOnlyFlags = new Set(["-V", "--version", "-h", "--help"]);
if (args.some((arg) => pythonInfoOnlyFlags.has(arg))) {
return false;
}
if (
args.some(
(arg) =>
arg === "-c" ||
arg === "-m" ||
arg.startsWith("-c") ||
arg.startsWith("-m") ||
arg === "--check-hash-based-pycs",
)
) {
return false;
}
return true;
}
if (executable === "node") {
const nodeInfoOnlyFlags = new Set(["-v", "--version", "-h", "--help", "-c", "--check"]);
if (args.some((arg) => nodeInfoOnlyFlags.has(arg))) {
return false;
}
if (
args.some(
(arg) =>
arg === "-e" ||
arg === "-p" ||
arg === "--eval" ||
arg === "--print" ||
arg.startsWith("--eval=") ||
arg.startsWith("--print=") ||
((arg.startsWith("-e") || arg.startsWith("-p")) && arg.length > 2),
)
) {
return false;
}
return true;
}
return false;
};
const hasScriptHintInSegment = (segment: string): boolean =>
/(?:^|[\s()<>])(?:"[^"\n\r`|&;()<>]*\.(?:py|js)"|'[^'\n\r`|&;()<>]*\.(?:py|js)'|[^"'`\s|&;()<>]+\.(?:py|js))(?=$|[\s()<>])/i.test(
segment,
);
const hasInterpreterAndScriptHintInSameSegment = (rawText: string): boolean => {
const segments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: true });
return segments.some((segment) => {
if (!isScriptExecutingInterpreterCommand(segment)) {
return false;
}
return hasScriptHintInSegment(segment);
});
};
const hasInterpreterPipelineScriptHintInSameSegment = (rawText: string): boolean => {
const commandSegments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: false });
return commandSegments.some((segment) => {
const pipelineCommands = splitShellSegmentsOutsideQuotes(segment, { splitPipes: true });
const hasScriptExecutingPipedInterpreter = pipelineCommands
.slice(1)
.some((pipelineCommand) => isScriptExecutingInterpreterCommand(pipelineCommand));
if (!hasScriptExecutingPipedInterpreter) {
return false;
}
return hasScriptHintInSegment(segment);
});
};
const hasInterpreterSegmentScriptHint =
hasInterpreterAndScriptHintInSameSegment(raw) ||
(shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload));
const hasInterpreterPipelineScriptHint =
hasInterpreterPipelineScriptHintInSameSegment(raw) ||
(shellWrappedPayload !== null &&
hasInterpreterPipelineScriptHintInSameSegment(shellWrappedPayload));
const hasShellWrappedInterpreterSegmentScriptHint =
shellWrappedPayload !== null && hasInterpreterAndScriptHintInSameSegment(shellWrappedPayload);
const hasShellWrappedInterpreterInvocation =
(nested.hasPython || nested.hasNode) &&
(hasShellWrappedInterpreterSegmentScriptHint ||
nested.hasScriptHint ||
nested.hasComplexSyntax ||
nested.hasProcessSubstitution);
const hasTopLevelInterpreterInvocation = splitShellSegmentsOutsideQuotes(raw, {
splitPipes: true,
}).some((segment) => hasInterpreterInvocationInSegment(segment));
const hasInterpreterInvocation =
isDirectInterpreterCommand ||
hasShellWrappedInterpreterInvocation ||
hasTopLevelInterpreterInvocation;
return {
hasInterpreterInvocation,
hasComplexSyntax: topLevel.hasComplexSyntax || hasShellWrappedInterpreterInvocation,
hasProcessSubstitution: topLevel.hasProcessSubstitution || nested.hasProcessSubstitution,
hasInterpreterSegmentScriptHint,
hasInterpreterPipelineScriptHint,
isDirectInterpreterCommand,
};
}
async function validateScriptFileForShellBleed(params: {
command: string;
workdir: string;
}): Promise<void> {
const target = extractScriptTargetFromCommand(params.command);
if (!target) {
const {
hasInterpreterInvocation,
hasComplexSyntax,
hasProcessSubstitution,
hasInterpreterSegmentScriptHint,
hasInterpreterPipelineScriptHint,
isDirectInterpreterCommand,
} = shouldFailClosedInterpreterPreflight(params.command);
if (
hasInterpreterInvocation &&
hasComplexSyntax &&
(hasInterpreterSegmentScriptHint ||
hasInterpreterPipelineScriptHint ||
(hasProcessSubstitution && isDirectInterpreterCommand))
) {
// Fail closed when interpreter-driven script execution is ambiguous; otherwise
// attackers can route script content through forms our fast parser cannot validate.
throw new Error(
"exec preflight: complex interpreter invocation detected; refusing to run without script preflight validation. " +
"Use a direct `python <file>.py` or `node <file>.js` command.",
);
}
return;
}
for (const relOrAbsPath of target.relOrAbsPaths) {
const absPath = path.isAbsolute(relOrAbsPath)
? path.resolve(relOrAbsPath)
: path.resolve(params.workdir, relOrAbsPath);
// Best-effort: only validate if file exists and is reasonably small.
let stat: { isFile(): boolean; size: number };
try {
await assertSandboxPath({
filePath: absPath,
cwd: params.workdir,
root: params.workdir,
});
stat = await fs.stat(absPath);
} catch {
continue;
}
if (!stat.isFile()) {
continue;
}
if (stat.size > 512 * 1024) {
continue;
}
const content = await fs.readFile(absPath, "utf-8");
// Common failure mode: shell env var syntax leaking into Python/JS.
// We deliberately match all-caps/underscore vars to avoid false positives with `$` as a JS identifier.
const envVarRegex = /\$[A-Z_][A-Z0-9_]{1,}/g;
const first = envVarRegex.exec(content);
if (first) {
const idx = first.index;
const before = content.slice(0, idx);
const line = before.split("\n").length;
const token = first[0];
throw new Error(
[
`exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${path.basename(
absPath,
)}:${line}.`,
target.kind === "python"
? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.`
: `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.`,
"(If this is inside a string literal on purpose, escape it or restructure the code.)",
].join("\n"),
);
}
// Another recurring pattern from the issue: shell commands accidentally emitted as JS.
if (target.kind === "node") {
const firstNonEmpty = content
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0);
if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
throw new Error(
`exec preflight: JS file starts with shell syntax (${firstNonEmpty}). ` +
`This looks like a shell command, not JavaScript.`,
);
}
}
}
}
type ParsedExecApprovalCommand = {
approvalId: string;
decision: "allow-once" | "allow-always" | "deny";
};
function parseExecApprovalShellCommand(raw: string): ParsedExecApprovalCommand | null {
const normalized = raw.trimStart();
const match = normalized.match(
/^\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(allow-once|allow-always|always|deny)\b/i,
);
if (!match) {
return null;
}
return {
approvalId: match[1],
decision:
match[2].toLowerCase() === "always"
? "allow-always"
: (match[2].toLowerCase() as ParsedExecApprovalCommand["decision"]),
};
}
function rejectExecApprovalShellCommand(command: string): void {
const isEnvAssignmentToken = (token: string): boolean =>
/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token);
const shellWrappers = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"]);
const commandStandaloneOptions = new Set(["-p", "-v", "-V"]);
const envOptionsWithValues = new Set([
"-C",
"-S",
"-u",
"--argv0",
"--block-signal",
"--chdir",
"--default-signal",
"--ignore-signal",
"--split-string",
"--unset",
]);
const execOptionsWithValues = new Set(["-a"]);
const execStandaloneOptions = new Set(["-c", "-l"]);
const sudoOptionsWithValues = new Set([
"-C",
"-D",
"-g",
"-p",
"-R",
"-T",
"-U",
"-u",
"--chdir",
"--close-from",
"--group",
"--host",
"--other-user",
"--prompt",
"--role",
"--type",
"--user",
]);
const sudoStandaloneOptions = new Set(["-A", "-E", "--askpass", "--preserve-env"]);
const extractEnvSplitStringPayload = (argv: string[]): string[] => {
const remaining = [...argv];
while (remaining[0] && isEnvAssignmentToken(remaining[0])) {
remaining.shift();
}
if (remaining[0] !== "env") {
return [];
}
remaining.shift();
const payloads: string[] = [];
while (remaining.length > 0) {
while (remaining[0] && isEnvAssignmentToken(remaining[0])) {
remaining.shift();
}
const token: string | undefined = remaining[0];
if (!token) {
break;
}
if (token === "--") {
remaining.shift();
continue;
}
if (!token.startsWith("-") || token === "-") {
break;
}
const option = remaining.shift()!;
const normalized = option.split("=", 1)[0];
if (normalized === "-S" || normalized === "--split-string") {
const value = option.includes("=")
? option.slice(option.indexOf("=") + 1)
: remaining.shift();
if (value?.trim()) {
payloads.push(value);
}
continue;
}
if (envOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) {
remaining.shift();
}
}
return payloads;
};
const stripApprovalCommandPrefixes = (argv: string[]): string[] => {
const remaining = [...argv];
while (remaining.length > 0) {
while (remaining[0] && isEnvAssignmentToken(remaining[0])) {
remaining.shift();
}
const token = remaining[0];
if (!token) {
break;
}
if (token === "--") {
remaining.shift();
continue;
}
if (token === "env") {
remaining.shift();
while (remaining.length > 0) {
while (remaining[0] && isEnvAssignmentToken(remaining[0])) {
remaining.shift();
}
const envToken = remaining[0];
if (!envToken) {
break;
}
if (envToken === "--") {
remaining.shift();
continue;
}
if (!envToken.startsWith("-") || envToken === "-") {
break;
}
const option = remaining.shift()!;
const normalized = option.split("=", 1)[0];
if (envOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) {
remaining.shift();
}
}
continue;
}
if (token === "command" || token === "builtin") {
remaining.shift();
while (remaining[0]?.startsWith("-")) {
const option = remaining.shift()!;
if (option === "--") {
break;
}
if (!commandStandaloneOptions.has(option.split("=", 1)[0])) {
continue;
}
}
continue;
}
if (token === "exec") {
remaining.shift();
while (remaining[0]?.startsWith("-")) {
const option = remaining.shift()!;
if (option === "--") {
break;
}
const normalized = option.split("=", 1)[0];
if (execStandaloneOptions.has(normalized)) {
continue;
}
if (execOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) {
remaining.shift();
}
}
continue;
}
if (token === "sudo") {
remaining.shift();
while (remaining[0]?.startsWith("-")) {
const option = remaining.shift()!;
if (option === "--") {
break;
}
const normalized = option.split("=", 1)[0];
if (sudoStandaloneOptions.has(normalized)) {
continue;
}
if (sudoOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) {
remaining.shift();
}
}
continue;
}
break;
}
return remaining;
};
const extractShellWrapperPayload = (argv: string[]): string[] => {
const [commandName, ...rest] = argv;
if (!commandName || !shellWrappers.has(path.basename(commandName))) {
return [];
}
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i];
if (!token) {
continue;
}
if (token === "-c" || token === "-lc" || token === "-ic" || token === "-xc") {
return rest[i + 1] ? [rest[i + 1]] : [];
}
if (/^-[^-]*c[^-]*$/u.test(token)) {
return rest[i + 1] ? [rest[i + 1]] : [];
}
}
return [];
};
const buildCandidates = (argv: string[]): string[] => {
const envSplitCandidates = extractEnvSplitStringPayload(argv).flatMap((payload) => {
const innerArgv = splitShellArgs(payload);
return innerArgv ? buildCandidates(innerArgv) : [payload];
});
const stripped = stripApprovalCommandPrefixes(argv);
const shellWrapperCandidates = extractShellWrapperPayload(stripped).flatMap((payload) => {
const innerArgv = splitShellArgs(payload);
return innerArgv ? buildCandidates(innerArgv) : [payload];
});
return [
...(stripped.length > 0 ? [stripped.join(" ")] : []),
...envSplitCandidates,
...shellWrapperCandidates,
];
};
const rawCommand = command.trim();
const analysis = analyzeShellCommand({ command: rawCommand });
const candidates = analysis.ok
? analysis.segments.flatMap((segment) => buildCandidates(segment.argv))
: rawCommand
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const argv = splitShellArgs(line);
return argv ? buildCandidates(argv) : [line];
});
for (const candidate of candidates) {
if (!parseExecApprovalShellCommand(candidate)) {
continue;
}
throw new Error(
[
"exec cannot run /approve commands.",
"Show the /approve command to the user as chat text, or route it through the approval command handler instead of shell execution.",
].join(" "),
);
}
}
/**
* Show the exact approved token in hints. Absolute paths stay absolute so the
* hint cannot imply an equivalent PATH lookup that resolves to a different binary.
*/
function deriveExecShortName(fullPath: string): string {
if (path.isAbsolute(fullPath)) {
return fullPath;
}
const base = path.basename(fullPath);
return base.replace(/\.exe$/i, "") || base;
}
function buildExecToolDescription(agentId?: string): string {
const base =
"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).";
if (process.platform !== "win32") {
return base;
}
const lines: string[] = [base];
lines.push(
"IMPORTANT (Windows): Run executables directly — do NOT wrap commands in `cmd /c`, `powershell -Command`, `& ` prefix, or WSL. Use backslash paths (C:\\path), not forward slashes. Use short executable names (e.g. `node`, `python3`) instead of full paths.",
);
try {
const approvalsFile = loadExecApprovals();
const approvals = resolveExecApprovalsFromFile({ file: approvalsFile, agentId });
const allowlist = approvals.allowlist.filter((entry) => {
const pattern = entry.pattern?.trim() ?? "";
return (
pattern.length > 0 &&
pattern !== "*" &&
!pattern.startsWith("=command:") &&
(pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"))
);
});
if (allowlist.length > 0) {
lines.push(
"Pre-approved executables (exact arguments are enforced at runtime; no approval prompt needed when args match):",
);
for (const entry of allowlist.slice(0, 10)) {
const shortName = deriveExecShortName(entry.pattern);
const argNote = entry.argPattern ? "(restricted args)" : "(any arguments)";
lines.push(` ${shortName} ${argNote}`);
}
}
} catch {
// Allowlist loading is best-effort; don't block tool creation.
}
return lines.join("\n");
}
export function createExecTool(
defaults?: ExecToolDefaults,
// oxlint-disable-next-line typescript/no-explicit-any
): AgentTool<any, ExecToolDetails> {
const defaultBackgroundMs = clampWithDefault(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
10_000,
10,
120_000,
);
const allowBackground = defaults?.allowBackground ?? true;
const defaultTimeoutSec =
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec
: 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const {
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
unprofiledSafeBins,
unprofiledInterpreterSafeBins,
} = resolveExecSafeBinRuntimePolicy({
local: {
safeBins: defaults?.safeBins,
safeBinTrustedDirs: defaults?.safeBinTrustedDirs,
safeBinProfiles: defaults?.safeBinProfiles,
},
onWarning: (message) => {
logInfo(message);
},
});
if (unprofiledSafeBins.length > 0) {
logInfo(
`exec: ignoring unprofiled safeBins entries (${unprofiledSafeBins.toSorted().join(", ")}); use allowlist or define tools.exec.safeBinProfiles.<bin>`,
);
}
if (unprofiledInterpreterSafeBins.length > 0) {
logInfo(
`exec: interpreter/runtime binaries in safeBins (${unprofiledInterpreterSafeBins.join(", ")}) are unsafe without explicit hardened profiles; prefer allowlist entries`,
);
}
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true;
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",
get description() {
return buildExecToolDescription(agentId);
},
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[] = [];
let execCommandOverride: string | undefined;
const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) {
warnings.push("Warning: background execution is disabled; running synchronously.");
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampWithDefault(
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
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 target = resolveExecTarget({
configuredTarget: defaults?.host,
requestedTarget: normalizeExecTarget(params.host),
elevatedRequested,
sandboxAvailable: Boolean(defaults?.sandbox),
});
const host: ExecHost = target.effectiveHost;
const approvalDefaults = loadExecApprovals().defaults;
const configuredSecurity =
defaults?.security ?? approvalDefaults?.security ?? (host === "sandbox" ? "deny" : "full");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset.
const configuredAsk = defaults?.ask ?? approvalDefaults?.ask ?? "off";
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;
if (target.selectedTarget === "sandbox" && !sandbox) {
throw new Error(
[
"exec host=sandbox requires a sandbox runtime for this session.",
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or use host=auto/gateway/node.',
].join("\n"),
);
}
const hasExplicitWorkdir = !!(params.workdir?.trim() || defaults?.cwd);
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
let workdir: string | undefined = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) {
const resolved = await resolveSandboxWorkdir({
workdir: rawWorkdir,
sandbox,
warnings,
});
workdir = resolved.hostWorkdir;
containerWorkdir = resolved.containerWorkdir;
} else if (host === "node") {
// For remote node execution, only forward an explicit cwd provided by the
// user or agent config. When no explicit cwd was given, the gateway's own
// process.cwd() is meaningless on the remote node (especially cross-platform,
// e.g. Linux gateway + Windows node) and would cause
// "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd".
// Passing undefined lets the node use its own default working directory.
if (!hasExplicitWorkdir) {
workdir = undefined;
}
} else {
workdir = resolveWorkdir(rawWorkdir, warnings);
}
rejectExecApprovalShellCommand(params.command);
const inheritedBaseEnv = coerceEnv(process.env);
const hostEnvResult =
host === "sandbox"
? null
: sanitizeHostExecEnvWithDiagnostics({
baseEnv: inheritedBaseEnv,
overrides: params.env,
blockPathOverrides: true,
});
if (
hostEnvResult &&
params.env &&
(hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
) {
const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys;
const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys;
const pathBlocked = blockedKeys.includes("PATH");
if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) {
throw new Error(
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
);
}
if (blockedKeys.length === 1 && invalidKeys.length === 0) {
throw new Error(
`Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`,
);
}
const details: string[] = [];
if (blockedKeys.length > 0) {
details.push(`blocked override keys: ${blockedKeys.join(", ")}`);
}
if (invalidKeys.length > 0) {
details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`);
}
const suffix = details.join("; ");
if (pathBlocked) {
throw new Error(
`Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`,
);
}
throw new Error(`Security Violation: ${suffix}.`);
}
const env =
sandbox && host === "sandbox"
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
paramsEnv: params.env,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
});
applyShellPath(env, shellPath);
}
// `tools.exec.pathPrepend` is only meaningful when exec runs locally (gateway) or in the sandbox.
// Node hosts intentionally ignore request-scoped PATH overrides, so don't pretend this applies.
if (host === "node" && defaultPathPrepend.length > 0) {
warnings.push(
"Warning: tools.exec.pathPrepend is ignored for host=node. Configure PATH on the node host/service instead.",
);
} else {
applyPathPrepend(env, defaultPathPrepend);
}
if (host === "node") {
return executeNodeHostCommand({
command: params.command,
workdir,
env,
requestedEnv: params.env,
requestedNode: params.node?.trim(),
boundNode: defaults?.node?.trim(),
sessionKey: defaults?.sessionKey,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
turnSourceThreadId: defaults?.currentThreadTs,
agentId,
security,
ask,
strictInlineEval: defaults?.strictInlineEval,
trigger: defaults?.trigger,
timeoutSec: params.timeout,
defaultTimeoutSec,
approvalRunningNoticeMs,
warnings,
notifySessionKey,
trustedSafeBinDirs,
});
}
if (host === "gateway" && !bypassApprovals) {
const gatewayResult = await processGatewayAllowlist({
command: params.command,
workdir,
env,
requestedEnv: params.env,
pty: params.pty === true && !sandbox,
timeoutSec: params.timeout,
defaultTimeoutSec,
security,
ask,
safeBins,
safeBinProfiles,
strictInlineEval: defaults?.strictInlineEval,
trigger: defaults?.trigger,
agentId,
sessionKey: defaults?.sessionKey,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
turnSourceThreadId: defaults?.currentThreadTs,
scopeKey: defaults?.scopeKey,
warnings,
notifySessionKey,
approvalRunningNoticeMs,
maxOutput,
pendingMaxOutput,
trustedSafeBinDirs,
});
if (gatewayResult.pendingResult) {
return gatewayResult.pendingResult;
}
execCommandOverride = gatewayResult.execCommandOverride;
if (gatewayResult.allowWithoutEnforcedCommand) {
execCommandOverride = undefined;
}
}
const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null;
const backgroundTimeoutBypass =
allowBackground && explicitTimeoutSec === null && (backgroundRequested || yieldRequested);
const effectiveTimeout = backgroundTimeoutBypass
? null
: (explicitTimeoutSec ?? defaultTimeoutSec);
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
const usePty = params.pty === true && !sandbox;
// Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources)
// before we execute and burn tokens in cron loops.
await validateScriptFileForShellBleed({ command: params.command, workdir });
const run = await runExecProcess({
command: params.command,
execCommand: execCommandOverride,
workdir,
env,
sandbox,
containerWorkdir,
usePty,
warnings,
maxOutput,
pendingMaxOutput,
notifyOnExit,
notifyOnExitEmptySuccess,
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<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;
}
resolve(
buildExecForegroundResult({
outcome,
cwd: run.session.cwd,
warningText: getWarningText(),
}),
);
})
.catch((err) => {
if (yieldTimer) {
clearTimeout(yieldTimer);
}
if (yielded || run.session.backgrounded) {
return;
}
reject(err as Error);
});
});
},
};
}
export const execTool = createExecTool();