/** Tests for the FILE `read` handler: line/size caps and read recording, over the real filesystem. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { CAPABILITY_ROUTER_SERVICE_TYPE, CapabilityError, type ElizaCapabilityRouter, type IAgentRuntime, UnavailableCapabilityRouter, } from "@elizaos/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { setupEnv, type TestEnv } from "./_test-helpers.js"; import { readFileHandler } from "./read.js"; describe("READ", () => { let env: TestEnv; beforeEach(async () => { env = await setupEnv("read-test"); }); afterEach(async () => { await env.cleanup(); }); it("reads a small file and returns numbered lines", async () => { const file = path.join(env.tmpDir, "hello.txt"); await fs.writeFile(file, "line one\nline two\nline three", "utf8"); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(true); expect(result.text).toContain(file); expect(result.text).toContain("\tline one"); expect(result.text).toContain("\tline two"); expect(result.text).toContain("\tline three"); const data = result.data as Record | undefined; expect(data?.totalLines).toBe(3); expect(data?.lines).toBe(3); }); it("right-pads line numbers to 6 chars and uses tab separator", async () => { const file = path.join(env.tmpDir, "lines.txt"); await fs.writeFile(file, "alpha\nbeta", "utf8"); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(true); expect(result.text).toContain(" 1\talpha"); expect(result.text).toContain(" 2\tbeta"); }); it("respects offset and limit and marks truncated", async () => { const file = path.join(env.tmpDir, "long.txt"); const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`); await fs.writeFile(file, lines.join("\n"), "utf8"); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file, offset: 10, limit: 5 }, }); expect(result.success).toBe(true); expect(result.text).toContain("\tline 11"); expect(result.text).toContain("\tline 15"); expect(result.text).not.toContain("\tline 10"); expect(result.text).not.toContain("\tline 16"); const data = result.data as Record | undefined; expect(data?.truncated).toBe(true); }); it("records the read in FileStateService", async () => { const file = path.join(env.tmpDir, "track.txt"); await fs.writeFile(file, "hello", "utf8"); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(true); const data = result.data as Record | undefined; const resolved = String(data?.path); const meta = env.fileState.get("test-room", resolved); expect(meta).toBeDefined(); expect(meta?.path).toBe(resolved); }); it("prefers capability router for file content when available", async () => { const file = path.join(env.tmpDir, "routed.txt"); await fs.writeFile(file, "local file content", "utf8"); const calls: string[] = []; const router: ElizaCapabilityRouter = { environment: "desktop", availability: async () => ({ environment: "desktop", available: true, capabilities: { fs: true, pty: false, git: false, model: false, plugin: false, }, }), fs: { list: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "fs unavailable", capability: "fs", method: "fs.list", }); }, readText: async (params) => { calls.push(params.path); return { path: params.path, text: "routed line one\nrouted line two", size: 29, truncated: false, }; }, writeText: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "fs unavailable", capability: "fs", method: "fs.writeText", }); }, }, pty: { runCommand: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "terminal unavailable", capability: "pty", method: "pty.command.run", }); }, }, git: { status: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "git unavailable", capability: "git", method: "git.status", }); }, diff: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "git unavailable", capability: "git", method: "git.diff", }); }, commandRun: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "git unavailable", capability: "git", method: "git.command.run", }); }, }, model: { status: async () => { throw new CapabilityError({ code: "CAPABILITY_UNAVAILABLE", message: "model unavailable", capability: "model", method: "model.status", }); }, }, plugin: new UnavailableCapabilityRouter("desktop").plugin, }; const runtime = { ...env.runtime, getService: (serviceType: string): T | null => serviceType === CAPABILITY_ROUTER_SERVICE_TYPE ? (router as T) : env.runtime.getService(serviceType), } as IAgentRuntime; const result = await readFileHandler(runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(true); expect(result.text).toContain("routed line one"); expect(result.text).not.toContain("local file content"); expect(calls).toEqual([file]); const meta = env.fileState.get("test-room", file); expect(meta).toBeDefined(); }); it("rejects relative paths", async () => { const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: "relative/path.txt" }, }); expect(result.success).toBe(false); expect(result.text).toContain("invalid_param"); }); it("rejects paths under the blocklist", async () => { const file = path.join(env.blockedPath, "secret.txt"); await fs.writeFile(file, "data"); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(false); expect(result.text).toContain("path_blocked"); }); it("rejects files larger than CODING_TOOLS_MAX_FILE_SIZE_BYTES", async () => { const env2 = await setupEnv("read-big", { extraSettings: { CODING_TOOLS_MAX_FILE_SIZE_BYTES: 32 }, }); try { const file = path.join(env2.tmpDir, "big.txt"); await fs.writeFile(file, "x".repeat(64), "utf8"); const result = await readFileHandler( env2.runtime, env2.message, undefined, { parameters: { file_path: file }, }, ); expect(result.success).toBe(false); expect(result.text).toContain("io_error"); expect(result.text).toContain("offset/limit"); } finally { await env2.cleanup(); } }); it("rejects binary files containing NUL bytes", async () => { const file = path.join(env.tmpDir, "binary.bin"); await fs.writeFile(file, Buffer.from([0x68, 0x69, 0x00, 0x21])); const result = await readFileHandler(env.runtime, env.message, undefined, { parameters: { file_path: file }, }); expect(result.success).toBe(false); expect(result.text).toContain("binary file"); }); it("fails when roomId is missing", async () => { const result = await readFileHandler( env.runtime, {} as typeof env.message, undefined, { parameters: { file_path: path.join(env.tmpDir, "any.txt") } }, ); expect(result.success).toBe(false); expect(result.text).toContain("roomId"); }); });