summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 01:14:29 +0900
committerAdam Malczewski <[email protected]>2026-06-05 01:14:29 +0900
commit64e9688cc27ceea6eba442d156868d82d7aafb75 (patch)
tree412b00ae47a98d3f1e46a953798122c750e8abde
parent7fca88f2ef9cf6eb9c8679844419241a12dd670f (diff)
downloaddispatch-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.lock10
-rw-r--r--packages/host-bin/package.json3
-rw-r--r--packages/host-bin/src/main.ts2
-rw-r--r--packages/tool-read-file/package.json11
-rw-r--r--packages/tool-read-file/src/extension.ts18
-rw-r--r--packages/tool-read-file/src/index.ts2
-rw-r--r--packages/tool-read-file/src/read-file.test.ts248
-rw-r--r--packages/tool-read-file/src/read-file.ts184
-rw-r--r--packages/tool-read-file/tsconfig.json6
-rw-r--r--tasks.md16
-rw-r--r--tsconfig.json1
11 files changed, 500 insertions, 1 deletions
diff --git a/bun.lock b/bun.lock
index 6fa16ea..0c5b609 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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" }]
+}
diff --git a/tasks.md b/tasks.md
index e523923..83e9bf7 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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" }
]
}