summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 16:01:33 +0900
committerAdam Malczewski <[email protected]>2026-06-10 16:01:33 +0900
commitbf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (patch)
tree073048a5775c605d8c28862d0f8c83e63327a17e
parent9e7554cde98f45df30dad1f9d356b6954138685b (diff)
downloaddispatch-bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5.tar.gz
dispatch-bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5.zip
feat(tools): add run_shell, edit_file, write_file + read_file directory listing
Four standard-tier tool extensions (one tool per extension, zero ABI change): - tool-read-file: read_file now lists directory contents (sorted, /-suffixed subdirs) - tool-shell: run_shell (foreground, streamed, cancellable, cwd, timeout + output cap) - tool-edit-file: edit_file (oldString/newString/replaceAll; errors on absent/non-unique) - tool-write-file: write_file (explicit overwrite flag) Registered in host-bin CORE_EXTENSIONS. Live boot clean (shell capability accepted). 686 vitest + 89 bun = 775 tests; tsc -b EXIT 0; biome clean.
-rw-r--r--bun.lock30
-rw-r--r--packages/host-bin/package.json3
-rw-r--r--packages/host-bin/src/main.ts6
-rw-r--r--packages/host-bin/tsconfig.json3
-rw-r--r--packages/tool-edit-file/package.json11
-rw-r--r--packages/tool-edit-file/src/edit-file.test.ts361
-rw-r--r--packages/tool-edit-file/src/edit-file.ts251
-rw-r--r--packages/tool-edit-file/src/extension.ts18
-rw-r--r--packages/tool-edit-file/src/index.ts2
-rw-r--r--packages/tool-edit-file/tsconfig.json6
-rw-r--r--packages/tool-read-file/src/index.ts2
-rw-r--r--packages/tool-read-file/src/read-file.test.ts78
-rw-r--r--packages/tool-read-file/src/read-file.ts61
-rw-r--r--packages/tool-shell/package.json11
-rw-r--r--packages/tool-shell/src/extension.ts19
-rw-r--r--packages/tool-shell/src/index.ts3
-rw-r--r--packages/tool-shell/src/shell.test.ts357
-rw-r--r--packages/tool-shell/src/shell.ts181
-rw-r--r--packages/tool-shell/src/spawn.ts46
-rw-r--r--packages/tool-shell/tsconfig.json6
-rw-r--r--packages/tool-write-file/package.json11
-rw-r--r--packages/tool-write-file/src/extension.ts18
-rw-r--r--packages/tool-write-file/src/index.ts7
-rw-r--r--packages/tool-write-file/src/write-file.test.ts291
-rw-r--r--packages/tool-write-file/src/write-file.ts210
-rw-r--r--packages/tool-write-file/tsconfig.json6
-rw-r--r--tasks.md29
-rw-r--r--tsconfig.json3
28 files changed, 2022 insertions, 8 deletions
diff --git a/bun.lock b/bun.lock
index 5911ffd..3cd4cc8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -55,7 +55,10 @@
"@dispatch/surface-loaded-extensions": "workspace:*",
"@dispatch/surface-registry": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
+ "@dispatch/tool-edit-file": "workspace:*",
"@dispatch/tool-read-file": "workspace:*",
+ "@dispatch/tool-shell": "workspace:*",
+ "@dispatch/tool-write-file": "workspace:*",
"@dispatch/transport-http": "workspace:*",
"@dispatch/transport-ws": "workspace:*",
},
@@ -130,6 +133,13 @@
"@dispatch/kernel": "workspace:*",
},
},
+ "packages/tool-edit-file": {
+ "name": "@dispatch/tool-edit-file",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ },
+ },
"packages/tool-read-file": {
"name": "@dispatch/tool-read-file",
"version": "0.0.0",
@@ -137,6 +147,20 @@
"@dispatch/kernel": "workspace:*",
},
},
+ "packages/tool-shell": {
+ "name": "@dispatch/tool-shell",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ },
+ },
+ "packages/tool-write-file": {
+ "name": "@dispatch/tool-write-file",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ },
+ },
"packages/trace-replay": {
"name": "@dispatch/trace-replay",
"version": "0.0.0",
@@ -236,8 +260,14 @@
"@dispatch/throughput-store": ["@dispatch/throughput-store@workspace:packages/throughput-store"],
+ "@dispatch/tool-edit-file": ["@dispatch/tool-edit-file@workspace:packages/tool-edit-file"],
+
"@dispatch/tool-read-file": ["@dispatch/tool-read-file@workspace:packages/tool-read-file"],
+ "@dispatch/tool-shell": ["@dispatch/tool-shell@workspace:packages/tool-shell"],
+
+ "@dispatch/tool-write-file": ["@dispatch/tool-write-file@workspace:packages/tool-write-file"],
+
"@dispatch/trace-replay": ["@dispatch/trace-replay@workspace:packages/trace-replay"],
"@dispatch/trace-store": ["@dispatch/trace-store@workspace:packages/trace-store"],
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index ccaddd2..9d29e4d 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -14,6 +14,9 @@
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-http": "workspace:*",
"@dispatch/tool-read-file": "workspace:*",
+ "@dispatch/tool-shell": "workspace:*",
+ "@dispatch/tool-edit-file": "workspace:*",
+ "@dispatch/tool-write-file": "workspace:*",
"@dispatch/journal-sink": "workspace:*",
"@dispatch/surface-loaded-extensions": "workspace:*",
"@dispatch/surface-registry": "workspace:*",
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 7f219d1..588dfb8 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -24,7 +24,10 @@ import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/st
import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions";
import { createSurfaceRegistryExtension } from "@dispatch/surface-registry";
import { extension as throughputStoreExt } from "@dispatch/throughput-store";
+import { extension as toolEditFileExt } from "@dispatch/tool-edit-file";
import { extension as toolReadFileExt } from "@dispatch/tool-read-file";
+import { extension as toolShellExt } from "@dispatch/tool-shell";
+import { extension as toolWriteFileExt } from "@dispatch/tool-write-file";
import { createTransportHttpExtension } from "@dispatch/transport-http";
import { createTransportWsExtension } from "@dispatch/transport-ws";
import type { ChildHandle } from "./collector-supervisor.js";
@@ -62,7 +65,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [
conversationStoreExt,
authApikeyExt,
providerOpenaiCompatExt,
+ toolEditFileExt,
toolReadFileExt,
+ toolShellExt,
+ toolWriteFileExt,
throughputStoreExt,
sessionOrchestratorExt,
createTransportHttpExtension(),
diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json
index 53762c7..9fedaf9 100644
--- a/packages/host-bin/tsconfig.json
+++ b/packages/host-bin/tsconfig.json
@@ -8,6 +8,9 @@
{ "path": "../surface-loaded-extensions" },
{ "path": "../surface-registry" },
{ "path": "../tool-read-file" },
+ { "path": "../tool-shell" },
+ { "path": "../tool-edit-file" },
+ { "path": "../tool-write-file" },
{ "path": "../throughput-store" },
{ "path": "../transport-http" },
{ "path": "../transport-ws" }
diff --git a/packages/tool-edit-file/package.json b/packages/tool-edit-file/package.json
new file mode 100644
index 0000000..f71ad80
--- /dev/null
+++ b/packages/tool-edit-file/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/tool-edit-file",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts
new file mode 100644
index 0000000..dba3d9e
--- /dev/null
+++ b/packages/tool-edit-file/src/edit-file.test.ts
@@ -0,0 +1,361 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ computeReplacement,
+ createEditFileTool,
+ isPathWithinWorkdir,
+ validateArgs,
+} from "./edit-file.js";
+
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
+}
+
+let workdir: string;
+
+beforeEach(async () => {
+ workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-"));
+});
+
+afterEach(async () => {
+ await rm(workdir, { recursive: true, force: true });
+});
+
+describe("validateArgs", () => {
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false });
+ });
+
+ it("parses replaceAll true", () => {
+ const result = validateArgs({
+ path: "f.txt",
+ oldString: "a",
+ newString: "b",
+ replaceAll: true,
+ });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true });
+ });
+
+ it("defaults replaceAll to false when omitted", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("replaceAll", false);
+ });
+
+ it("returns error for null args", () => {
+ const result = validateArgs(null);
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing path", () => {
+ const result = validateArgs({ oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing oldString", () => {
+ const result = validateArgs({ path: "f.txt", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing newString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string path", () => {
+ const result = validateArgs({ path: 123, oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string oldString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+});
+
+describe("computeReplacement", () => {
+ it("replaces a single occurrence", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("replaces all occurrences when replaceAll is true", () => {
+ const result = computeReplacement("aaa", "a", "b", true);
+ expect(result).toEqual({ content: "bbb", count: 3 });
+ });
+
+ it("returns identical error when newString equals oldString", () => {
+ const result = computeReplacement("hello", "hello", "hello", false);
+ expect(result).toEqual({ kind: "identical" });
+ });
+
+ it("returns notFound error when oldString is not in content", () => {
+ const result = computeReplacement("hello", "xyz", "abc", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => {
+ const result = computeReplacement("abc abc abc", "abc", "xyz", false);
+ expect(result).toEqual({ kind: "notUnique", count: 3 });
+ });
+
+ it("replaces only the single match when unique", () => {
+ const result = computeReplacement("foo bar baz", "bar", "qux", false);
+ expect(result).toEqual({ content: "foo qux baz", count: 1 });
+ });
+
+ it("handles replaceAll with multiple occurrences", () => {
+ const result = computeReplacement("one two one two", "two", "three", true);
+ expect(result).toEqual({ content: "one three one three", count: 2 });
+ });
+
+ it("handles empty oldString as notFound (empty string not searched)", () => {
+ // empty oldString would cause infinite loop in split, so we treat it as not-found
+ const result = computeReplacement("hello", "", "x", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("handles oldString at start of content", () => {
+ const result = computeReplacement("hello world", "hello", "goodbye", false);
+ expect(result).toEqual({ content: "goodbye world", count: 1 });
+ });
+
+ it("handles oldString at end of content", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("handles multiline oldString and newString", () => {
+ const content = "line1\nold line\nline3";
+ const result = computeReplacement(content, "old line", "new line", false);
+ expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 });
+ });
+});
+
+describe("isPathWithinWorkdir", () => {
+ it("accepts a path within workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir/file.txt", "/tmp/workdir")).toBe(true);
+ });
+
+ it("accepts the workdir itself", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir", "/tmp/workdir")).toBe(true);
+ });
+
+ it("rejects a path outside workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/other/file.txt", "/tmp/workdir")).toBe(false);
+ });
+
+ it("rejects a prefix attack (workdir prefix but different dir)", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir-evil/file.txt", "/tmp/workdir")).toBe(false);
+ });
+});
+
+describe("createEditFileTool", () => {
+ it("replaces a single occurrence", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello world\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "world", newString: "there" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there\n");
+ });
+
+ it("replaces all occurrences when replaceAll is true", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "aaa\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "a", newString: "b", replaceAll: true },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 3 occurrences");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("bbb\n");
+ });
+
+ it("errors when oldString is not found", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "xyz", newString: "abc" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("oldString not found");
+ });
+
+ it("errors when oldString is non-unique and replaceAll is false", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "abc abc abc\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "abc", newString: "xyz" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Found 3 matches");
+ });
+
+ it("errors when newString equals oldString", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "hello", newString: "hello" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("newString must differ from oldString");
+ });
+
+ it("errors / not-found for a nonexistent file", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "nonexistent.txt", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("not found");
+ });
+
+ it("rejects a path outside the working directory", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "../escape.txt", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("rejects an absolute path outside workdir", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "/etc/passwd", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("handles symlink escape attempt", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "outside-"));
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "secret data", "utf8");
+
+ const symlinkPath = join(workdir, "link.txt");
+ const { symlink } = await import("node:fs/promises");
+ await symlink(outsideFile, symlinkPath);
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "link.txt", oldString: "secret", newString: "leaked" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+
+ await rm(outsideDir, { recursive: true, force: true });
+ });
+
+ it("reads file under ctx.cwd when set", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const filePath = join(ctxDir, "ctx-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "ctx-file.txt", oldString: "world", newString: "there" },
+ stubCtx({ cwd: ctxDir }),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ const filePath = join(workdir, "baked-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const ctx = stubCtx();
+ expect(ctx.cwd).toBeUndefined();
+ const result = await tool.execute(
+ { path: "baked-file.txt", oldString: "world", newString: "there" },
+ ctx,
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+ });
+
+ it("never throws on bad input (always returns ToolResult)", async () => {
+ const tool = createEditFileTool(workdir);
+
+ const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
+ for (const input of inputs) {
+ const result = await tool.execute(input, stubCtx());
+ expect(result).toHaveProperty("content");
+ expect(typeof result.content).toBe("string");
+ }
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = createEditFileTool(workdir);
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("has correct name and parameters shape", () => {
+ const tool = createEditFileTool(workdir);
+ expect(tool.name).toBe("edit_file");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]);
+ expect(tool.parameters.properties?.path?.type).toBe("string");
+ expect(tool.parameters.properties?.oldString?.type).toBe("string");
+ expect(tool.parameters.properties?.newString?.type).toBe("string");
+ expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean");
+ });
+});
diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts
new file mode 100644
index 0000000..af630aa
--- /dev/null
+++ b/packages/tool-edit-file/src/edit-file.ts
@@ -0,0 +1,251 @@
+import { readFile, realpath, writeFile } from "node:fs/promises";
+import { resolve, sep } from "node:path";
+import type { ToolContract, ToolResult } from "@dispatch/kernel";
+
+// --- Pure types ---
+
+interface ValidatedArgs {
+ readonly path: string;
+ readonly oldString: string;
+ readonly newString: string;
+ readonly replaceAll: boolean;
+}
+
+export type ReplacementError =
+ | { readonly kind: "identical" }
+ | { readonly kind: "notFound" }
+ | { readonly kind: "notUnique"; readonly count: number };
+
+export interface ReplacementSuccess {
+ readonly content: string;
+ readonly count: number;
+}
+
+// --- Pure functions ---
+
+/** Pure: validate and coerce args from the model. */
+export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
+ return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' };
+ }
+
+ const rawOld = obj.oldString;
+ if (typeof rawOld !== "string" || rawOld.length === 0) {
+ return {
+ error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).',
+ };
+ }
+
+ const rawNew = obj.newString;
+ if (typeof rawNew !== "string") {
+ return {
+ error: 'Error: Missing or invalid "newString" parameter (must be a string).',
+ };
+ }
+
+ const rawReplaceAll = obj.replaceAll;
+ const replaceAll = rawReplaceAll === true;
+
+ return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll };
+}
+
+/** Pure: compute the replacement result given file content + params. */
+export function computeReplacement(
+ content: string,
+ oldString: string,
+ newString: string,
+ replaceAll: boolean,
+): ReplacementSuccess | ReplacementError {
+ if (oldString === newString) {
+ return { kind: "identical" };
+ }
+
+ if (oldString === "") {
+ return { kind: "notFound" };
+ }
+
+ if (!content.includes(oldString)) {
+ return { kind: "notFound" };
+ }
+
+ if (replaceAll) {
+ const parts = content.split(oldString);
+ const count = parts.length - 1;
+ return { content: parts.join(newString), count };
+ }
+
+ // Single replacement — check uniqueness.
+ const firstIndex = content.indexOf(oldString);
+ const secondIndex = content.indexOf(oldString, firstIndex + oldString.length);
+ if (secondIndex !== -1) {
+ // Count total occurrences.
+ let count = 0;
+ let idx = 0;
+ while (true) {
+ idx = content.indexOf(oldString, idx);
+ if (idx === -1) break;
+ count++;
+ idx += oldString.length;
+ }
+ return { kind: "notUnique", count };
+ }
+
+ return {
+ content:
+ content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length),
+ count: 1,
+ };
+}
+
+/** Pure: check that a resolved absolute path is within the workdir (prefix check). */
+export function isPathWithinWorkdir(resolvedPath: string, workdir: string): boolean {
+ const normalizedWorkdir = workdir.endsWith(sep) ? workdir : workdir + sep;
+ return resolvedPath === workdir || resolvedPath.startsWith(normalizedWorkdir);
+}
+
+// --- Shell / edge ---
+
+/**
+ * Factory: create an edit_file ToolContract bound to a working directory.
+ * The working directory is injected so the tool is testable.
+ */
+export function createEditFileTool(workingDirectory: string): ToolContract {
+ const workdir = resolve(workingDirectory);
+
+ return {
+ name: "edit_file",
+ description:
+ "Perform an exact string replacement in an existing file. " +
+ "Provide oldString (the text to find) and newString (the replacement). " +
+ "By default replaces a single occurrence; set replaceAll to replace every match.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file, relative to the working directory.",
+ },
+ oldString: {
+ type: "string",
+ description: "The exact string to find and replace.",
+ },
+ newString: {
+ type: "string",
+ description: "The string to replace oldString with.",
+ },
+ replaceAll: {
+ type: "boolean",
+ description: "Replace all occurrences (default: false).",
+ default: false,
+ },
+ },
+ required: ["path", "oldString", "newString"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { path: relPath, oldString, newString, replaceAll } = validated;
+
+ // Effective base: per-turn ctx.cwd overrides the baked workdir.
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
+
+ // Resolve the requested path against the effective base.
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ // Basic prefix check.
+ if (!isPathWithinWorkdir(resolvedPath, effectiveBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // Symlink hardening: realpath both and re-check containment.
+ let realResolved: string;
+ let realBase: string;
+ try {
+ [realResolved, realBase] = await Promise.all([
+ realpath(resolvedPath),
+ realpath(effectiveBase),
+ ]);
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return { content: `Error: File "${relPath}" not found.`, isError: true };
+ }
+ return {
+ content: `Error accessing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ if (!isPathWithinWorkdir(realResolved, realBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // Read the file.
+ let content: string;
+ try {
+ content = await readFile(resolvedPath, "utf8");
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return { content: `Error: File "${relPath}" not found.`, isError: true };
+ }
+ return {
+ content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ // Pure replacement decision.
+ const result = computeReplacement(content, oldString, newString, replaceAll);
+
+ if ("kind" in result) {
+ switch (result.kind) {
+ case "identical":
+ return {
+ content: "Error: newString must differ from oldString.",
+ isError: true,
+ };
+ case "notFound":
+ return {
+ content: `Error: oldString not found in content of "${relPath}".`,
+ isError: true,
+ };
+ case "notUnique":
+ return {
+ content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`,
+ isError: true,
+ };
+ }
+ }
+
+ // Write the modified content back.
+ try {
+ await writeFile(resolvedPath, result.content, "utf8");
+ } catch (err: unknown) {
+ return {
+ content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ const plural = result.count === 1 ? "" : "s";
+ return { content: `Replaced ${result.count} occurrence${plural} in "${relPath}".` };
+ },
+ };
+}
diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts
new file mode 100644
index 0000000..a4bb19e
--- /dev/null
+++ b/packages/tool-edit-file/src/extension.ts
@@ -0,0 +1,18 @@
+import type { Extension } from "@dispatch/kernel";
+import { createEditFileTool } from "./edit-file.js";
+
+export const extension: Extension = {
+ manifest: {
+ id: "tool-edit-file",
+ name: "Edit File Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { fs: true },
+ contributes: { tools: ["edit_file"] },
+ },
+ activate(host) {
+ host.defineTool(createEditFileTool(process.cwd()));
+ },
+};
diff --git a/packages/tool-edit-file/src/index.ts b/packages/tool-edit-file/src/index.ts
new file mode 100644
index 0000000..49baf93
--- /dev/null
+++ b/packages/tool-edit-file/src/index.ts
@@ -0,0 +1,2 @@
+export { createEditFileTool } from "./edit-file.js";
+export { extension } from "./extension.js";
diff --git a/packages/tool-edit-file/tsconfig.json b/packages/tool-edit-file/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/tool-edit-file/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/packages/tool-read-file/src/index.ts b/packages/tool-read-file/src/index.ts
index 2903efc..1846972 100644
--- a/packages/tool-read-file/src/index.ts
+++ b/packages/tool-read-file/src/index.ts
@@ -1,2 +1,2 @@
export { extension } from "./extension.js";
-export { createReadFileTool } from "./read-file.js";
+export { createReadFileTool, type DirEntry, formatDirectoryEntries } from "./read-file.js";
diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts
index 2725a05..25b29ff 100644
--- a/packages/tool-read-file/src/read-file.test.ts
+++ b/packages/tool-read-file/src/read-file.test.ts
@@ -1,10 +1,11 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createReadFileTool,
+ formatDirectoryEntries,
isPathWithinWorkdir,
renderLines,
sliceLines,
@@ -137,6 +138,33 @@ describe("renderLines", () => {
});
});
+describe("formatDirectoryEntries", () => {
+ it("lists directory entries sorted with trailing slash on subdirectories", () => {
+ const entries = [
+ { name: "zebra.txt", isDirectory: false },
+ { name: "alpha", isDirectory: true },
+ { name: "readme.md", isDirectory: false },
+ { name: "beta", isDirectory: true },
+ ];
+ const result = formatDirectoryEntries(entries, "mydir");
+ expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt");
+ });
+
+ it("returns empty-directory message for an empty dir", () => {
+ const result = formatDirectoryEntries([], "empty-dir");
+ expect(result).toBe("(empty directory: empty-dir)");
+ });
+
+ it("handles mixed files and directories with same name sorting", () => {
+ const entries = [
+ { name: "b", isDirectory: false },
+ { name: "a", isDirectory: true },
+ ];
+ const result = formatDirectoryEntries(entries, ".");
+ expect(result).toBe("a/\nb");
+ });
+});
+
describe("createReadFileTool", () => {
it("reads a real temp file", async () => {
const filePath = join(workdir, "hello.txt");
@@ -316,4 +344,52 @@ describe("createReadFileTool", () => {
expect(result.isError).toBeUndefined();
expect(result.content).toContain("1: from baked workdir");
});
+
+ it("lists directory entries sorted with trailing slash on subdirectories", async () => {
+ await mkdir(join(workdir, "subdir"));
+ await writeFile(join(workdir, "zebra.txt"), "z", "utf8");
+ await writeFile(join(workdir, "alpha.txt"), "a", "utf8");
+
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "." }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt");
+ });
+
+ it("returns empty-directory message for an empty dir", async () => {
+ await mkdir(join(workdir, "empty-dir"));
+
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "empty-dir" }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe("(empty directory: empty-dir)");
+ });
+
+ it("reads a file unchanged (regression: line numbers + offset/limit)", async () => {
+ await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8");
+
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe("2: b\n3: c\n4: d");
+ });
+
+ it("rejects a directory path outside the working directory (containment still enforced)", async () => {
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "../outside-dir" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("returns not-found for a nonexistent path", async () => {
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "nonexistent-path" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("not found");
+ });
});
diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts
index d4a4de8..99b396e 100644
--- a/packages/tool-read-file/src/read-file.ts
+++ b/packages/tool-read-file/src/read-file.ts
@@ -1,4 +1,4 @@
-import { readFile, realpath } from "node:fs/promises";
+import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { resolve, sep } from "node:path";
import type { ToolContract, ToolResult } from "@dispatch/kernel";
@@ -70,6 +70,24 @@ export function renderLines(lines: readonly string[], offset: number): string {
return lines.map((line, i) => `${offset + i}: ${line}`).join("\n");
}
+/** A directory entry with its type. */
+export interface DirEntry {
+ readonly name: string;
+ readonly isDirectory: boolean;
+}
+
+/**
+ * Pure: format directory entries into a sorted listing string.
+ * Subdirectories get a trailing `/`. Empty input returns an empty-directory message.
+ */
+export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: string): string {
+ if (entries.length === 0) {
+ return `(empty directory: ${dirPath})`;
+ }
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
+ return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n");
+}
+
/**
* Factory: create a read_file ToolContract bound to a working directory.
* The working directory is injected so the tool is testable.
@@ -80,8 +98,10 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
return {
name: "read_file",
description:
- "Read the contents of a file. Returns lines with 1-indexed line numbers. " +
- "Supports offset/limit for reading specific sections of large files.",
+ "Read the contents of a file or list a directory's contents. " +
+ "For files, returns lines with 1-indexed line numbers. " +
+ "Supports offset/limit for reading specific sections of large files. " +
+ "For directories, returns sorted entries with subdirectories suffixed by /.",
parameters: {
type: "object",
properties: {
@@ -151,7 +171,40 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
};
}
- // Read the file.
+ // Stat to determine if this is a file or directory.
+ let pathStat: import("node:fs").Stats;
+ try {
+ pathStat = await stat(resolvedPath);
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return { content: `Error: File "${relPath}" not found.`, isError: true };
+ }
+ return {
+ content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ // Directory listing branch.
+ if (pathStat.isDirectory()) {
+ let rawEntries: import("node:fs").Dirent<string>[];
+ try {
+ rawEntries = await readdir(resolvedPath, { encoding: "utf8", withFileTypes: true });
+ } catch (err: unknown) {
+ return {
+ content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ const dirEntries = rawEntries.map((e) => ({
+ name: e.name,
+ isDirectory: e.isDirectory(),
+ }));
+ return { content: formatDirectoryEntries(dirEntries, relPath) };
+ }
+
+ // File branch — read the file.
let content: string;
try {
content = await readFile(resolvedPath, "utf8");
diff --git a/packages/tool-shell/package.json b/packages/tool-shell/package.json
new file mode 100644
index 0000000..3c5995c
--- /dev/null
+++ b/packages/tool-shell/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/tool-shell",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts
new file mode 100644
index 0000000..1a89de0
--- /dev/null
+++ b/packages/tool-shell/src/extension.ts
@@ -0,0 +1,19 @@
+import type { Extension } from "@dispatch/kernel";
+import { createRunShellTool } from "./shell.js";
+import { realSpawn } from "./spawn.js";
+
+export const extension: Extension = {
+ manifest: {
+ id: "tool-shell",
+ name: "Shell Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { shell: true },
+ contributes: { tools: ["run_shell"] },
+ },
+ activate(host) {
+ host.defineTool(createRunShellTool({ workdir: process.cwd(), spawn: realSpawn }));
+ },
+};
diff --git a/packages/tool-shell/src/index.ts b/packages/tool-shell/src/index.ts
new file mode 100644
index 0000000..efd36fc
--- /dev/null
+++ b/packages/tool-shell/src/index.ts
@@ -0,0 +1,3 @@
+export { extension } from "./extension.js";
+export type { SpawnResult, SpawnShell, ValidatedArgs } from "./shell.js";
+export { createRunShellTool } from "./shell.js";
diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts
new file mode 100644
index 0000000..a70693b
--- /dev/null
+++ b/packages/tool-shell/src/shell.test.ts
@@ -0,0 +1,357 @@
+import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import {
+ buildResult,
+ createRunShellTool,
+ type SpawnShell,
+ truncateOutput,
+ validateArgs,
+} from "./shell.js";
+
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
+}
+
+function fakeSpawn(result: { exitCode: number | null; timedOut: boolean }): SpawnShell {
+ return async () => result;
+}
+
+describe("validateArgs", () => {
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ command: "echo hello" });
+ expect(result).toEqual({ command: "echo hello", timeout: 120_000 });
+ });
+
+ it("parses custom timeout", () => {
+ const result = validateArgs({ command: "echo hello", timeout: 5000 });
+ expect(result).toEqual({ command: "echo hello", timeout: 5000 });
+ });
+
+ it("floors fractional timeout", () => {
+ const result = validateArgs({ command: "echo hello", timeout: 5000.7 });
+ expect(result).toEqual({ command: "echo hello", timeout: 5000 });
+ });
+
+ it("returns error for null args", () => {
+ const result = validateArgs(null);
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-object args", () => {
+ const result = validateArgs("string");
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing command", () => {
+ const result = validateArgs({});
+ expect(result).toHaveProperty("error");
+ });
+
+ it("rejects missing or empty command", () => {
+ const empty = validateArgs({ command: "" });
+ expect(empty).toHaveProperty("error");
+ const whitespace = validateArgs({ command: " " });
+ expect(whitespace).toHaveProperty("error");
+ const missing = validateArgs({ timeout: 5000 });
+ expect(missing).toHaveProperty("error");
+ });
+
+ it("returns error for non-string command", () => {
+ const result = validateArgs({ command: 123 });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for invalid timeout", () => {
+ const negative = validateArgs({ command: "echo", timeout: -1 });
+ expect(negative).toHaveProperty("error");
+ const zero = validateArgs({ command: "echo", timeout: 0 });
+ expect(zero).toHaveProperty("error");
+ const nan = validateArgs({ command: "echo", timeout: Number.NaN });
+ expect(nan).toHaveProperty("error");
+ });
+});
+
+describe("truncateOutput", () => {
+ it("returns output unchanged when under cap", () => {
+ const output = "short output";
+ expect(truncateOutput(output, 100)).toBe("short output");
+ });
+
+ it("returns output unchanged when exactly at cap", () => {
+ const output = "exact";
+ expect(truncateOutput(output, 5)).toBe("exact");
+ });
+
+ it("truncates output beyond the cap and appends a notice", () => {
+ const output = "a".repeat(100);
+ const result = truncateOutput(output, 50);
+ expect(result).toContain("a".repeat(50));
+ expect(result).toContain("[Output truncated: exceeded 50 characters]");
+ expect(result.length).toBeLessThan(output.length + 100);
+ });
+});
+
+describe("buildResult", () => {
+ it("maps a zero exit code to a success result", () => {
+ const result = buildResult({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ output: "all good",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("all good");
+ expect(result.isError).toBeUndefined();
+ });
+
+ it("maps a non-zero exit code to an isError result", () => {
+ const result = buildResult({
+ exitCode: 1,
+ timedOut: false,
+ aborted: false,
+ output: "some error",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("some error");
+ expect(result.isError).toBe(true);
+ });
+
+ it("reports a timeout as an isError result", () => {
+ const result = buildResult({
+ exitCode: null,
+ timedOut: true,
+ aborted: false,
+ output: "partial",
+ cap: 50_000,
+ });
+ expect(result.content).toContain("partial");
+ expect(result.content).toContain("[Command timed out]");
+ expect(result.isError).toBe(true);
+ });
+
+ it("reports abort as an isError result", () => {
+ const result = buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output: "interrupted",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("interrupted");
+ expect(result.isError).toBe(true);
+ });
+
+ it("truncates output in result when over cap", () => {
+ const output = "x".repeat(60_000);
+ const result = buildResult({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ output,
+ cap: 50_000,
+ });
+ expect(result.content).toContain("[Output truncated");
+ expect(result.content.length).toBeLessThan(60_000);
+ });
+});
+
+describe("createRunShellTool", () => {
+ it("has correct name and parameters shape", () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ });
+ expect(tool.name).toBe("run_shell");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["command"]);
+ expect(tool.parameters.properties?.command?.type).toBe("string");
+ expect(tool.parameters.properties?.timeout?.type).toBe("number");
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ });
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("rejects missing or empty command", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ });
+ const result = await tool.execute({}, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Missing or empty");
+ });
+
+ it("maps a zero exit code to a success result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async (_params) => {
+ _params.onOutput("hello\n", "stdout");
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ const result = await tool.execute({ command: "echo hello" }, stubCtx());
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("hello");
+ });
+
+ it("maps a non-zero exit code to an isError result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async (_params) => {
+ _params.onOutput("error output\n", "stderr");
+ return { exitCode: 1, timedOut: false };
+ },
+ });
+ const result = await tool.execute({ command: "false" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("error output");
+ });
+
+ it("reports a timeout as an isError result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async (_params) => {
+ _params.onOutput("partial\n", "stdout");
+ return { exitCode: null, timedOut: true };
+ },
+ });
+ const result = await tool.execute({ command: "sleep 999" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("[Command timed out]");
+ });
+
+ it("truncates output beyond the cap and appends a notice", async () => {
+ const cap = 100;
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ outputCap: cap,
+ spawn: async (_params) => {
+ _params.onOutput("a".repeat(200), "stdout");
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ const result = await tool.execute({ command: "gen" }, stubCtx());
+ expect(result.content).toContain("[Output truncated");
+ expect(result.content.length).toBeLessThan(200);
+ });
+
+ it("streams output to ctx.onOutput", async () => {
+ const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async (params) => {
+ params.onOutput("line1\n", "stdout");
+ params.onOutput("err1\n", "stderr");
+ params.onOutput("line2\n", "stdout");
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ await tool.execute(
+ { command: "test" },
+ stubCtx({
+ onOutput: (data, stream) => chunks.push({ data, stream }),
+ }),
+ );
+ expect(chunks).toEqual([
+ { data: "line1\n", stream: "stdout" },
+ { data: "err1\n", stream: "stderr" },
+ { data: "line2\n", stream: "stdout" },
+ ]);
+ });
+
+ it("uses ctx.cwd when present over baked workdir", async () => {
+ let receivedCwd = "";
+ const tool = createRunShellTool({
+ workdir: "/baked",
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" }));
+ expect(receivedCwd).toBe("/custom");
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ let receivedCwd = "";
+ const tool = createRunShellTool({
+ workdir: "/baked",
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ await tool.execute({ command: "pwd" }, stubCtx());
+ expect(receivedCwd).toBe("/baked");
+ });
+
+ it("returns error for spawn failure", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async () => {
+ throw new Error("spawn failed");
+ },
+ });
+ const result = await tool.execute({ command: "bad" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Error spawning command");
+ });
+
+ it("reports abort as isError when signal fires before spawn completes", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async () => ({ exitCode: 0, timedOut: false }),
+ });
+ const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal }));
+ expect(result.isError).toBe(true);
+ });
+
+ it("passes timeout to spawn", async () => {
+ let receivedTimeout = 0;
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ spawn: async (params) => {
+ receivedTimeout = params.timeout;
+ return { exitCode: 0, timedOut: false };
+ },
+ });
+ await tool.execute({ command: "test", timeout: 5000 }, stubCtx());
+ expect(receivedTimeout).toBe(5000);
+ });
+});
+
+describe("createRunShellTool (integration)", () => {
+ it("runs a real echo command and captures stdout + cwd", async () => {
+ const { realSpawn } = await import("./spawn.js");
+ const tool = createRunShellTool({ workdir: "/tmp", spawn: realSpawn });
+ let streamed = "";
+ const result = await tool.execute(
+ { command: "echo hello-from-shell" },
+ stubCtx({
+ onOutput: (data) => {
+ streamed += data;
+ },
+ }),
+ );
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("hello-from-shell");
+ expect(streamed).toContain("hello-from-shell");
+ });
+});
diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts
new file mode 100644
index 0000000..d96d73e
--- /dev/null
+++ b/packages/tool-shell/src/shell.ts
@@ -0,0 +1,181 @@
+import { resolve } from "node:path";
+import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
+
+const DEFAULT_TIMEOUT = 120_000;
+const OUTPUT_CAP = 50_000;
+
+export interface ValidatedArgs {
+ readonly command: string;
+ readonly timeout: number;
+}
+
+export interface SpawnResult {
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+}
+
+export type SpawnShell = (params: {
+ readonly command: string;
+ readonly cwd: string;
+ readonly signal: AbortSignal;
+ readonly timeout: number;
+ readonly onOutput: (data: string, stream: "stdout" | "stderr") => void;
+}) => Promise<SpawnResult>;
+
+export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawCommand = obj.command;
+ if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) {
+ return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' };
+ }
+
+ let timeout = DEFAULT_TIMEOUT;
+ if (obj.timeout !== undefined) {
+ const n = Number(obj.timeout);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' };
+ }
+ timeout = Math.floor(n);
+ }
+
+ return { command: rawCommand, timeout };
+}
+
+export function truncateOutput(output: string, cap: number): string {
+ if (output.length <= cap) {
+ return output;
+ }
+ const truncated = output.slice(0, cap);
+ return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`;
+}
+
+export function buildResult(params: {
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+ readonly aborted: boolean;
+ readonly output: string;
+ readonly cap: number;
+}): ToolResult {
+ if (params.aborted) {
+ return {
+ content: truncateOutput(params.output, params.cap),
+ isError: true,
+ };
+ }
+ if (params.timedOut) {
+ const content = truncateOutput(params.output, params.cap);
+ return {
+ content: `${content}\n\n[Command timed out]`,
+ isError: true,
+ };
+ }
+ const exitCode = params.exitCode;
+ if (exitCode !== null && exitCode !== 0) {
+ return {
+ content: truncateOutput(params.output, params.cap),
+ isError: true,
+ };
+ }
+ return {
+ content: truncateOutput(params.output, params.cap),
+ };
+}
+
+export function createRunShellTool(deps: {
+ readonly workdir: string;
+ readonly spawn: SpawnShell;
+ readonly outputCap?: number;
+}): ToolContract {
+ const workdir = resolve(deps.workdir);
+ const cap = deps.outputCap ?? OUTPUT_CAP;
+
+ return {
+ name: "run_shell",
+ description:
+ "Execute a shell command and return its output. " +
+ "Use for running CLI tools, scripts, or system commands.",
+ parameters: {
+ type: "object",
+ properties: {
+ command: {
+ type: "string",
+ description: "The shell command to execute.",
+ },
+ timeout: {
+ type: "number",
+ description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`,
+ default: DEFAULT_TIMEOUT,
+ },
+ },
+ required: ["command"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { command, timeout } = validated;
+ const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir;
+
+ if (ctx.signal.aborted) {
+ return buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output: "",
+ cap,
+ });
+ }
+
+ let output = "";
+ const appendOutput = (data: string, _stream: "stdout" | "stderr") => {
+ output += data;
+ };
+
+ let spawnResult: SpawnResult;
+ let aborted = false;
+
+ try {
+ spawnResult = await deps.spawn({
+ command,
+ cwd: effectiveCwd,
+ signal: ctx.signal,
+ timeout,
+ onOutput: (data, stream) => {
+ ctx.onOutput(data, stream);
+ appendOutput(data, stream);
+ },
+ });
+ } catch (err: unknown) {
+ if (ctx.signal.aborted) {
+ aborted = true;
+ return buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output,
+ cap,
+ });
+ }
+ return {
+ content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ return buildResult({
+ exitCode: spawnResult.exitCode,
+ timedOut: spawnResult.timedOut,
+ aborted,
+ output,
+ cap,
+ });
+ },
+ };
+}
diff --git a/packages/tool-shell/src/spawn.ts b/packages/tool-shell/src/spawn.ts
new file mode 100644
index 0000000..9025c26
--- /dev/null
+++ b/packages/tool-shell/src/spawn.ts
@@ -0,0 +1,46 @@
+import { spawn as nodeSpawn } from "node:child_process";
+import type { SpawnResult, SpawnShell } from "./shell.js";
+
+export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => {
+ return new Promise<SpawnResult>((resolve) => {
+ const child = nodeSpawn("sh", ["-c", params.command], {
+ cwd: params.cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let timedOut = false;
+ let killed = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ child.kill("SIGKILL");
+ }, params.timeout);
+
+ const onAbort = () => {
+ killed = true;
+ child.kill("SIGKILL");
+ };
+ params.signal.addEventListener("abort", onAbort, { once: true });
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stdout");
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stderr");
+ });
+
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ params.signal.removeEventListener("abort", onAbort);
+ resolve({ exitCode: code, timedOut });
+ });
+
+ child.on("error", () => {
+ clearTimeout(timer);
+ params.signal.removeEventListener("abort", onAbort);
+ if (!killed && !timedOut) {
+ resolve({ exitCode: 1, timedOut: false });
+ }
+ });
+ });
+};
diff --git a/packages/tool-shell/tsconfig.json b/packages/tool-shell/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/tool-shell/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/packages/tool-write-file/package.json b/packages/tool-write-file/package.json
new file mode 100644
index 0000000..4aa3481
--- /dev/null
+++ b/packages/tool-write-file/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/tool-write-file",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/tool-write-file/src/extension.ts b/packages/tool-write-file/src/extension.ts
new file mode 100644
index 0000000..2008954
--- /dev/null
+++ b/packages/tool-write-file/src/extension.ts
@@ -0,0 +1,18 @@
+import type { Extension } from "@dispatch/kernel";
+import { createWriteFileTool } from "./write-file.js";
+
+export const extension: Extension = {
+ manifest: {
+ id: "tool-write-file",
+ name: "Write File Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { fs: true },
+ contributes: { tools: ["write_file"] },
+ },
+ activate(host) {
+ host.defineTool(createWriteFileTool(process.cwd()));
+ },
+};
diff --git a/packages/tool-write-file/src/index.ts b/packages/tool-write-file/src/index.ts
new file mode 100644
index 0000000..521a429
--- /dev/null
+++ b/packages/tool-write-file/src/index.ts
@@ -0,0 +1,7 @@
+export { extension } from "./extension.js";
+export {
+ createWriteFileTool,
+ decideOverwrite,
+ isPathWithinWorkdir,
+ validateArgs,
+} from "./write-file.js";
diff --git a/packages/tool-write-file/src/write-file.test.ts b/packages/tool-write-file/src/write-file.test.ts
new file mode 100644
index 0000000..cf4fa64
--- /dev/null
+++ b/packages/tool-write-file/src/write-file.test.ts
@@ -0,0 +1,291 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ createWriteFileTool,
+ decideOverwrite,
+ isPathWithinWorkdir,
+ validateArgs,
+} from "./write-file.js";
+
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
+}
+
+let workdir: string;
+
+beforeEach(async () => {
+ workdir = await mkdtemp(join(tmpdir(), "tool-write-file-test-"));
+});
+
+afterEach(async () => {
+ await rm(workdir, { recursive: true, force: true });
+});
+
+describe("decideOverwrite", () => {
+ it("returns create when file absent and overwrite is false", () => {
+ expect(decideOverwrite(false, false)).toBe("create");
+ });
+
+ it("returns create when file absent and overwrite is false (default)", () => {
+ expect(decideOverwrite(false, false)).toBe("create");
+ });
+
+ it("returns error when file exists and overwrite is false", () => {
+ const result = decideOverwrite(true, false);
+ expect(typeof result).toBe("object");
+ if (typeof result === "object") {
+ expect(result.error).toContain("already exists");
+ }
+ });
+
+ it("returns overwrite when file exists and overwrite is true", () => {
+ expect(decideOverwrite(true, true)).toBe("overwrite");
+ });
+
+ it("returns error when file absent and overwrite is true", () => {
+ const result = decideOverwrite(false, true);
+ expect(typeof result).toBe("object");
+ if (typeof result === "object") {
+ expect(result.error).toContain("does not exist");
+ }
+ });
+
+ it("covers all four rows of the truth table", () => {
+ expect(decideOverwrite(false, false)).toBe("create");
+ expect(decideOverwrite(true, false)).toEqual(
+ expect.objectContaining({ error: expect.any(String) }),
+ );
+ expect(decideOverwrite(true, true)).toBe("overwrite");
+ expect(decideOverwrite(false, true)).toEqual(
+ expect.objectContaining({ error: expect.any(String) }),
+ );
+ });
+});
+
+describe("isPathWithinWorkdir", () => {
+ it("accepts a path within workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir/file.txt", "/tmp/workdir")).toBe(true);
+ });
+
+ it("accepts the workdir itself", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir", "/tmp/workdir")).toBe(true);
+ });
+
+ it("rejects a path outside workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/other/file.txt", "/tmp/workdir")).toBe(false);
+ });
+
+ it("rejects a prefix attack (workdir prefix but different dir)", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir-evil/file.txt", "/tmp/workdir")).toBe(false);
+ });
+});
+
+describe("validateArgs", () => {
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ path: "foo.txt", content: "hello" });
+ expect(result).toEqual({ path: "foo.txt", content: "hello", overwrite: false });
+ });
+
+ it("parses overwrite as true", () => {
+ const result = validateArgs({ path: "foo.txt", content: "x", overwrite: true });
+ expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: true });
+ });
+
+ it("defaults overwrite to false", () => {
+ const result = validateArgs({ path: "foo.txt", content: "x" });
+ expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: false });
+ });
+
+ it("accepts empty string content", () => {
+ const result = validateArgs({ path: "foo.txt", content: "" });
+ expect(result).toEqual({ path: "foo.txt", content: "", overwrite: false });
+ });
+
+ it("returns error for null args", () => {
+ expect(validateArgs(null)).toHaveProperty("error");
+ });
+
+ it("returns error for missing path", () => {
+ expect(validateArgs({ content: "x" })).toHaveProperty("error");
+ });
+
+ it("returns error for missing content", () => {
+ expect(validateArgs({ path: "foo.txt" })).toHaveProperty("error");
+ });
+
+ it("returns error for non-string content", () => {
+ expect(validateArgs({ path: "foo.txt", content: 123 })).toHaveProperty("error");
+ });
+
+ it("returns error for non-boolean overwrite", () => {
+ expect(validateArgs({ path: "foo.txt", content: "x", overwrite: "yes" })).toHaveProperty(
+ "error",
+ );
+ });
+});
+
+describe("createWriteFileTool", () => {
+ it("creates a new file when overwrite is unset and the file is absent", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Created");
+ const written = await readFile(join(workdir, "new-file.txt"), "utf8");
+ expect(written).toBe("hello world");
+ });
+
+ it("errors when the file exists and overwrite is unset", async () => {
+ await writeFile(join(workdir, "existing.txt"), "old content", "utf8");
+
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("already exists");
+ expect(result.content).toContain("overwrite");
+ const unchanged = await readFile(join(workdir, "existing.txt"), "utf8");
+ expect(unchanged).toBe("old content");
+ });
+
+ it("overwrites an existing file when overwrite is true", async () => {
+ await writeFile(join(workdir, "existing.txt"), "old content", "utf8");
+
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute(
+ { path: "existing.txt", content: "new content", overwrite: true },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Overwrote");
+ const written = await readFile(join(workdir, "existing.txt"), "utf8");
+ expect(written).toBe("new content");
+ });
+
+ it("errors when overwrite is true but the file is absent", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute(
+ { path: "nonexistent.txt", content: "data", overwrite: true },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("does not exist");
+ });
+
+ it("errors when the parent directory does not exist", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Parent directory");
+ });
+
+ it("rejects a path outside the working directory", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "../escape.txt", content: "data" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("rejects an absolute path outside workdir", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "/tmp/escape.txt", content: "data" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = createWriteFileTool(workdir);
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("has correct name and parameters shape", () => {
+ const tool = createWriteFileTool(workdir);
+ expect(tool.name).toBe("write_file");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["path", "content"]);
+ expect(tool.parameters.properties?.path?.type).toBe("string");
+ expect(tool.parameters.properties?.content?.type).toBe("string");
+ expect(tool.parameters.properties?.overwrite?.type).toBe("boolean");
+ });
+
+ it("never throws on bad input (always returns ToolResult)", async () => {
+ const tool = createWriteFileTool(workdir);
+ const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
+ for (const input of inputs) {
+ const result = await tool.execute(input, stubCtx());
+ expect(result).toHaveProperty("content");
+ expect(typeof result.content).toBe("string");
+ }
+ });
+
+ it("respects ctx.cwd over baked workdir", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute(
+ { path: "ctx-file.txt", content: "from ctx" },
+ stubCtx({ cwd: ctxDir }),
+ );
+
+ expect(result.isError).toBeUndefined();
+ const written = await readFile(join(ctxDir, "ctx-file.txt"), "utf8");
+ expect(written).toBe("from ctx");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("handles symlink escape attempt", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "outside-"));
+ try {
+ const symlinkPath = join(workdir, "link.txt");
+ const { symlink } = await import("node:fs/promises");
+ await symlink(join(outsideDir, "target.txt"), symlinkPath);
+
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "link.txt", content: "escape" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ } finally {
+ await rm(outsideDir, { recursive: true, force: true });
+ }
+ });
+
+ it("writes empty content", async () => {
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ const written = await readFile(join(workdir, "empty.txt"), "utf8");
+ expect(written).toBe("");
+ });
+
+ it("writes content in subdirectory that exists", async () => {
+ await mkdir(join(workdir, "sub"));
+ const tool = createWriteFileTool(workdir);
+ const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ const written = await readFile(join(workdir, "sub", "file.txt"), "utf8");
+ expect(written).toBe("nested");
+ });
+});
diff --git a/packages/tool-write-file/src/write-file.ts b/packages/tool-write-file/src/write-file.ts
new file mode 100644
index 0000000..16cfc83
--- /dev/null
+++ b/packages/tool-write-file/src/write-file.ts
@@ -0,0 +1,210 @@
+import { access, lstat, readlink, realpath, stat, writeFile } from "node:fs/promises";
+import { dirname, resolve, sep } from "node:path";
+import type { ToolContract, ToolResult } from "@dispatch/kernel";
+
+interface ValidatedArgs {
+ readonly path: string;
+ readonly content: string;
+ readonly overwrite: boolean;
+}
+
+export type OverwriteDecision = "create" | "overwrite" | { readonly error: string };
+
+/** Pure: decide the action based on file existence and the overwrite flag. */
+export function decideOverwrite(fileExists: boolean, overwrite: boolean): OverwriteDecision {
+ if (!fileExists && !overwrite) return "create";
+ if (fileExists && !overwrite) {
+ return { error: "Error: File already exists; set overwrite: true to replace it." };
+ }
+ if (fileExists && overwrite) return "overwrite";
+ return { error: "Error: overwrite: true but the file does not exist." };
+}
+
+/** Pure: check that a resolved absolute path is within the workdir (prefix check). */
+export function isPathWithinWorkdir(resolvedPath: string, workdir: string): boolean {
+ const normalizedWorkdir = workdir.endsWith(sep) ? workdir : workdir + sep;
+ return resolvedPath === workdir || resolvedPath.startsWith(normalizedWorkdir);
+}
+
+/** Pure: validate and coerce args from the model. */
+export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
+ return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' };
+ }
+
+ const rawContent = obj.content;
+ if (typeof rawContent !== "string") {
+ return {
+ error: 'Error: Missing or invalid "content" parameter (must be a string).',
+ };
+ }
+
+ let overwrite = false;
+ if (obj.overwrite !== undefined) {
+ if (typeof obj.overwrite !== "boolean") {
+ return { error: 'Error: Invalid "overwrite" parameter (must be a boolean).' };
+ }
+ overwrite = obj.overwrite;
+ }
+
+ return { path: rawPath, content: rawContent, overwrite };
+}
+
+/**
+ * Factory: create a write_file ToolContract bound to a working directory.
+ * The working directory is injected so the tool is testable.
+ */
+export function createWriteFileTool(workingDirectory: string): ToolContract {
+ const workdir = resolve(workingDirectory);
+
+ return {
+ name: "write_file",
+ description:
+ "Write a whole file to disk. " +
+ "By default, creates a new file; errors if it already exists. " +
+ "Set overwrite: true to replace an existing file (errors if the file does not exist). " +
+ "Parent directories are NOT auto-created — the parent must already exist.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file, relative to the working directory.",
+ },
+ content: {
+ type: "string",
+ description: "The full content to write to the file.",
+ },
+ overwrite: {
+ type: "boolean",
+ description:
+ "When false/unset: creates a new file (errors if it already exists). " +
+ "When true: replaces an existing file (errors if it does not exist).",
+ default: false,
+ },
+ },
+ required: ["path", "content"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { path: relPath, content, overwrite } = validated;
+
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ if (!isPathWithinWorkdir(resolvedPath, effectiveBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // Symlink hardening: realpath the parent directory and the base, then re-check.
+ let realParent: string;
+ let realBase: string;
+ try {
+ const parentDir = dirname(resolvedPath);
+ [realParent, realBase] = await Promise.all([realpath(parentDir), realpath(effectiveBase)]);
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return {
+ content: `Error: Parent directory for "${relPath}" does not exist.`,
+ isError: true,
+ };
+ }
+ return {
+ content: `Error resolving path: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ const realResolvedPath = realParent + sep + resolvedPath.split(sep).at(-1);
+ if (!isPathWithinWorkdir(realResolvedPath, realBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // If the resolved path itself is a symlink, verify the target is contained.
+ try {
+ const linkStat = await lstat(resolvedPath);
+ if (linkStat.isSymbolicLink()) {
+ const linkTarget = await readlink(resolvedPath);
+ const resolvedTarget = resolve(dirname(resolvedPath), linkTarget);
+ if (!isPathWithinWorkdir(resolvedTarget, realBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+ }
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code !== "ENOENT") {
+ return {
+ content: `Error checking path: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ }
+
+ // Check existence.
+ let fileExists = false;
+ try {
+ await access(resolvedPath);
+ fileExists = true;
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code !== "ENOENT") {
+ return {
+ content: `Error checking file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ }
+
+ // Pure decision.
+ const decision = decideOverwrite(fileExists, overwrite);
+ if (typeof decision === "object") {
+ return { content: decision.error, isError: true };
+ }
+
+ // Verify it's not a directory.
+ if (fileExists) {
+ const pathStat = await stat(resolvedPath);
+ if (pathStat.isDirectory()) {
+ return {
+ content: `Error: "${relPath}" is a directory, not a file.`,
+ isError: true,
+ };
+ }
+ }
+
+ // Write the file.
+ try {
+ await writeFile(resolvedPath, content, "utf8");
+ } catch (err: unknown) {
+ return {
+ content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ const action = decision === "create" ? "Created" : "Overwrote";
+ return { content: `${action} "${relPath}" (${content.length} bytes).` };
+ },
+ };
+}
diff --git a/packages/tool-write-file/tsconfig.json b/packages/tool-write-file/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/tool-write-file/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/tasks.md b/tasks.md
index 0cfcbb1..afb304a 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,14 +5,15 @@
> Keep this lean and current; do not let it re-accrete a step-by-step changelog.
## Status (current)
-`tsc -b` EXIT 0 · biome clean · **576 vitest + 89 bun = 665 tests**.
+`tsc -b` EXIT 0 · biome clean · **686 vitest + 89 bun = 775 tests**.
Built and verified live (full-fidelity: every feature is a manifest-loaded
extension through the host):
- **kernel** — contracts (ABI), bus, `runTurn` turn loop, extension host.
- **core extensions** — storage-sqlite, auth-apikey, provider-openai-compat
(OpenCode Go), conversation-store, session-orchestrator, transport-http,
- credential-store; tool extension `read_file`.
+ credential-store; tool extensions `read_file` (files + directory listing), `run_shell`,
+ `edit_file`, `write_file`.
- **observability** — structured Logger/Span ABI + journal-sink → out-of-process
collector → trace-store (`bun:sqlite`); host-bin supervises the collector;
nested turn→step→{prompt, provider.request, ttft, decode} spans; D5 verbatim
@@ -93,6 +94,30 @@ deferred); dedup = **content-addressed bodies** (body-hash, NOT fingerprint-gate
stored bodies), prune cadence fires cleanly (14× `prune completed`). Optional
follow-up: host-bin env-override for the retention policy.
+## Standard tools — fs + shell (DONE)
+User-gated calls: **one tool per extension** (matches `tool-read-file` precedent); tools are
+**standard** tier (a turn completes with `tools:[]`, §2.6/§2.8). **Zero ABI change** — the
+`ToolContract`/`ToolExecuteContext` already carry `signal`/`onOutput`/`cwd`/`log`.
+- **Wave 1 (parallel, disjoint pkgs, kernel-only dep) — all green:**
+ - [x] `tool-read-file` — EXTENDED `read_file` to list directory contents (sorted, `/`-suffixed
+ subdirs; files unchanged). 41 tests.
+ - [x] `tool-shell` (new) — `run_shell`: foreground, streamed via `ctx.onOutput`, `ctx.signal`
+ cancel, `ctx.cwd`, timeout + output cap, `concurrencySafe:false`; injected `spawn`. 31 tests.
+ - [x] `tool-edit-file` (new) — `edit_file`: `oldString`/`newString`/`replaceAll`; errors on
+ absent/non-unique/identical; workdir-contained; `concurrencySafe:false`. 38 tests.
+ - [x] `tool-write-file` (new) — `write_file`: explicit `overwrite` flag (absent+unset→create;
+ exists+unset→error; exists+true→overwrite; absent+true→error); no parent auto-create. 33 tests.
+- **Wave 2 (done):** orchestrator added 3 root tsconfig refs + `bun install`; host-bin owner
+ registered the 3 new extensions in `CORE_EXTENSIONS` (same pattern as `read_file`).
+- **Live-verified:** clean boot (`Dispatch booted`, collector up, no activation/capability-gate
+ error — the new `shell` capability is accepted); full-graph `tsc -b` EXIT 0, biome clean.
+- **Recovery notes (scar tissue):** `tool-write-file` first returned plan-only (§5a) → re-summoned
+ with "IMPLEMENT NOW". `tool-edit-file` hung vitest at collection — `computeReplacement` infinite-
+ looped on empty `oldString` (`"".indexOf("") === 0`, index never advances) invoked at a test's
+ `describe` scope; fixed with an early empty-string guard + validation. One agent deleted
+ `ORCHESTRATOR.md` out-of-lane → caught by post-wave `git status`, restored from git.
+- Deferred (not selected): `glob`, `grep`/`search_code`, background shells.
+
## Open items
- **`prefix.fingerprint` / `warm|real` cache-bust attributes (deferred):** decoupled
from dedup by the content-addressed decision; also gated on cache-warming being
diff --git a/tsconfig.json b/tsconfig.json
index 68d373c..5c8d6b5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,6 +17,9 @@
{ "path": "./packages/session-orchestrator" },
{ "path": "./packages/transport-http" },
{ "path": "./packages/tool-read-file" },
+ { "path": "./packages/tool-shell" },
+ { "path": "./packages/tool-edit-file" },
+ { "path": "./packages/tool-write-file" },
{ "path": "./packages/cli" },
{ "path": "./packages/journal-sink" },
{ "path": "./packages/trace-store" },