src / execution / processRunner.ts
src / execution / processRunner.ts
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, lstat } from "node:fs/promises";
import { basename, join } from "node:path";
import {
spawn,
spawnSync,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import type { WorkspaceBoundary } from "../workspace/boundary";
import { resolveExecutable, type ResolvedExecutable } from "./resolveExecutable";
export type CommandStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "timed_out"
| "canceled"
| "orphaned";
export interface CommandSpec {
executable: string;
args?: string[];
cwd?: string;
input?: string;
timeoutSeconds?: number;
idempotencyKey?: string;
runId?: string;
}
export interface CommandResult {
id: string;
specHash: string;
status: CommandStatus;
executable: string;
args: string[];
cwd: string;
resolvedExecutable?: string;
resolvedScript?: string;
startedAt: string;
finishedAt?: string;
durationMs?: number;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
timedOut: boolean;
stdoutPreview: string;
stderrPreview: string;
stdoutBytes: number;
stderrBytes: number;
stdoutCapturedBytes: number;
stderrCapturedBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
artifacts: ArtifactRef[];
error?: string;
}
/** One row of the job recovery index (`ProcessService.list`). */
export interface CommandJobSummary {
id: string;
status: CommandStatus;
executable: string;
exitCode?: number | null;
startedAt: string;
}
interface RunningJob {
workspaceRoot: string;
process: ChildProcessWithoutNullStreams;
result: CommandResult;
promise: Promise<CommandResult>;
cancel: () => void;
}
const jobs = new Map<string, RunningJob>();
const jobStartLocks = new Map<string, Promise<void>>();
function activeJobKey(workspaceRoot: string, id: string): string {
return `${workspaceRoot}\0${id}`;
}
async function withJobStartLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const previous = jobStartLocks.get(id) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
jobStartLocks.set(id, queued);
await previous;
try {
return await action();
} finally {
release();
if (jobStartLocks.get(id) === queued) jobStartLocks.delete(id);
}
}
/**
* Absolute path to taskkill. resolveExecutable.ts refuses bare-name resolution
* for exactly this reason, and a stripped PATH without System32 would otherwise
* degrade the tree kill silently.
*/
const TASKKILL_PATH = join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"taskkill.exe",
);
/**
* Process-tree kills actually issued. Exported as an observability seam so a
* test can assert that a kill was, or was not, issued without reaching into
* the child process.
*/
export const killMetrics = { treeKillsIssued: 0 };
/**
* Stops a job's process together with everything it spawned. Windows has no
* process groups and child.kill() reaches only the direct child; a Node child
* takes its descendants with it (libuv keeps them in a kill-on-close job
* object), but a cmd/git/python/cargo child does not, and a surviving
* grandchild also holds the inherited stdio pipes open, so "close" would
* never fire and cancel()/the timeout would never settle. taskkill /T /F
* walks the parent-pid tree instead. POSIX keeps the existing behavior:
* SIGTERM to the direct child, SIGKILL after 2 s.
*
* Exported for tests; production callers reach it through a job's kill().
*/
export function killProcessTree(child: ChildProcess): void {
// Node closes the OS process handle at "exit", long before "close" — and
// "close" stays pending for as long as a surviving grandchild holds the
// inherited stdio. In that window the pid is dead, the kernel may already
// have recycled it, and taskkill /T /F would force-terminate whatever tree
// now owns it. child.kill() is a harmless no-op there (libuv checks its own
// saved handle), but taskkill is not, so nothing is issued at all: a process
// that has already exited cannot be killed usefully anyway.
if (child.exitCode !== null || child.signalCode !== null) return;
killMetrics.treeKillsIssued += 1;
if (process.platform === "win32" && child.pid !== undefined) {
const result = spawnSync(TASKKILL_PATH, ["/PID", String(child.pid), "/T", "/F"], {
shell: false,
windowsHide: true,
stdio: "ignore",
});
// taskkill missing (ENOENT) or refusing (exit 128 process already gone,
// exit 1 access denied): fall back to the direct kill so the job still
// settles whenever the child itself is still alive.
if (result.error || result.status !== 0) child.kill("SIGKILL");
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => child.kill("SIGKILL"), 2000);
hardKill.unref();
}
class PreviewCollector {
private readonly headLimit: number;
private readonly tailLimit: number;
private head = "";
private tail = "";
public totalChars = 0;
public constructor(private readonly maxChars: number) {
this.headLimit = Math.floor(Math.max(0, maxChars) * 0.6);
this.tailLimit = Math.max(0, maxChars) - this.headLimit;
}
public add(chunk: string): void {
if (!chunk) return;
this.totalChars += chunk.length;
if (this.head.length < this.headLimit) {
const remaining = this.headLimit - this.head.length;
this.head += chunk.slice(0, remaining);
chunk = chunk.slice(remaining);
}
if (chunk.length > 0 && this.tailLimit > 0) {
this.tail = (this.tail + chunk).slice(-this.tailLimit);
}
}
public render(): string {
if (this.maxChars <= 0) return "";
if (this.totalChars <= this.maxChars) return (this.head + this.tail).slice(0, this.maxChars);
let marker = `\n[output omitted; full captured stream stored as an artifact]\n`;
if (marker.length >= this.maxChars) return marker.slice(0, this.maxChars);
let usable = this.maxChars - marker.length;
let headChars = Math.floor(usable * 0.6);
let tailChars = usable - headChars;
let omitted = Math.max(0, this.totalChars - headChars - tailChars);
marker = `\n[${omitted.toLocaleString()} characters omitted; captured stream stored as an artifact]\n`;
usable = Math.max(0, this.maxChars - marker.length);
headChars = Math.floor(usable * 0.6);
tailChars = usable - headChars;
omitted = Math.max(0, this.totalChars - headChars - tailChars);
marker = `\n[${omitted.toLocaleString()} characters omitted; captured stream stored as an artifact]\n`;
return `${this.head.slice(0, headChars)}${marker}${
tailChars > 0 ? this.tail.slice(-tailChars) : ""
}`.slice(0, this.maxChars);
}
}
class StreamArtifact {
private readonly hash = createHash("sha256");
private writtenBytes = 0;
private totalBytes = 0;
private capped = false;
private constructor(
private readonly relativePath: string,
private readonly stream: ReturnType<Awaited<ReturnType<typeof open>>["createWriteStream"]>,
private readonly maxBytes: number,
) {}
public static async create(
relativePath: string,
absolutePath: string,
maxBytes: number,
): Promise<StreamArtifact> {
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
const handle = await open(
absolutePath,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | noFollow,
0o600,
);
return new StreamArtifact(relativePath, handle.createWriteStream(), maxBytes);
}
public get totalByteCount(): number {
return this.totalBytes;
}
public get capturedByteCount(): number {
return this.writtenBytes;
}
public get truncated(): boolean {
return this.capped;
}
public write(chunk: Buffer): void {
this.totalBytes += chunk.length;
if (this.writtenBytes >= this.maxBytes) {
this.capped = true;
return;
}
const remaining = this.maxBytes - this.writtenBytes;
const part = chunk.subarray(0, remaining);
if (part.length > 0) {
this.stream.write(part);
this.hash.update(part);
this.writtenBytes += part.length;
}
if (part.length < chunk.length) this.capped = true;
}
public async finish(kind: "stdout" | "stderr", jobId: string): Promise<ArtifactRef> {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
this.stream.off("finish", onFinish);
reject(error);
};
const onFinish = () => {
this.stream.off("error", onError);
resolve();
};
this.stream.once("error", onError);
this.stream.once("finish", onFinish);
this.stream.end();
});
return {
kind: "command_output",
path: this.relativePath,
sha256: this.hash.digest("hex"),
bytes: this.writtenBytes,
description: this.capped
? `${kind} for command job ${jobId}; captured ${this.writtenBytes.toLocaleString()} of ${this.totalBytes.toLocaleString()} bytes`
: `${kind} for command job ${jobId}`,
};
}
}
/** Default number of characters returned by a tail of a live job. */
export const DEFAULT_TAIL_CHARS = 2000;
/** Smallest tail a caller may ask for. */
export const MIN_TAIL_CHARS = 100;
/** Largest tail a caller may ask for; also the clamp inside the service. */
export const MAX_TAIL_CHARS = 20_000;
/** Worst case UTF-8 bytes per character, used to size the read window. */
const MAX_UTF8_BYTES_PER_CHAR = 4;
function validateId(id: string): void {
if (!/^job_[a-z0-9_]+$/i.test(id)) {
// Three models in the live matrix invented a job id rather than reading one
// back, so the refusal has to say where real ones come from.
throw new AgenticError(
"INVALID_INPUT",
`Invalid command job id: ${id}. Job ids are returned by workspace_command start and look like job_…; re-read the start result (or workspace_inspect changes) rather than inventing one.`,
);
}
}
export interface ProcessServiceOptions {
allowedExecutables: string[];
inheritEnvironment: boolean;
maxTimeoutSeconds: number;
maxPreviewChars: number;
maxArtifactBytes: number;
}
function commandEnvironment(inherit: boolean): NodeJS.ProcessEnv {
if (inherit) return { ...process.env };
const allowed = [
"PATH",
"Path",
"PATHEXT",
"SystemRoot",
"WINDIR",
"HOME",
"USERPROFILE",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"TERM",
];
const environment: NodeJS.ProcessEnv = { CI: "1", NO_COLOR: "1" };
for (const key of allowed) {
if (process.env[key] !== undefined) environment[key] = process.env[key];
}
return environment;
}
export class ProcessService {
private readonly jobsRelative: string;
private readonly allowed: Set<string>;
/**
* Successful resolutions, keyed by name, PATH, and cwd. The service is
* rebuilt on every tools-provider call, so the memo lives for one
* prediction; failures are never cached, so an install earlier in the same
* turn can still make an executable appear.
*/
private readonly resolutionCache = new Map<string, ResolvedExecutable>();
public constructor(
private readonly boundary: WorkspaceBoundary,
private readonly storage: InternalStorage,
private readonly journal: Journal,
private readonly options: ProcessServiceOptions,
) {
this.jobsRelative = storage.relative("jobs");
this.allowed = new Set(
options.allowedExecutables
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
);
}
/**
* The configured ceiling, which is also what an omitted `timeoutSeconds`
* becomes. Exposed so a refusal can name the number the model would get.
*/
public get maxTimeoutSeconds(): number {
return this.options.maxTimeoutSeconds;
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.jobsRelative);
}
public async run(spec: CommandSpec, signal?: AbortSignal): Promise<CommandResult> {
const started = await this.start(spec, signal);
return await started.promise;
}
public async start(
spec: CommandSpec,
signal?: AbortSignal,
): Promise<{ id: string; result: CommandResult; promise: Promise<CommandResult> }> {
if (signal?.aborted) {
const error = new Error("Command canceled before start.");
error.name = "AbortError";
throw error;
}
if (!spec.idempotencyKey) return await this.startUnlocked(spec, signal);
const id = `job_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`;
const key = activeJobKey(this.boundary.realRoot, id);
return await withJobStartLock(key, async () => await this.startUnlocked(spec, signal));
}
private async startUnlocked(
spec: CommandSpec,
signal?: AbortSignal,
): Promise<{ id: string; result: CommandResult; promise: Promise<CommandResult> }> {
await this.initialize();
const normalized = await this.normalizeSpec(spec);
const id = spec.idempotencyKey
? `job_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`
: createId("job");
const specHash = this.specHash(normalized);
const jobKey = activeJobKey(this.boundary.realRoot, id);
const active = jobs.get(jobKey);
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("EDIT_CONFLICT", "Command id belongs to another workspace.");
}
if (active.result.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The command idempotency key is already active with a different command specification.",
);
}
return { id, result: active.result, promise: active.promise };
}
const stateRelative = this.stateRelative(id);
if (await this.storage.exists(stateRelative)) {
const stored = await this.storage.readJson<CommandResult>(stateRelative);
if (stored.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The command idempotency key was already used with a different command specification.",
);
}
if (stored.status !== "running" && stored.status !== "queued") {
return { id, result: stored, promise: Promise.resolve(stored) };
}
const orphaned: CommandResult = {
...stored,
status: "orphaned",
finishedAt: new Date().toISOString(),
error: "Plugin restarted while the command was running; process ownership was lost.",
};
await this.storage.writeJson(stateRelative, orphaned);
return { id, result: orphaned, promise: Promise.resolve(orphaned) };
}
// Resolution happens only once the job is known to be new: replaying a
// completed or orphaned job must not touch PATH or the filesystem again.
const environment = commandEnvironment(this.options.inheritEnvironment);
const resolutionKey = `${normalized.executable}\0${
environment.PATH ?? environment.Path ?? ""
}\0${normalized.cwd}`;
let resolved = this.resolutionCache.get(resolutionKey);
if (!resolved) {
resolved = await resolveExecutable(normalized.executable, {
cwd: normalized.cwd,
workspaceRoot: this.boundary.root,
env: environment,
});
this.resolutionCache.set(resolutionKey, resolved);
}
const jobRelative = `${this.jobsRelative}/${id}`;
await this.storage.ensureDirectory(jobRelative);
const stdoutRelative = `${jobRelative}/stdout.log`;
const stderrRelative = `${jobRelative}/stderr.log`;
const [stdoutAbsolute, stderrAbsolute] = await Promise.all([
this.storage.resolveWrite(stdoutRelative),
this.storage.resolveWrite(stderrRelative),
]);
const stdoutArtifact = await StreamArtifact.create(
stdoutRelative,
stdoutAbsolute,
this.options.maxArtifactBytes,
);
const stderrArtifact = await StreamArtifact.create(
stderrRelative,
stderrAbsolute,
this.options.maxArtifactBytes,
);
const stdoutPreview = new PreviewCollector(this.options.maxPreviewChars);
const stderrPreview = new PreviewCollector(this.options.maxPreviewChars);
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
const startedAt = new Date();
const result: CommandResult = {
id,
specHash,
status: "queued",
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
resolvedExecutable: resolved.file,
resolvedScript: resolved.argsPrefix[0],
startedAt: startedAt.toISOString(),
timedOut: false,
stdoutPreview: "",
stderrPreview: "",
stdoutBytes: 0,
stderrBytes: 0,
stdoutCapturedBytes: 0,
stderrCapturedBytes: 0,
stdoutTruncated: false,
stderrTruncated: false,
artifacts: [],
};
await this.storage.writeJson(stateRelative, result);
await this.journal.record({
category: "command",
action: "start_requested",
summary: `Starting ${normalized.executable} as ${id}.`,
...(spec.runId ? { runId: spec.runId } : {}),
details: {
args: normalized.args,
cwd: result.cwd,
resolvedExecutable: resolved.file,
resolvedScript: resolved.argsPrefix[0],
},
});
const child = spawn(resolved.file, [...resolved.argsPrefix, ...normalized.args], {
cwd: normalized.cwd,
shell: false,
windowsHide: true,
stdio: "pipe",
env: environment,
});
result.status = "running";
let timedOut = false;
let canceled = false;
let settled = false;
let killIssued = false;
const kill = (reason: "timeout" | "cancel"): void => {
if (settled) return;
if (reason === "timeout") timedOut = true;
else canceled = true;
// One tree kill per job. kill() stays reachable until "close" fires, so a
// timeout followed by a user cancel — or two concurrent cancel() calls —
// would otherwise each block the event loop on a fresh taskkill.
if (killIssued) return;
killIssued = true;
killProcessTree(child);
};
// timeoutSeconds 0 arms no timer at all: the job lives until it exits, is
// canceled, or the plugin restarts — after which the next status() finds no
// in-memory owner and reports the persisted record as orphaned.
const timeout =
normalized.timeoutSeconds > 0
? setTimeout(() => kill("timeout"), normalized.timeoutSeconds * 1000)
: undefined;
timeout?.unref();
const abortListener = () => kill("cancel");
signal?.addEventListener("abort", abortListener, { once: true });
child.stdout.on("data", (chunk: Buffer) => {
stdoutArtifact.write(chunk);
stdoutPreview.add(stdoutDecoder.write(chunk));
result.stdoutBytes = stdoutArtifact.totalByteCount;
result.stdoutCapturedBytes = stdoutArtifact.capturedByteCount;
result.stdoutTruncated = stdoutArtifact.truncated;
result.stdoutPreview = stdoutPreview.render();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrArtifact.write(chunk);
stderrPreview.add(stderrDecoder.write(chunk));
result.stderrBytes = stderrArtifact.totalByteCount;
result.stderrCapturedBytes = stderrArtifact.capturedByteCount;
result.stderrTruncated = stderrArtifact.truncated;
result.stderrPreview = stderrPreview.render();
});
child.stdin.on("error", () => undefined);
if (normalized.input !== undefined) child.stdin.end(normalized.input);
else child.stdin.end();
const promise = new Promise<CommandResult>((resolve) => {
let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
});
child.once("close", async (exitCode, exitSignal) => {
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", abortListener);
stdoutPreview.add(stdoutDecoder.end());
stderrPreview.add(stderrDecoder.end());
let artifacts: ArtifactRef[] = [];
let artifactError: Error | undefined;
try {
artifacts = await Promise.all([
stdoutArtifact.finish("stdout", id),
stderrArtifact.finish("stderr", id),
]);
} catch (error) {
artifactError = error instanceof Error ? error : new Error(String(error));
}
const finishedAt = new Date();
const status: CommandStatus = canceled
? "canceled"
: timedOut
? "timed_out"
: spawnError || artifactError || exitCode !== 0
? "failed"
: "completed";
const complete: CommandResult = {
...result,
status,
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode,
signal: exitSignal,
timedOut,
stdoutPreview: stdoutPreview.render(),
stderrPreview: stderrPreview.render(),
stdoutBytes: stdoutArtifact.totalByteCount,
stderrBytes: stderrArtifact.totalByteCount,
stdoutCapturedBytes: stdoutArtifact.capturedByteCount,
stderrCapturedBytes: stderrArtifact.capturedByteCount,
stdoutTruncated: stdoutArtifact.truncated,
stderrTruncated: stderrArtifact.truncated,
artifacts,
...(spawnError || artifactError
? { error: [spawnError?.message, artifactError?.message].filter(Boolean).join("; ") }
: {}),
};
await this.storage.writeJson(stateRelative, complete).catch(() => undefined);
await this.journal
.record({
category: "command",
action: status,
summary: `${normalized.executable} exited with ${exitCode ?? exitSignal ?? status}.`,
...(spec.runId ? { runId: spec.runId } : {}),
details: {
jobId: id,
args: normalized.args,
durationMs: complete.durationMs,
timedOut,
stdoutBytes: complete.stdoutBytes,
stderrBytes: complete.stderrBytes,
stdoutTruncated: complete.stdoutTruncated,
stderrTruncated: complete.stderrTruncated,
},
})
.catch(() => undefined);
jobs.delete(jobKey);
resolve(complete);
});
});
const running: RunningJob = {
workspaceRoot: this.boundary.root,
process: child,
result,
promise,
cancel: () => kill("cancel"),
};
jobs.set(jobKey, running);
return { id, result, promise };
}
/**
* The durable recovery index for command jobs: `.agentic/jobs/<id>/state.json`,
* most recent first.
*
* The workflow hint promises the model that `workspace_inspect changes`
* relists job ids after compaction, and nothing could deliver that — jobs
* appeared in neither `changes` nor `overview` and there was no way to
* enumerate them at all. Three models in the live matrix invented ids instead
* of recovering one.
*
* A job whose stored state still says running but which this plugin process
* no longer owns is reported as orphaned exactly as `status()` would, but
* without persisting that verdict: listing is a read.
*/
public async list(limit = 20): Promise<CommandJobSummary[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.jobsRelative, {
withFileTypes: true,
});
const summaries: CommandJobSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("job_")) continue;
const active = jobs.get(activeJobKey(this.boundary.realRoot, entry.name));
let result: CommandResult;
if (active && active.workspaceRoot === this.boundary.root) {
result = active.result;
} else {
try {
result = await this.storage.readJson<CommandResult>(this.stateRelative(entry.name));
} catch {
// A half-written or hand-damaged job directory stays inspectable on
// disk rather than taking the whole listing down.
continue;
}
if (result.status === "running" || result.status === "queued") {
result = { ...result, status: "orphaned" };
}
}
summaries.push({
id: result.id,
status: result.status,
executable: result.executable,
...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
startedAt: result.startedAt,
});
}
return summaries
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async status(id: string): Promise<CommandResult> {
validateId(id);
const active = jobs.get(activeJobKey(this.boundary.realRoot, id));
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("NOT_FOUND", `Command job not found: ${id}`);
}
return active.result;
}
const stateRelative = this.stateRelative(id);
if (!(await this.storage.exists(stateRelative))) {
throw new AgenticError("NOT_FOUND", `Command job not found: ${id}`);
}
const stored = await this.storage.readJson<CommandResult>(stateRelative);
if (stored.status === "running" || stored.status === "queued") {
const orphaned: CommandResult = {
...stored,
status: "orphaned",
finishedAt: new Date().toISOString(),
error: "Plugin restarted while the command was running; process ownership was lost.",
};
await this.storage.writeJson(stateRelative, orphaned);
return orphaned;
}
return stored;
}
/**
* Last `chars` characters of a job's stdout and stderr logs. The same files
* back a running job's live output and a finished job's stored artifacts, so
* one path serves both. A long-running job's log grows to the configured
* artifact cap (20 MB by default), so the file is never read whole: only the
* last `chars * 4` bytes — the UTF-8 worst case — are read, from an explicit
* offset. A job that has not written anything yet, or whose logs no longer
* exist, tails to empty strings instead of failing.
*/
public async tail(id: string, chars: number): Promise<{ stdout: string; stderr: string }> {
validateId(id);
if (!Number.isInteger(chars) || chars < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Command tail length must be a whole number of at least one character.",
);
}
const limit = Math.min(chars, MAX_TAIL_CHARS);
const [stdout, stderr] = await Promise.all([
this.tailFile(`${this.jobsRelative}/${id}/stdout.log`, limit),
this.tailFile(`${this.jobsRelative}/${id}/stderr.log`, limit),
]);
return { stdout, stderr };
}
private async tailFile(relativePath: string, chars: number): Promise<string> {
if (!(await this.storage.exists(relativePath))) return "";
const absolute = await this.storage.resolveRead(relativePath);
const handle = await open(absolute, constants.O_RDONLY);
try {
const { size } = await handle.stat();
if (size <= 0) return "";
const window = Math.min(size, chars * MAX_UTF8_BYTES_PER_CHAR);
const start = size - window;
const buffer = Buffer.allocUnsafe(window);
const { bytesRead } = await handle.read(buffer, 0, window, start);
let offset = 0;
// A window that opens inside a code point starts with continuation bytes
// (10xxxxxx); at most three of them can precede the next character.
if (start > 0) {
while (offset < bytesRead && offset < 3 && (buffer[offset] & 0xc0) === 0x80) offset += 1;
}
// StringDecoder emits only complete characters and keeps a trailing
// partial sequence buffered, so a live log read mid-write never throws
// and never decodes a half-written character to U+FFFD.
const text = new StringDecoder("utf8").write(buffer.subarray(offset, bytesRead));
return text.length > chars ? text.slice(-chars) : text;
} finally {
await handle.close();
}
}
public async cancel(id: string): Promise<CommandResult> {
validateId(id);
const active = jobs.get(activeJobKey(this.boundary.realRoot, id));
if (!active || active.workspaceRoot !== this.boundary.root) {
return await this.status(id);
}
active.cancel();
return await active.promise;
}
/** Normalizes a spec (allowlist, cwd, limits) and returns its hash without spawning. */
public async describe(spec: CommandSpec): Promise<{
executable: string;
args: string[];
cwd: string;
specHash: string;
}> {
const normalized = await this.normalizeSpec(spec);
return {
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
specHash: this.specHash(normalized),
};
}
private specHash(normalized: Awaited<ReturnType<ProcessService["normalizeSpec"]>>): string {
return sha256Text(
JSON.stringify({
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
inputSha256:
normalized.input === undefined ? undefined : sha256Text(normalized.input),
timeoutSeconds: normalized.timeoutSeconds,
}),
);
}
private async normalizeSpec(spec: CommandSpec): Promise<{
executable: string;
args: string[];
cwd: string;
input?: string;
timeoutSeconds: number;
}> {
if (typeof spec.executable !== "string") {
throw new AgenticError("INVALID_INPUT", "Command executable must be a string.");
}
const executable = spec.executable.trim();
if (executable.includes("\0")) {
throw new AgenticError("INVALID_INPUT", "Command executable may not contain NUL bytes.");
}
if (!executable || executable !== basename(executable) || /[\\/]/.test(executable)) {
throw new AgenticError(
"EXECUTABLE_DENIED",
"Executable must be a bare command name from the configured allowlist.",
);
}
if (!this.allowed.has(executable.toLowerCase())) {
// Name the repair: a model that only sees "not in the allowlist" retries the
// same command until its budget runs out (observed 14 times in a row live).
const allowed = [...this.allowed].sort();
throw new AgenticError(
"EXECUTABLE_DENIED",
`Executable '${executable}' is not in the allowlist (allowedExecutables: ${
allowed.length > 0 ? allowed.join(", ") : "none"
}). Only the user can add it in the plugin settings, so do not retry this command: use an allowed executable instead, or continue without '${executable}' and tell the user what you skipped.`,
{ allowedExecutables: allowed },
);
}
const args = spec.args ?? [];
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
throw new AgenticError("INVALID_INPUT", "Command args must be an array of strings.");
}
if (args.length > 200) {
throw new AgenticError("INVALID_INPUT", "Command may have at most 200 arguments.");
}
if (args.some((arg) => arg.includes("\0"))) {
throw new AgenticError("INVALID_INPUT", "Command arguments may not contain NUL bytes.");
}
if (Buffer.byteLength(args.join("\0")) > 1_000_000) {
throw new AgenticError("INVALID_INPUT", "Combined command arguments are limited to 1 MB.");
}
if (spec.input !== undefined) {
if (typeof spec.input !== "string") {
throw new AgenticError("INVALID_INPUT", "Command stdin must be a string.");
}
if (Buffer.byteLength(spec.input) > 1_000_000) {
throw new AgenticError("INVALID_INPUT", "Command stdin is limited to 1 MB.");
}
}
const cwd = await this.boundary.resolveRead(spec.cwd ?? ".");
const info = await lstat(cwd);
if (!info.isDirectory()) {
throw new AgenticError("INVALID_INPUT", "Command cwd must be a directory.");
}
const configuredMaximum = this.options.maxTimeoutSeconds;
if (!Number.isFinite(configuredMaximum) || configuredMaximum < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Configured command timeout maximum must be a finite number of at least one second.",
);
}
const requestedTimeout = spec.timeoutSeconds ?? configuredMaximum;
// Zero is the explicit opt-in to a long-running background job: no timer is
// armed and the configured maximum does not cap it. Every other sub-second
// value stays invalid, so a mistyped fraction can never become "forever".
let timeoutSeconds = 0;
if (requestedTimeout !== 0) {
if (!Number.isFinite(requestedTimeout) || requestedTimeout < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Command timeout must be 0 (no timeout) or a finite number of at least one second.",
);
}
timeoutSeconds = Math.min(requestedTimeout, configuredMaximum);
}
return {
executable,
args,
cwd,
...(spec.input === undefined ? {} : { input: spec.input }),
timeoutSeconds,
};
}
private stateRelative(id: string): string {
validateId(id);
return `${this.jobsRelative}/${id}/state.json`;
}
}
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, lstat } from "node:fs/promises";
import { basename, join } from "node:path";
import {
spawn,
spawnSync,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import type { WorkspaceBoundary } from "../workspace/boundary";
import { resolveExecutable, type ResolvedExecutable } from "./resolveExecutable";
export type CommandStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "timed_out"
| "canceled"
| "orphaned";
export interface CommandSpec {
executable: string;
args?: string[];
cwd?: string;
input?: string;
timeoutSeconds?: number;
idempotencyKey?: string;
runId?: string;
}
export interface CommandResult {
id: string;
specHash: string;
status: CommandStatus;
executable: string;
args: string[];
cwd: string;
resolvedExecutable?: string;
resolvedScript?: string;
startedAt: string;
finishedAt?: string;
durationMs?: number;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
timedOut: boolean;
stdoutPreview: string;
stderrPreview: string;
stdoutBytes: number;
stderrBytes: number;
stdoutCapturedBytes: number;
stderrCapturedBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
artifacts: ArtifactRef[];
error?: string;
}
/** One row of the job recovery index (`ProcessService.list`). */
export interface CommandJobSummary {
id: string;
status: CommandStatus;
executable: string;
exitCode?: number | null;
startedAt: string;
}
interface RunningJob {
workspaceRoot: string;
process: ChildProcessWithoutNullStreams;
result: CommandResult;
promise: Promise<CommandResult>;
cancel: () => void;
}
const jobs = new Map<string, RunningJob>();
const jobStartLocks = new Map<string, Promise<void>>();
function activeJobKey(workspaceRoot: string, id: string): string {
return `${workspaceRoot}\0${id}`;
}
async function withJobStartLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const previous = jobStartLocks.get(id) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
jobStartLocks.set(id, queued);
await previous;
try {
return await action();
} finally {
release();
if (jobStartLocks.get(id) === queued) jobStartLocks.delete(id);
}
}
/**
* Absolute path to taskkill. resolveExecutable.ts refuses bare-name resolution
* for exactly this reason, and a stripped PATH without System32 would otherwise
* degrade the tree kill silently.
*/
const TASKKILL_PATH = join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"taskkill.exe",
);
/**
* Process-tree kills actually issued. Exported as an observability seam so a
* test can assert that a kill was, or was not, issued without reaching into
* the child process.
*/
export const killMetrics = { treeKillsIssued: 0 };
/**
* Stops a job's process together with everything it spawned. Windows has no
* process groups and child.kill() reaches only the direct child; a Node child
* takes its descendants with it (libuv keeps them in a kill-on-close job
* object), but a cmd/git/python/cargo child does not, and a surviving
* grandchild also holds the inherited stdio pipes open, so "close" would
* never fire and cancel()/the timeout would never settle. taskkill /T /F
* walks the parent-pid tree instead. POSIX keeps the existing behavior:
* SIGTERM to the direct child, SIGKILL after 2 s.
*
* Exported for tests; production callers reach it through a job's kill().
*/
export function killProcessTree(child: ChildProcess): void {
// Node closes the OS process handle at "exit", long before "close" — and
// "close" stays pending for as long as a surviving grandchild holds the
// inherited stdio. In that window the pid is dead, the kernel may already
// have recycled it, and taskkill /T /F would force-terminate whatever tree
// now owns it. child.kill() is a harmless no-op there (libuv checks its own
// saved handle), but taskkill is not, so nothing is issued at all: a process
// that has already exited cannot be killed usefully anyway.
if (child.exitCode !== null || child.signalCode !== null) return;
killMetrics.treeKillsIssued += 1;
if (process.platform === "win32" && child.pid !== undefined) {
const result = spawnSync(TASKKILL_PATH, ["/PID", String(child.pid), "/T", "/F"], {
shell: false,
windowsHide: true,
stdio: "ignore",
});
// taskkill missing (ENOENT) or refusing (exit 128 process already gone,
// exit 1 access denied): fall back to the direct kill so the job still
// settles whenever the child itself is still alive.
if (result.error || result.status !== 0) child.kill("SIGKILL");
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => child.kill("SIGKILL"), 2000);
hardKill.unref();
}
class PreviewCollector {
private readonly headLimit: number;
private readonly tailLimit: number;
private head = "";
private tail = "";
public totalChars = 0;
public constructor(private readonly maxChars: number) {
this.headLimit = Math.floor(Math.max(0, maxChars) * 0.6);
this.tailLimit = Math.max(0, maxChars) - this.headLimit;
}
public add(chunk: string): void {
if (!chunk) return;
this.totalChars += chunk.length;
if (this.head.length < this.headLimit) {
const remaining = this.headLimit - this.head.length;
this.head += chunk.slice(0, remaining);
chunk = chunk.slice(remaining);
}
if (chunk.length > 0 && this.tailLimit > 0) {
this.tail = (this.tail + chunk).slice(-this.tailLimit);
}
}
public render(): string {
if (this.maxChars <= 0) return "";
if (this.totalChars <= this.maxChars) return (this.head + this.tail).slice(0, this.maxChars);
let marker = `\n[output omitted; full captured stream stored as an artifact]\n`;
if (marker.length >= this.maxChars) return marker.slice(0, this.maxChars);
let usable = this.maxChars - marker.length;
let headChars = Math.floor(usable * 0.6);
let tailChars = usable - headChars;
let omitted = Math.max(0, this.totalChars - headChars - tailChars);
marker = `\n[${omitted.toLocaleString()} characters omitted; captured stream stored as an artifact]\n`;
usable = Math.max(0, this.maxChars - marker.length);
headChars = Math.floor(usable * 0.6);
tailChars = usable - headChars;
omitted = Math.max(0, this.totalChars - headChars - tailChars);
marker = `\n[${omitted.toLocaleString()} characters omitted; captured stream stored as an artifact]\n`;
return `${this.head.slice(0, headChars)}${marker}${
tailChars > 0 ? this.tail.slice(-tailChars) : ""
}`.slice(0, this.maxChars);
}
}
class StreamArtifact {
private readonly hash = createHash("sha256");
private writtenBytes = 0;
private totalBytes = 0;
private capped = false;
private constructor(
private readonly relativePath: string,
private readonly stream: ReturnType<Awaited<ReturnType<typeof open>>["createWriteStream"]>,
private readonly maxBytes: number,
) {}
public static async create(
relativePath: string,
absolutePath: string,
maxBytes: number,
): Promise<StreamArtifact> {
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
const handle = await open(
absolutePath,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | noFollow,
0o600,
);
return new StreamArtifact(relativePath, handle.createWriteStream(), maxBytes);
}
public get totalByteCount(): number {
return this.totalBytes;
}
public get capturedByteCount(): number {
return this.writtenBytes;
}
public get truncated(): boolean {
return this.capped;
}
public write(chunk: Buffer): void {
this.totalBytes += chunk.length;
if (this.writtenBytes >= this.maxBytes) {
this.capped = true;
return;
}
const remaining = this.maxBytes - this.writtenBytes;
const part = chunk.subarray(0, remaining);
if (part.length > 0) {
this.stream.write(part);
this.hash.update(part);
this.writtenBytes += part.length;
}
if (part.length < chunk.length) this.capped = true;
}
public async finish(kind: "stdout" | "stderr", jobId: string): Promise<ArtifactRef> {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
this.stream.off("finish", onFinish);
reject(error);
};
const onFinish = () => {
this.stream.off("error", onError);
resolve();
};
this.stream.once("error", onError);
this.stream.once("finish", onFinish);
this.stream.end();
});
return {
kind: "command_output",
path: this.relativePath,
sha256: this.hash.digest("hex"),
bytes: this.writtenBytes,
description: this.capped
? `${kind} for command job ${jobId}; captured ${this.writtenBytes.toLocaleString()} of ${this.totalBytes.toLocaleString()} bytes`
: `${kind} for command job ${jobId}`,
};
}
}
/** Default number of characters returned by a tail of a live job. */
export const DEFAULT_TAIL_CHARS = 2000;
/** Smallest tail a caller may ask for. */
export const MIN_TAIL_CHARS = 100;
/** Largest tail a caller may ask for; also the clamp inside the service. */
export const MAX_TAIL_CHARS = 20_000;
/** Worst case UTF-8 bytes per character, used to size the read window. */
const MAX_UTF8_BYTES_PER_CHAR = 4;
function validateId(id: string): void {
if (!/^job_[a-z0-9_]+$/i.test(id)) {
// Three models in the live matrix invented a job id rather than reading one
// back, so the refusal has to say where real ones come from.
throw new AgenticError(
"INVALID_INPUT",
`Invalid command job id: ${id}. Job ids are returned by workspace_command start and look like job_…; re-read the start result (or workspace_inspect changes) rather than inventing one.`,
);
}
}
export interface ProcessServiceOptions {
allowedExecutables: string[];
inheritEnvironment: boolean;
maxTimeoutSeconds: number;
maxPreviewChars: number;
maxArtifactBytes: number;
}
function commandEnvironment(inherit: boolean): NodeJS.ProcessEnv {
if (inherit) return { ...process.env };
const allowed = [
"PATH",
"Path",
"PATHEXT",
"SystemRoot",
"WINDIR",
"HOME",
"USERPROFILE",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"TERM",
];
const environment: NodeJS.ProcessEnv = { CI: "1", NO_COLOR: "1" };
for (const key of allowed) {
if (process.env[key] !== undefined) environment[key] = process.env[key];
}
return environment;
}
export class ProcessService {
private readonly jobsRelative: string;
private readonly allowed: Set<string>;
/**
* Successful resolutions, keyed by name, PATH, and cwd. The service is
* rebuilt on every tools-provider call, so the memo lives for one
* prediction; failures are never cached, so an install earlier in the same
* turn can still make an executable appear.
*/
private readonly resolutionCache = new Map<string, ResolvedExecutable>();
public constructor(
private readonly boundary: WorkspaceBoundary,
private readonly storage: InternalStorage,
private readonly journal: Journal,
private readonly options: ProcessServiceOptions,
) {
this.jobsRelative = storage.relative("jobs");
this.allowed = new Set(
options.allowedExecutables
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
);
}
/**
* The configured ceiling, which is also what an omitted `timeoutSeconds`
* becomes. Exposed so a refusal can name the number the model would get.
*/
public get maxTimeoutSeconds(): number {
return this.options.maxTimeoutSeconds;
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.jobsRelative);
}
public async run(spec: CommandSpec, signal?: AbortSignal): Promise<CommandResult> {
const started = await this.start(spec, signal);
return await started.promise;
}
public async start(
spec: CommandSpec,
signal?: AbortSignal,
): Promise<{ id: string; result: CommandResult; promise: Promise<CommandResult> }> {
if (signal?.aborted) {
const error = new Error("Command canceled before start.");
error.name = "AbortError";
throw error;
}
if (!spec.idempotencyKey) return await this.startUnlocked(spec, signal);
const id = `job_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`;
const key = activeJobKey(this.boundary.realRoot, id);
return await withJobStartLock(key, async () => await this.startUnlocked(spec, signal));
}
private async startUnlocked(
spec: CommandSpec,
signal?: AbortSignal,
): Promise<{ id: string; result: CommandResult; promise: Promise<CommandResult> }> {
await this.initialize();
const normalized = await this.normalizeSpec(spec);
const id = spec.idempotencyKey
? `job_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`
: createId("job");
const specHash = this.specHash(normalized);
const jobKey = activeJobKey(this.boundary.realRoot, id);
const active = jobs.get(jobKey);
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("EDIT_CONFLICT", "Command id belongs to another workspace.");
}
if (active.result.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The command idempotency key is already active with a different command specification.",
);
}
return { id, result: active.result, promise: active.promise };
}
const stateRelative = this.stateRelative(id);
if (await this.storage.exists(stateRelative)) {
const stored = await this.storage.readJson<CommandResult>(stateRelative);
if (stored.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The command idempotency key was already used with a different command specification.",
);
}
if (stored.status !== "running" && stored.status !== "queued") {
return { id, result: stored, promise: Promise.resolve(stored) };
}
const orphaned: CommandResult = {
...stored,
status: "orphaned",
finishedAt: new Date().toISOString(),
error: "Plugin restarted while the command was running; process ownership was lost.",
};
await this.storage.writeJson(stateRelative, orphaned);
return { id, result: orphaned, promise: Promise.resolve(orphaned) };
}
// Resolution happens only once the job is known to be new: replaying a
// completed or orphaned job must not touch PATH or the filesystem again.
const environment = commandEnvironment(this.options.inheritEnvironment);
const resolutionKey = `${normalized.executable}\0${
environment.PATH ?? environment.Path ?? ""
}\0${normalized.cwd}`;
let resolved = this.resolutionCache.get(resolutionKey);
if (!resolved) {
resolved = await resolveExecutable(normalized.executable, {
cwd: normalized.cwd,
workspaceRoot: this.boundary.root,
env: environment,
});
this.resolutionCache.set(resolutionKey, resolved);
}
const jobRelative = `${this.jobsRelative}/${id}`;
await this.storage.ensureDirectory(jobRelative);
const stdoutRelative = `${jobRelative}/stdout.log`;
const stderrRelative = `${jobRelative}/stderr.log`;
const [stdoutAbsolute, stderrAbsolute] = await Promise.all([
this.storage.resolveWrite(stdoutRelative),
this.storage.resolveWrite(stderrRelative),
]);
const stdoutArtifact = await StreamArtifact.create(
stdoutRelative,
stdoutAbsolute,
this.options.maxArtifactBytes,
);
const stderrArtifact = await StreamArtifact.create(
stderrRelative,
stderrAbsolute,
this.options.maxArtifactBytes,
);
const stdoutPreview = new PreviewCollector(this.options.maxPreviewChars);
const stderrPreview = new PreviewCollector(this.options.maxPreviewChars);
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
const startedAt = new Date();
const result: CommandResult = {
id,
specHash,
status: "queued",
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
resolvedExecutable: resolved.file,
resolvedScript: resolved.argsPrefix[0],
startedAt: startedAt.toISOString(),
timedOut: false,
stdoutPreview: "",
stderrPreview: "",
stdoutBytes: 0,
stderrBytes: 0,
stdoutCapturedBytes: 0,
stderrCapturedBytes: 0,
stdoutTruncated: false,
stderrTruncated: false,
artifacts: [],
};
await this.storage.writeJson(stateRelative, result);
await this.journal.record({
category: "command",
action: "start_requested",
summary: `Starting ${normalized.executable} as ${id}.`,
...(spec.runId ? { runId: spec.runId } : {}),
details: {
args: normalized.args,
cwd: result.cwd,
resolvedExecutable: resolved.file,
resolvedScript: resolved.argsPrefix[0],
},
});
const child = spawn(resolved.file, [...resolved.argsPrefix, ...normalized.args], {
cwd: normalized.cwd,
shell: false,
windowsHide: true,
stdio: "pipe",
env: environment,
});
result.status = "running";
let timedOut = false;
let canceled = false;
let settled = false;
let killIssued = false;
const kill = (reason: "timeout" | "cancel"): void => {
if (settled) return;
if (reason === "timeout") timedOut = true;
else canceled = true;
// One tree kill per job. kill() stays reachable until "close" fires, so a
// timeout followed by a user cancel — or two concurrent cancel() calls —
// would otherwise each block the event loop on a fresh taskkill.
if (killIssued) return;
killIssued = true;
killProcessTree(child);
};
// timeoutSeconds 0 arms no timer at all: the job lives until it exits, is
// canceled, or the plugin restarts — after which the next status() finds no
// in-memory owner and reports the persisted record as orphaned.
const timeout =
normalized.timeoutSeconds > 0
? setTimeout(() => kill("timeout"), normalized.timeoutSeconds * 1000)
: undefined;
timeout?.unref();
const abortListener = () => kill("cancel");
signal?.addEventListener("abort", abortListener, { once: true });
child.stdout.on("data", (chunk: Buffer) => {
stdoutArtifact.write(chunk);
stdoutPreview.add(stdoutDecoder.write(chunk));
result.stdoutBytes = stdoutArtifact.totalByteCount;
result.stdoutCapturedBytes = stdoutArtifact.capturedByteCount;
result.stdoutTruncated = stdoutArtifact.truncated;
result.stdoutPreview = stdoutPreview.render();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrArtifact.write(chunk);
stderrPreview.add(stderrDecoder.write(chunk));
result.stderrBytes = stderrArtifact.totalByteCount;
result.stderrCapturedBytes = stderrArtifact.capturedByteCount;
result.stderrTruncated = stderrArtifact.truncated;
result.stderrPreview = stderrPreview.render();
});
child.stdin.on("error", () => undefined);
if (normalized.input !== undefined) child.stdin.end(normalized.input);
else child.stdin.end();
const promise = new Promise<CommandResult>((resolve) => {
let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
});
child.once("close", async (exitCode, exitSignal) => {
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", abortListener);
stdoutPreview.add(stdoutDecoder.end());
stderrPreview.add(stderrDecoder.end());
let artifacts: ArtifactRef[] = [];
let artifactError: Error | undefined;
try {
artifacts = await Promise.all([
stdoutArtifact.finish("stdout", id),
stderrArtifact.finish("stderr", id),
]);
} catch (error) {
artifactError = error instanceof Error ? error : new Error(String(error));
}
const finishedAt = new Date();
const status: CommandStatus = canceled
? "canceled"
: timedOut
? "timed_out"
: spawnError || artifactError || exitCode !== 0
? "failed"
: "completed";
const complete: CommandResult = {
...result,
status,
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode,
signal: exitSignal,
timedOut,
stdoutPreview: stdoutPreview.render(),
stderrPreview: stderrPreview.render(),
stdoutBytes: stdoutArtifact.totalByteCount,
stderrBytes: stderrArtifact.totalByteCount,
stdoutCapturedBytes: stdoutArtifact.capturedByteCount,
stderrCapturedBytes: stderrArtifact.capturedByteCount,
stdoutTruncated: stdoutArtifact.truncated,
stderrTruncated: stderrArtifact.truncated,
artifacts,
...(spawnError || artifactError
? { error: [spawnError?.message, artifactError?.message].filter(Boolean).join("; ") }
: {}),
};
await this.storage.writeJson(stateRelative, complete).catch(() => undefined);
await this.journal
.record({
category: "command",
action: status,
summary: `${normalized.executable} exited with ${exitCode ?? exitSignal ?? status}.`,
...(spec.runId ? { runId: spec.runId } : {}),
details: {
jobId: id,
args: normalized.args,
durationMs: complete.durationMs,
timedOut,
stdoutBytes: complete.stdoutBytes,
stderrBytes: complete.stderrBytes,
stdoutTruncated: complete.stdoutTruncated,
stderrTruncated: complete.stderrTruncated,
},
})
.catch(() => undefined);
jobs.delete(jobKey);
resolve(complete);
});
});
const running: RunningJob = {
workspaceRoot: this.boundary.root,
process: child,
result,
promise,
cancel: () => kill("cancel"),
};
jobs.set(jobKey, running);
return { id, result, promise };
}
/**
* The durable recovery index for command jobs: `.agentic/jobs/<id>/state.json`,
* most recent first.
*
* The workflow hint promises the model that `workspace_inspect changes`
* relists job ids after compaction, and nothing could deliver that — jobs
* appeared in neither `changes` nor `overview` and there was no way to
* enumerate them at all. Three models in the live matrix invented ids instead
* of recovering one.
*
* A job whose stored state still says running but which this plugin process
* no longer owns is reported as orphaned exactly as `status()` would, but
* without persisting that verdict: listing is a read.
*/
public async list(limit = 20): Promise<CommandJobSummary[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.jobsRelative, {
withFileTypes: true,
});
const summaries: CommandJobSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("job_")) continue;
const active = jobs.get(activeJobKey(this.boundary.realRoot, entry.name));
let result: CommandResult;
if (active && active.workspaceRoot === this.boundary.root) {
result = active.result;
} else {
try {
result = await this.storage.readJson<CommandResult>(this.stateRelative(entry.name));
} catch {
// A half-written or hand-damaged job directory stays inspectable on
// disk rather than taking the whole listing down.
continue;
}
if (result.status === "running" || result.status === "queued") {
result = { ...result, status: "orphaned" };
}
}
summaries.push({
id: result.id,
status: result.status,
executable: result.executable,
...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
startedAt: result.startedAt,
});
}
return summaries
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async status(id: string): Promise<CommandResult> {
validateId(id);
const active = jobs.get(activeJobKey(this.boundary.realRoot, id));
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("NOT_FOUND", `Command job not found: ${id}`);
}
return active.result;
}
const stateRelative = this.stateRelative(id);
if (!(await this.storage.exists(stateRelative))) {
throw new AgenticError("NOT_FOUND", `Command job not found: ${id}`);
}
const stored = await this.storage.readJson<CommandResult>(stateRelative);
if (stored.status === "running" || stored.status === "queued") {
const orphaned: CommandResult = {
...stored,
status: "orphaned",
finishedAt: new Date().toISOString(),
error: "Plugin restarted while the command was running; process ownership was lost.",
};
await this.storage.writeJson(stateRelative, orphaned);
return orphaned;
}
return stored;
}
/**
* Last `chars` characters of a job's stdout and stderr logs. The same files
* back a running job's live output and a finished job's stored artifacts, so
* one path serves both. A long-running job's log grows to the configured
* artifact cap (20 MB by default), so the file is never read whole: only the
* last `chars * 4` bytes — the UTF-8 worst case — are read, from an explicit
* offset. A job that has not written anything yet, or whose logs no longer
* exist, tails to empty strings instead of failing.
*/
public async tail(id: string, chars: number): Promise<{ stdout: string; stderr: string }> {
validateId(id);
if (!Number.isInteger(chars) || chars < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Command tail length must be a whole number of at least one character.",
);
}
const limit = Math.min(chars, MAX_TAIL_CHARS);
const [stdout, stderr] = await Promise.all([
this.tailFile(`${this.jobsRelative}/${id}/stdout.log`, limit),
this.tailFile(`${this.jobsRelative}/${id}/stderr.log`, limit),
]);
return { stdout, stderr };
}
private async tailFile(relativePath: string, chars: number): Promise<string> {
if (!(await this.storage.exists(relativePath))) return "";
const absolute = await this.storage.resolveRead(relativePath);
const handle = await open(absolute, constants.O_RDONLY);
try {
const { size } = await handle.stat();
if (size <= 0) return "";
const window = Math.min(size, chars * MAX_UTF8_BYTES_PER_CHAR);
const start = size - window;
const buffer = Buffer.allocUnsafe(window);
const { bytesRead } = await handle.read(buffer, 0, window, start);
let offset = 0;
// A window that opens inside a code point starts with continuation bytes
// (10xxxxxx); at most three of them can precede the next character.
if (start > 0) {
while (offset < bytesRead && offset < 3 && (buffer[offset] & 0xc0) === 0x80) offset += 1;
}
// StringDecoder emits only complete characters and keeps a trailing
// partial sequence buffered, so a live log read mid-write never throws
// and never decodes a half-written character to U+FFFD.
const text = new StringDecoder("utf8").write(buffer.subarray(offset, bytesRead));
return text.length > chars ? text.slice(-chars) : text;
} finally {
await handle.close();
}
}
public async cancel(id: string): Promise<CommandResult> {
validateId(id);
const active = jobs.get(activeJobKey(this.boundary.realRoot, id));
if (!active || active.workspaceRoot !== this.boundary.root) {
return await this.status(id);
}
active.cancel();
return await active.promise;
}
/** Normalizes a spec (allowlist, cwd, limits) and returns its hash without spawning. */
public async describe(spec: CommandSpec): Promise<{
executable: string;
args: string[];
cwd: string;
specHash: string;
}> {
const normalized = await this.normalizeSpec(spec);
return {
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
specHash: this.specHash(normalized),
};
}
private specHash(normalized: Awaited<ReturnType<ProcessService["normalizeSpec"]>>): string {
return sha256Text(
JSON.stringify({
executable: normalized.executable,
args: normalized.args,
cwd: this.boundary.relativePath(normalized.cwd),
inputSha256:
normalized.input === undefined ? undefined : sha256Text(normalized.input),
timeoutSeconds: normalized.timeoutSeconds,
}),
);
}
private async normalizeSpec(spec: CommandSpec): Promise<{
executable: string;
args: string[];
cwd: string;
input?: string;
timeoutSeconds: number;
}> {
if (typeof spec.executable !== "string") {
throw new AgenticError("INVALID_INPUT", "Command executable must be a string.");
}
const executable = spec.executable.trim();
if (executable.includes("\0")) {
throw new AgenticError("INVALID_INPUT", "Command executable may not contain NUL bytes.");
}
if (!executable || executable !== basename(executable) || /[\\/]/.test(executable)) {
throw new AgenticError(
"EXECUTABLE_DENIED",
"Executable must be a bare command name from the configured allowlist.",
);
}
if (!this.allowed.has(executable.toLowerCase())) {
// Name the repair: a model that only sees "not in the allowlist" retries the
// same command until its budget runs out (observed 14 times in a row live).
const allowed = [...this.allowed].sort();
throw new AgenticError(
"EXECUTABLE_DENIED",
`Executable '${executable}' is not in the allowlist (allowedExecutables: ${
allowed.length > 0 ? allowed.join(", ") : "none"
}). Only the user can add it in the plugin settings, so do not retry this command: use an allowed executable instead, or continue without '${executable}' and tell the user what you skipped.`,
{ allowedExecutables: allowed },
);
}
const args = spec.args ?? [];
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
throw new AgenticError("INVALID_INPUT", "Command args must be an array of strings.");
}
if (args.length > 200) {
throw new AgenticError("INVALID_INPUT", "Command may have at most 200 arguments.");
}
if (args.some((arg) => arg.includes("\0"))) {
throw new AgenticError("INVALID_INPUT", "Command arguments may not contain NUL bytes.");
}
if (Buffer.byteLength(args.join("\0")) > 1_000_000) {
throw new AgenticError("INVALID_INPUT", "Combined command arguments are limited to 1 MB.");
}
if (spec.input !== undefined) {
if (typeof spec.input !== "string") {
throw new AgenticError("INVALID_INPUT", "Command stdin must be a string.");
}
if (Buffer.byteLength(spec.input) > 1_000_000) {
throw new AgenticError("INVALID_INPUT", "Command stdin is limited to 1 MB.");
}
}
const cwd = await this.boundary.resolveRead(spec.cwd ?? ".");
const info = await lstat(cwd);
if (!info.isDirectory()) {
throw new AgenticError("INVALID_INPUT", "Command cwd must be a directory.");
}
const configuredMaximum = this.options.maxTimeoutSeconds;
if (!Number.isFinite(configuredMaximum) || configuredMaximum < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Configured command timeout maximum must be a finite number of at least one second.",
);
}
const requestedTimeout = spec.timeoutSeconds ?? configuredMaximum;
// Zero is the explicit opt-in to a long-running background job: no timer is
// armed and the configured maximum does not cap it. Every other sub-second
// value stays invalid, so a mistyped fraction can never become "forever".
let timeoutSeconds = 0;
if (requestedTimeout !== 0) {
if (!Number.isFinite(requestedTimeout) || requestedTimeout < 1) {
throw new AgenticError(
"INVALID_INPUT",
"Command timeout must be 0 (no timeout) or a finite number of at least one second.",
);
}
timeoutSeconds = Math.min(requestedTimeout, configuredMaximum);
}
return {
executable,
args,
cwd,
...(spec.input === undefined ? {} : { input: spec.input }),
timeoutSeconds,
};
}
private stateRelative(id: string): string {
validateId(id);
return `${this.jobsRelative}/${id}/state.json`;
}
}