refactor(agent): add result-based execution env

This commit is contained in:
Mario Zechner
2026-05-14 18:26:29 +02:00
parent 9fcf924ac9
commit 846906e4d1
13 changed files with 783 additions and 383 deletions

View File

@@ -18,6 +18,12 @@ The intended rule is:
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite. A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
## Error handling
The target error-handling model is `Result<TValue, TError>` for fallible harness operations instead of thrown exceptions. Implementations should catch backend/provider/filesystem exceptions at the boundary and normalize them into typed error results; callers should inspect returned results instead of relying on thrown exceptions.
This is currently implemented for `ExecutionEnv`, `NodeExecutionEnv`, shell-output capture, and skill/prompt-template resource loading. Older harness/session/compaction APIs still throw in several paths; finishing that migration is tracked in the cleanup todo.
## State model ## State model
The harness separates state into four categories. The harness separates state into four categories.
@@ -90,7 +96,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase
- `compact` - `compact`
- `navigateTree` - `navigateTree`
Starting another structural operation while the harness is not idle throws. Starting another structural operation while the harness is not idle currently throws. This should become a typed result failure as part of the error-handling cleanup.
The following operations are allowed during a turn where appropriate: The following operations are allowed during a turn where appropriate:
@@ -141,7 +147,7 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review. No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message. If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation currently throws and the harness returns to idle. This should become a typed result failure as part of the error-handling cleanup. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
## Hooks and events ## Hooks and events
@@ -307,7 +313,7 @@ Implemented so far:
- `setTools(tools, activeToolNames?)` - `setTools(tools, activeToolNames?)`
- `setActiveTools(toolNames)` - `setActiveTools(toolNames)`
- invalid active tool names throw - invalid active tool names currently throw; convert to result errors
- generic common app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>` - generic common app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`
- `QueueMode` exported from `Agent` - `QueueMode` exported from `Agent`
- `AgentHarnessOptions.steeringMode` / `followUpMode` - `AgentHarnessOptions.steeringMode` / `followUpMode`
@@ -355,7 +361,33 @@ Still needed:
- Document or change timing for model/thinking/stream-option events that may fire before queued session entries persist while busy. - Document or change timing for model/thinking/stream-option events that may fire before queued session entries persist while busy.
- Audit `abort()` barrier semantics. - Audit `abort()` barrier semantics.
### 7. Later coding-agent migration plan ### 7. Complete `Result`/non-throwing harness cleanup
Started.
Implemented so far:
- Added generic `Result<TValue, TError>` plus helpers.
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
- Updated shell output capture to return a result and use `ExecutionEnv` instead of Node APIs directly, including full-output spill via `appendFile()`.
- Removed `NodeExecutionEnv` from the browser-safe `execution-env.ts` re-export; Node-specific callers import from `harness/env/nodejs.js`.
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output full-output spill.
Still needed:
- Remove remaining throws from `src/harness` APIs and helpers, except explicit adapter/test helpers such as `getOrThrow()`.
- Convert session storage/repo/session APIs to typed result returns.
- Convert structural `AgentHarness` operations to typed result returns for busy, missing-resource, auth, compaction, and branch-summary failures.
- Convert compaction helpers to typed result returns.
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts` and Node-backed storage/session implementations, or move those implementations behind explicit Node-only entry points.
- Replace Node globals in generic harness utilities, especially `Buffer` usage in truncation utilities, with runtime-neutral implementations.
- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv` or JSONL storage.
- Keep expanding `ExecutionEnv` and shell-output contract tests as the API evolves, especially for non-Node implementations.
- Add tests proving harness APIs return `ok: false` instead of throwing for expected failure paths.
### 8. Later coding-agent migration plan
Not started. Not started.
@@ -367,7 +399,7 @@ Still needed:
- Preserve UI/session behavior outside core. - Preserve UI/session behavior outside core.
- Move coding-agent stream/auth/retry/header behavior onto the harness stream configuration and provider hooks. - Move coding-agent stream/auth/retry/header behavior onto the harness stream configuration and provider hooks.
### 8. Final lifecycle hardening suite ### 9. Final lifecycle hardening suite
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event. Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event.

View File

