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-write-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-write-file')
| -rw-r--r-- | packages/tool-write-file/package.json | 11 | ||||
| -rw-r--r-- | packages/tool-write-file/src/extension.ts | 18 | ||||
| -rw-r--r-- | packages/tool-write-file/src/index.ts | 7 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.test.ts | 291 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.ts | 210 | ||||
| -rw-r--r-- | packages/tool-write-file/tsconfig.json | 6 |
6 files changed, 543 insertions, 0 deletions
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" }] +} |
