summaryrefslogtreecommitdiffhomepage
path: root/packages
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 /packages
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).
Diffstat (limited to 'packages')
-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
8 files changed, 473 insertions, 1 deletions
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" }]
+}