@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,475 lines • 59.5 kB
JavaScript
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
import { createInterface } from "node:readline";
import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
import { killProcessTree, shutdownDebug } from "./kill-tree.js";
import { __resetSecretEnvScrubNoticeAnnouncement, announceSecretEnvScrub, inheritEnvPolicyProblem, inheritedHostEnv, scrubSecretEnv } from "../../core/secret-env.js";
import { physicalPathOfAsync } from "../../core/physical-path.js";
import { isUncSpelling } from "../../tools/fs/safety.js";
import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
import { BackgroundShellError } from "../../core/background-shell.js";
import { SchedulerError } from "../../core/scheduler.js";
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function resolvePath(cwd, path) {
return isAbsolute(path) ? path : resolve(cwd, path);
}
export function resolveExecTimeoutMs(timeoutSeconds) {
if (typeof timeoutSeconds !== "number" ||
!Number.isFinite(timeoutSeconds) ||
timeoutSeconds <= 0) {
return undefined;
}
const milliseconds = Math.floor(timeoutSeconds * 1000);
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
return 1;
}
return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS);
}
function fileKindFromStats(stats) {
if (stats.isFile()) {
return "file";
}
if (stats.isDirectory()) {
return "directory";
}
if (stats.isSymbolicLink()) {
return "symlink";
}
return undefined;
}
const HAS_POSIX_MODE_FACE = process.platform !== "win32";
export function anchorAtLinkDir(linkDir, target, separator) {
const endsInSeparator = linkDir.endsWith(separator) || (separator === "\\" && linkDir.endsWith("/"));
return endsInSeparator ? `${linkDir}${target}` : `${linkDir}${separator}${target}`;
}
const SYMLINK_CHAIN_HOP_BOUND = 64;
function fileInfoFromStats(path, stats) {
const kind = fileKindFromStats(stats);
if (!kind) {
return err(new FileError("invalid", "Unsupported file type", path));
}
return ok({
name: (() => {
const t = path.replace(/[\\/]+$/, "");
return t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1) || path;
})(),
path,
kind,
size: stats.size,
mtimeMs: stats.mtimeMs,
...(HAS_POSIX_MODE_FACE && typeof stats.mode === "number" ? { mode: stats.mode & 0o7777 } : {}),
});
}
function isNodeError(error) {
return error instanceof Error && "code" in error;
}
function toFileError(error, path) {
if (error instanceof FileError) {
return error;
}
const cause = toError(error);
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, cause);
case "ENOENT":
return new FileError("not_found", message, path, cause);
case "EACCES":
case "EPERM":
return new FileError("permission_denied", message, path, cause);
case "ENOTDIR":
return new FileError("not_directory", message, path, cause);
case "EISDIR":
return new FileError("is_directory", message, path, cause);
case "EINVAL":
return new FileError("invalid", message, path, cause);
default:
break;
}
}
return new FileError("unknown", cause.message, path, cause);
}
function abortResult(signal, path) {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
}
function isFsSupportError(error) {
const code = isNodeError(error) ? error.code : undefined;
return code === "EINVAL" || code === "ENOTSUP" || code === "EPERM" || code === "ENOSYS";
}
const RENAME_FALLBACK_CODES = new Set(["EXDEV", "EPERM", "EEXIST", "EBUSY"]);
async function pathExists(path) {
try {
await access(path, constants.F_OK);
return true;
}
catch {
return false;
}
}
async function runCommand(command, args, timeoutMs) {
return await new Promise((resolveLocal) => {
let stdout = "";
let child;
try {
child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
env: scrubSecretEnv(process.env),
});
}
catch {
resolveLocal({ stdout: "", status: null });
return;
}
const timeout = setTimeout(() => {
if (child.pid) {
killProcessTree(child.pid, { force: true });
}
}, timeoutMs);
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stdout?.on("error", () => { });
child.on("error", () => {
clearTimeout(timeout);
resolveLocal({ stdout: "", status: null });
});
child.on("close", (status) => {
clearTimeout(timeout);
resolveLocal({ stdout, status });
});
});
}
export function isWslBashLauncher(p) {
return /[/\\](system32|windowsapps)[/\\]bash\.exe$/i.test(p);
}
async function findBashOnPath() {
const result = process.platform === "win32"
? await runCommand("where", ["bash.exe"], 5000)
: await runCommand("which", ["bash"], 5000);
if (result.status !== 0 || !result.stdout) {
return null;
}
const lines = result.stdout.trim().split(/\r?\n/);
const firstMatch = process.platform === "win32" ? lines.find((l) => !isWslBashLauncher(l)) : lines[0];
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
export async function getShellConfig(customShellPath) {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return ok({ shell: customShellPath, args: ["-c"] });
}
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
}
if (process.platform === "win32") {
const candidates = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
candidates.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return ok({ shell: candidate, args: ["-c"] });
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return ok({ shell: "/bin/bash", args: ["-c"] });
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
}
return ok({ shell: "sh", args: ["-c"] });
}
const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
let secretScrubSeatDefectAnnounced = false;
export function __resetSecretScrubAnnouncement() {
secretScrubSeatDefectAnnounced = false;
__resetSecretEnvScrubNoticeAnnouncement();
}
function getShellEnv(inheritEnv, baseEnv, extraEnv, onScrub, onNotice) {
const scrubbed = [];
const inherited = inheritedHostEnv(inheritEnv, scrubbed);
const findings = scrubbed.filter((f) => baseEnv?.[f.key] === undefined && extraEnv?.[f.key] === undefined);
if (findings.length > 0) {
if (typeof onScrub === "function") {
try {
const r = onScrub(findings);
if (typeof r?.then === "function") {
r.then(undefined, () => {
});
}
}
catch {
}
}
else if (onScrub !== undefined && !secretScrubSeatDefectAnnounced) {
secretScrubSeatDefectAnnounced = true;
try {
console.warn(`onSecretEnvScrub seat holds ${onScrub === null ? "null" : typeof onScrub} — not a function; scrub findings fall back to the console summary (once per process)`);
}
catch {
}
}
if (!(typeof onScrub === "function" && onNotice === undefined)) {
announceSecretEnvScrub(onNotice, findings);
}
}
return {
...inherited,
...baseEnv,
...extraEnv,
};
}
const EXIT_SETTLE_DRAIN_MS = 500;
const EXEC_SPOOL_PUMP_MS = 200;
const EXEC_SPOOL_ROTATE_BYTES = 8 * 1024 * 1024;
const EXEC_SPOOL_FINAL_TAIL_BYTES = 8 * 1024 * 1024;
const EXEC_SPOOL_HARD_CAP_BYTES = 16 * 1024 * 1024;
export function decideSpoolReclaim(args) {
const rotate = args.rotateBytes ?? EXEC_SPOOL_ROTATE_BYTES;
const hardCap = args.hardCapBytes ?? EXEC_SPOOL_HARD_CAP_BYTES;
if (args.writerLive)
return { reclaim: false };
if (args.cursor < rotate)
return { reclaim: false };
if (args.size === args.cursor)
return { reclaim: true, skippedBytes: 0 };
if (args.size > args.cursor && args.size >= hardCap)
return { reclaim: true, skippedBytes: args.size - args.cursor };
return { reclaim: false };
}
const SIGNUM = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
export function openSpoolPair(base) {
const outPath = `${base}.out.log`;
const errPath = `${base}.err.log`;
const fdOut = openSync(outPath, "ax", 0o600);
let fdErr;
try {
fdErr = openSync(errPath, "ax", 0o600);
}
catch (e) {
try {
closeSync(fdOut);
}
catch {
}
throw e;
}
return { outPath, errPath, fds: [fdOut, fdErr] };
}
export class NodeExecutionEnv {
cwd;
get homeDir() {
return getShellEnv(this.inheritEnv, this.shellEnv, undefined, () => { }).HOME;
}
shellPath;
shellEnv;
inheritEnv;
scheduler;
bgShells = new Map();
bgCounter = 0;
retainBackgroundProcesses;
onSecretEnvScrub;
onNotice;
bgSpoolDir;
backgroundCapabilities;
constructor(options) {
this.cwd = options.cwd;
this.shellPath = options.shellPath;
this.shellEnv = options.shellEnv;
const inheritEnvProblem = inheritEnvPolicyProblem(options.inheritEnv);
if (inheritEnvProblem !== undefined)
throw new Error(`NodeExecutionEnv: ${inheritEnvProblem}`);
this.inheritEnv = Array.isArray(options.inheritEnv) ? [...options.inheritEnv] : (options.inheritEnv ?? "scrub");
this.scheduler = options.scheduler;
this.retainBackgroundProcesses = options.retainBackgroundProcesses ?? false;
this.onSecretEnvScrub = options.onSecretEnvScrub;
this.onNotice = options.onNotice;
this.backgroundCapabilities = {
supported: true,
maxConcurrent: 8,
defaultBgTimeoutSec: 60 * 60,
maxBgTimeoutSec: 2 * 60 * 60,
supportsDetach: true,
retainBackgroundProcesses: this.retainBackgroundProcesses,
};
}
get schedulerCapabilities() {
return (this.scheduler?.schedulerCapabilities ?? {
supported: false,
maxScheduledPerScope: 0,
minDelaySec: 0,
minCronIntervalSec: 0,
maxScheduledHorizonSec: 0,
});
}
async schedule(intent, ctx) {
if (!this.scheduler)
return err(new SchedulerError("unsupported", "No scheduler backend wired on this environment."));
return this.scheduler.schedule(intent, ctx);
}
async cancel(id, ctx) {
if (!this.scheduler)
return err(new SchedulerError("unsupported", "No scheduler backend wired on this environment."));
return this.scheduler.cancel(id, ctx);
}
async list(ctx) {
if (!this.scheduler)
return err(new SchedulerError("unsupported", "No scheduler backend wired on this environment."));
return this.scheduler.list(ctx);
}
async absolutePath(path) {
return ok(resolvePath(this.cwd, path));
}
async joinPath(parts) {
return ok(join(...parts));
}
async exec(command, options) {
if (options?.abortSignal?.aborted) {
return err(new ExecutionError("aborted", "aborted"));
}
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) {
return shellConfig;
}
return await new Promise((resolvePromise) => {
const stdoutTail = new RollingTailBuffer();
const stderrTail = new RollingTailBuffer();
let settled = false;
let timedOut = false;
let terminationInFlight = false;
let callbackError;
let child;
const timeoutRef = {};
const forceSettleRef = {};
const spoolPumpRef = {};
const execSpoolRef = {};
const adoptOnTimeoutRef = {};
const pumpSpoolRef = {};
const drainSpoolRef = {};
const writerLive = () => !settled && child !== undefined && child.exitCode === null && child.signalCode === null;
const armForceSettle = (result) => {
if (forceSettleRef.current || settled)
return;
forceSettleRef.current = setTimeout(() => {
try {
child?.stdout?.destroy();
child?.stderr?.destroy();
}
catch {
}
settle(result);
}, EXEC_FORCE_SETTLE_GRACE_MS);
forceSettleRef.current.unref?.();
};
const partialOutput = () => {
drainSpoolRef.current?.();
const so = stdoutTail.result();
const se = stderrTail.result();
return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes) };
};
const onAbort = () => {
terminationInFlight = true;
if (child?.pid) {
const abortTarget = child;
killProcessTree(child.pid, { stillRunning: () => abortTarget.exitCode === null && abortTarget.signalCode === null });
}
if (callbackError) {
const p = partialOutput();
callbackError.partialStdout = p.stdout;
callbackError.partialStderr = p.stderr;
armForceSettle(err(callbackError));
return;
}
armForceSettle(err(new ExecutionError("aborted", "aborted", undefined, partialOutput())));
};
const settle = (result) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (forceSettleRef.current) {
clearTimeout(forceSettleRef.current);
}
if (options?.abortSignal) {
options.abortSignal.removeEventListener("abort", onAbort);
}
if (settled) {
return;
}
settled = true;
if (spoolPumpRef.current)
clearInterval(spoolPumpRef.current);
if (execSpoolRef.current && !(result.ok && result.value.detached !== undefined)) {
for (const path of [execSpoolRef.current.outPath, execSpoolRef.current.errPath]) {
try {
unlinkSync(path);
}
catch {
}
}
execSpoolRef.current = undefined;
}
resolvePromise(result);
};
let execSpoolFds;
if (process.platform !== "win32") {
try {
if (!this.bgSpoolDir)
this.bgSpoolDir = mkdtempSync(join(tmpdir(), "sema-bg-"));
const pair = openSpoolPair(join(this.bgSpoolDir, `fg_${randomUUID()}`));
execSpoolRef.current = { outPath: pair.outPath, errPath: pair.errPath };
execSpoolFds = pair.fds;
}
catch {
execSpoolRef.current = undefined;
execSpoolFds = undefined;
}
}
const closeSpoolFds = () => {
if (!execSpoolFds)
return;
for (const fd of execSpoolFds) {
try {
closeSync(fd);
}
catch {
}
}
execSpoolFds = undefined;
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env, this.onSecretEnvScrub, this.onNotice),
stdio: execSpoolFds ? ["ignore", execSpoolFds[0], execSpoolFds[1]] : ["ignore", "pipe", "pipe"],
windowsHide: true,
});
}
catch (error) {
closeSpoolFds();
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
return;
}
closeSpoolFds();
const timeoutMs = resolveExecTimeoutMs(options?.timeout);
timeoutRef.current =
timeoutMs === undefined
? undefined
: setTimeout(() => {
if (options?.autoBackgroundOnTimeout && !terminationInFlight && adoptOnTimeoutRef.current) {
adoptOnTimeoutRef.current();
return;
}
terminationInFlight = true;
timedOut = true;
if (child?.pid) {
const timeoutTarget = child;
killProcessTree(child.pid, { stillRunning: () => timeoutTarget.exitCode === null && timeoutTarget.signalCode === null });
}
armForceSettle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`, undefined, partialOutput())));
}, timeoutMs);
if (options?.abortSignal) {
if (options.abortSignal.aborted) {
onAbort();
}
else {
options.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
const spoolCursor = { out: 0, err: 0 };
const readSpoolIncrement = (path, cursor) => {
try {
const size = statSync(path).size;
if (size <= spoolCursor[cursor])
return undefined;
const fd = openSync(path, "r");
try {
const want = Math.min(size - spoolCursor[cursor], 8 * 1024 * 1024 + 4096);
const buf = Buffer.alloc(want);
const got = readSync(fd, buf, 0, want, spoolCursor[cursor]);
if (got <= 0)
return undefined;
spoolCursor[cursor] += got;
return buf.subarray(0, got);
}
finally {
closeSync(fd);
}
}
catch {
return undefined;
}
};
const feed = (tail, cb, buf) => {
tail.push(buf);
if (cb === undefined || settled)
return;
try {
cb(buf.toString("utf8"));
}
catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
};
const spoolLanes = () => {
const sp = execSpoolRef.current;
if (sp === undefined)
return [];
return [
{ path: sp.outPath, cursor: "out", tail: stdoutTail, cb: options?.onStdout },
{ path: sp.errPath, cursor: "err", tail: stderrTail, cb: options?.onStderr },
];
};
const skipAhead = (lane, keep) => {
try {
const behind = statSync(lane.path).size - spoolCursor[lane.cursor];
if (behind <= keep)
return;
lane.tail.recordSkippedBytes(behind - keep);
spoolCursor[lane.cursor] += behind - keep;
}
catch {
}
};
const reclaimSpool = (lane) => {
if (spoolCursor[lane.cursor] < EXEC_SPOOL_ROTATE_BYTES)
return;
try {
const d = decideSpoolReclaim({ size: statSync(lane.path).size, cursor: spoolCursor[lane.cursor], writerLive: writerLive() });
if (!d.reclaim)
return;
if (d.skippedBytes > 0)
lane.tail.recordSkippedBytes(d.skippedBytes);
truncateSync(lane.path, 0);
spoolCursor[lane.cursor] = 0;
}
catch {
}
};
const pumpSpool = () => {
for (const lane of spoolLanes()) {
skipAhead(lane, EXEC_SPOOL_ROTATE_BYTES);
const inc = readSpoolIncrement(lane.path, lane.cursor);
if (inc)
feed(lane.tail, lane.cb, inc);
reclaimSpool(lane);
}
};
pumpSpoolRef.current = pumpSpool;
const drainSpoolFinal = () => {
for (const lane of spoolLanes()) {
skipAhead(lane, EXEC_SPOOL_FINAL_TAIL_BYTES);
for (let i = 0; i < 4; i++) {
const inc = readSpoolIncrement(lane.path, lane.cursor);
if (!inc)
break;
feed(lane.tail, lane.cb, inc);
}
}
};
drainSpoolRef.current = drainSpoolFinal;
if (execSpoolRef.current) {
spoolPumpRef.current = setInterval(pumpSpool, EXEC_SPOOL_PUMP_MS);
spoolPumpRef.current.unref?.();
}
const onDetach = (cause) => {
if (settled || terminationInFlight || !child)
return false;
pumpSpoolRef.current?.();
if (settled || terminationInFlight)
return false;
const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err } } : undefined);
if (shellId === undefined)
return false;
if (spoolPumpRef.current)
clearInterval(spoolPumpRef.current);
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
const dso = stdoutTail.result();
const dse = stderrTail.result();
settle(ok({ stdout: markTruncated(dso.text, dso.droppedBytes), stderr: markTruncated(dse.text, dse.droppedBytes), exitCode: 0, detached: { shellId, ...(cause !== undefined ? { cause } : {}) } }));
return true;
};
adoptOnTimeoutRef.current = () => {
if (onDetach("timeout"))
return;
if (terminationInFlight)
return;
terminationInFlight = true;
timedOut = true;
if (child?.pid) {
const adoptFallbackTarget = child;
killProcessTree(child.pid, { stillRunning: () => adoptFallbackTarget.exitCode === null && adoptFallbackTarget.signalCode === null });
}
armForceSettle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`, undefined, partialOutput())));
};
if (options?.detachSignal) {
if (options.detachSignal.aborted)
onDetach();
else {
const dl = () => onDetach();
options.detachSignal.addEventListener("abort", dl, { once: true });
}
}
if (!execSpoolRef.current) {
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdoutTail.push(Buffer.from(chunk, "utf8"));
try {
options?.onStdout?.(chunk);
}
catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.stderr?.on("data", (chunk) => {
stderrTail.push(Buffer.from(chunk, "utf8"));
try {
options?.onStderr?.(chunk);
}
catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
}
child.stdout?.on("error", () => { });
child.stderr?.on("error", () => { });
child.on("error", (error) => {
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted", undefined, partialOutput())));
return;
}
settle(err(new ExecutionError("spawn_error", error.message, error)));
});
const settleFromTermination = (code, signal) => {
drainSpoolFinal();
if (callbackError) {
const p = partialOutput();
callbackError.partialStdout = p.stdout;
callbackError.partialStderr = p.stderr;
settle(err(callbackError));
return;
}
if (timedOut) {
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`, undefined, partialOutput())));
return;
}
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted", undefined, partialOutput())));
return;
}
const so = stdoutTail.result();
const se = stderrTail.result();
if (code === null && signal) {
const signum = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
settle(ok({
stdout: markTruncated(so.text, so.droppedBytes),
stderr: `${markTruncated(se.text, se.droppedBytes)}${se.text.endsWith("\n") || se.text === "" ? "" : "\n"}[process terminated by ${signal}]`,
exitCode: 128 + (signum[signal] ?? 9),
}));
return;
}
settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes), exitCode: code ?? 0 }));
};
child.on("close", (code, signal) => settleFromTermination(code, signal));
child.on("exit", (code, signal) => {
if (execSpoolRef.current === undefined)
return;
const drain = setTimeout(() => {
settleFromTermination(code, signal);
}, EXIT_SETTLE_DRAIN_MS);
drain.unref?.();
child.once("close", () => clearTimeout(drain));
});
});
}
async readTextFile(path, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async readTextLines(path, options) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(options?.abortSignal, resolved);
if (aborted) {
return aborted;
}
if (options?.maxLines !== undefined && options.maxLines <= 0) {
return ok([]);
}
let stream;
let lineReader;
try {
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
const lines = [];
for await (const line of lineReader) {
const loopAbort = abortResult(options?.abortSignal, resolved);
if (loopAbort) {
return loopAbort;
}
lines.push(line);
if (options?.maxLines !== undefined && lines.length >= options.maxLines) {
break;
}
}
const afterReadAbort = abortResult(options?.abortSignal, resolved);
if (afterReadAbort) {
return afterReadAbort;
}
return ok(lines);
}
catch (error) {
return err(toFileError(error, resolved));
}
finally {
lineReader?.close();
stream?.destroy();
}
}
async readBinaryFile(path, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
return ok(await readFile(resolved, { signal: abortSignal }));
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async writeFile(path, content, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
}
catch (error) {
return err(toFileError(error, resolved));
}
const afterMkdirAbort = abortResult(abortSignal, resolved);
if (afterMkdirAbort) {
return afterMkdirAbort;
}
let target = resolved;
let existingMode;
let st;
try {
st = await lstat(resolved);
}
catch {
st = undefined;
}
if (st !== undefined) {
if (st.isSymbolicLink()) {
let hop;
try {
const link = await readlink(resolved);
const parentReal = await physicalPathOfAsync(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
hop = isAbsolute(link) ? link : resolve(parentReal, link);
}
catch {
hop = undefined;
}
if (hop === undefined)
return await this.writeFileInPlace(resolved, content, abortSignal);
let hopStat;
try {
hopStat = await lstat(hop);
}
catch {
hopStat = undefined;
}
if (hopStat !== undefined && !hopStat.isFile()) {
return await this.writeFileInPlace(resolved, content, abortSignal);
}
target = hop;
existingMode = hopStat !== undefined ? hopStat.mode & 0o7777 : undefined;
}
else if (!st.isFile()) {
return await this.writeFileInPlace(resolved, content, abortSignal);
}
else {
existingMode = st.mode & 0o7777;
}
}
const tmp = `${target}.tmp.${process.pid}.${randomUUID().replace(/-/g, "").slice(0, 12)}`;
let tmpDurable = false;
try {
const handle = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0));
try {
await handle.writeFile(content, { signal: abortSignal });
if (existingMode !== undefined) {
try {
await handle.chmod(existingMode);
}
catch (error) {
if (!isFsSupportError(error))
throw error;
}
}
try {
await handle.sync();
}
catch (error) {
if (!isFsSupportError(error))
throw error;
}
tmpDurable = true;
}
finally {
await handle.close().catch(() => { });
}
}
catch (error) {
await unlink(tmp).catch(() => { });
if (isNodeError(error) && error.code === "EACCES" && existingMode !== undefined) {
return await this.writeFileInPlace(resolved, content, abortSignal);
}
return err(toFileError(error, resolved));
}
const preRenameAbort = abortResult(abortSignal, resolved);
if (preRenameAbort) {
await unlink(tmp).catch(() => { });
return preRenameAbort;
}
try {
await rename(tmp, target);
return ok(undefined);
}
catch (error) {
const code = isNodeError(error) ? error.code : undefined;
if (tmpDurable && code !== undefined && RENAME_FALLBACK_CODES.has(code)) {
const inPlace = await this.writeFileInPlace(resolved, content, abortSignal);
if (inPlace.ok) {
await unlink(tmp).catch(() => { });
return inPlace;
}
return err(new FileError("unknown", `write to ${resolved} failed after the atomic rename was refused (${code}): ${inPlace.error.message}. The new content was preserved at ${tmp}`, resolved, inPlace.error));
}
await unlink(tmp).catch(() => { });
return err(toFileError(error, resolved));
}
}
async writeFileInPlace(resolved, content, abortSignal) {
try {
await writeFile(resolved, content, { signal: abortSignal });
return ok(undefined);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async writeFileExclusive(path, content, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
}
catch (error) {
return err(toFileError(error, resolved));
}
const afterMkdirAbort = abortResult(abortSignal, resolved);
if (afterMkdirAbort) {
return afterMkdirAbort;
}
try {
await writeFile(resolved, content, { signal: abortSignal, flag: "wx" });
return ok(undefined);
}
catch (error) {
if (error?.code === "EEXIST") {
return err(new FileError("already_exists", `file already exists: ${resolved}`, resolved, error instanceof Error ? error : undefined));
}
return err(toFileError(error, resolved));
}
}
async appendFile(path, content) {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
await appendFile(resolved, content);
return ok(undefined);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async fileInfo(path) {
const resolved = resolvePath(this.cwd, path);
try {
return fileInfoFromStats(resolved, await lstat(resolved));
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async setFileMode(path, mode, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
if (!HAS_POSIX_MODE_FACE) {
return err(new FileError("not_supported", "This platform has no POSIX permission model", resolved));
}
try {
const handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
await handle.chmod(mode & 0o7777);
}
finally {
await handle.close().catch(() => { });
}
return ok(undefined);
}
catch (error) {
if (isNodeError(error) && error.code === "ELOOP") {
return err(new FileError("invalid", "Refusing to change the mode of a symlink (the target is not the addressed path)", resolved));
}
return err(toFileError(error, resolved));
}
}
async listDir(path, abortSignal) {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
const entries = await readdir(resolved, { withFileTypes: true });
const infos = [];
for (const entry of entries) {
const loopAbort = abortResult(abortSignal, resolved);
if (loopAbort) {
return loopAbort;
}
const entryPath = resolve(resolved, entry.name);
try {
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
if (info.ok) {
infos.push(info.value);
}
}
catch (error) {
return err(toFileError(error, entryPath));
}
}
return ok(infos);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async canonicalPath(path) {
const resolved = resolvePath(this.cwd, path);
try {
return ok(await physicalPathOfAsync(resolved));
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async fileIdentity(path) {
const resolved = resolvePath(this.cwd, path);
try {
const s = await stat(resolved, { bigint: true });
return ok(`${s.dev}:${s.ino}`);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async readLink(path) {
const resolved = resolvePath(this.cwd, path);
try {
return ok(await readlink(resolved));
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async canonicalChain(path) {
const resolved = resolvePath(this.cwd, path);
const hops = [];
const seen = new Set([resolved]);
let cur = resolved;
for (let hop = 0; hop < SYMLINK_CHAIN_HOP_BOUND; hop++) {
let target;
try {
target = await readlink(cur);
}
catch (error) {
if (isNodeError(error) && (error.code === "EINVAL" || error.code === "ENOENT" || error.code === "ENOTDIR"))
return ok({ hops });
return err(toFileError(error, cur));
}
const next = isAbsolute(target) ? target : anchorAtLinkDir(dirname(cur), target, sep);
if (seen.has(next))
break;
hops.push(next);
if (process.platform === "win32" && isUncSpelling(next))
break;
seen.add(next);
cur = next;
}
return ok({ hops });
}
async exists(path) {
const result = await this.fileInfo(path);
if (result.ok) {
return ok(true);
}
if (result.error.code === "not_found") {
return ok(false);
}
return err(result.error);
}
async createDir(path, options) {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolved, { recursive: options?.recursive ?? true });
return ok(undefined);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async remove(path, options) {
const resolved = resolvePath(this.cwd, path);
try {
await rm(resolved, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
});
return ok(undefined);
}
catch (error) {
return err(toFileError(error, resolved));
}
}
async createTempDir(prefix = "tmp-") {
try {
return ok(await mkdtemp(join(tmpdir(), prefix)));
}
catch (error) {
return err(toFileError(error));
}
}
async createTempFile(options) {
const dir = await this.createTempDir("tmp-");
if (!dir.ok) {
return dir;
}
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
try {
await writeFile(filePath, "");
return ok(filePath);
}
catch (error) {
return err(toFileError(error, filePath));
}
}
adoptRunningChild(child, stdoutSoFar, stderrSoFar, handover) {
const liveCount = [...this.bgShells.values()].filter((e) => e.status === "running").length;
if (liveCount >= this.backgroundCapabilities.maxConcurrent)
return undefined;
const shellId = `bg_${++this.bgCounter}_${randomUUID()}`;
const entry = {
child,
stdout: { ...newStreamCursorState(), totalBytes: 0 },
stderr: { ...newStreamCursorState(), totalBytes: 0 },
status: "running",
...(handover !== undefined
? { spool: { outPath: handover.spool.outPath, errPath: handover.spool.errPath, fileCursor: { ...handover.cursors }, ephemeral: true } }
: {}),
};
const seedOut = Buffer.from(stdoutSoFar.text, "utf8");
const seedErr = Buffer.from(stderrSoFar.text, "utf8");
entry.stdout.tail.recordSkippedBytes(stdoutSoFar.droppedBytes);
entry.stderr.tail.recordSkippedBytes(stderrSoFar.droppedBytes);
entry.stdout.tail.push(seedOut);
entry.stderr.tail.push(seedErr);
entry.stdout.totalBytes += stdoutSoFar.droppedBytes + seedOut.length;
entry.stderr.totalBytes += stderrSoFar.droppedBytes + seedErr.length;
entry.stdout.acceptedBytes += seedOut.length;
entry.stderr.acceptedBytes += seedErr.length;
child.stdout?.on("data", (chunk) => {
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
entry.stdout.tail.push(buf);
entry.stdout.totalBytes += buf.length;
entry.stdout.acceptedBytes += buf.length;
});
child.stderr?.on("data", (chunk) => {
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
entry.stderr.tail.push(buf);
entry.stderr.totalBytes += buf.length;
entry.stderr.acceptedBytes += buf.length;
});
child.on("error", () => {
if (entry.timer)
clearTimeout(entry.timer);
if (entry.status === "running")
entry.status = "failed";
});
child.on("close", (code, signal) => {
if (entry.timer)
clearTimeout(entry.timer);
this.reclaimEphemeralSpool(entry);
if (entry.status === "killed")
return;
entry.status = "exited";
entry.exitCode = code === null && signal ? 128 + (SIGNUM[signal] ?? 9) : (code ?? 0);
});
if (child.exitCode !== null || child.signalCode !== null) {
entry.status = "exited";
entry.exitCode = child.exitCode ?? (child.signalCode ? 128 + (SIGNUM[child.signalCode] ?? 9) : 0);
this.reclaimEphemeralSpool(entry);
}
else {
const timeoutSec = this.backgroundCapabilities.defaultBgTimeoutSec;
entry.timeoutSecApplied = timeoutSec;
entry.timer = setTimeout(() => {
entry.status = "killed";
entry.killCause = "timeout";
if (child.pid)
killProcessTree(child.pid, { force: true });
}, timeoutSec * 1000);
entry.timer.unref?.();
}
if (entry.spool?.ephemeral) {
entry.spoolPump = setInterval(() => this.syncSpool(entry), 1000);
entry.spoolPump.unref?.();
}
this.bgShells.set(shellId, entry);
return shellId;
}
async spawnBackground(command, options) {
const liveCount = [...this.bgShells.values()].filter((e) => e.status === "running").length;
if (liveCount >= this.backgroundCapabilities.maxConcurrent) {
return err(new BackgroundShellError("limit_exceeded", `Too many running background shells (max ${this.backgroundCapabilities.maxConcurrent}); TaskStop one first.`));
}
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) {
return err(new BackgroundShellError("spawn_failed", shellConfig.error.message, shellConfig.error));
}
let spool;
let spoolFds;
if (this.retainBackgroundProcesses || process.platform !== "win32") {
try {
if (!this.bgSpoolDir)
this.bgSpoolDir = mkdtempSync(join(tmpdir(), "sema-bg-"));
const pair = openSpoolPair(join(this.bgSpoolDir, `bg_${this.bgCounter + 1}_${randomUUID()}`));
spool = {
outPath: pair.outPath,
errPath: pair.errPath,
fileCursor: { out: 0, err: 0 },
...(this.retainBackgroundProcesses ? {} : { ephemeral: true }),
};
spoolFds = pair.fds;
}
catch {
spool = undefined;
spoolFds = undefined;
}
}
let child;
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd: options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env, this.onSecretEnvScrub, this.onNotice),
stdio: spoolFds ? ["ignore", spoolFds[0], spoolFds[1]] : ["ignore", "pipe", "pipe"],
windowsHide: true,
});
}
catch (error) {
if (spoolFds) {
for (const fd of spoolFds) {
try {
closeSync(fd);
}
catch {
}
}
}
const cause = toError(error);
return err(new BackgroundShellError("spawn_failed", cause.message, cause));
}
if (spoolFds) {
for (const fd of spoolFds) {
try {
closeSync(fd);
}
catch {
}
}
}
const shellId = `bg_${++this.bgCounter}_${randomUUID()}`;
const entry = {
child,
stdout: { ...newStreamCursorState(), totalBytes: 0 },
stderr: { ...newStreamCursorState(), totalBytes: 0 },
status: "running",
...(spool ? { spool } : {}),
};
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
const buf = Buffer.from(chunk, "utf8");
entry.stdout.tail.push(buf);
entry.stdout.totalBytes += buf.length;
entry.stdout.acceptedBytes += buf.length;
});
child.stderr?.on("data", (chunk) => {
const buf = Buffer.from(chunk, "utf8");
entry.stderr.tail.push(buf);
entry.stderr.totalBytes += buf.length;
entry.stderr.acceptedBytes += buf.length;
});
child.stdout?.on("error", () => { });
child.stderr?.on("error", () => { });
child.on("error", () => {
if (entry.timer)
clearTimeout(entry.timer);
if (entry.status === "running")
entry.status = "failed";
});
child.on("close", (code, signal) => {
if (entry.timer)
clearTimeout(entry.timer);
this.reclaimEphemeralSpool(entry);
if (entry.status === "killed")
return;
entry.status = "exited";
entry.exitCode = code === null && signal ? 128 + (SIGNUM[signal] ?? 9) : (code ?? 0);
});
const timeoutSec = Math.min(options?.timeout ?? this.backgroundCapabilities.defaultBgTimeoutSec, this.backgroundCapabilities.maxBgTimeoutSec);
entry.timeoutSecApplied = timeoutSec;
entry.timer = setTimeout(() => {
if (child.pid && entry.status === "running") {
entry.status = "killed";
entry.killCause = "timeout";
shutdownDebug("bg hard-wall timeout", { shellId, pid: child.pid, timeoutSec });
killProcessTree(child.pid, { force: true });
}
}, timeoutSec * 1000);
entry.timer.unref?.();
if (entry.spool?.ephemeral) {
entry.spoolPump = setInterval(() => this.syncSpool(entry), 1000);
entry.spoolPump.unref?.();
}
this.bgShells.set(shellId, entry);
return ok({ shellId });
}
async pollBackground(shellId) {
const entry = this.bgShells.get(shellId);
if (!entry) {
return err(new BackgroundShellError("not_found", `Unknown background shell: ${shellId}`));
}
this.syncSpool(entry);
const terminal = entry.status !== "running";
const out = sliceStreamIncrement(entry.stdout, terminal);
const er = sliceStreamIncrement(entry.stderr, terminal);
const droppedBeforeCursor = out.droppedBeforeCursor + er.droppedBeforeCursor;
return ok({
stdout: out.inc,
stderr: er.inc,
status: entry.status,
...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}),
...(droppedBeforeCursor > 0 ? { truncated: true, bytesDroppedBeforeCursor: droppedBeforeCursor } : {}),
...(entry.killCause === "timeout"
? { timedOut: true, ...(entry.timeoutSecApplied !== undefined ? { timeoutSec: entry.timeoutSecApplied } : {}) }
: {}),
bytesFromStart: entry.stdout.totalBytes + entry.stderr.totalBytes,
});
}
async killBackground(shellId) {
const entry = this.bgShells.get(shellId);
if (!entry) {
return err(new BackgroundShellError("not_found", `Unknown background shell: ${shellId}`));
}
if (entry.status === "running" && entry.child.pid) {
entry.status = "killed";
if (entry.timer)
clearTimeout(entry.timer);
shutdownDebug("killBackground", { shellId, pid: entry.child.pid });
killProcessTree(entry.child.pid, { force: true });
}
return ok(undefined);
}
killBackgroundNow(shellId) {
const entry = this.bgShells.get(shellId);
if (!entry || entry.hardKilled)
return "already_gone";
const child = entry.child;
if (child.exitCode !== null || child.signalCode !== null)
return "already_gone";
const pid = child.pid;
if (pid === undefined || pid <= 0)
return "no_pgid";
if (process.platform === "win32")
return "no_pgid";
shutdownDebug("killBackgroundNow", { shellId, pid });
try {
process.kill(-pid, "SIGKILL");
}
catch (e) {
return e.code === "ESRCH" ? "already_gone" : "no_pgid";
}
entry.hardKilled = true;
if (entry.timer)
clearTimeout(entry.timer);
if (entry.status === "running")
entry.status = "killed";
return "killed";
}
syncSpool(entry) {
const spool = entry.spool;
if (!spool)
return;
const streams = [
[entry.stdout, spool.outPath, "out"],
[entry.stderr, spool.errPath, "err"],
];
for (const [stream, path, lane] of streams) {
try {
const size = statSync(path).size;
if (spool.ephemeral) {
const behind = size - spool.fileCursor[lane];
if (behind > EXEC_SPOOL_ROTATE_BYTES) {
const skipped = behind - EXEC_SPOOL_ROTATE_BYTES;
stream.tail.recordSkippedBytes(skipped);
stream.totalBytes += skipped;
spool.fileCursor[lane] += skipped;
}
}
if (size > spool.fileCursor[lane]) {
const fd = openSync(path, "r");
try {
const want = Math.min(size - spool.fileCursor[lane], 8 * 1024 * 1024 + 4096);
const buf = Buffer.alloc(want);
const got = readSync(fd, buf, 0, want, spool.fileCursor[lane]);
if (got > 0) {
stream.tail.push(buf.subarray(0, got));
stream.totalBytes += got;
stream.acceptedBytes += got;
spool.fileCursor[lane] += got;
}
}
finally {
closeSync(fd);
}
}
if (spool.ephemeral && spool.fileCursor[lane] >= EXEC_SPOOL_ROTATE_BYTES) {
const d = decideSpoolReclaim({
size: statSync(path).size,
cursor: spool.fileCursor[lane],
writerLive: entry.status === "running" || (entry.child.exitCode === null && entry.child.signalCode === null),
});
if (d.reclaim) {
if (d.skippedBytes > 0) {
stream.tail.recordSkippedBytes(d.skippedBytes);
stream.totalBytes += d.skippedBytes;
}
truncateSync(path, 0);
spool.fileCursor[lane] = 0;
}
}
}
catch {
}
}
}
reclaimEphemeralSpool(entry) {
if (entry.spoolPump !== undefined) {
clearInterval(entry.spoolPump);
entry.spoolPump = undefined;
}
const spool = entry.spool;
if (!spool?.ephemeral)
return;
this.syncSpool(entry);
entry.spool = undefined;
for (const path of [spool.outPath, spool.errPath]) {
try {
unlinkSync(path);
}
catch {
}
}
}
async disposeBackgroundShells(opts) {
if (this.retainBackgroundProcesses)
return;
const keep = opts?.except !== undefined && opts.except.length > 0 ? new Set(opts.except) : undefined;
shutdownDebug("disposeBackgroundShells", { entries: this.bgShells.size, except: opts?.except?.length ?? 0 });
for (const [id, entry] of this.bgShells) {
if (keep?.has(id))
continue;
try {
if (entry.timer)
clearTimeout(entry.timer);
shutdownDebug("dispose entry", { id, status: entry.status, pid: entry.child.pid, action: entry.status === "running" ? "group-kill" : "spare" });
if (entry.status === "running" && entry.child.pid) {
entry.status = "killed";
killProcessTree(entry.child.pid, { force: true });
}
this.reclaimEphemeralSpool(entry);
}
catch {
}
if (keep !== undefined)
this.bgShells.delete(id);
}
if (keep === undefined)
this.bgShells.clear();
}
async cleanup() {
await this.disposeBackgroundShells();
}
}