/** * KHAELOR * File: src/workspace/local.ts * Description: LocalWorkspace — the only V1 Workspace implementation; files and foreground exec. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { WorkspaceError } from "./workspace.js"; import type { Command, ProcessResult, Workspace } from "./workspace.js"; /** Files larger than this refuse to load (TOOL_PROTOCOL.md §2.2 — use grep/offset instead). */ const DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024; /** Null-byte sniff window for binary detection. */ const BINARY_SNIFF_BYTES = 4096; /** Per-stream capture cap for `exec` — full output belongs to spill files, not memory. */ const DEFAULT_MAX_STREAM_BYTES = 1024 * 1024; export interface LocalWorkspaceOptions { /** Override the read size guard (bytes). Default 10 MiB. */ maxReadBytes?: number; /** Override the per-stream exec capture cap (bytes). Default 1 MiB. */ maxExecStreamBytes?: number; /** Shell used for `exec`. Default: $SHELL, falling back to /bin/sh. */ shell?: string; } function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } /** * Local filesystem + process workspace. This class (with the rest of * `src/workspace/`) is the only place in KHAELOR that touches * `node:fs` / `node:child_process` (ADR-13). */ export class LocalWorkspace implements Workspace { private readonly root: string; private readonly maxReadBytes: number; private readonly maxStreamBytes: number; private readonly shell: string; constructor(rootCwd: string, options: LocalWorkspaceOptions = {}) { this.root = path.resolve(rootCwd); this.maxReadBytes = options.maxReadBytes ?? DEFAULT_MAX_READ_BYTES; this.maxStreamBytes = options.maxExecStreamBytes ?? DEFAULT_MAX_STREAM_BYTES; this.shell = options.shell ?? process.env["SHELL"] ?? "/bin/sh"; } cwd(): string { return this.root; } /** Resolve a possibly-relative path against the workspace root. */ resolve(p: string): string { return path.resolve(this.root, p); } async readFile(p: string): Promise { const abs = this.resolve(p); let stat; try { stat = await fsp.stat(abs); } catch { throw new WorkspaceError("file-not-found", `File not found: ${abs}`, { path: abs }); } if (stat.isDirectory()) { throw new WorkspaceError("file-is-directory", `Path is a directory, not a file: ${abs}`, { path: abs, }); } if (stat.size > this.maxReadBytes) { throw new WorkspaceError( "file-too-large", `File is too large to read (${stat.size} bytes > ${this.maxReadBytes} byte limit): ${abs}. ` + "Use grep for content search or read a region with offset/limit.", { path: abs, size: stat.size, limit: this.maxReadBytes }, ); } const buffer = await fsp.readFile(abs); if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0)) { throw new WorkspaceError("file-binary", `File appears to be binary: ${abs} (${stat.size} bytes)`, { path: abs, size: stat.size, }); } return buffer.toString("utf8"); } async writeFile(p: string, content: string): Promise { const abs = this.resolve(p); const dir = path.dirname(abs); await fsp.mkdir(dir, { recursive: true }); // Preserve the mode bits of an existing file across the atomic replace. let existingMode: number | undefined; try { existingMode = (await fsp.stat(abs)).mode & 0o7777; } catch { // New file — default mode applies. } const tmp = path.join(dir, `.${path.basename(abs)}.khaelor-tmp-${randomBytes(6).toString("hex")}`); try { const handle = await fsp.open(tmp, "w"); try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close(); } if (existingMode !== undefined) { await fsp.chmod(tmp, existingMode); } await fsp.rename(tmp, abs); } catch (cause) { await fsp.rm(tmp, { force: true }).catch(() => undefined); throw new WorkspaceError("write-failed", `Failed to write ${abs}: ${messageOf(cause)}`, { path: abs, }); } } exec(command: Command): Promise { const cwd = command.cwd !== undefined ? this.resolve(command.cwd) : this.root; const startedAt = Date.now(); const maxBytes = this.maxStreamBytes; return new Promise((resolvePromise, rejectPromise) => { // Own process group (detached) so timeout/abort can kill the whole tree. const child = spawn(this.shell, ["-c", command.cmd], { cwd, env: { ...process.env, ...command.env }, detached: true, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let stdoutBytes = 0; let stderrBytes = 0; let truncated = false; let killed = false; let settled = false; const killGroup = (): void => { const pid = child.pid; if (pid === undefined) return; try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { // Already gone. } } }; const timer = setTimeout(() => { killed = true; killGroup(); }, command.timeoutMs); const onAbort = (): void => { killed = true; killGroup(); }; command.signal?.addEventListener("abort", onAbort, { once: true }); const cleanup = (): void => { clearTimeout(timer); command.signal?.removeEventListener("abort", onAbort); }; child.stdout?.on("data", (chunk: Buffer) => { if (stdoutBytes >= maxBytes) { truncated = true; return; } const room = maxBytes - stdoutBytes; stdoutBytes += chunk.length; if (chunk.length > room) { stdout += chunk.subarray(0, room).toString("utf8"); truncated = true; } else { stdout += chunk.toString("utf8"); } }); child.stderr?.on("data", (chunk: Buffer) => { if (stderrBytes >= maxBytes) { truncated = true; return; } const room = maxBytes - stderrBytes; stderrBytes += chunk.length; if (chunk.length > room) { stderr += chunk.subarray(0, room).toString("utf8"); truncated = true; } else { stderr += chunk.toString("utf8"); } }); child.on("error", (cause) => { if (settled) return; settled = true; cleanup(); rejectPromise( new WorkspaceError("exec-failed", `Command failed to start: ${messageOf(cause)}`, { cmd: command.cmd, }), ); }); child.on("close", (code) => { if (settled) return; settled = true; cleanup(); resolvePromise({ exitCode: killed ? null : code, stdout, stderr, durationMs: Date.now() - startedAt, truncated, }); }); }); } }