@@ -1,33 +1,59 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { constants } from "node:fs"; import { constants } from "node:fs";
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import {
access,
appendFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path"; import { isAbsolute, join, resolve } from "node:path";
import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js"; import {
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
ok,
type Result,
} from "../types.js";
function resolvePath(cwd: string, path: string): string { function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path); return isAbsolute(path) ? path : resolve(cwd, path);
} }
function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind { function fileKindFromStats(stats: {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
}): FileKind | undefined {
if (stats.isFile()) return "file"; if (stats.isFile()) return "file";
if (stats.isDirectory()) return "directory"; if (stats.isDirectory()) return "directory";
if (stats.isSymbolicLink()) return "symlink"; if (stats.isSymbolicLink()) return "symlink";
throw new FileError("invalid", "Unsupported file type"); return undefined;
} }
function fileInfoFromStats( function fileInfoFromStats(
path: string, path: string,
stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number }, stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number },
): FileInfo { ): Result<FileInfo, FileError> {
return { const kind = fileKindFromStats(stats);
if (!kind) return err(new FileError("invalid", "Unsupported file type", path));
return ok({
name: path.replace(/\/+$/, "").split("/").pop() ?? path, name: path.replace(/\/+$/, "").split("/").pop() ?? path,
path, path,
kind: fileKindFromStats(stats), kind,
size: stats.size, size: stats.size,
mtimeMs: stats.mtimeMs, mtimeMs: stats.mtimeMs,
}; });
} }
function isNodeError(error: unknown): error is NodeJS.ErrnoException { function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -39,20 +65,26 @@ function toFileError(error: unknown, path?: string): FileError {
if (isNodeError(error)) { if (isNodeError(error)) {
const message = error.message; const message = error.message;
switch (error.code) { switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, error);
case "ENOENT": case "ENOENT":
return new FileError("not_found", message, path, { cause: error }); return new FileError("not_found", message, path, error);
case "EACCES": case "EACCES":
case "EPERM": case "EPERM":
return new FileError("permission_denied", message, path, { cause: error }); return new FileError("permission_denied", message, path, error);
case "ENOTDIR": case "ENOTDIR":
return new FileError("not_directory", message, path, { cause: error }); return new FileError("not_directory", message, path, error);
case "EISDIR": case "EISDIR":
return new FileError("is_directory", message, path, { cause: error }); return new FileError("is_directory", message, path, error);
case "EINVAL": case "EINVAL":
return new FileError("invalid", message, path, { cause: error }); return new FileError("invalid", message, path, error);
} }
} }
return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error }); return new FileError("unknown", error instanceof Error ? error.message : String(error), path, error);
}
function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
} }
async function pathExists(path: string): Promise<boolean> { async function pathExists(path: string): Promise<boolean> {
@@ -71,7 +103,13 @@ async function runCommand(
): Promise<{ stdout: string; status: number | null }> { ): Promise<{ stdout: string; status: number | null }> {
return await new Promise((resolve) => { return await new Promise((resolve) => {
let stdout = ""; let stdout = "";
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] }); let child: ReturnType<typeof spawn>;
try {
child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
} catch {
resolve({ stdout: "", status: null });
return;
}
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (child.pid) killProcessTree(child.pid); if (child.pid) killProcessTree(child.pid);
}, timeoutMs); }, timeoutMs);
@@ -100,12 +138,14 @@ async function findBashOnPath(): Promise<string | null> {
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
} }
async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> { async function getShellConfig(
customShellPath?: string,
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
if (customShellPath) { if (customShellPath) {
if (await pathExists(customShellPath)) { if (await pathExists(customShellPath)) {
return { shell: customShellPath, args: ["-c"] }; return ok({ shell: customShellPath, args: ["-c"] });
} }
throw new Error(`Custom shell path not found: ${customShellPath}`); return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
} }
if (process.platform === "win32") { if (process.platform === "win32") {
const candidates: string[] = []; const candidates: string[] = [];
@@ -115,24 +155,24 @@ async function getShellConfig(customShellPath?: string): Promise<{ shell: string
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of candidates) { for (const candidate of candidates) {
if (await pathExists(candidate)) { if (await pathExists(candidate)) {
return { shell: candidate, args: ["-c"] }; return ok({ shell: candidate, args: ["-c"] });
} }
} }
const bashOnPath = await findBashOnPath(); const bashOnPath = await findBashOnPath();
if (bashOnPath) { if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] }; return ok({ shell: bashOnPath, args: ["-c"] });
} }
throw new Error("No bash shell found"); return err(new ExecutionError("shell_unavailable", "No bash shell found"));
} }
if (await pathExists("/bin/bash")) { if (await pathExists("/bin/bash")) {
return { shell: "/bin/bash", args: ["-c"] }; return ok({ shell: "/bin/bash", args: ["-c"] });
} }
const bashOnPath = await findBashOnPath(); const bashOnPath = await findBashOnPath();
if (bashOnPath) { if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] }; return ok({ shell: bashOnPath, args: ["-c"] });
} }
return { shell: "sh", args: ["-c"] }; return ok({ shell: "sh", args: ["-c"] });
} }
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv { function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -184,46 +224,69 @@ export class NodeExecutionEnv implements ExecutionEnv {
cwd?: string; cwd?: string;
env?: Record<string, string>; env?: Record<string, string>;
timeout?: number; timeout?: number;
signal?: AbortSignal; abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void; onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void; onStderr?: (chunk: string) => void;
}, },
): Promise<{ stdout: string; stderr: string; exitCode: number }> { ): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
const { shell, args } = await getShellConfig(this.shellPath);
return await new Promise((resolvePromise, reject) => { 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) => {
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
let timedOut = false; let timedOut = false;
const child = spawn(shell, [...args, command], { let callbackError: ExecutionError | undefined;
cwd, let child: ReturnType<typeof spawn> | undefined;
detached: process.platform !== "win32", let timeoutId: ReturnType<typeof setTimeout> | undefined;
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutId = const onAbort = () => {
if (child?.pid) {
killProcessTree(child.pid);
}
};
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
resolvePromise(result);
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
settle(
err(new ExecutionError("spawn_error", error instanceof Error ? error.message : String(error), error)),
);
return;
}
timeoutId =
typeof options?.timeout === "number" typeof options?.timeout === "number"
? setTimeout(() => { ? setTimeout(() => {
timedOut = true; timedOut = true;
if (child.pid) { if (child?.pid) {
killProcessTree(child.pid); killProcessTree(child.pid);
} }
}, options.timeout * 1000) }, options.timeout * 1000)
: undefined; : undefined;
const onAbort = () => { if (options?.abortSignal) {
if (child.pid) { if (options.abortSignal.aborted) {
killProcessTree(child.pid);
}
};
if (options?.signal) {
if (options.signal.aborted) {
onAbort(); onAbort();
} else { } else {
options.signal.addEventListener("abort", onAbort, { once: true }); options.abortSignal.addEventListener("abort", onAbort, { once: true });
} }
} }
@@ -231,137 +294,231 @@ export class NodeExecutionEnv implements ExecutionEnv {
child.stderr?.setEncoding("utf8"); child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => { child.stdout?.on("data", (chunk: string) => {
stdout += chunk; stdout += chunk;
options?.onStdout?.(chunk); try {
options?.onStdout?.(chunk);
} catch (error) {
callbackError = new ExecutionError(
"unknown",
error instanceof Error ? error.message : String(error),
error,
);
onAbort();
}
}); });
child.stderr?.on("data", (chunk: string) => { child.stderr?.on("data", (chunk: string) => {
stderr += chunk; stderr += chunk;
options?.onStderr?.(chunk); try {
options?.onStderr?.(chunk);
} catch (error) {
callbackError = new ExecutionError(
"unknown",
error instanceof Error ? error.message : String(error),
error,
);
onAbort();
}
}); });
child.on("error", (error) => { child.on("error", (error) => {
if (timeoutId) clearTimeout(timeoutId); settle(err(new ExecutionError("spawn_error", error.message, error)));
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
reject(error);
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId); if (callbackError) {
if (options?.signal) options.signal.removeEventListener("abort", onAbort); settle(err(callbackError));
if (settled) return; return;
settled = true; }
if (options?.signal?.aborted) { if (options?.abortSignal?.aborted) {
reject(new Error("aborted")); settle(err(new ExecutionError("aborted", "aborted")));
return; return;
} }
if (timedOut) { if (timedOut) {
reject(new Error(`timeout:${options?.timeout}`)); settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return; return;
} }
resolvePromise({ stdout, stderr, exitCode: code ?? 0 }); settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
}); });
}); });
} }
async readTextFile(path: string): Promise<string> { async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
return await readFile(resolved, "utf8"); return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async readBinaryFile(path: string): Promise<Uint8Array> { async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
return await readFile(resolved); return ok(await readFile(resolved, { signal: abortSignal }));
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async writeFile(path: string, content: string | Uint8Array): Promise<void> { async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
await mkdir(resolve(resolved, ".."), { recursive: true }); await mkdir(resolve(resolved, ".."), { recursive: true });
await writeFile(resolved, content); const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
if (afterMkdirAbort) return afterMkdirAbort;
await writeFile(resolved, content, { signal: abortSignal });
return ok(undefined);
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async fileInfo(path: string): Promise<FileInfo> { async appendFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(abortSignal, resolved);
if (aborted) return aborted;
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
if (afterMkdirAbort) return afterMkdirAbort;
await appendFile(resolved, content);
const afterAppendAbort = abortResult<void>(abortSignal, resolved);
if (afterAppendAbort) return afterAppendAbort;
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<FileInfo>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
return fileInfoFromStats(resolved, await lstat(resolved)); return fileInfoFromStats(resolved, await lstat(resolved));
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async listDir(path: string): Promise<FileInfo[]> { async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
const entries = await readdir(resolved, { withFileTypes: true }); const entries = await readdir(resolved, { withFileTypes: true });
const infos: FileInfo[] = []; const infos: FileInfo[] = [];
for (const entry of entries) { for (const entry of entries) {
const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
if (loopAbort) return loopAbort;
const entryPath = resolve(resolved, entry.name); const entryPath = resolve(resolved, entry.name);
try { try {
infos.push(fileInfoFromStats(entryPath, await lstat(entryPath))); const info = fileInfoFromStats(entryPath, await lstat(entryPath));
if (info.ok) infos.push(info.value);
} catch (error) { } catch (error) {
if (error instanceof FileError && error.code === "invalid") continue; return err(toFileError(error, entryPath));
throw error;
} }
} }
return infos; return ok(infos);
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async realPath(path: string): Promise<string> { async realPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;
try { try {
return await realpath(resolved); return ok(await realpath(resolved));
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async exists(path: string): Promise<boolean> { async exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>> {
try { const result = await this.fileInfo(path, abortSignal);
await this.fileInfo(path); if (result.ok) return ok(true);
return true; if (result.error.code === "not_found") return ok(false);
} catch (error) { return err(result.error);
if (error instanceof FileError && error.code === "not_found") return false;
throw error;
}
} }
async createDir(path: string, options?: { recursive?: boolean }): Promise<void> { async createDir(
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive }); path: string,
} options?: { recursive?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>> {
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
const resolved = resolvePath(this.cwd, path); const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(options?.abortSignal, resolved);
if (aborted) return aborted;
try {
await mkdir(resolved, { recursive: options?.recursive ?? true });
const afterMkdirAbort = abortResult<void>(options?.abortSignal, resolved);
if (afterMkdirAbort) return afterMkdirAbort;
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async remove(
path: string,
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(options?.abortSignal, resolved);
if (aborted) return aborted;
try { try {
await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false }); await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
const afterRemoveAbort = abortResult<void>(options?.abortSignal, resolved);
if (afterRemoveAbort) return afterRemoveAbort;
return ok(undefined);
} catch (error) { } catch (error) {
throw toFileError(error, resolved); return err(toFileError(error, resolved));
} }
} }
async createTempDir(prefix: string = "tmp-"): Promise<string> { async createTempDir(prefix: string = "tmp-", abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
return await mkdtemp(join(tmpdir(), prefix)); const aborted = abortResult<string>(abortSignal);
if (aborted) return aborted;
try {
const path = await mkdtemp(join(tmpdir(), prefix));
const afterMkdtempAbort = abortResult<string>(abortSignal, path);
if (afterMkdtempAbort) return afterMkdtempAbort;
return ok(path);
} catch (error) {
return err(toFileError(error));
}
} }
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> { async createTempFile(options?: {
const dir = await this.createTempDir("tmp-"); prefix?: string;
const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); suffix?: string;
await writeFile(filePath, ""); abortSignal?: AbortSignal;
return filePath; }): Promise<Result<string, FileError>> {
const dir = await this.createTempDir("tmp-", options?.abortSignal);
if (!dir.ok) return dir;
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
const aborted = abortResult<string>(options?.abortSignal, filePath);
if (aborted) return aborted;
try {
await writeFile(filePath, "", { signal: options?.abortSignal });
return ok(filePath);
} catch (error) {
return err(toFileError(error, filePath));
}
} }
async cleanup(): Promise<void> { async cleanup(): Promise<void> {

View File

@@ -1,3 +1,10 @@
export { NodeExecutionEnv } from "./env/nodejs.js"; export type {
export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js"; ExecutionEnv,
export { FileError } from "./types.js"; ExecutionEnvExecOptions,
ExecutionErrorCode,
FileErrorCode,
FileInfo,
FileKind,
Result,
} from "./types.js";
export { ExecutionError, err, FileError, getOrThrow, getOrUndefined, ok } from "./types.js";

View File

@@ -1,5 +1,5 @@
import { parse } from "yaml"; import { parse } from "yaml";
import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js"; import { type ExecutionEnv, type FileInfo, getOrUndefined, type PromptTemplate, type Result } from "./types.js";
/** Warning produced while loading prompt templates. */ /** Warning produced while loading prompt templates. */
export interface PromptTemplateDiagnostic { export interface PromptTemplateDiagnostic {
@@ -30,7 +30,7 @@ export async function loadPromptTemplates(
const promptTemplates: PromptTemplate[] = []; const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = []; const diagnostics: PromptTemplateDiagnostic[] = [];
for (const path of Array.isArray(paths) ? paths : [paths]) { for (const path of Array.isArray(paths) ? paths : [paths]) {
const info = await safeFileInfo(env, path); const info = getOrUndefined(await env.fileInfo(path));
if (!info) continue; if (!info) continue;
const kind = await resolveKind(env, info); const kind = await resolveKind(env, info);
if (kind === "directory") { if (kind === "directory") {
@@ -83,17 +83,16 @@ async function loadTemplatesFromDir(
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { ): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
const promptTemplates: PromptTemplate[] = []; const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = []; const diagnostics: PromptTemplateDiagnostic[] = [];
let entries: FileInfo[]; const entriesResult = await env.listDir(dir);
try { if (!entriesResult.ok) {
entries = await env.listDir(dir);
} catch (error) {
diagnostics.push({ diagnostics.push({
type: "warning", type: "warning",
message: errorMessage(error, "failed to list prompt template directory"), message: entriesResult.error.message,
path: dir, path: dir,
}); });
return { promptTemplates, diagnostics }; return { promptTemplates, diagnostics };
} }
const entries = entriesResult.value;
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const kind = await resolveKind(env, entry); const kind = await resolveKind(env, entry);
@@ -110,60 +109,66 @@ async function loadTemplateFromFile(
filePath: string, filePath: string,
): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { ): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> {
const diagnostics: PromptTemplateDiagnostic[] = []; const diagnostics: PromptTemplateDiagnostic[] = [];
try { const rawContent = await env.readTextFile(filePath);
const rawContent = await env.readTextFile(filePath); if (!rawContent.ok) {
const { frontmatter, body } = parseFrontmatter<PromptTemplateFrontmatter>(rawContent);
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
} catch (error) {
diagnostics.push({ diagnostics.push({
type: "warning", type: "warning",
message: errorMessage(error, "failed to load prompt template"), message: rawContent.error.message,
path: filePath, path: filePath,
}); });
return { promptTemplate: null, diagnostics }; return { promptTemplate: null, diagnostics };
} }
}
async function safeFileInfo(env: ExecutionEnv, path: string): Promise<FileInfo | undefined> { const parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value);
try { if (!parsed.ok) {
return await env.fileInfo(path); diagnostics.push({
} catch { type: "warning",
return undefined; message: parsed.error.message,
path: filePath,
});
return { promptTemplate: null, diagnostics };
} }
const { frontmatter, body } = parsed.value;
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
} }
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> { async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind; if (info.kind === "file" || info.kind === "directory") return info.kind;
try { const realPath = await env.realPath(info.path);
const realPath = await env.realPath(info.path); if (!realPath.ok) return undefined;
const target = await env.fileInfo(realPath); const target = getOrUndefined(await env.fileInfo(realPath.value));
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; if (!target) return undefined;
} catch { return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
return undefined;
}
} }
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } { function parseFrontmatter<T extends Record<string, unknown>>(
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); content: string,
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; ): Result<{ frontmatter: T; body: string }, Error> {
const endIndex = normalized.indexOf("\n---", 3); try {
if (endIndex === -1) return { frontmatter: {} as T, body: normalized }; const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const yamlString = normalized.slice(4, endIndex); if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const body = normalized.slice(endIndex + 4).trim(); const endIndex = normalized.indexOf("\n---", 3);
return { frontmatter: (parse(yamlString) ?? {}) as T, body }; if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
} }
function basenameEnvPath(path: string): string { function basenameEnvPath(path: string): string {
@@ -172,10 +177,6 @@ function basenameEnvPath(path: string): string {
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
} }
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
/** Parse an argument string using simple shell-style single and double quotes. */ /** Parse an argument string using simple shell-style single and double quotes. */
export function parseCommandArgs(argsString: string): string[] { export function parseCommandArgs(argsString: string): string[] {
const args: string[] = []; const args: string[] = [];

View File

@@ -1,6 +1,6 @@
import ignore from "ignore"; import ignore from "ignore";
import { parse } from "yaml"; import { parse } from "yaml";
import type { ExecutionEnv, Skill } from "./types.js"; import { type ExecutionEnv, type FileInfo, getOrUndefined, type Result, type Skill } from "./types.js";
const MAX_NAME_LENGTH = 64; const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024; const MAX_DESCRIPTION_LENGTH = 1024;
@@ -44,7 +44,7 @@ export async function loadSkills(
const skills: Skill[] = []; const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = []; const diagnostics: SkillDiagnostic[] = [];
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
const rootInfo = await safeFileInfo(env, dir); const rootInfo = getOrUndefined(await env.fileInfo(dir));
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue; if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path); const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
skills.push(...result.skills); skills.push(...result.skills);
@@ -89,18 +89,13 @@ async function loadSkillsFromDirInternal(
const skills: Skill[] = []; const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = []; const diagnostics: SkillDiagnostic[] = [];
if (!(await env.exists(dir))) return { skills, diagnostics }; const dirInfo = getOrUndefined(await env.fileInfo(dir));
const dirInfo = await safeFileInfo(env, dir);
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics }; if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir); await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>; const entries = getOrUndefined(await env.listDir(dir));
try { if (!entries) return { skills, diagnostics };
entries = await env.listDir(dir);
} catch {
return { skills, diagnostics };
}
for (const entry of entries) { for (const entry of entries) {
if (entry.name !== "SKILL.md") continue; if (entry.name !== "SKILL.md") continue;
@@ -148,16 +143,15 @@ async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string,
for (const filename of IGNORE_FILE_NAMES) { for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = joinEnvPath(dir, filename); const ignorePath = joinEnvPath(dir, filename);
const info = await safeFileInfo(env, ignorePath); const info = getOrUndefined(await env.fileInfo(ignorePath));
if (info?.kind !== "file") continue; if (info?.kind !== "file") continue;
try { const content = await env.readTextFile(ignorePath);
const content = await env.readTextFile(ignorePath); if (!content.ok) continue;
const patterns = content const patterns = content.value
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix)) .map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line)); .filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns); if (patterns.length > 0) ig.add(patterns);
} catch {}
} }
} }
@@ -184,40 +178,47 @@ async function loadSkillFromFile(
filePath: string, filePath: string,
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { ): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
const diagnostics: SkillDiagnostic[] = []; const diagnostics: SkillDiagnostic[] = [];
try { const rawContent = await env.readTextFile(filePath);
const rawContent = await env.readTextFile(filePath); if (!rawContent.ok) {
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent); diagnostics.push({ type: "warning", message: rawContent.error.message, path: filePath });
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
for (const error of validateDescription(frontmatter.description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const name = frontmatter.name || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description: frontmatter.description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
diagnostics.push({ type: "warning", message, path: filePath });
return { skill: null, diagnostics }; return { skill: null, diagnostics };
} }
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({ type: "warning", message: parsed.error.message, path: filePath });
return { skill: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
for (const error of validateDescription(description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
const name = frontmatterName || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!description || description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
} }
function validateName(name: string, parentDirName: string): string[] { function validateName(name: string, parentDirName: string): string[] {
@@ -242,39 +243,29 @@ function validateDescription(description: string | undefined): string[] {
return errors; return errors;
} }
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } { function parseFrontmatter<T extends Record<string, unknown>>(
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); content: string,
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; ): Result<{ frontmatter: T; body: string }, Error> {
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
}
async function safeFileInfo(
env: ExecutionEnv,
path: string,
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
try { try {
return await env.fileInfo(path); const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
} catch { if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
return undefined; const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
} }
} }
async function resolveKind( async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
env: ExecutionEnv,
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind; if (info.kind === "file" || info.kind === "directory") return info.kind;
try { const realPath = await env.realPath(info.path);
const realPath = await env.realPath(info.path); if (!realPath.ok) return undefined;
const target = await env.fileInfo(realPath); const target = getOrUndefined(await env.fileInfo(realPath.value));
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; if (!target) return undefined;
} catch { return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
return undefined;
}
} }
function joinEnvPath(base: string, child: string): string { function joinEnvPath(base: string, child: string): string {

View File

@@ -3,6 +3,30 @@ import type { QueueMode } from "../agent.js";
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js"; import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js";
import type { Session } from "./session/session.js"; import type { Session } from "./session/session.js";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */
export function getOrThrow<TValue, TError>(result: Result<TValue, TError>): TValue {
if (!result.ok) throw result.error;
return result.value;
}
/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */
export function getOrUndefined<TValue extends object, TError>(result: Result<TValue, TError>): TValue | undefined {
return result.ok ? result.value : undefined;
}
/** /**
* Skill loaded from a `SKILL.md` file or provided by an application. * Skill loaded from a `SKILL.md` file or provided by an application.
* *
@@ -73,8 +97,9 @@ export interface AgentHarnessStreamOptionsPatch
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */ /** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
export type FileKind = "file" | "directory" | "symlink"; export type FileKind = "file" | "directory" | "symlink";
/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */ /** Stable, backend-independent file error codes returned by {@link ExecutionEnv} file operations. */
export type FileErrorCode = export type FileErrorCode =
| "aborted"
| "not_found" | "not_found"
| "permission_denied" | "permission_denied"
| "not_directory" | "not_directory"
@@ -83,7 +108,7 @@ export type FileErrorCode =
| "not_supported" | "not_supported"
| "unknown"; | "unknown";
/** Error thrown by {@link ExecutionEnv} file operations. */ /** Error returned by {@link ExecutionEnv} file operations. */
export class FileError extends Error { export class FileError extends Error {
constructor( constructor(
/** Backend-independent error code. */ /** Backend-independent error code. */
@@ -91,13 +116,29 @@ export class FileError extends Error {
message: string, message: string,
/** Absolute addressed path associated with the failure, when available. */ /** Absolute addressed path associated with the failure, when available. */
public path?: string, public path?: string,
options?: ErrorOptions, cause?: unknown,
) { ) {
super(message, options); super(message, cause === undefined ? undefined : { cause });
this.name = "FileError"; this.name = "FileError";
} }
} }
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
export type ExecutionErrorCode = "aborted" | "timeout" | "shell_unavailable" | "spawn_error" | "unknown";
/** Error returned by {@link ExecutionEnv.exec}. */
export class ExecutionError extends Error {
constructor(
/** Backend-independent error code. */
public code: ExecutionErrorCode,
message: string,
cause?: unknown,
) {
super(message, cause === undefined ? undefined : { cause });
this.name = "ExecutionError";
}
}
/** Metadata for one filesystem object in an {@link ExecutionEnv}. */ /** Metadata for one filesystem object in an {@link ExecutionEnv}. */
export interface FileInfo { export interface FileInfo {
/** Basename of {@link path}. */ /** Basename of {@link path}. */
@@ -114,14 +155,14 @@ export interface FileInfo {
/** Options for {@link ExecutionEnv.exec}. */ /** Options for {@link ExecutionEnv.exec}. */
export interface ExecutionEnvExecOptions { export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */ /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string; cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. */ /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>; env?: Record<string, string>;
/** Timeout in seconds. Implementations should reject when the command exceeds this duration. */ /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number; timeout?: number;
/** Abort signal used to terminate the command. */ /** Abort signal used to terminate the command. Defaults to no abort signal. */
signal?: AbortSignal; abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */ /** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void; onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */ /** Called with stderr chunks as they are produced. */
@@ -134,7 +175,8 @@ export interface ExecutionEnvExecOptions {
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute
* addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}. * addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}.
* *
* File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors. * Operation methods must never throw or reject. All filesystem/process failures, including unexpected backend failures,
* must be encoded in the returned {@link Result}. Implementations must preserve this invariant.
*/ */
export interface ExecutionEnv { export interface ExecutionEnv {
/** Current working directory for relative paths and command execution. */ /** Current working directory for relative paths and command execution. */
@@ -144,32 +186,44 @@ export interface ExecutionEnv {
exec( exec(
command: string, command: string,
options?: ExecutionEnvExecOptions, options?: ExecutionEnvExecOptions,
): Promise<{ stdout: string; stderr: string; exitCode: number }>; ): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Read a UTF-8 text file. Throws {@link FileError}. */ /** Read a UTF-8 text file. */
readTextFile(path: string): Promise<string>; readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a binary file. Throws {@link FileError}. */ /** Read a binary file. */
readBinaryFile(path: string): Promise<Uint8Array>; readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
/** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */ /** Create or overwrite a file, creating parent directories when supported. */
writeFile(path: string, content: string | Uint8Array): Promise<void>; writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */ /** Create or append to a file, creating parent directories when supported. */
fileInfo(path: string): Promise<FileInfo>; appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */ /** Return metadata for the addressed path without following symlinks. */
listDir(path: string): Promise<FileInfo[]>; fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */ /** List direct children of a directory without following symlinks. */
realPath(path: string): Promise<string>; listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */ /** Return the canonical path for a path, following symlinks. */
exists(path: string): Promise<boolean>; realPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Create a directory. */ /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
createDir(path: string, options?: { recursive?: boolean }): Promise<void>; exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** Remove a file or directory. */ /** Create a directory. Defaults: `recursive: true`, no abort signal. */
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>; createDir(
/** Create a temporary directory and return its absolute path. */ path: string,
createTempDir(prefix?: string): Promise<string>; options?: { recursive?: boolean; abortSignal?: AbortSignal },
/** Create a temporary file and return its absolute path. */ ): Promise<Result<void, FileError>>;
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>; /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
remove(
path: string,
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
createTempFile(options?: {
prefix?: string;
suffix?: string;
abortSignal?: AbortSignal;
}): Promise<Result<string, FileError>>;
/** Release resources owned by the environment. */ /** Release resources owned by the environment. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>; cleanup(): Promise<void>;
} }

View File

@@ -1,8 +1,4 @@
import { randomBytes } from "node:crypto"; import { type ExecutionEnv, type ExecutionEnvExecOptions, ExecutionError, err, ok, type Result } from "../types.js";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExecutionEnv, ExecutionEnvExecOptions } from "../types.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js"; import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js";
export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> { export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> {
@@ -17,6 +13,12 @@ export interface ShellCaptureResult {
fullOutputPath?: string; fullOutputPath?: string;
} }
function toExecutionError(error: unknown): ExecutionError {
return error instanceof ExecutionError
? error
: new ExecutionError("unknown", error instanceof Error ? error.message : String(error), error);
}
export function sanitizeBinaryOutput(str: string): string { export function sanitizeBinaryOutput(str: string): string {
return Array.from(str) return Array.from(str)
.filter((char) => { .filter((char) => {
@@ -34,41 +36,62 @@ export async function executeShellWithCapture(
env: ExecutionEnv, env: ExecutionEnv,
command: string, command: string,
options?: ShellCaptureOptions, options?: ShellCaptureOptions,
): Promise<ShellCaptureResult> { ): Promise<Result<ShellCaptureResult, ExecutionError>> {
const outputChunks: string[] = []; const outputChunks: string[] = [];
let outputBytes = 0; let outputBytes = 0;
const maxOutputBytes = DEFAULT_MAX_BYTES * 2; const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
const encoder = new TextEncoder();
let tempFilePath: string | undefined;
let tempFileStream: WriteStream | undefined;
let totalBytes = 0; let totalBytes = 0;
let fullOutputPath: string | undefined;
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
let captureError: ExecutionError | undefined;
const ensureTempFile = () => { const appendFullOutput = (text: string): void => {
if (tempFilePath) return; if (!fullOutputPath || captureError) return;
const id = randomBytes(8).toString("hex"); const path = fullOutputPath;
tempFilePath = join(tmpdir(), `bash-${id}.log`); writeChain = writeChain.then(async (previous) => {
tempFileStream = createWriteStream(tempFilePath); if (!previous.ok) return previous;
for (const chunk of outputChunks) { const appendResult = await env.appendFile(path, text, options?.abortSignal);
tempFileStream.write(chunk); return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
} });
};
const ensureFullOutputFile = (initialContent: string): void => {
if (fullOutputPath || captureError) return;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const tempFile = await env.createTempFile({
prefix: "bash-",
suffix: ".log",
abortSignal: options?.abortSignal,
});
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
fullOutputPath = tempFile.value;
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
}; };
const onChunk = (chunk: string) => { const onChunk = (chunk: string) => {
totalBytes += Buffer.byteLength(chunk, "utf-8"); try {
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); totalBytes += encoder.encode(chunk).byteLength;
if (totalBytes > DEFAULT_MAX_BYTES) { const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
ensureTempFile(); if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
ensureFullOutputFile(outputChunks.join("") + text);
} else {
appendFullOutput(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
} catch (error) {
captureError = toExecutionError(error);
} }
if (tempFileStream) {
tempFileStream.write(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
}; };
try { try {
@@ -77,37 +100,36 @@ export async function executeShellWithCapture(
onStdout: onChunk, onStdout: onChunk,
onStderr: onChunk, onStderr: onChunk,
}); });
const fullOutput = outputChunks.join(""); const tailOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput); const truncationResult = truncateTail(tailOutput);
if (truncationResult.truncated) { if (truncationResult.truncated && !fullOutputPath) {
ensureTempFile(); ensureFullOutputFile(tailOutput);
} }
tempFileStream?.end(); const writeResult = await writeChain;
const cancelled = options?.signal?.aborted ?? false; if (!writeResult.ok) return err(writeResult.error);
return { if (captureError) return err(captureError);
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: cancelled ? undefined : result.exitCode, if (!result.ok) {
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath,
});
}
return err(result.error);
}
const cancelled = options?.abortSignal?.aborted ?? false;
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: cancelled ? undefined : result.value.exitCode,
cancelled, cancelled,
truncated: truncationResult.truncated, truncated: truncationResult.truncated,
fullOutputPath: tempFilePath, fullOutputPath,
}; });
} catch (err) { } catch (error) {
if (options?.signal?.aborted) { return err(toExecutionError(error));
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
}
tempFileStream?.end();
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
}
tempFileStream?.end();
throw err;
} }
} }

View File

@@ -1,7 +1,7 @@
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai"; import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js"; import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { Session } from "../../src/harness/session/session.js"; import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { calculateTool } from "../utils/calculate.js"; import { calculateTool } from "../utils/calculate.js";

View File

@@ -1,7 +1,7 @@
import { getModel } from "@earendil-works/pi-ai"; import { getModel } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js"; import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { Session } from "../../src/harness/session/session.js"; import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { PromptTemplate, Skill } from "../../src/harness/types.js"; import type { PromptTemplate, Skill } from "../../src/harness/types.js";

View File

@@ -1,7 +1,9 @@
import { access, chmod, realpath, symlink } from "node:fs/promises"; import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { FileError, getOrThrow } from "../../src/harness/execution-env.js";
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.js";
import { createTempDir } from "./session-test-utils.js"; import { createTempDir } from "./session-test-utils.js";
const chmodRestorePaths: string[] = []; const chmodRestorePaths: string[] = [];
@@ -19,61 +21,66 @@ describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => { it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("nested", { recursive: true }); getOrThrow(await env.createDir("nested/child"));
await env.writeFile("nested/file.txt", "hello"); getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
expect(await env.readTextFile("nested/file.txt")).toBe("hello"); getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello"); expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
const entries = await env.listDir("nested"); const entries = getOrThrow(await env.listDir("nested/child"));
expect(entries).toHaveLength(1); expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ expect(entries[0]).toMatchObject({
name: "file.txt", name: "file.txt",
path: join(root, "nested/file.txt"), path: join(root, "nested/child/file.txt"),
kind: "file", kind: "file",
size: 5, size: 5,
}); });
expect(typeof entries[0]!.mtimeMs).toBe("number"); expect(typeof entries[0]!.mtimeMs).toBe("number");
expect(await env.exists("nested/file.txt")).toBe(true); expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
await env.remove("nested/file.txt"); getOrThrow(await env.remove("nested/child/file.txt"));
expect(await env.exists("nested/file.txt")).toBe(false); expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
}); });
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => { it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("dir", { recursive: true }); getOrThrow(await env.createDir("dir", { recursive: true }));
await env.writeFile("dir/file.txt", "hello"); getOrThrow(await env.writeFile("dir/file.txt", "hello"));
await symlink(join(root, "dir/file.txt"), join(root, "file-link")); await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
await symlink(join(root, "dir"), join(root, "dir-link")); await symlink(join(root, "dir"), join(root, "dir-link"));
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" }); expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
expect(await env.fileInfo("dir/file.txt")).toMatchObject({ name: "dir",
path: join(root, "dir"),
kind: "directory",
});
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
name: "file.txt", name: "file.txt",
path: join(root, "dir/file.txt"), path: join(root, "dir/file.txt"),
kind: "file", kind: "file",
size: 5, size: 5,
}); });
expect(await env.fileInfo("file-link")).toMatchObject({ expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
name: "file-link", name: "file-link",
path: join(root, "file-link"), path: join(root, "file-link"),
kind: "symlink", kind: "symlink",
}); });
expect(await env.fileInfo("dir-link")).toMatchObject({ expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
name: "dir-link", name: "dir-link",
path: join(root, "dir-link"), path: join(root, "dir-link"),
kind: "symlink", kind: "symlink",
}); });
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt"))); expect(getOrThrow(await env.realPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
}); });
it("lists symlinks as symlinks", async () => { it("lists symlinks as symlinks", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.txt", "hello"); getOrThrow(await env.writeFile("target.txt", "hello"));
await symlink(join(root, "target.txt"), join(root, "link.txt")); await symlink(join(root, "target.txt"), join(root, "link.txt"));
const entries = await env.listDir("."); const entries = getOrThrow(await env.listDir("."));
expect( expect(
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)), entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
).toEqual([ ).toEqual([
@@ -82,41 +89,112 @@ describe("NodeExecutionEnv", () => {
]); ]);
}); });
it("throws FileError for missing paths and keeps exists false for missing paths", async () => { it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({ const info = await env.fileInfo("missing.txt");
name: "FileError", expect(info.ok).toBe(false);
code: "not_found", if (!info.ok) {
path: join(root, "missing.txt"), expect(info.error).toBeInstanceOf(FileError);
}); expect(info.error).toMatchObject({
expect(await env.exists("missing.txt")).toBe(false); name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
}
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
}); });
it("throws FileError for listing non-directories", async () => { it("returns FileError for listing non-directories", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("file.txt", "hello"); getOrThrow(await env.writeFile("file.txt", "hello"));
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError); const result = await env.listDir("file.txt");
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" }); expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(FileError);
expect(result.error).toMatchObject({ code: "not_directory" });
}
});
it("appends to new files and creates parent directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
}); });
it("creates temporary directories and files", async () => { it("creates temporary directories and files", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
const tempDir = await env.createTempDir("node-env-test-"); const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
await expect(access(tempDir)).resolves.toBeUndefined(); await expect(access(tempDir)).resolves.toBeUndefined();
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }); const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
await expect(access(tempFile)).resolves.toBeUndefined(); await expect(access(tempFile)).resolves.toBeUndefined();
expect(tempFile.endsWith(".txt")).toBe(true); expect(tempFile.endsWith(".txt")).toBe(true);
}); });
it("honors createDir recursive false and remove recursive/force options", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const createResult = await env.createDir("missing/child", { recursive: false });
expect(createResult.ok).toBe(false);
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
const removeDirectory = await env.remove("dir", { recursive: false });
expect(removeDirectory.ok).toBe(false);
getOrThrow(await env.remove("dir", { recursive: true }));
expect(getOrThrow(await env.exists("dir"))).toBe(false);
const removeMissing = await env.remove("missing", { force: false });
expect(removeMissing.ok).toBe(false);
getOrThrow(await env.remove("missing", { force: true }));
});
it("returns aborted results for pre-aborted file operations", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const controller = new AbortController();
controller.abort();
const signal = controller.signal;
const results = await Promise.all([
env.readTextFile("file.txt", signal),
env.readBinaryFile("file.txt", signal),
env.writeFile("other.txt", "hello", signal),
env.appendFile("other.txt", "hello", signal),
env.fileInfo("file.txt", signal),
env.listDir(".", signal),
env.realPath("file.txt", signal),
env.exists("file.txt", signal),
env.createDir("dir", { abortSignal: signal }),
env.remove("file.txt", { abortSignal: signal }),
env.createTempDir("node-env-test-", signal),
env.createTempFile({ abortSignal: signal }),
]);
for (const result of results) {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
}
});
it("cleanup is best-effort", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.cleanup()).resolves.toBeUndefined();
});
it("executes commands in cwd with env overrides", async () => { it("executes commands in cwd with env overrides", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', { const result = getOrThrow(
env: { NODE_ENV_TEST: "ok" }, await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
}); env: { NODE_ENV_TEST: "ok" },
}),
);
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
}); });
@@ -125,25 +203,83 @@ describe("NodeExecutionEnv", () => {
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
const result = await env.exec("printf out; printf err >&2", { const result = getOrThrow(
onStdout: (chunk) => { await env.exec("printf out; printf err >&2", {
stdout += chunk; onStdout: (chunk) => {
}, stdout += chunk;
onStderr: (chunk) => { },
stderr += chunk; onStderr: (chunk) => {
}, stderr += chunk;
}); },
}),
);
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 }); expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
expect(stdout).toBe("out"); expect(stdout).toBe("out");
expect(stderr).toBe("err"); expect(stderr).toBe("err");
}); });
it("rejects aborted commands", async () => { it("returns non-zero command exit codes as successful execution results", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await env.exec("exit 7"));
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
});
it("returns timeout errors for commands exceeding the timeout", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("sleep 5", { timeout: 0.01 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
});
it("returns callback errors from exec stream handlers", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("printf out", {
onStdout: () => {
throw new Error("callback failed");
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "unknown", message: "callback failed" });
});
it("returns shell unavailable and spawn errors", async () => {
const root = createTempDir();
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
const missingShell = await missingShellEnv.exec("printf ok");
expect(missingShell.ok).toBe(false);
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
const shellPath = join(root, "not-executable-shell");
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, "not executable"));
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const spawnError = await spawnErrorEnv.exec("printf ok");
expect(spawnError.ok).toBe(false);
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
});
it("returns an aborted result for aborted commands", async () => {
const root = createTempDir(); const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root }); const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController(); const controller = new AbortController();
const promise = env.exec("sleep 5", { signal: controller.signal }); const promise = env.exec("sleep 5", { abortSignal: controller.signal });
controller.abort(); controller.abort();
await expect(promise).rejects.toThrow("aborted"); const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
});
it("captures large shell output to a full output file through the execution env", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
expect(result.truncated).toBe(true);
expect(result.fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
expect(result.output.length).toBeLessThan(fullOutput.length);
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { symlink } from "node:fs/promises"; import { symlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { import {
formatPromptTemplateInvocation, formatPromptTemplateInvocation,
loadPromptTemplates, loadPromptTemplates,

View File

@@ -1,7 +1,7 @@
import { symlink } from "node:fs/promises"; import { symlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js"; import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js";
import { createTempDir } from "./session-test-utils.js"; import { createTempDir } from "./session-test-utils.js";

View File

@@ -1,13 +1,13 @@
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai"; import { getModel } from "@earendil-works/pi-ai";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { import {
AgentHarness, AgentHarness,
formatSkillsForSystemPrompt, formatSkillsForSystemPrompt,
loadSourcedPromptTemplates, loadSourcedPromptTemplates,
loadSourcedSkills, loadSourcedSkills,
NodeExecutionEnv,
type PromptTemplate, type PromptTemplate,
Session, Session,
type Skill, type Skill,