SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
7.2 KB · 237 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/workspace/local.ts4 * Description: LocalWorkspace — the only V1 Workspace implementation; files and foreground exec.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { spawn } from "node:child_process";11import { randomBytes } from "node:crypto";12import * as fsp from "node:fs/promises";13import * as path from "node:path";14import { WorkspaceError } from "./workspace.js";15import type { Command, ProcessResult, Workspace } from "./workspace.js";1617/** Files larger than this refuse to load (TOOL_PROTOCOL.md §2.2 — use grep/offset instead). */18const DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;19/** Null-byte sniff window for binary detection. */20const BINARY_SNIFF_BYTES = 4096;21/** Per-stream capture cap for `exec` — full output belongs to spill files, not memory. */22const DEFAULT_MAX_STREAM_BYTES = 1024 * 1024;2324export interface LocalWorkspaceOptions {25  /** Override the read size guard (bytes). Default 10 MiB. */26  maxReadBytes?: number;27  /** Override the per-stream exec capture cap (bytes). Default 1 MiB. */28  maxExecStreamBytes?: number;29  /** Shell used for `exec`. Default: $SHELL, falling back to /bin/sh. */30  shell?: string;31}3233function messageOf(cause: unknown): string {34  return cause instanceof Error ? cause.message : String(cause);35}3637/**38 * Local filesystem + process workspace. This class (with the rest of39 * `src/workspace/`) is the only place in KHAELOR that touches40 * `node:fs` / `node:child_process` (ADR-13).41 */42export class LocalWorkspace implements Workspace {43  private readonly root: string;44  private readonly maxReadBytes: number;45  private readonly maxStreamBytes: number;46  private readonly shell: string;4748  constructor(rootCwd: string, options: LocalWorkspaceOptions = {}) {49    this.root = path.resolve(rootCwd);50    this.maxReadBytes = options.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;51    this.maxStreamBytes = options.maxExecStreamBytes ?? DEFAULT_MAX_STREAM_BYTES;52    this.shell = options.shell ?? process.env["SHELL"] ?? "/bin/sh";53  }5455  cwd(): string {56    return this.root;57  }5859  /** Resolve a possibly-relative path against the workspace root. */60  resolve(p: string): string {61    return path.resolve(this.root, p);62  }6364  async readFile(p: string): Promise<string> {65    const abs = this.resolve(p);66    let stat;67    try {68      stat = await fsp.stat(abs);69    } catch {70      throw new WorkspaceError("file-not-found", `File not found: ${abs}`, { path: abs });71    }72    if (stat.isDirectory()) {73      throw new WorkspaceError("file-is-directory", `Path is a directory, not a file: ${abs}`, {74        path: abs,75      });76    }77    if (stat.size > this.maxReadBytes) {78      throw new WorkspaceError(79        "file-too-large",80        `File is too large to read (${stat.size} bytes > ${this.maxReadBytes} byte limit): ${abs}. ` +81          "Use grep for content search or read a region with offset/limit.",82        { path: abs, size: stat.size, limit: this.maxReadBytes },83      );84    }85    const buffer = await fsp.readFile(abs);86    if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {87      throw new WorkspaceError("file-binary", `File appears to be binary: ${abs} (${stat.size} bytes)`, {88        path: abs,89        size: stat.size,90      });91    }92    return buffer.toString("utf8");93  }9495  async writeFile(p: string, content: string): Promise<void> {96    const abs = this.resolve(p);97    const dir = path.dirname(abs);98    await fsp.mkdir(dir, { recursive: true });99100    // Preserve the mode bits of an existing file across the atomic replace.101    let existingMode: number | undefined;102    try {103      existingMode = (await fsp.stat(abs)).mode & 0o7777;104    } catch {105      // New file — default mode applies.106    }107108    const tmp = path.join(dir, `.${path.basename(abs)}.khaelor-tmp-${randomBytes(6).toString("hex")}`);109    try {110      const handle = await fsp.open(tmp, "w");111      try {112        await handle.writeFile(content, "utf8");113        await handle.sync();114      } finally {115        await handle.close();116      }117      if (existingMode !== undefined) {118        await fsp.chmod(tmp, existingMode);119      }120      await fsp.rename(tmp, abs);121    } catch (cause) {122      await fsp.rm(tmp, { force: true }).catch(() => undefined);123      throw new WorkspaceError("write-failed", `Failed to write ${abs}: ${messageOf(cause)}`, {124        path: abs,125      });126    }127  }128129  exec(command: Command): Promise<ProcessResult> {130    const cwd = command.cwd !== undefined ? this.resolve(command.cwd) : this.root;131    const startedAt = Date.now();132    const maxBytes = this.maxStreamBytes;133134    return new Promise<ProcessResult>((resolvePromise, rejectPromise) => {135      // Own process group (detached) so timeout/abort can kill the whole tree.136      const child = spawn(this.shell, ["-c", command.cmd], {137        cwd,138        env: { ...process.env, ...command.env },139        detached: true,140        stdio: ["ignore", "pipe", "pipe"],141      });142143      let stdout = "";144      let stderr = "";145      let stdoutBytes = 0;146      let stderrBytes = 0;147      let truncated = false;148      let killed = false;149      let settled = false;150151      const killGroup = (): void => {152        const pid = child.pid;153        if (pid === undefined) return;154        try {155          process.kill(-pid, "SIGKILL");156        } catch {157          try {158            child.kill("SIGKILL");159          } catch {160            // Already gone.161          }162        }163      };164165      const timer = setTimeout(() => {166        killed = true;167        killGroup();168      }, command.timeoutMs);169170      const onAbort = (): void => {171        killed = true;172        killGroup();173      };174      command.signal?.addEventListener("abort", onAbort, { once: true });175176      const cleanup = (): void => {177        clearTimeout(timer);178        command.signal?.removeEventListener("abort", onAbort);179      };180181      child.stdout?.on("data", (chunk: Buffer) => {182        if (stdoutBytes >= maxBytes) {183          truncated = true;184          return;185        }186        const room = maxBytes - stdoutBytes;187        stdoutBytes += chunk.length;188        if (chunk.length > room) {189          stdout += chunk.subarray(0, room).toString("utf8");190          truncated = true;191        } else {192          stdout += chunk.toString("utf8");193        }194      });195196      child.stderr?.on("data", (chunk: Buffer) => {197        if (stderrBytes >= maxBytes) {198          truncated = true;199          return;200        }201        const room = maxBytes - stderrBytes;202        stderrBytes += chunk.length;203        if (chunk.length > room) {204          stderr += chunk.subarray(0, room).toString("utf8");205          truncated = true;206        } else {207          stderr += chunk.toString("utf8");208        }209      });210211      child.on("error", (cause) => {212        if (settled) return;213        settled = true;214        cleanup();215        rejectPromise(216          new WorkspaceError("exec-failed", `Command failed to start: ${messageOf(cause)}`, {217            cmd: command.cmd,218          }),219        );220      });221222      child.on("close", (code) => {223        if (settled) return;224        settled = true;225        cleanup();226        resolvePromise({227          exitCode: killed ? null : code,228          stdout,229          stderr,230          durationMs: Date.now() - startedAt,231          truncated,232        });233      });234    });235  }236}237