diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:01:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:01:33 +0900 |
| commit | bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (patch) | |
| tree | 073048a5775c605d8c28862d0f8c83e63327a17e | |
| parent | 9e7554cde98f45df30dad1f9d356b6954138685b (diff) | |
| download | dispatch-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.
28 files changed, 2022 insertions, 8 deletions
@@ -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" }] +} @@ -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" }, |
