summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-read-file/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
committerAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
commit1ff0eac44cd44751af979c51c746a1774c268e8a (patch)
treebf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-read-file/src
parent54db4583e66134010375a1fa94256f36034ffdff (diff)
downloaddispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.tar.gz
dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.zip
feat(ssh): wave 2 — route filesystem/shell tools behind ExecBackend
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint tool packages). The tools now resolve an ExecBackend per-call from ctx.computerId and call backend.spawn / backend.readFile / etc. instead of node:fs and node:child_process directly — so they are transport-agnostic (local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY this wave (computerId always undefined -> LocalExecBackend, behavior-identical). - tool-shell: factory takes resolveBackend; execute calls backend.spawn. spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's LocalExecBackend.spawn — logic moved to the sanctioned shared package). manifest dependsOn:[exec-backend]; host.getService at activation. - tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched; ENOENT .code branches kept). - tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched). - tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1; local path byte-identical to today). LSP lookup stays lazy. - orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool package.jsons + bun install (build/config, my lane) so isolated verify resolved cleanly; agents added the ../exec-backend tsconfig ref. Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/tool-read-file/src')
-rw-r--r--packages/tool-read-file/src/extension.ts6
-rw-r--r--packages/tool-read-file/src/read-file.test.ts117
-rw-r--r--packages/tool-read-file/src/read-file.ts50
3 files changed, 141 insertions, 32 deletions
diff --git a/packages/tool-read-file/src/extension.ts b/packages/tool-read-file/src/extension.ts
index 8c3a064..5a0b7c5 100644
--- a/packages/tool-read-file/src/extension.ts
+++ b/packages/tool-read-file/src/extension.ts
@@ -1,3 +1,4 @@
+import { execBackendHandle } from "@dispatch/exec-backend";
import type { Extension } from "@dispatch/kernel";
import { createReadFileTool } from "./read-file.js";
@@ -11,8 +12,11 @@ export const extension: Extension = {
activation: "eager",
capabilities: { fs: true },
contributes: { tools: ["read_file"] },
+ // Host activates exec-backend first → host.getService at activation is safe.
+ dependsOn: ["exec-backend"],
},
activate(host) {
- host.defineTool(createReadFileTool(process.cwd()));
+ const resolveBackend = host.getService(execBackendHandle);
+ host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() }));
},
};
diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts
index 619ba34..bac5902 100644
--- a/packages/tool-read-file/src/read-file.test.ts
+++ b/packages/tool-read-file/src/read-file.test.ts
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { localExecBackend } from "@dispatch/exec-backend";
import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
@@ -25,6 +26,15 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
};
}
+/**
+ * Build a read_file tool wired to the real local ExecBackend (node:fs,
+ * behavior-identical to today's inline calls). No `@dispatch/*` mocking — the
+ * real fs edge is exercised, matching the constitution's strict-core rule.
+ */
+function makeTool(workdir: string) {
+ return createReadFileTool({ resolveBackend: () => localExecBackend, workdir });
+}
+
let workdir: string;
beforeEach(async () => {
@@ -151,7 +161,7 @@ describe("createReadFileTool", () => {
const filePath = join(workdir, "hello.txt");
await writeFile(filePath, "hello\nworld\n", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "hello.txt" }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -163,7 +173,7 @@ describe("createReadFileTool", () => {
const filePath = join(workdir, "lines.txt");
await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -171,7 +181,7 @@ describe("createReadFileTool", () => {
});
it("returns error for missing file", async () => {
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx());
expect(result.isError).toBe(true);
@@ -182,7 +192,7 @@ describe("createReadFileTool", () => {
const filePath = join(workdir, "empty.txt");
await writeFile(filePath, "", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "empty.txt" }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -194,7 +204,7 @@ describe("createReadFileTool", () => {
const filePath = join(workdir, "short.txt");
await writeFile(filePath, "one\n", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx());
expect(result.isError).toBe(true);
@@ -202,7 +212,7 @@ describe("createReadFileTool", () => {
});
it("never throws on bad input (always returns ToolResult)", async () => {
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
for (const input of inputs) {
@@ -213,12 +223,12 @@ describe("createReadFileTool", () => {
});
it("concurrencySafe is true", () => {
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
expect(tool.concurrencySafe).toBe(true);
});
it("has correct name and parameters shape", () => {
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
expect(tool.name).toBe("read_file");
expect(tool.parameters.type).toBe("object");
expect(tool.parameters.required).toEqual(["path"]);
@@ -231,7 +241,7 @@ describe("createReadFileTool", () => {
const filePath = join(ctxDir, "ctx-file.txt");
await writeFile(filePath, "from ctx cwd", "utf8");
- const tool = createReadFileTool(workdir); // baked workdir is different
+ const tool = makeTool(workdir); // baked workdir is different
const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir }));
expect(result.isError).toBeUndefined();
@@ -245,7 +255,7 @@ describe("createReadFileTool", () => {
const filePath = join(workdir, "baked-file.txt");
await writeFile(filePath, "from baked workdir", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const ctx = stubCtx();
// Ensure cwd is undefined
expect(ctx.cwd).toBeUndefined();
@@ -260,7 +270,7 @@ describe("createReadFileTool", () => {
await writeFile(join(workdir, "zebra.txt"), "z", "utf8");
await writeFile(join(workdir, "alpha.txt"), "a", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "." }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -270,7 +280,7 @@ describe("createReadFileTool", () => {
it("returns empty-directory message for an empty dir", async () => {
await mkdir(join(workdir, "empty-dir"));
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "empty-dir" }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -280,7 +290,7 @@ describe("createReadFileTool", () => {
it("reads a file unchanged (regression: line numbers + offset/limit)", async () => {
await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8");
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -288,10 +298,89 @@ describe("createReadFileTool", () => {
});
it("returns not-found for a nonexistent path", async () => {
- const tool = createReadFileTool(workdir);
+ const tool = makeTool(workdir);
const result = await tool.execute({ path: "nonexistent-path" }, stubCtx());
expect(result.isError).toBe(true);
expect(result.content).toContain("not found");
});
+
+ it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => {
+ // A fake backend records what it is asked to do. Proves the tool programs
+ // against the ExecBackend surface (not node:fs) and that the resolver is
+ // invoked with ctx.computerId — the SSH seam. No real fs involved.
+ let statCalls = 0;
+ let readFileCalls = 0;
+ let readdirCalls = 0;
+ let receivedComputerId: string | undefined = "__sentinel__";
+ const fakeBackend = {
+ spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }),
+ readFile: async (path: string) => {
+ readFileCalls++;
+ expect(path).toContain("seam.txt");
+ return "fake-line-1\nfake-line-2";
+ },
+ writeFile: async () => {},
+ stat: async (path: string) => {
+ statCalls++;
+ expect(path).toContain("seam.txt");
+ return { isFile: true, isDirectory: false };
+ },
+ readdir: async () => {
+ readdirCalls++;
+ return [];
+ },
+ exists: async () => true,
+ } as const;
+
+ const tool = createReadFileTool({
+ resolveBackend: (computerId) => {
+ receivedComputerId = computerId;
+ return fakeBackend;
+ },
+ workdir,
+ });
+ const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" }));
+
+ expect(receivedComputerId).toBe("prod-ssh");
+ expect(statCalls).toBe(1);
+ expect(readFileCalls).toBe(1);
+ expect(readdirCalls).toBe(0);
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe("1: fake-line-1\n2: fake-line-2");
+ });
+
+ it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => {
+ // computerId undefined → resolver returns localExecBackend → real fs.
+ const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir });
+ const filePath = join(workdir, "compat.txt");
+ await writeFile(filePath, "real fs via backend\n", "utf8");
+
+ const result = await tool.execute({ path: "compat.txt" }, stubCtx());
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("1: real fs via backend");
+ });
+
+ it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => {
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), {
+ code: "ENOENT",
+ });
+ const fakeBackend = {
+ spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }),
+ readFile: async () => "unused",
+ writeFile: async () => {},
+ stat: async () => {
+ throw enoent;
+ },
+ readdir: async () => [],
+ exists: async () => false,
+ } as const;
+
+ const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir });
+ const result = await tool.execute({ path: "ghost.txt" }, stubCtx());
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toBe('Error: File "ghost.txt" not found.');
+ });
});
diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts
index 216f165..b88c241 100644
--- a/packages/tool-read-file/src/read-file.ts
+++ b/packages/tool-read-file/src/read-file.ts
@@ -1,5 +1,5 @@
-import { readdir, readFile, stat } from "node:fs/promises";
import { resolve } from "node:path";
+import type { ExecBackend, ExecBackendResolver, StatResult } from "@dispatch/exec-backend";
import type { ToolContract, ToolResult } from "@dispatch/kernel";
const DEFAULT_LIMIT = 500;
@@ -83,11 +83,21 @@ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: st
}
/**
- * Factory: create a read_file ToolContract bound to a working directory.
- * The working directory is injected so the tool is testable.
+ * Factory: create a read_file ToolContract.
+ *
+ * `resolveBackend` is the injected seam: each `execute` resolves an
+ * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set
+ * id → a remote SSH backend in a later wave). The tool programs against the
+ * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic.
+ *
+ * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is
+ * injected so the tool is testable; `execute` prefers `ctx.cwd` when present.
*/
-export function createReadFileTool(workingDirectory: string): ToolContract {
- const workdir = resolve(workingDirectory);
+export function createReadFileTool(deps: {
+ readonly resolveBackend: ExecBackendResolver;
+ readonly workdir?: string;
+}): ToolContract {
+ const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined;
return {
name: "read_file",
@@ -126,12 +136,21 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
const { path: relPath, offset, limit } = validated;
const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
+ if (effectiveBase === undefined) {
+ return {
+ content:
+ "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).",
+ isError: true,
+ };
+ }
const resolvedPath = resolve(effectiveBase, relPath);
+ const backend: ExecBackend = deps.resolveBackend(ctx.computerId);
+
// Stat to determine if this is a file or directory.
- let pathStat: import("node:fs").Stats;
+ let pathStat: StatResult;
try {
- pathStat = await stat(resolvedPath);
+ pathStat = await backend.stat(resolvedPath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
@@ -143,28 +162,25 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
};
}
- // Directory listing branch.
- if (pathStat.isDirectory()) {
- let rawEntries: import("node:fs").Dirent<string>[];
+ // Directory listing branch. backend.readdir already returns
+ // {name, isDirectory}[] entries, so no per-entry collapse is needed.
+ if (pathStat.isDirectory) {
+ let entries: readonly DirEntry[];
try {
- rawEntries = await readdir(resolvedPath, { encoding: "utf8", withFileTypes: true });
+ entries = await backend.readdir(resolvedPath);
} catch (err: unknown) {
return {
content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
- const dirEntries = rawEntries.map((e) => ({
- name: e.name,
- isDirectory: e.isDirectory(),
- }));
- return { content: formatDirectoryEntries(dirEntries, relPath) };
+ return { content: formatDirectoryEntries(entries, relPath) };
}
// File branch — read the file.
let content: string;
try {
- content = await readFile(resolvedPath, "utf8");
+ content = await backend.readFile(resolvedPath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {