diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 01:14:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 01:14:29 +0900 |
| commit | 64e9688cc27ceea6eba442d156868d82d7aafb75 (patch) | |
| tree | 412b00ae47a98d3f1e46a953798122c750e8abde | |
| parent | 7fca88f2ef9cf6eb9c8679844419241a12dd670f (diff) | |
| download | dispatch-64e9688cc27ceea6eba442d156868d82d7aafb75.tar.gz dispatch-64e9688cc27ceea6eba442d156868d82d7aafb75.zip | |
feat(tool-read-file): add read_file tool extension + wire into host-bin
First TOOL extension (standard tier, fs capability). Pure-core/shell split with
workdir containment (realpath symlink guard). host-bin registers it in
CORE_EXTENSIONS; flows into runTurn via session-orchestrator's resolveTools.
Verified: typecheck clean, 214 tests pass (was 185), biome clean. Live curl
against flash produced a real tool-call + tool-result round-trip with correct
final answer. Proves the kernel tool-dispatch loop end-to-end (plan §3.3).
| -rw-r--r-- | bun.lock | 10 | ||||
| -rw-r--r-- | packages/host-bin/package.json | 3 | ||||
| -rw-r--r-- | packages/host-bin/src/main.ts | 2 | ||||
| -rw-r--r-- | packages/tool-read-file/package.json | 11 | ||||
| -rw-r--r-- | packages/tool-read-file/src/extension.ts | 18 | ||||
| -rw-r--r-- | packages/tool-read-file/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.test.ts | 248 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.ts | 184 | ||||
| -rw-r--r-- | packages/tool-read-file/tsconfig.json | 6 | ||||
| -rw-r--r-- | tasks.md | 16 | ||||
| -rw-r--r-- | tsconfig.json | 1 |
11 files changed, 500 insertions, 1 deletions
@@ -35,6 +35,7 @@ "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/storage-sqlite": "workspace:*", + "@dispatch/tool-read-file": "workspace:*", "@dispatch/transport-http": "workspace:*", }, }, @@ -64,6 +65,13 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/tool-read-file": { + "name": "@dispatch/tool-read-file", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + }, + }, "packages/transport-http": { "name": "@dispatch/transport-http", "version": "0.0.0", @@ -107,6 +115,8 @@ "@dispatch/storage-sqlite": ["@dispatch/storage-sqlite@workspace:packages/storage-sqlite"], + "@dispatch/tool-read-file": ["@dispatch/tool-read-file@workspace:packages/tool-read-file"], + "@dispatch/transport-http": ["@dispatch/transport-http@workspace:packages/transport-http"], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 59ea899..12ae633 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -10,6 +10,7 @@ "@dispatch/auth-apikey": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/transport-http": "workspace:*" + "@dispatch/transport-http": "workspace:*", + "@dispatch/tool-read-file": "workspace:*" } } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index fe36e80..e26b8c6 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -19,6 +19,7 @@ import { import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; +import { extension as toolReadFileExt } from "@dispatch/tool-read-file"; import { createServer, extension as transportHttpExt } from "@dispatch/transport-http"; import { configMapToAccess, envToConfigMap } from "./config.js"; @@ -93,6 +94,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ conversationStoreExt, authApikeyExt, providerOpenaiCompatExt, + toolReadFileExt, sessionOrchestratorExt, transportHttpExt, ]; diff --git a/packages/tool-read-file/package.json b/packages/tool-read-file/package.json new file mode 100644 index 0000000..3a98fa7 --- /dev/null +++ b/packages/tool-read-file/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/tool-read-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-read-file/src/extension.ts b/packages/tool-read-file/src/extension.ts new file mode 100644 index 0000000..8c3a064 --- /dev/null +++ b/packages/tool-read-file/src/extension.ts @@ -0,0 +1,18 @@ +import type { Extension } from "@dispatch/kernel"; +import { createReadFileTool } from "./read-file.js"; + +export const extension: Extension = { + manifest: { + id: "tool-read-file", + name: "Read File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["read_file"] }, + }, + activate(host) { + host.defineTool(createReadFileTool(process.cwd())); + }, +}; diff --git a/packages/tool-read-file/src/index.ts b/packages/tool-read-file/src/index.ts new file mode 100644 index 0000000..2903efc --- /dev/null +++ b/packages/tool-read-file/src/index.ts @@ -0,0 +1,2 @@ +export { extension } from "./extension.js"; +export { createReadFileTool } 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 new file mode 100644 index 0000000..0745b0b --- /dev/null +++ b/packages/tool-read-file/src/read-file.test.ts @@ -0,0 +1,248 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ToolExecuteContext } from "@dispatch/kernel"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createReadFileTool, + isPathWithinWorkdir, + renderLines, + sliceLines, + validateArgs, +} from "./read-file.js"; + +function stubCtx(): ToolExecuteContext { + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + }; +} + +let workdir: string; + +beforeEach(async () => { + workdir = await mkdtemp(join(tmpdir(), "tool-read-file-test-")); +}); + +afterEach(async () => { + await rm(workdir, { recursive: true, force: true }); +}); + +describe("validateArgs", () => { + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "foo.txt" }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 500 }); + }); + + it("parses offset and limit", () => { + const result = validateArgs({ path: "foo.txt", offset: 5, limit: 10 }); + expect(result).toEqual({ path: "foo.txt", offset: 5, limit: 10 }); + }); + + it("clamps limit to hard cap of 5000", () => { + const result = validateArgs({ path: "foo.txt", limit: 99999 }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-string path", () => { + const result = validateArgs({ path: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid offset", () => { + const result = validateArgs({ path: "foo.txt", offset: -1 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid limit", () => { + const result = validateArgs({ path: "foo.txt", limit: 0 }); + expect(result).toHaveProperty("error"); + }); +}); + +describe("sliceLines", () => { + it("returns all lines with offset=1, limit=500", () => { + const content = "line1\nline2\nline3"; + const result = sliceLines(content, 1, 500); + expect(result.lines).toEqual(["line1", "line2", "line3"]); + expect(result.totalLines).toBe(3); + }); + + it("slices with offset", () => { + const content = "line1\nline2\nline3\nline4"; + const result = sliceLines(content, 2, 2); + expect(result.lines).toEqual(["line2", "line3"]); + expect(result.totalLines).toBe(4); + }); + + it("handles offset beyond content", () => { + const content = "line1\nline2"; + const result = sliceLines(content, 10, 5); + expect(result.lines).toEqual([]); + expect(result.totalLines).toBe(2); + }); + + it("handles single line (no newline)", () => { + const content = "only line"; + const result = sliceLines(content, 1, 10); + expect(result.lines).toEqual(["only line"]); + expect(result.totalLines).toBe(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("renderLines", () => { + it("renders lines with 1-indexed line numbers", () => { + const result = renderLines(["a", "b", "c"], 1); + expect(result).toBe("1: a\n2: b\n3: c"); + }); + + it("renders with custom offset", () => { + const result = renderLines(["x", "y"], 10); + expect(result).toBe("10: x\n11: y"); + }); +}); + +describe("createReadFileTool", () => { + it("reads a real temp file", async () => { + const filePath = join(workdir, "hello.txt"); + await writeFile(filePath, "hello\nworld\n", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "hello.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: hello"); + expect(result.content).toContain("2: world"); + }); + + it("respects offset and limit", async () => { + const filePath = join(workdir, "lines.txt"); + await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c"); + }); + + it("returns error for missing file", async () => { + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("returns error for path escape via ..", async () => { + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "../escape.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("outside the working directory"); + }); + + it("returns error for absolute path outside workdir", async () => { + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "/etc/passwd" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("outside the working directory"); + }); + + it("returns empty-file content for empty file", async () => { + const filePath = join(workdir, "empty.txt"); + await writeFile(filePath, "", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "empty.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("empty file"); + expect(result.content).toContain("empty.txt"); + }); + + it("returns error for offset beyond file length", async () => { + const filePath = join(workdir, "short.txt"); + await writeFile(filePath, "one\n", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("exceeds total lines"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = createReadFileTool(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("handles symlink escape attempt", async () => { + // Create a symlink inside workdir pointing outside + 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 = createReadFileTool(workdir); + const result = await tool.execute({ path: "link.txt" }, stubCtx()); + + // The symlink resolves to outside workdir, so should be rejected + expect(result.isError).toBe(true); + expect(result.content).toContain("outside the working directory"); + + await rm(outsideDir, { recursive: true, force: true }); + }); + + it("concurrencySafe is true", () => { + const tool = createReadFileTool(workdir); + expect(tool.concurrencySafe).toBe(true); + }); + + it("has correct name and parameters shape", () => { + const tool = createReadFileTool(workdir); + expect(tool.name).toBe("read_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + }); +}); diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts new file mode 100644 index 0000000..b5bb0f1 --- /dev/null +++ b/packages/tool-read-file/src/read-file.ts @@ -0,0 +1,184 @@ +import { readFile, realpath } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import type { ToolContract, ToolResult } from "@dispatch/kernel"; + +const DEFAULT_LIMIT = 500; +const HARD_CAP = 5000; + +interface ValidatedArgs { + readonly path: string; + readonly offset: number; + readonly limit: number; +} + +/** 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).' }; + } + + let offset = 1; + if (obj.offset !== undefined) { + const n = Number(obj.offset); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; + } + offset = Math.floor(n); + } + + let limit = DEFAULT_LIMIT; + if (obj.limit !== undefined) { + const n = Number(obj.limit); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; + } + limit = Math.min(Math.floor(n), HARD_CAP); + } else { + limit = Math.min(limit, HARD_CAP); + } + + return { path: rawPath, offset, limit }; +} + +/** Pure: slice lines from content (1-indexed offset). */ +export function sliceLines( + content: string, + offset: number, + limit: number, +): { readonly lines: readonly string[]; readonly totalLines: number } { + const allLines = content.split("\n"); + const totalLines = allLines.length; + const start = offset - 1; // convert to 0-indexed + const sliced = allLines.slice(start, start + limit); + return { lines: sliced, totalLines }; +} + +/** 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: render lines into a string with line numbers. */ +export function renderLines(lines: readonly string[], offset: number): string { + return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); +} + +/** + * Factory: create a read_file ToolContract bound to a working directory. + * The working directory is injected so the tool is testable. + */ +export function createReadFileTool(workingDirectory: string): ToolContract { + const workdir = resolve(workingDirectory); + + 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.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + offset: { + type: "number", + description: "1-indexed start line number (default: 1).", + default: 1, + }, + limit: { + type: "number", + description: "Maximum number of lines to return (default: 500, hard cap: 5000).", + default: 500, + }, + }, + required: ["path"], + }, + concurrencySafe: true, + async execute(args: unknown, _ctx): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, offset, limit } = validated; + + // Resolve the requested path against the working directory. + const resolvedPath = resolve(workdir, relPath); + + // Basic prefix check (catches ".." and absolute paths outside workdir). + if (!isPathWithinWorkdir(resolvedPath, workdir)) { + return { + content: `Error: Path "${relPath}" is outside the working directory.`, + isError: true, + }; + } + + // Symlink hardening: realpath both and re-check containment. + let realResolved: string; + let realWorkdir: string; + try { + [realResolved, realWorkdir] = await Promise.all([ + realpath(resolvedPath), + realpath(workdir), + ]); + } 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, + }; + } + + if (!isPathWithinWorkdir(realResolved, realWorkdir)) { + 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, + }; + } + + // Handle empty file. + if (content.length === 0) { + return { content: `(empty file: ${relPath})` }; + } + + // Apply offset/limit line slicing. + const { lines, totalLines } = sliceLines(content, offset, limit); + + if (offset > totalLines) { + return { + content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, + isError: true, + }; + } + + return { content: renderLines(lines, offset) }; + }, + }; +} diff --git a/packages/tool-read-file/tsconfig.json b/packages/tool-read-file/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/tool-read-file/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} @@ -84,3 +84,19 @@ returned a real response with auth-apikey on the path (boot log shows auth-apike activates before provider; provider registered = creds resolved through contract). NOTE: host-bin's `buildPostActivationHostAPI` stub is slated for removal in Step 3 (host CR-1). Summons: prompts/step1-kernel-host.md, prompts/step1-provider.md (mimo-v2.5-pro). + +### Step 2 — First TOOL extension (read_file) [x] DONE (verified live) +New unit `packages/tool-read-file/` (owner-agent, mimo-v2.5-pro). Pure-core/shell +split: `createReadFileTool(workdir)` → `ToolContract` named `read_file` (offset/ +limit pagination, 1-indexed; two-layer workdir containment incl. realpath symlink +guard); `activate` calls `host.defineTool`. 29 unit tests. session-orchestrator's +`resolveTools: () => [...host.getTools().values()]` flows it into runTurn for free. +Orchestrator wiring CRs (done): root tsconfig ref, host-bin dep + import + +CORE_EXTENSIONS (before session-orchestrator), bun install, biome import-sort. + +**Step 2 RESULT:** done + verified. typecheck clean, **214 tests pass** (185→+29), +biome clean. LIVE: booted on 24203, asked flash to read a test file → stream +contained a real **`tool-call` + `tool-result`** round-trip and the final answer +quoted the file's secret passphrase (MAGENTA-OTTER-42) correctly. The kernel +tool-dispatch loop is now proven end-to-end against a live model (§3.3). Summon: +prompts/step2-tool-read-file.md, report: reports/step2-tool-read-file.md. diff --git a/tsconfig.json b/tsconfig.json index fdf58f5..505d883 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/conversation-store" }, { "path": "./packages/session-orchestrator" }, { "path": "./packages/transport-http" }, + { "path": "./packages/tool-read-file" }, { "path": "./packages/host-bin" } ] } |
