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 /packages/tool-edit-file | |
| 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.
Diffstat (limited to 'packages/tool-edit-file')
| -rw-r--r-- | packages/tool-edit-file/package.json | 11 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/edit-file.test.ts | 361 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/edit-file.ts | 251 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/extension.ts | 18 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/tool-edit-file/tsconfig.json | 6 |
6 files changed, 649 insertions, 0 deletions
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" }] +} |
