From 2f5f7d1cb916f182be66d12a7aa11ca33b649bd1 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 14 Oct 2026 15:13:39 +0300 Subject: [PATCH 1/3] fix(auto-approval): match symlinked workspace roots Recognize lexical and verified canonical workspace-root spellings without resolving individual file aliases. Preserve ordered grants, exclusions, literal root names, and lexical-parent rules across POSIX, Windows drives, and UNC shares. Reject ambiguous Windows drive-relative inputs without widening read or write permissions. Centralize platform selection in WorkspaceRootStrategy.forPlatform and cover literal UNC share names with regression tests. --- .../WorkspaceRootCanonicalizer.ts | 44 ++++ .../WorkspaceRootCanonicalizer.spec.ts | 171 ++++++++++++++ .../__tests__/WorkspaceRootStrategy.spec.ts | 57 +++++ .../__tests__/filePatterns.spec.ts | 2 +- .../__tests__/posixPaths.spec.ts | 31 +++ .../windowsDriveRelativeApproval.spec.ts | 56 +++++ .../__tests__/windowsPaths.spec.ts | 214 ++++++++++++++++++ .../workspaceRootEquivalence.spec.ts | 204 +++++++++++++++++ .../workspaceRootSafety.integration.spec.ts | 41 ++++ src/core/auto-approval/filePatterns.ts | 158 +++++++------ src/core/auto-approval/index.ts | 27 ++- .../strategies/PosixWorkspaceRootStrategy.ts | 19 ++ .../WindowsWorkspaceRootStrategy.ts | 32 +++ .../strategies/WorkspaceRootStrategy.ts | 13 ++ 14 files changed, 997 insertions(+), 72 deletions(-) create mode 100644 src/core/auto-approval/WorkspaceRootCanonicalizer.ts create mode 100644 src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts create mode 100644 src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts create mode 100644 src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts create mode 100644 src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts create mode 100644 src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts create mode 100644 src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts create mode 100644 src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts create mode 100644 src/core/auto-approval/strategies/WorkspaceRootStrategy.ts diff --git a/src/core/auto-approval/WorkspaceRootCanonicalizer.ts b/src/core/auto-approval/WorkspaceRootCanonicalizer.ts new file mode 100644 index 0000000000..d78579a1f9 --- /dev/null +++ b/src/core/auto-approval/WorkspaceRootCanonicalizer.ts @@ -0,0 +1,44 @@ +import type { WorkspaceRootStrategy } from "./strategies/WorkspaceRootStrategy" + +/** Translate only a verified workspace-root spelling, never a file symlink. */ +export class WorkspaceRootCanonicalizer { + private constructor( + private readonly root: string | undefined, + private readonly canonicalRoot: string | undefined, + private readonly strategy: WorkspaceRootStrategy, + ) {} + + static forStrategy( + cwd: string | undefined, + canonicalCwd: string | undefined, + strategy: WorkspaceRootStrategy, + ): WorkspaceRootCanonicalizer { + const root = strategy.normalizeAbsoluteRoot(cwd) + const canonicalRoot = strategy.normalizeAbsoluteRoot(canonicalCwd) + return new WorkspaceRootCanonicalizer(root, canonicalRoot, strategy) + } + + canonicalize(value: string): string { + if (this.root === undefined || this.canonicalRoot === undefined) return value + + return this.replaceWorkspaceRootPrefix(value, this.root, this.canonicalRoot) + } + + /** Keep both verified spellings so globs spanning the root retain their meaning. */ + equivalentPaths(value: string): string[] { + if (this.root === undefined || this.canonicalRoot === undefined) return [value] + + const canonical = this.canonicalize(value) + const equivalent = + canonical !== value ? canonical : this.replaceWorkspaceRootPrefix(value, this.canonicalRoot, this.root) + return equivalent === value ? [value] : [value, equivalent] + } + + private replaceWorkspaceRootPrefix(value: string, root: string, canonicalRoot: string): string { + const comparableValue = this.strategy.comparisonKey(value) + const comparableRoot = this.strategy.comparisonKey(root) + return comparableValue === comparableRoot || comparableValue.startsWith(`${comparableRoot}/`) + ? `${canonicalRoot}${value.slice(root.length)}` + : value + } +} diff --git a/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts b/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts new file mode 100644 index 0000000000..8a46b4b6f4 --- /dev/null +++ b/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts @@ -0,0 +1,171 @@ +import { PosixWorkspaceRootStrategy } from "../strategies/PosixWorkspaceRootStrategy" +import { WindowsWorkspaceRootStrategy } from "../strategies/WindowsWorkspaceRootStrategy" +import { WorkspaceRootStrategy } from "../strategies/WorkspaceRootStrategy" +import { WorkspaceRootCanonicalizer } from "../WorkspaceRootCanonicalizer" + +it("normalizes roots once in the factory before any path comparisons", () => { + const normalizeAbsoluteRoot = vi + .fn() + .mockReturnValueOnce("/aliases/project") + .mockReturnValueOnce("/storage/project") + const comparisonKey = vi.fn((value: string) => value) + const strategy: WorkspaceRootStrategy = { + normalizeAbsoluteRoot, + comparisonKey, + toMatcherAbsolutePath: vi.fn((value: string) => value), + } + + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy("/aliases/project/", "/storage/project/", strategy) + + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(1, "/aliases/project/") + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(2, "/storage/project/") + expect(comparisonKey).not.toHaveBeenCalled() + + expect(canonicalizer.canonicalize("/aliases/project/docs/file.md")).toBe("/storage/project/docs/file.md") + expect(canonicalizer.canonicalize("/aliases/project/notes.md")).toBe("/storage/project/notes.md") + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) +}) + +describe.each([ + { + platform: "POSIX", + isWindows: false, + cwd: "/aliases/project", + canonicalCwd: "/storage/project", + root: "/aliases/project", + canonicalRoot: "/storage/project", + }, + { + platform: "Windows", + isWindows: true, + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + root: "/C/Aliases/Project", + canonicalRoot: "/D/Storage/Project", + }, +])("workspace-root canonicalization on $platform", ({ isWindows, cwd, canonicalCwd, root, canonicalRoot }) => { + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy(cwd, canonicalCwd, strategy) + + it("translates an exact root and preserves descendant spelling and globs", () => { + expect(canonicalizer.canonicalize(root)).toBe(canonicalRoot) + expect(canonicalizer.canonicalize(`${root}/Docs/NOTES.md`)).toBe(`${canonicalRoot}/Docs/NOTES.md`) + expect(canonicalizer.canonicalize(`${root}/docs/**`)).toBe(`${canonicalRoot}/docs/**`) + }) + + it("does not translate similarly prefixed siblings or lexical parents", () => { + for (const value of [`${root}-other/docs/file.md`, `${root}2/file.md`, "/elsewhere/docs/file.md"]) { + expect(canonicalizer.canonicalize(value)).toBe(value) + } + }) + + it("leaves canonical-root paths and relative values unchanged", () => { + for (const value of [canonicalRoot, `${canonicalRoot}/docs/file.md`, "docs/file.md", "../shared/file.md"]) { + expect(canonicalizer.canonicalize(value)).toBe(value) + } + }) + + it.each([undefined, "", "relative/project", "C:", "C:relative"])( + "does not infer equivalence from an unknown or relative root: %s", + (invalidRoot) => { + const value = `${root}/docs/file.md` + expect( + WorkspaceRootCanonicalizer.forStrategy(invalidRoot, canonicalCwd, strategy).canonicalize(value), + ).toBe(value) + expect(WorkspaceRootCanonicalizer.forStrategy(cwd, invalidRoot, strategy).canonicalize(value)).toBe(value) + }, + ) +}) + +describe("POSIX workspace-root strategy", () => { + it("compares case-sensitively and keeps backslashes as filename characters", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "/aliases/My\\Project", + "/storage/My\\Project", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/aliases/My\\Project/docs/file.md")).toBe( + "/storage/My\\Project/docs/file.md", + ) + expect(canonicalizer.canonicalize("/aliases/my\\project/docs/file.md")).toBe( + "/aliases/my\\project/docs/file.md", + ) + expect(canonicalizer.canonicalize("/aliases/My/Project/docs/file.md")).toBe("/aliases/My/Project/docs/file.md") + }) + + it("normalizes root dot segments and trailing separators", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "/aliases/temporary/../project/", + "/storage/./project/", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/aliases/project/docs/file.md")).toBe("/storage/project/docs/file.md") + }) + + it("does not interpret Windows drive paths as absolute roots", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:/aliases/project", + "D:/storage/project", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/C/aliases/project/docs/file.md")).toBe("/C/aliases/project/docs/file.md") + }) +}) + +describe("Windows workspace-root strategy", () => { + it("compares case-insensitively across drives without changing descendant case", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:\\Aliases\\Project", + "D:\\Storage\\Project", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/c/ALIASES/project/Docs/NOTES.md")).toBe("/D/Storage/Project/Docs/NOTES.md") + expect(canonicalizer.canonicalize("/E/Aliases/Project/Docs/NOTES.md")).toBe("/E/Aliases/Project/Docs/NOTES.md") + }) + + it("normalizes mixed root separators, dot segments, and trailing separators", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:\\Aliases/temporary/../Project\\", + "D:/Storage/./Project/", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/C/Aliases/Project/docs/file.md")).toBe("/D/Storage/Project/docs/file.md") + }) + + it("supports drive roots and drive-less absolute roots in matcher notation", () => { + const strategy = new WindowsWorkspaceRootStrategy() + expect(WorkspaceRootCanonicalizer.forStrategy("C:/", "D:/", strategy).canonicalize("/C/docs/file.md")).toBe( + "/D/docs/file.md", + ) + expect( + WorkspaceRootCanonicalizer.forStrategy("\\Aliases\\Project", "/Storage/Project", strategy).canonicalize( + "/ROOT/Aliases/Project/docs/file.md", + ), + ).toBe("/ROOT/Storage/Project/docs/file.md") + }) + + it("normalizes network roots into the shared UNC matcher namespace", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "\\\\server\\share\\project", + "\\\\server\\storage\\project", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/UNC/SERVER/SHARE/project/docs/file.md")).toBe( + "/UNC/server/storage/project/docs/file.md", + ) + }) +}) + +it.each([false, true])("preserves filesystem-root equivalence (Windows=%s)", (isWindows) => { + const prefix = isWindows ? "/ROOT" : "" + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + expect( + WorkspaceRootCanonicalizer.forStrategy("/", "/storage", strategy).canonicalize(`${prefix}/docs/file.md`), + ).toBe(`${prefix}/storage/docs/file.md`) + expect( + WorkspaceRootCanonicalizer.forStrategy("/aliases", "/", strategy).canonicalize( + `${prefix}/aliases/docs/file.md`, + ), + ).toBe(`${prefix}/docs/file.md`) +}) diff --git a/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts b/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts new file mode 100644 index 0000000000..db0c8080cc --- /dev/null +++ b/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts @@ -0,0 +1,57 @@ +import { isFileMatchedByPatterns } from "../filePatterns" +import { WorkspaceRootStrategy } from "../strategies/WorkspaceRootStrategy" + +describe("WorkspaceRootStrategy.forPlatform", () => { + afterEach(() => vi.restoreAllMocks()) + + it("selects POSIX behavior without interpreting drives, network prefixes, or backslashes", () => { + const strategy = WorkspaceRootStrategy.forPlatform(false) + + expect(strategy.toMatcherAbsolutePath("/tmp/notes.md", "/C")).toBe("/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("//server/share/notes.md", "/C")).toBe("//server/share/notes.md") + expect(strategy.toMatcherAbsolutePath("/tmp/my\\file.md", "/C")).toBe("/tmp/my\\file.md") + expect(strategy.comparisonKey("/tmp/NOTES.md")).toBe("/tmp/NOTES.md") + }) + + it("selects Windows behavior for drives, rooted paths, and the distinct UNC namespace", () => { + const strategy = WorkspaceRootStrategy.forPlatform(true) + + expect(strategy.toMatcherAbsolutePath("D:/tmp/notes.md", "/C")).toBe("/D/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("/tmp/notes.md", "/C")).toBe("/C/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("//server/share/notes.md", "/C")).toBe("/UNC/server/share/notes.md") + expect(strategy.toMatcherAbsolutePath("//C/share/notes.md", "/C")).not.toBe("/C/share/notes.md") + expect(strategy.comparisonKey("/D/Tmp/NOTES.md")).toBe("/d/tmp/notes.md") + }) + + it("uses one selected strategy for candidates, every pattern, and both workspace roots", () => { + const toMatcherAbsolutePath = vi.fn((value: string) => value) + const normalizeAbsoluteRoot = vi + .fn() + .mockReturnValueOnce("/aliases/project") + .mockReturnValueOnce("/storage/project") + const comparisonKey = vi.fn((value: string) => value) + const strategy: WorkspaceRootStrategy = { toMatcherAbsolutePath, normalizeAbsoluteRoot, comparisonKey } + const selectStrategy = vi.spyOn(WorkspaceRootStrategy, "forPlatform").mockReturnValue(strategy) + + expect( + isFileMatchedByPatterns({ + filePath: "/storage/project/docs/notes.md", + cwd: "/aliases/project", + canonicalCwd: "/storage/project", + patterns: ["docs/**", "!/aliases/project/docs/private.md", "/aliases/project/docs/notes.md"], + isWindows: false, + }), + ).toBe(true) + + expect(selectStrategy).toHaveBeenCalledOnce() + expect(selectStrategy).toHaveBeenCalledWith(false) + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/storage/project/docs/notes.md", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project/docs/private.md", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project/docs/notes.md", "") + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(1, "/aliases/project") + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(2, "/storage/project") + expect(comparisonKey).toHaveBeenCalled() + }) +}) diff --git a/src/core/auto-approval/__tests__/filePatterns.spec.ts b/src/core/auto-approval/__tests__/filePatterns.spec.ts index 6b1c3ff6b9..23ceaa1089 100644 --- a/src/core/auto-approval/__tests__/filePatterns.spec.ts +++ b/src/core/auto-approval/__tests__/filePatterns.spec.ts @@ -86,7 +86,7 @@ describe("toMatcherPattern", () => { // name, so `C:/tmp/notes.md` names a file inside it, relative to the workspace. describe("on Windows", () => { // The colon is dropped, leaving the drive as an ordinary leading path - // segment; see `toPosixAbsolutePath` for why it cannot stay. + // segment: the Windows strategy avoids drive-rooted paths rejected by `ignore`. it("keeps the drive of an absolute pattern as its first path segment", () => { expect(toWindowsPattern("D:/tmp/notes.md")).toBe("/D/tmp/notes.md") }) diff --git a/src/core/auto-approval/__tests__/posixPaths.spec.ts b/src/core/auto-approval/__tests__/posixPaths.spec.ts index 5e9da6312f..bb697dc0cd 100644 --- a/src/core/auto-approval/__tests__/posixPaths.spec.ts +++ b/src/core/auto-approval/__tests__/posixPaths.spec.ts @@ -20,6 +20,25 @@ describe("matching off Windows", () => { const matches = (filePath: string, patterns: string[]) => matcher().isFileMatchedByPatterns({ filePath, cwd: CWD, patterns }) + it("keeps backslashes in a workspace root literal", () => { + const rootMatch = (filePath: string) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "/aliases/my\\project", + canonicalCwd: "/storage/my\\project", + patterns: ["docs/**"], + }) + expect(rootMatch("/aliases/my\\project/docs/notes.md")).toBe(true) + expect(rootMatch("/storage/my\\project/docs/notes.md")).toBe(true) + expect(rootMatch("/aliases/my/project/docs/notes.md")).toBe(false) + }) + + it("matches a relative grant at the filesystem root", () => { + expect(matcher().isFileMatchedByPatterns({ filePath: "/docs/notes.md", cwd: "/", patterns: ["docs/**"] })).toBe( + true, + ) + }) + // A backslash is a legal filename character here, where `my\file` is one file // rather than `file` inside `my`. it("reads a backslash as part of the filename, not as a separator", () => { @@ -39,6 +58,18 @@ describe("matching off Windows", () => { expect(matches("/tmp/notes.md", ["C:/tmp/notes.md"])).toBe(false) }) + it("keeps drive-relative-looking names usable as literal POSIX paths", () => { + expect(matches("C:notes.md", ["C:notes.md"])).toBe(true) + expect(matches("D:docs/notes.md", ["D:docs/**"])).toBe(true) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "docs/notes.md", + cwd: "/workspace/C:", + patterns: ["docs/**"], + }), + ).toBe(true) + }) + it("matches a bare pattern anywhere in the workspace, and nowhere else", () => { expect(matches("notes.md", ["notes.md"])).toBe(true) expect(matches("deeply/nested/notes.md", ["notes.md"])).toBe(true) diff --git a/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts b/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts new file mode 100644 index 0000000000..990eeb7f59 --- /dev/null +++ b/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts @@ -0,0 +1,56 @@ +import * as fs from "fs/promises" +import { baseState } from "./fixtures" +import { matcherForPlatform } from "./matcherForPlatform" + +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn() } +}) + +describe("approval with a drive-relative Windows workspace", () => { + matcherForPlatform("win32") + + beforeEach(() => { + vi.mocked(fs.realpath).mockReset().mockResolvedValue("C:/repo") + }) + + it.each(["readFile", "newFileCreated"] as const)( + "does not approve %s outside the actual workspace through relative grants", + async (tool) => { + const { checkAutoApproval } = await import("../index") + const state = { ...baseState, allowedReadFiles: ["docs/**"], allowedWriteFiles: ["docs/**"] } + for (const path of ["C:/docs/private.md", "../docs/private.md"]) { + expect( + await checkAutoApproval({ + state, + cwd: "C:", + ask: "tool", + text: JSON.stringify({ tool, path, isOutsideWorkspace: true }), + }), + ).toEqual({ decision: "ask" }) + } + + const text = JSON.stringify({ tool, path: "C:/docs/private.md", isOutsideWorkspace: true }) + expect( + await checkAutoApproval({ + state: { ...state, allowedReadFiles: ["C:/docs/**"], allowedWriteFiles: ["C:/docs/**"] }, + cwd: "C:", + ask: "tool", + text, + }), + ).toEqual({ decision: "approve" }) + expect( + await checkAutoApproval({ + state: { + ...state, + allowedReadFiles: ["C:/docs/**", "!C:/docs/private.md"], + allowedWriteFiles: ["C:/docs/**", "!C:/docs/private.md"], + }, + cwd: "C:", + ask: "tool", + text, + }), + ).toEqual({ decision: "ask" }) + }, + ) +}) diff --git a/src/core/auto-approval/__tests__/windowsPaths.spec.ts b/src/core/auto-approval/__tests__/windowsPaths.spec.ts index acedfa726c..fc94221b03 100644 --- a/src/core/auto-approval/__tests__/windowsPaths.spec.ts +++ b/src/core/auto-approval/__tests__/windowsPaths.spec.ts @@ -30,6 +30,190 @@ describe("matching on Windows", () => { const matches = (filePath: string, patterns: string[], cwd: string | undefined = WINDOWS_CWD) => matcher().isFileMatchedByPatterns({ filePath, cwd, patterns, homeDir: WINDOWS_HOME }) + it("keeps share-rooted paths separate from the internal UNC namespace", () => { + const cwd = "\\\\server\\share\\project" + const rooted = "/UNC/server/share/project/docs/**" + expect(matches("docs/private.md", [rooted], cwd)).toBe(false) + expect(matches("//server/share/project/docs/private.md", [rooted], cwd)).toBe(false) + expect(matches("//server/share/UNC/server/share/project/docs/private.md", [rooted], cwd)).toBe(true) + expect(matches("/UNC/server/share/project/docs/private.md", ["docs/**"], cwd)).toBe(false) + expect(matches("docs/private.md", ["docs/**", `!${rooted}`], cwd)).toBe(true) + }) + + it("resolves share-rooted paths against the workspace share", () => { + const cwd = "\\\\server\\share\\project" + expect(matches("//server/share/docs/private.md", ["/docs/**"], cwd)).toBe(true) + expect(matches("/docs/private.md", ["//server/share/docs/**"], cwd)).toBe(true) + expect(matches("//other/share/docs/private.md", ["/docs/**"], cwd)).toBe(false) + }) + + it.each(["C:/", "//server/share", "//server/share/"])("matches relative grants at a volume root: %s", (cwd) => { + const absolutePath = `${cwd.replace(/\/$/, "")}/docs/private.md` + expect(matches("docs/private.md", ["docs/**"], cwd)).toBe(true) + expect(matches(absolutePath, ["docs/**"], cwd)).toBe(true) + expect(matches("docs/private.md", ["docs/**", "!docs/private.md"], cwd)).toBe(false) + expect(matches(absolutePath, ["docs/**", "!docs/private.md"], cwd)).toBe(false) + }) + + it.each(["C:", "c:", "C:repo"])("does not anchor relative grants to a drive-relative workspace: %s", (cwd) => { + expect(matcher().toMatcherPattern("docs/**", cwd)).toBeUndefined() + for (const canonicalCwd of [undefined, "C:/repo"]) { + for (const filePath of [ + "docs/private.md", + "../docs/private.md", + "C:/docs/private.md", + "C:/repo/docs/private.md", + ]) { + expect(matcher().isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: ["docs/**"] })).toBe( + false, + ) + } + expect( + matcher().isFileMatchedByPatterns({ + filePath: "C:/docs/notes.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**"], + }), + ).toBe(true) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "C:/docs/private.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**", "!C:/docs/private.md"], + }), + ).toBe(false) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "../docs/notes.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**"], + }), + ).toBe(false) + } + }) + + it.each(["C:", "C:docs/private.md", "D:docs/private.md"])( + "does not guess the location of a drive-relative candidate or pattern: %s", + (filePath) => { + expect(matches(filePath, ["**"])).toBe(false) + expect(matcher().toMatcherPattern(filePath, WINDOWS_CWD)).toBeUndefined() + }, + ) + + it("clamps lexical parents at drive and share roots without crossing matcher namespaces", () => { + const cwd = "\\\\server\\share\\project" + expect(matches("//server/share/docs/private.md", ["../../../docs/**"], cwd)).toBe(true) + expect(matches("C:/docs/private.md", ["../../../../C/docs/**"], cwd)).toBe(false) + expect(matches("../../../../C/docs/private.md", ["C:/docs/**"], cwd)).toBe(false) + expect(matches("C:/docs/private.md", ["../../../docs/**"], "C:/project")).toBe(true) + expect(matches("//server/share/docs/private.md", ["../../UNC/server/share/docs/**"], "C:/project")).toBe(false) + }) + + it.each(["//server/sh[a]re/shared/x.md", "\\\\server\\sh[a]re\\shared\\x.md"])( + "keeps UNC share names literal when resolving a lexical-parent grant: %s", + (filePath) => { + const cwd = "\\\\server\\sh[a]re\\project" + const patterns = ["../shared/x.md"] + expect(matches(filePath, patterns, cwd)).toBe(true) + expect(matches("../shared/x.md", patterns, cwd)).toBe(true) + for (const share of ["share", "shaare"]) { + expect(matches(`//server/${share}/shared/x.md`, patterns, cwd)).toBe(false) + } + }, + ) + + it("keeps unknown-volume rooted paths separate from explicit UNC paths", () => { + expect( + matcher().isFileMatchedByPatterns({ + filePath: "//server/share/docs/private.md", + patterns: ["/UNC/server/share/docs/**"], + }), + ).toBe(false) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "/UNC/server/share/docs/private.md", + patterns: ["//server/share/docs/**"], + }), + ).toBe(false) + }) + + it("preserves wildcard exclusions spanning drive and UNC workspace roots", () => { + const rootMatch = (filePath: string, patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "C:/Aliases/Project", + canonicalCwd: "//server/share/Project", + patterns, + }) + for (const filePath of ["docs/private.md", "//server/share/Project/docs/private.md"]) { + for (const exclusion of ["!C:/Aliases/**/private.md", "!//server/share/Proj*/docs/private.md"]) { + expect(rootMatch(filePath, ["docs/**", exclusion])).toBe(false) + expect(rootMatch(filePath, [exclusion, "docs/**"])).toBe(true) + expect(rootMatch(filePath, ["docs/**", exclusion, "C:/Aliases/Project/docs/private.md"])).toBe(true) + } + } + }) + + it.each(["\\\\server\\share\\project\\docs\\notes.md", "//server/share/project/docs/notes.md"])( + "matches an absolute UNC candidate against a relative grant: %s", + (filePath) => { + expect(matches(filePath, ["docs/**"], "\\\\server\\share\\project")).toBe(true) + }, + ) + + it("matches explicit UNC patterns from a local-drive workspace and without a workspace", () => { + const filePath = "\\\\server\\share\\project\\docs\\notes.md" + const patterns = ["//server/share/project/docs/**"] + expect(matches(filePath, patterns)).toBe(true) + expect(matcher().isFileMatchedByPatterns({ filePath, patterns, homeDir: WINDOWS_HOME })).toBe(true) + }) + + it.each([ + { cwd: "\\\\alias\\share\\project", canonicalCwd: "\\\\server\\storage\\project" }, + { cwd: "C:\\Aliases\\Project", canonicalCwd: "\\\\server\\storage\\project" }, + { cwd: "\\\\alias\\share\\project", canonicalCwd: "D:\\Storage\\Project" }, + ])("matches equivalent local and UNC roots ($cwd -> $canonicalCwd)", ({ cwd, canonicalCwd }) => { + const filePath = `${canonicalCwd}\\DOCS\\NOTES.md` + const rootMatch = (patterns: string[]) => + matcher().isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns, homeDir: WINDOWS_HOME }) + expect(rootMatch(["docs/**"])).toBe(true) + expect(rootMatch([`${cwd}/docs/**`])).toBe(true) + expect(rootMatch([`${canonicalCwd}/docs/**`])).toBe(true) + expect(rootMatch(["docs/**", `!${cwd}/docs/notes.md`])).toBe(false) + expect(rootMatch(["docs/**", `!${canonicalCwd}/docs/notes.md`])).toBe(false) + expect(rootMatch(["docs/**", `!${cwd}/docs/notes.md`, `${canonicalCwd}/docs/notes.md`])).toBe(true) + }) + + it("honors UNC exclusions for both relative and absolute candidates", () => { + const cwd = "\\\\server\\share\\project" + const patterns = ["docs/**", "!//server/share/project/docs/private.md"] + expect(matches("docs/private.md", patterns, cwd)).toBe(false) + expect(matches(`${cwd}\\docs\\private.md`, patterns, cwd)).toBe(false) + expect(matches(`${cwd}\\docs\\notes.md`, patterns, cwd)).toBe(true) + expect(matches("docs/private.md", [...patterns, "//server/share/project/docs/private.md"], cwd)).toBe(true) + }) + + it("keeps different UNC servers, shares, and similarly prefixed siblings separate", () => { + const cwd = "\\\\server\\share\\project" + for (const filePath of [ + "\\\\other-server\\share\\project\\docs\\notes.md", + "\\\\server\\other-share\\project\\docs\\notes.md", + "\\\\server\\share\\project-other\\docs\\notes.md", + ]) { + expect(matches(filePath, ["docs/**"], cwd)).toBe(false) + } + }) + + it("does not confuse a UNC server named C with the local C drive", () => { + expect(matches("C:\\share\\project\\docs\\notes.md", ["docs/**"], "\\\\C\\share\\project")).toBe(false) + expect(matches("\\\\C\\share\\project\\docs\\notes.md", ["docs/**"], "C:\\share\\project")).toBe(false) + expect(matches("C:\\share\\project\\docs\\notes.md", ["//C/share/project/docs/**"])).toBe(false) + expect(matches("\\\\C\\share\\project\\docs\\notes.md", ["C:/share/project/docs/**"])).toBe(false) + }) + it("matches a workspace-relative path against a bare pattern", () => { expect(matches("notes.md", ["notes.md"])).toBe(true) expect(matches("docs\\notes.md", ["notes.md"])).toBe(true) @@ -99,4 +283,34 @@ describe("matching on Windows", () => { expect(matches("C:/path/to/repo/etc/passwd", ["passwd"])).toBe(true) expect(matches("C:/etc/passwd", ["passwd"])).toBe(false) }) + + it("matches equivalent workspace roots across drives with Windows case and ordered exclusions", () => { + const rootMatch = (patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath: "d:\\storage\\project\\DOCS\\NOTES.md", + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + patterns, + }) + expect(rootMatch(["docs/**"])).toBe(true) + expect(rootMatch(["c:/aliases/PROJECT/docs/**"])).toBe(true) + expect(rootMatch(["d:/STORAGE/project/docs/**"])).toBe(true) + expect(rootMatch(["docs/**", "!c:/aliases/project/docs/notes.md"])).toBe(false) + expect(rootMatch(["docs/**", "!D:/Storage/Project/docs/notes.md"])).toBe(false) + }) + + it("keeps Windows siblings and lexical-parent outside patterns separate from canonical roots", () => { + const rootMatch = (filePath: string, patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + patterns, + }) + expect(rootMatch("C:/Aliases/shared/file.md", ["../shared/file.md"])).toBe(true) + expect(rootMatch("D:/Storage/shared/file.md", ["../shared/file.md"])).toBe(false) + expect(rootMatch("D:/Storage/shared/file.md", ["D:/Storage/shared/file.md"])).toBe(true) + expect(rootMatch("D:/Storage/Project-other/docs/file.md", ["docs/**"])).toBe(false) + expect(rootMatch("D:/Storage/Project-other/docs/file.md", ["C:/Aliases/Project-other/docs/**"])).toBe(false) + }) }) diff --git a/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts b/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts new file mode 100644 index 0000000000..36dafa3f31 --- /dev/null +++ b/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts @@ -0,0 +1,204 @@ +import * as fs from "fs/promises" +import type { ClineSayTool } from "@roo-code/types" +import { checkAutoApproval } from "../index" +import { isFileMatchedByPatterns } from "../filePatterns" +import { baseState, type State } from "./fixtures" + +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn() } +}) + +const CWD = "/aliases/project" +const CANONICAL_CWD = "/storage/project" + +function matches(filePath: string, patterns: string[]) { + return isFileMatchedByPatterns({ filePath, cwd: CWD, canonicalCwd: CANONICAL_CWD, patterns, isWindows: false }) +} + +function approval(tool: ClineSayTool, state: Partial, cwd: string | undefined = CWD, isProtected = false) { + return checkAutoApproval({ + state: { ...baseState, ...state }, + cwd, + ask: "tool", + text: JSON.stringify(tool), + isProtected, + }) +} + +beforeEach(() => { + vi.mocked(fs.realpath).mockReset().mockResolvedValue(CANONICAL_CWD) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe("read matcher workspace-root equivalence", () => { + it.each([CWD, CANONICAL_CWD, "docs/private.md"])( + "preserves wildcard exclusions spanning either root for %s", + (candidateRoot) => { + const candidate = candidateRoot === "docs/private.md" ? candidateRoot : `${candidateRoot}/docs/private.md` + for (const exclusion of ["!/aliases/**/private.md", "!/storage/proj*/docs/private.md"]) { + expect(matches(candidate, ["docs/**", exclusion])).toBe(false) + expect(matches(candidate, [exclusion, "docs/**"])).toBe(true) + expect(matches(candidate, ["docs/**", exclusion, `${CANONICAL_CWD}/docs/private.md`])).toBe(true) + } + }, + ) + + it.each(["project[1]", "project*", "project?"])("keeps the canonical root %s literal", (name) => { + const rootMatch = (filePath: string) => + isFileMatchedByPatterns({ + filePath, + cwd: CWD, + canonicalCwd: `/storage/${name}`, + patterns: ["docs/**"], + isWindows: false, + }) + expect(rootMatch(`/storage/${name}/docs/notes.md`)).toBe(true) + expect(rootMatch(`${CWD}/docs/notes.md`)).toBe(true) + expect(rootMatch("/storage/project1/docs/notes.md")).toBe(false) + }) + + it.each(["project[1]", "project*", "project?"])("keeps the lexical root %s literal", (name) => { + const rootMatch = (filePath: string) => + isFileMatchedByPatterns({ + filePath, + cwd: `/aliases/${name}`, + canonicalCwd: CANONICAL_CWD, + patterns: ["docs/**"], + isWindows: false, + }) + expect(rootMatch(`/aliases/${name}/docs/notes.md`)).toBe(true) + expect(rootMatch(`${CANONICAL_CWD}/docs/notes.md`)).toBe(true) + expect(rootMatch("/aliases/project1/docs/notes.md")).toBe(false) + }) + + it.each([CWD, CANONICAL_CWD])("honors %s exclusions and later re-inclusion in one ordered matcher", (root) => { + const candidate = `${CANONICAL_CWD}/docs/private.md` + const exclusion = `!${root}/docs/private.md` + + expect(matches(candidate, ["docs/**", exclusion])).toBe(false) + expect(matches(candidate, [exclusion, "docs/**"])).toBe(true) + expect(matches(candidate, ["docs/**", exclusion, `${CWD}/docs/private.md`])).toBe(true) + }) + + it("resolves escaping patterns against the lexical parent, not the canonical parent", () => { + expect(matches("/aliases/shared/file.md", ["../shared/file.md"])).toBe(true) + expect(matches("/storage/shared/file.md", ["../shared/file.md"])).toBe(false) + expect(matches("/storage/shared/file.md", ["/storage/shared/file.md"])).toBe(true) + expect(matches("/aliases/shared/file.md", ["/storage/shared/file.md"])).toBe(false) + expect(matches(`${CANONICAL_CWD}/docs/file.md`, ["../project/docs/file.md"])).toBe(true) + }) + + it("does not translate similarly prefixed siblings or permit relative rules to cover other roots", () => { + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) + expect(matches(`${CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) + expect(matches(`${CANONICAL_CWD}/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) + expect(matches(`${CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(true) + }) +}) + +describe("read-only root equivalence approval", () => { + it("keeps read and write allowlist exclusions independent while writes still imply reads", async () => { + const tool: ClineSayTool = { tool: "readFile", path: `${CANONICAL_CWD}/docs/private.md` } + const excludedRead = ["docs/**", `!${CWD}/docs/private.md`] + const excludedWrite = ["docs/**", `!${CANONICAL_CWD}/docs/private.md`] + + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: excludedWrite })).toEqual({ + decision: "ask", + }) + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: ["docs/**"] })).toEqual({ + decision: "approve", + }) + expect(await approval(tool, { allowedReadFiles: ["docs/**"], allowedWriteFiles: excludedWrite })).toEqual({ + decision: "approve", + }) + }) + + it("does not extend write matching or protected-file permission when roots are equivalent", async () => { + const canonicalTool: ClineSayTool = { tool: "newFileCreated", path: `${CANONICAL_CWD}/AGENTS.md` } + expect(await approval(canonicalTool, { allowedReadFiles: ["*.md"], allowedWriteFiles: ["*.md"] })).toEqual({ + decision: "ask", + }) + expect(await approval(canonicalTool, { allowedWriteFiles: [`${CANONICAL_CWD}/AGENTS.md`] }, CWD, true)).toEqual( + { decision: "ask" }, + ) + expect( + await approval({ tool: "newFileCreated", path: "AGENTS.md" }, { allowedWriteFiles: ["*.md"] }, CWD, true), + ).toEqual({ decision: "ask" }) + expect( + await approval( + { tool: "listFilesRecursive", path: `${CANONICAL_CWD}/docs` }, + { allowedReadFiles: ["docs/**"] }, + ), + ).toEqual({ decision: "ask" }) + expect(fs.realpath).not.toHaveBeenCalled() + }) + + it("requires every named batch target to match and refuses unnamed targets", async () => { + const state = { allowedReadFiles: ["docs/**", `!${CWD}/docs/private.md`] } + const listed = { path: `${CANONICAL_CWD}/docs/notes.md`, lineSnippet: "", key: "notes" } + const excluded = { path: `${CANONICAL_CWD}/docs/private.md`, lineSnippet: "", key: "private" } + const outside = { path: "/elsewhere/docs/notes.md", lineSnippet: "", key: "outside" } + + expect( + await approval( + { + tool: "readFile", + batchFiles: [listed, { path: `${CWD}/docs/other.md`, lineSnippet: "", key: "other" }], + }, + state, + ), + ).toEqual({ decision: "approve" }) + expect(await approval({ tool: "readFile", batchFiles: [listed, excluded] }, state)).toEqual({ decision: "ask" }) + expect(await approval({ tool: "readFile", batchFiles: [listed, outside] }, state)).toEqual({ decision: "ask" }) + expect(await approval({ tool: "readFile", batchFiles: [listed], additionalFileCount: 1 }, state)).toEqual({ + decision: "ask", + }) + }) + + it.each(["ENOENT", "EACCES"])( + "does not infer root equivalence when task root resolution fails with %s", + async (code) => { + const error = Object.assign(new Error("root unavailable"), { code }) + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(fs.realpath).mockRejectedValue(error) + const tool: ClineSayTool = { + tool: "readFile", + path: `${CANONICAL_CWD}/docs/notes.md`, + isOutsideWorkspace: true, + } + + expect(await approval(tool, { allowedReadFiles: ["docs/**"] })).toEqual({ decision: "ask" }) + expect(await approval(tool, { allowedReadFiles: [`${CWD}/docs/**`] })).toEqual({ decision: "ask" }) + expect(await approval(tool, { allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] })).toEqual({ + decision: "approve", + }) + expect(errorLog).toHaveBeenCalledTimes(3) + expect(errorLog).toHaveBeenCalledWith(`[auto-approval] Failed to resolve workspace root ${CWD}:`, error) + }, + ) + + it.each([undefined, ""])("does not resolve workspace-relative grants without a task root: %s", async (cwd) => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}) + const text = JSON.stringify({ + tool: "readFile", + path: `${CANONICAL_CWD}/docs/notes.md`, + isOutsideWorkspace: true, + }) + expect( + await checkAutoApproval({ state: { ...baseState, allowedReadFiles: ["docs/**"] }, cwd, ask: "tool", text }), + ).toEqual({ decision: "ask" }) + expect( + await checkAutoApproval({ + state: { ...baseState, allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] }, + cwd, + ask: "tool", + text, + }), + ).toEqual({ decision: "approve" }) + expect(fs.realpath).not.toHaveBeenCalled() + expect(errorLog).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts b/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts new file mode 100644 index 0000000000..4d0643d336 --- /dev/null +++ b/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts @@ -0,0 +1,41 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { checkAutoApproval } from "../index" +import { baseState } from "./fixtures" + +it("keeps permissions and wildcard exclusions scoped to a real symlinked workspace", async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-workspace-approval-")) + try { + const canonicalRoot = path.join(temp, "storage", "project[1]") + const lexicalParent = path.join(temp, "aliases") + const cwd = path.join(lexicalParent, "project") + const inside = path.join(canonicalRoot, "docs", "private.md") + const outside = path.join(temp, "storage", "project1", "docs", "private.md") + await fs.mkdir(path.dirname(inside), { recursive: true }) + await fs.mkdir(path.dirname(outside), { recursive: true }) + await fs.mkdir(lexicalParent) + await fs.writeFile(inside, "allowed workspace") + await fs.writeFile(outside, "unrelated workspace") + // A directory junction also works on Windows without symlink privileges. + await fs.symlink(canonicalRoot, cwd, "junction") + + const approval = async (filePath: string, patterns: string[]) => + checkAutoApproval({ + cwd, + state: { ...baseState, allowedReadFiles: patterns }, + ask: "tool", + text: JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), + }) + const canonicalInside = await fs.realpath(inside) + const canonicalOutside = await fs.realpath(outside) + expect(await approval(canonicalInside, ["docs/**"])).toEqual({ decision: "approve" }) + expect(await approval(canonicalOutside, ["docs/**"])).toEqual({ decision: "ask" }) + const exclusion = `!${lexicalParent}/**/private.md` + expect(await approval(canonicalInside, ["docs/**", exclusion])).toEqual({ decision: "ask" }) + expect(await approval("docs/private.md", ["docs/**", exclusion])).toEqual({ decision: "ask" }) + expect(await approval(canonicalInside, [exclusion, "docs/**"])).toEqual({ decision: "approve" }) + } finally { + await fs.rm(temp, { recursive: true, force: true }) + } +}) diff --git a/src/core/auto-approval/filePatterns.ts b/src/core/auto-approval/filePatterns.ts index 923d29815d..0df97d8981 100644 --- a/src/core/auto-approval/filePatterns.ts +++ b/src/core/auto-approval/filePatterns.ts @@ -3,6 +3,9 @@ import path from "path" import ignore from "ignore" +import { WorkspaceRootCanonicalizer } from "./WorkspaceRootCanonicalizer" +import { WorkspaceRootStrategy } from "./strategies/WorkspaceRootStrategy" + /** * Matching of file paths against user-configured file patterns. * @@ -72,7 +75,7 @@ import ignore from "ignore" * Both the configured patterns and the path being checked are therefore * rewritten into a single form the library accepts: a path relative to the * filesystem root (`/`), with any Windows drive as its first segment, in the - * spelling `toPosixAbsolutePath` gives it. + * spelling supplied by the selected path strategy. * * An alternative implementation would be to remember for each pattern whether * it's a workspace-scoped pattern or an absolute-filesystem pattern, and then @@ -155,56 +158,37 @@ function pathsepsToPosix(value: string, isWindows: boolean): string { } /** - * A Windows drive at the root of a path: the `C:` in `C:/tmp/x`, or `C:` alone. + * A Windows drive at the root of an absolute path: the `C:` in `C:/tmp/x`. * - * The lookahead is what limits this to a drive *root*. Windows also reads `C:tmp` - * as naming a drive, but relative to the current directory on it, which is not a - * place this module can resolve. + * Require a separator after the colon. Both `C:` and `C:tmp` are relative to + * the current directory on that drive, which this module cannot safely infer. */ -const DRIVE_PREFIX = /^([a-zA-Z]):(?=\/|$)/ +const DRIVE_PREFIX = /^([a-zA-Z]):(?=\/)/ /** - * Rewrite an absolute path into the plain POSIX form the rest of this module - * works in, where a Windows drive is an ordinary first path segment. - * - * - `"C:/tmp/x"` -> `"/C/tmp/x"` - * - `"/tmp/x"` with `workspaceDrive = "/D"` -> `"/D/tmp/x"`, since Windows reads a - * path written without a drive as being on the current one - * - `"/tmp/x"` off Windows -> `"/tmp/x"`, where there are no drives to place it on - * - * The colon has to go, working around `ignore` behaviour: - * On Windows, `ignore` rejects any path that looks drive-rooted - * (it tests `/^[a-z]:\//i` and throws `RangeError`, - * "path should be a `path.relative()`d string"). - * - * The bare letter cannot be mistaken for a directory of the same name, because it - * only ever goes where Windows puts the drive, at the very root of the path: a - * directory called `C` always sits in a later segment. - * - * Its case is left as typed, since a drive only occurs on Windows, where the - * matcher ignores case anyway (see the case-sensitivity note at the top), so - * `C:/x` and `c:/x` already agree. - * - * A drive is only read as one when reading paths as Windows does. Elsewhere `C:` - * is an ordinary directory name (`mkdir 'C:'` succeeds on Linux), so `C:/notes.md` - * is a *relative* path naming a file in it, and taking it for a drive would anchor - * it to the filesystem root instead of the workspace. + * The Windows volume in matcher notation: /C for a drive, /UNC/server/share + * for a network share. Empty when no volume is known, and always off Windows. */ -function toPosixAbsolutePath(absolutePath: string, workspaceDrive: string, isWindows: boolean): string { +function volumeSegmentOf(absolutePath: string, isWindows: boolean): string { + if (isWindows) { + const unc = /^\/\/([^/]+)\/([^/]+)/.exec(absolutePath) + if (unc) return `/UNC/${unc[1]}/${unc[2]}` + } const drive = isWindows ? DRIVE_PREFIX.exec(absolutePath) : null - return drive ? `/${drive[1]}${absolutePath.slice(drive[0].length)}` : `${workspaceDrive}${absolutePath}` + return drive ? `/${drive[1]}` : "" } -/** - * The drive a path is on, spelled as the path segment that `toPosixAbsolutePath` - * gives it: `"/C"` for `"C:/tmp/x"`. Empty when the path names no drive, as is - * always the case off Windows. - */ -function driveSegmentOf(absolutePath: string, isWindows: boolean): string { - const drive = isWindows ? DRIVE_PREFIX.exec(absolutePath) : null +/** Filesystem-provided prefixes are literal names, not user-authored globs. */ +function escapePatternLiteral(value: string): string { + // `ignore` expands ? even when escaped or inside a character class. A hex + // character class preserves a literal ? in filesystem-provided names. + return value.replace(/[\\*?[\]]/g, (character) => (character === "?" ? "[\\x3f]" : `\\${character}`)) +} - return drive ? `/${drive[1]}` : "" +/** A Windows drive/share is a filesystem root; .. cannot climb above it. */ +function resolveMatcherPath(root: string, relativePath: string, volume: string): string { + return `${volume}${path.posix.resolve(root.slice(volume.length) || "/", relativePath)}`.replace(/\/$/, "") || "/" } /** @@ -215,6 +199,11 @@ function isAbsolutePath(value: string, isWindows: boolean): boolean { return value.startsWith("/") || (isWindows && DRIVE_PREFIX.test(value)) } +/** Windows drive-relative paths depend on per-drive process state, not the task workspace. */ +function isDriveRelativePath(value: string, isWindows: boolean): boolean { + return isWindows && /^[a-zA-Z]:(?!\/)/.test(value) +} + function escapesWorkspace(posixPath: string): boolean { return posixPath.split("/").includes("..") } @@ -270,7 +259,7 @@ function toRootRelativePath(posixAbsolutePath: string): string { * - `"../shared/notes.md"` -> `"/path/to/shared/notes.md"` * resolved against the workspace root, so it lands outside the workspace * - `"~/notes.md"` -> `"/home/me/notes.md"` - * - `"C:/tmp/notes.md"` on Windows -> `"/C/tmp/notes.md"`, see `toPosixAbsolutePath()` + * - `"C:/tmp/notes.md"` on Windows -> `"/C/tmp/notes.md"`, using the path strategy * - `"!docs/secret.md"` -> `"!/path/to/repo/docs/secret.md"` * - `"mydir/"` -> `undefined` (because it names a dir) * - `"~"` -> `undefined` (because it names a dir) @@ -297,6 +286,16 @@ export function toMatcherPattern( cwd?: string, isWindows = runningOnWindows(), homeDir = os.homedir(), +): string | undefined { + return toMatcherPatternWithStrategy(pattern, cwd, isWindows, homeDir, WorkspaceRootStrategy.forPlatform(isWindows)) +} + +function toMatcherPatternWithStrategy( + pattern: string, + cwd: string | undefined, + isWindows: boolean, + homeDir: string, + strategy: WorkspaceRootStrategy, ): string | undefined { // Set gitignore's negation aside so the path is rewritten on its own merits, // then restore it, so that a negation is anchored exactly like the pattern it @@ -307,14 +306,20 @@ export function toMatcherPattern( // as gitignore's escape character and a legal filename character. let normalized = pathsepsToPosix(pattern.slice(negation.length), isWindows) - if (!normalized.trim() || normalized === "." || normalized === "~" || normalized.endsWith("/")) { + if ( + !normalized.trim() || + isDriveRelativePath(normalized, isWindows) || + normalized === "." || + normalized === "~" || + normalized.endsWith("/") + ) { return undefined } const workspacePosix = pathsepsToPosix(cwd ?? "", isWindows) - // A path written without a drive is read as being on the workspace's, which is - // the drive the tool call it came from would resolve it against. - const workspaceDrive = driveSegmentOf(workspacePosix, isWindows) + // A rooted Windows path belongs to the workspace's drive or network share. + const workspaceVolume = volumeSegmentOf(workspacePosix, isWindows) + const matcherVolume = strategy.toMatcherAbsolutePath("/", workspaceVolume).replace(/\/$/, "") if (normalized.startsWith("~/")) { // Left in the user's own spelling, drive and all, for the absolute branch @@ -323,21 +328,23 @@ export function toMatcherPattern( } if (isAbsolutePath(normalized, isWindows)) { - return `${negation}${toPosixAbsolutePath(normalized, workspaceDrive, isWindows)}` + return `${negation}${strategy.toMatcherAbsolutePath(normalized, workspaceVolume)}` } - if (!cwd) { - // No workspace for a relative pattern to be relative to; see the note above + if (!cwd || isDriveRelativePath(workspacePosix, isWindows)) { + // An unknown or drive-relative workspace cannot anchor a relative pattern; see the note above // on why this is not matched loosely instead. return undefined } // `resolve` also normalises, so that a `cwd` with a trailing slash or a `..` // segment does not reach the patterns below. - const workspaceRoot = path.posix.resolve(toPosixAbsolutePath(workspacePosix, workspaceDrive, isWindows)) + const workspaceRoot = escapePatternLiteral( + resolveMatcherPath(strategy.toMatcherAbsolutePath(workspacePosix, workspaceVolume), ".", matcherVolume), + ) if (escapesWorkspace(normalized)) { - return `${negation}${path.posix.resolve(workspaceRoot, normalized)}` + return `${negation}${resolveMatcherPath(workspaceRoot, normalized, escapePatternLiteral(matcherVolume))}` } // "./notes.md" names the workspace root explicitly. @@ -355,7 +362,7 @@ export function toMatcherPattern( // number of directories including none, restores its reach. const matchesInAnyDirectory = !isWorkspaceRootAnchored && !normalized.includes("/") - return `${negation}${workspaceRoot}/${matchesInAnyDirectory ? "**/" : ""}${normalized}` + return `${negation}${workspaceRoot.replace(/\/$/, "")}/${matchesInAnyDirectory ? "**/" : ""}${normalized}` } /** @@ -365,31 +372,37 @@ export function toMatcherPattern( * @returns The rewritten path, or `undefined` when it names no file, or when it * is relative and there is no workspace root to resolve it against. */ -function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boolean): string | undefined { +function toMatcherPath( + filePath: string, + cwd: string | undefined, + isWindows: boolean, + strategy: WorkspaceRootStrategy, +): string | undefined { // Not trimmed: whitespace can be part of a filename. const normalized = pathsepsToPosix(filePath, isWindows) - if (!normalized.trim() || normalized === ".") { + if (!normalized.trim() || isDriveRelativePath(normalized, isWindows) || normalized === ".") { return undefined } const workspacePosix = pathsepsToPosix(cwd ?? "", isWindows) - const workspaceDrive = driveSegmentOf(workspacePosix, isWindows) + const workspaceVolume = volumeSegmentOf(workspacePosix, isWindows) + const matcherVolume = strategy.toMatcherAbsolutePath("/", workspaceVolume).replace(/\/$/, "") if (isAbsolutePath(normalized, isWindows)) { - return toRootRelativePath(toPosixAbsolutePath(normalized, workspaceDrive, isWindows)) + return toRootRelativePath(strategy.toMatcherAbsolutePath(normalized, workspaceVolume)) } - if (!cwd) { - // A relative path cannot be placed on the filesystem without a root, and + if (!cwd || isDriveRelativePath(workspacePosix, isWindows)) { + // A relative path cannot be placed without a known, non-drive-relative root, and // the only patterns that survive without one are absolute, which such a // path could never match anyway. return undefined } - const workspaceRoot = toPosixAbsolutePath(workspacePosix, workspaceDrive, isWindows) + const workspaceRoot = strategy.toMatcherAbsolutePath(workspacePosix, workspaceVolume) - return toRootRelativePath(path.posix.resolve(workspaceRoot, normalized)) + return toRootRelativePath(resolveMatcherPath(workspaceRoot, normalized, matcherVolume)) } /** @@ -411,12 +424,15 @@ function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boo export function isFileMatchedByPatterns({ filePath, cwd, + canonicalCwd, patterns, isWindows = runningOnWindows(), homeDir = os.homedir(), }: { filePath?: string cwd?: string + /** Verified realpath of cwd, used only by read approval. */ + canonicalCwd?: string patterns?: string[] isWindows?: boolean homeDir?: string @@ -425,14 +441,17 @@ export function isFileMatchedByPatterns({ return false } - const candidate = toMatcherPath(filePath, cwd, isWindows) + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + const candidate = toMatcherPath(filePath, cwd, isWindows, strategy) if (!candidate) { return false } + const workspaceRoot = WorkspaceRootCanonicalizer.forStrategy(cwd, canonicalCwd, strategy) + const candidates = workspaceRoot.equivalentPaths(`/${candidate}`).map(toRootRelativePath) const matcherPatterns = patterns - .map((pattern) => toMatcherPattern(pattern, cwd, isWindows, homeDir)) + .map((pattern) => toMatcherPatternWithStrategy(pattern, cwd, isWindows, homeDir, strategy)) .filter((pattern): pattern is string => !!pattern) if (!matcherPatterns.length) { @@ -440,9 +459,18 @@ export function isFileMatchedByPatterns({ } try { - return ignore({ ignoreCase: isWindows }) - .add([...matcherPatterns, MATCH_DIRECTORIES_ONLY_PATTERN]) - .ignores(candidate) + let matched = false + for (const pattern of matcherPatterns) { + const negated = pattern.startsWith("!") + const matcher = ignore({ ignoreCase: isWindows }).add([ + negated ? pattern.slice(1) : pattern, + MATCH_DIRECTORIES_ONLY_PATTERN, + ]) + // Apply each rule to both spellings BEFORE advancing to the next rule. + // OR-ing two complete matchers would let one spelling bypass an exclusion. + if (candidates.some((value) => matcher.ignores(value))) matched = !negated + } + return matched } catch (error) { // A path the matcher rejects cannot be confirmed as matching, so treat it // as unmatched. diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 3539eef24d..9352eb4281 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -9,6 +9,7 @@ import { } from "@roo-code/types" import type { DcgDecision } from "../../services/destructive-command-guard/runner" +import { realpath } from "fs/promises" import { ClineAskResponse } from "../../shared/WebviewMessage" @@ -87,6 +88,17 @@ function areAllNamedFilesMatched(tool: ClineSayTool, matchFun: (filePath: string return paths.every((filePath) => matchFun(filePath)) } +async function resolveCanonicalWorkspaceRoot(cwd: string | undefined): Promise { + if (!cwd) return undefined + + try { + return await realpath(cwd) + } catch (error) { + console.error(`[auto-approval] Failed to resolve workspace root ${cwd}:`, error) + return undefined + } +} + /** * Whether a read-only tool action is fully covered by the read allowlist patterns. * @@ -105,20 +117,23 @@ function areAllNamedFilesMatched(tool: ClineSayTool, matchFun: (filePath: string * narrows the list it appears in. This also means that negating a read is * ineffective while a non-negated write pattern still matches the file. */ -function isReadAllowedByPatterns( +async function isReadAllowedByPatterns( tool: ClineSayTool, cwd: string | undefined, state: Pick, -): boolean { - if (tool.tool !== "readFile") { +): Promise { + if (tool.tool !== "readFile" || (!state.allowedReadFiles?.length && !state.allowedWriteFiles?.length)) { return false } + // Task-local root only. Unknown roots keep lexical matching; never resolve + // individual file aliases here, since those can point outside the workspace. + const canonicalCwd = await resolveCanonicalWorkspaceRoot(cwd) return areAllNamedFilesMatched( tool, (filePath) => - isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedReadFiles }) || - isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedWriteFiles }), + isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: state.allowedReadFiles }) || + isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: state.allowedWriteFiles }), ) } @@ -392,7 +407,7 @@ export async function checkAutoApproval({ // `alwaysAllowReadOnly` permission. Such a pattern names its // location, including outside the workspace, so it also stands in for // `alwaysAllowReadOnlyOutsideWorkspace`. - const isAllowedReadFile = isReadAllowedByPatterns(tool, cwd, state) + const isAllowedReadFile = await isReadAllowedByPatterns(tool, cwd, state) const isReadAllowed = isAllowedReadFile || diff --git a/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts new file mode 100644 index 0000000000..799f3e75c8 --- /dev/null +++ b/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts @@ -0,0 +1,19 @@ +import path from "path" + +import type { WorkspaceRootStrategy } from "./WorkspaceRootStrategy" + +export class PosixWorkspaceRootStrategy implements WorkspaceRootStrategy { + toMatcherAbsolutePath(absolutePath: string): string { + return absolutePath + } + + normalizeAbsoluteRoot(root: string | undefined): string | undefined { + if (!root?.startsWith("/")) return undefined + + return path.posix.resolve(this.toMatcherAbsolutePath(root)).replace(/\/$/, "") + } + + comparisonKey(value: string): string { + return value + } +} diff --git a/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts new file mode 100644 index 0000000000..06d10e9eab --- /dev/null +++ b/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts @@ -0,0 +1,32 @@ +import path from "path" + +import type { WorkspaceRootStrategy } from "./WorkspaceRootStrategy" + +export class WindowsWorkspaceRootStrategy implements WorkspaceRootStrategy { + /** Drop drive colons rejected by the Windows matcher while keeping UNC paths in their own namespace. */ + toMatcherAbsolutePath(absolutePath: string, workspaceVolume: string): string { + // Keep UNC paths independent of the current drive and distinct from drive letters. + if (absolutePath.startsWith("//")) return `/UNC/${absolutePath.replace(/^\/+/, "")}` + + const drive = /^([a-zA-Z]):(?=\/)/.exec(absolutePath) + // Rooted paths belong to the workspace drive/share. Without a known volume, + // keep them in a separate namespace so /UNC/... cannot impersonate a share. + return drive + ? `/${drive[1]}${absolutePath.slice(drive[0].length)}` + : `${workspaceVolume || "/ROOT"}${absolutePath}` + } + + normalizeAbsoluteRoot(root: string | undefined): string | undefined { + const normalized = (root ?? "").replace(/\\/g, "/") + const drive = /^([a-zA-Z]):(?=\/)/.exec(normalized) + if (!normalized.startsWith("/") && !drive) return undefined + + // Match the allowlist representation: a Windows drive is a POSIX path segment. + const absolutePath = this.toMatcherAbsolutePath(normalized, "") + return path.posix.resolve(absolutePath).replace(/\/$/, "") + } + + comparisonKey(value: string): string { + return value.toLowerCase() + } +} diff --git a/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts new file mode 100644 index 0000000000..301a3f0768 --- /dev/null +++ b/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts @@ -0,0 +1,13 @@ +import { PosixWorkspaceRootStrategy } from "./PosixWorkspaceRootStrategy" +import { WindowsWorkspaceRootStrategy } from "./WindowsWorkspaceRootStrategy" + +export abstract class WorkspaceRootStrategy { + static forPlatform(isWindows: boolean): WorkspaceRootStrategy { + return isWindows ? new WindowsWorkspaceRootStrategy() : new PosixWorkspaceRootStrategy() + } + + /** Convert a slash-normalized absolute path into the matcher's platform-specific namespace. */ + abstract toMatcherAbsolutePath(absolutePath: string, workspaceVolume: string): string + abstract normalizeAbsoluteRoot(root: string | undefined): string | undefined + abstract comparisonKey(value: string): string +} From c10dece8d734d6ffcfaaaab0100758433dd5b12c Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Tue, 13 Oct 2026 16:22:04 +0300 Subject: [PATCH 2/3] fix(read-file): bind approved reads and honor cancellation Keep the symlinked-workspace approval prerequisite unchanged. Pin approved targets to verified descriptors, consume descriptor-bound text/document/image bytes, close resources on all paths, and honor cancellation in both native and legacy readers while preserving received approval feedback. --- src/core/tools/ReadFileTool.ts | 465 +++-- src/core/tools/__tests__/readFileTool.spec.ts | 41 +- .../file-reading/ReadFileCancellation.ts | 33 + .../file-reading/ReadFileCancelledError.ts | 6 + src/core/tools/file-reading/ReadFileTarget.ts | 69 + .../__tests__/ReadFileCancellation.spec.ts | 132 ++ .../__tests__/ReadFileTarget.spec.ts | 69 + .../readApprovalStrategy.contract.spec.ts | 46 + .../readFileSafety.integration.spec.ts | 1545 +++++++++++++++++ src/core/tools/helpers/imageHelpers.ts | 25 +- 10 files changed, 2243 insertions(+), 188 deletions(-) create mode 100644 src/core/tools/file-reading/ReadFileCancellation.ts create mode 100644 src/core/tools/file-reading/ReadFileCancelledError.ts create mode 100644 src/core/tools/file-reading/ReadFileTarget.ts create mode 100644 src/core/tools/file-reading/__tests__/ReadFileCancellation.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/ReadFileTarget.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readApprovalStrategy.contract.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 00fc507692..a85ffc8c8d 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -20,7 +20,7 @@ import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" +import { extractTextFromBuffer, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" @@ -34,6 +34,8 @@ import { ImageMemoryTracker, } from "./helpers/imageHelpers" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { ReadFileTarget } from "./file-reading/ReadFileTarget" +import { ReadFileCancellation } from "./file-reading/ReadFileCancellation" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -62,8 +64,10 @@ interface FileResult { imageDataUrl?: string feedbackText?: string feedbackImages?: string[] + approvalStatus?: "approved" | "denied" // Store the original entry for mode processing entry?: InternalFileEntry + target?: ReadFileTarget } // ─── Tool Implementation ────────────────────────────────────────────────────── @@ -84,6 +88,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { * Execute new single-file format with slice/indentation mode support. */ private async executeNew(params: ReadFileParams, task: Task, callbacks: ToolCallbacks): Promise { + const cancellation = new ReadFileCancellation(task) + if (cancellation.isCancelled()) return const { pushToolResult } = callbacks const modelInfo = task.api.getModel().info const filePath = params.path @@ -148,7 +154,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { // RooIgnore validation const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { + if (accessAllowed) { + fileResult.target = await ReadFileTarget.resolve(path.resolve(task.cwd, relPath), cancellation) + } + if (!accessAllowed || !task.rooIgnoreController?.validateAccess(fileResult.target!.fullPath)) { await task.say("rooignore_error", relPath) const errorMsg = formatResponse.rooIgnoreError(relPath) updateFileResult(relPath, { @@ -163,7 +172,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Phase 2: Request user approval - await this.requestApproval(task, filesToApprove, updateFileResult) + await this.requestApproval(task, filesToApprove, updateFileResult, cancellation) + cancellation.throwIfCancelled() // Phase 3: Process approved files const imageMemoryTracker = new ImageMemoryTracker() @@ -181,49 +191,58 @@ export class ReadFileTool extends BaseTool<"read_file"> { const entry = fileResult.entry! try { - // Check if path is a directory - const stats = await fs.stat(fullPath) - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.` - updateFileResult(relPath, { - status: "error", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - continue - } + await fileResult.target!.withHandle(async (file, stats) => { + // Check if path is a directory + if (stats.isDirectory()) { + const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.` + updateFileResult(relPath, { + status: "error", + error: errorMsg, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + return + } - // Check for binary file - const isBinary = await isBinaryFile(fullPath) - - if (isBinary) { - await this.handleBinaryFile( - task, - relPath, - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker, - updateFileResult, - ) - continue - } + // Check for binary file + const probe = Buffer.alloc(512) + const { bytesRead } = await file.read(probe, 0, probe.length, 0) + cancellation.throwIfCancelled() + const isBinary = await isBinaryFile(probe, bytesRead) + cancellation.throwIfCancelled() + + if (isBinary) { + await this.handleBinaryFile( + task, + relPath, + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker, + updateFileResult, + file, + cancellation, + ) + return + } - // Read text file content with lossy UTF-8 conversion - // Reading as Buffer first allows graceful handling of non-UTF8 bytes - // (they become U+FFFD replacement characters instead of throwing) - const buffer = await fs.readFile(fullPath) - const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) + // Read text file content with lossy UTF-8 conversion + // Reading as Buffer first allows graceful handling of non-UTF8 bytes + // (they become U+FFFD replacement characters instead of throwing) + const buffer = await file.readFile() + cancellation.throwIfCancelled() + const fileContent = buffer.toString("utf-8") + const result = this.processTextFile(fileContent, entry) - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, + updateFileResult(relPath, { + nativeContent: `File: ${relPath}\n${result}`, + }) }) } catch (error) { + if (cancellation.isCancelled()) throw error const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { status: "error", @@ -235,13 +254,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Phase 4: Build and return result + cancellation.throwIfCancelled() const hasErrors = fileResults.some((r) => r.status === "error" || r.status === "blocked") if (hasErrors) { task.didToolFailInCurrentTurn = true } - this.buildAndPushResult(task, fileResults, pushToolResult) + this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) } catch (error) { + if (cancellation.isCancelled()) { + this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) + return + } const relPath = filePath || "unknown" const errorMsg = error instanceof Error ? error.message : String(error) @@ -251,7 +275,15 @@ export class ReadFileTool extends BaseTool<"read_file"> { nativeContent: `File: ${relPath}\nError: ${errorMsg}`, }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + try { + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + } catch (diagnosticError) { + if (!cancellation.isCancelled()) throw diagnosticError + } + if (cancellation.isCancelled()) { + this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) + return + } task.didToolFailInCurrentTurn = true const errorResult = fileResults @@ -341,6 +373,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { maxTotalImageSize: number, imageMemoryTracker: ImageMemoryTracker, updateFileResult: (path: string, updates: Partial) => void, + file: fs.FileHandle, + cancellation: ReadFileCancellation, ): Promise { const fileExtension = path.extname(relPath).toLowerCase() const supportedBinaryFormats = getSupportedBinaryFormats() @@ -354,7 +388,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { maxImageFileSize, maxTotalImageSize, imageMemoryTracker.getTotalMemoryUsed(), + file, ) + cancellation.throwIfCancelled() if (!validationResult.isValid) { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -364,7 +400,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { return } - const imageResult = await processImageFile(fullPath) + const imageResult = await processImageFile(fullPath, file, cancellation) + cancellation.throwIfCancelled() imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -374,6 +411,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { }) return } catch (error) { + if (cancellation.isCancelled()) throw error const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { status: "error", @@ -388,7 +426,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Handle other supported binary formats (PDF, DOCX, etc.) if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { try { - const content = await extractTextFromFile(fullPath) + const source = await file.readFile() + cancellation.throwIfCancelled() + const content = await extractTextFromBuffer(source, fullPath) + cancellation.throwIfCancelled() const numberedContent = addLineNumbers(content) const lineCount = content.split("\n").length @@ -402,6 +443,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { }) return } catch (error) { + if (cancellation.isCancelled()) throw error const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { status: "error", @@ -428,8 +470,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { task: Task, filesToApprove: FileResult[], updateFileResult: (path: string, updates: Partial) => void, + cancellation: ReadFileCancellation, ): Promise { if (filesToApprove.length === 0) return + cancellation.throwIfCancelled() if (filesToApprove.length > 1) { // Batch approval @@ -500,15 +544,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Single file approval const fileResult = filesToApprove[0] const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const fullPath = fileResult.target!.fullPath + const isOutsideWorkspace = await ReadFileTarget.isOutsideWorkspace(fullPath) + cancellation.throwIfCancelled() const lineSnippet = this.getLineSnippet(fileResult.entry!) const startLine = this.getStartLine(fileResult.entry!) const completeMessage = JSON.stringify({ tool: "readFile", - path: getReadablePath(task.cwd, relPath), + path: getReadablePath(task.cwd, fullPath), isOutsideWorkspace, content: fullPath, reason: lineSnippet, @@ -517,8 +562,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { const { response, text, images } = await task.ask("tool", completeMessage, false) + updateFileResult(relPath, { + approvalStatus: response === "yesButtonClicked" ? "approved" : "denied", + feedbackText: text, + feedbackImages: images, + }) + cancellation.throwIfCancelled() + if (text) await task.say("user_feedback", text, images) + cancellation.throwIfCancelled() + if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) task.didRejectTool = true updateFileResult(relPath, { status: "denied", @@ -527,7 +580,6 @@ export class ReadFileTool extends BaseTool<"read_file"> { feedbackImages: images, }) } else { - if (text) await task.say("user_feedback", text, images) updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } } @@ -569,7 +621,22 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Build and push the final result to the tool output. */ - private buildAndPushResult(task: Task, fileResults: FileResult[], pushToolResult: PushToolResult): void { + private buildAndPushResult( + task: Task, + fileResults: FileResult[], + pushToolResult: PushToolResult, + cancellation: ReadFileCancellation, + ): void { + const cancelled = cancellation.isCancelled() + if (cancelled) { + fileResults = fileResults.map((result) => ({ + ...result, + status: result.approvalStatus ?? "pending", + nativeContent: undefined, + imageDataUrl: undefined, + })) + if (!fileResults.some((result) => result.feedbackText || result.feedbackImages?.length)) return + } const finalResult = fileResults .filter((r) => r.nativeContent) .map((r) => r.nativeContent) @@ -578,7 +645,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { const fileImageUrls = fileResults.filter((r) => r.imageDataUrl).map((r) => r.imageDataUrl as string) let statusMessage = "" - let feedbackImages: string[] = [] + let feedbackImages: string[] = cancelled ? fileResults.flatMap((result) => result.feedbackImages ?? []) : [] const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) @@ -665,151 +732,205 @@ export class ReadFileTool extends BaseTool<"read_file"> { * This handles the old format: { files: [{ path: string, lineRanges?: [...] }] } */ private async executeLegacy(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { + const cancellation = new ReadFileCancellation(task) + if (cancellation.isCancelled()) return const { pushToolResult } = callbacks const modelInfo = task.api.getModel().info - - if (!fileEntries || fileEntries.length === 0) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files") - pushToolResult(`Error: ${errorMsg}`) - return - } - - const supportsImages = modelInfo.supportsImages ?? false - - // Process each file sequentially (legacy behavior) const results: string[] = [] + const approvalResults: FileResult[] = [] - for (const entry of fileEntries) { - const relPath = entry.path - const fullPath = path.resolve(task.cwd, relPath) - - // RooIgnore validation - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - // Mirror the native path: a blocked file marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - continue + try { + if (!fileEntries || fileEntries.length === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("read_file") + const errorMsg = await cancellation.run(() => task.sayAndCreateMissingParamError("read_file", "files")) + pushToolResult(`Error: ${errorMsg}`) + return } - // Request approval for single file - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - let lineSnippet = "" - if (entry.lineRanges && entry.lineRanges.length > 0) { - const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`) - lineSnippet = ranges.join(", ") - } + const supportsImages = modelInfo.supportsImages ?? false - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet || undefined, - } satisfies ClineSayTool) - - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - results.push(`File: ${relPath}\nStatus: Denied by user`) - continue - } - - if (text) await task.say("user_feedback", text, images) + // Process each file sequentially (legacy behavior). + for (const entry of fileEntries) { + cancellation.throwIfCancelled() + const relPath = entry.path + const fullPath = path.resolve(task.cwd, relPath) - try { - // Check if the path is a directory - const stats = await fs.stat(fullPath) - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory.` + // RooIgnore validation + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + let target: ReadFileTarget | undefined + try { + if (accessAllowed) { + target = await ReadFileTarget.resolve(fullPath, cancellation) + } + } catch (error) { + if (cancellation.isCancelled()) throw error + const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - // Mirror the native path: a failed read marks the tool turn as failed. + await cancellation.run(() => task.say("error", `Error reading file ${relPath}: ${errorMsg}`)) + task.didToolFailInCurrentTurn = true + continue + } + if (!accessAllowed || !task.rooIgnoreController?.validateAccess(target!.fullPath)) { + await cancellation.run(() => task.say("rooignore_error", relPath)) + const errorMsg = formatResponse.rooIgnoreError(relPath) + results.push(`File: ${relPath}\nError: ${errorMsg}`) + // Mirror the native path: a blocked file marks the tool turn as failed. task.didToolFailInCurrentTurn = true continue } - const isBinary = await isBinaryFile(fullPath).catch(() => false) - - if (isBinary) { - // Handle binary files (images) - const fileExtension = path.extname(relPath).toLowerCase() - if (supportsImages && isSupportedImageFormat(fileExtension)) { - const state = await task.providerRef.deref()?.getState() - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - const validation = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - 0, // Legacy path doesn't track cumulative memory - ) - if (!validation.isValid) { - results.push(`File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`) - continue - } - const imageResult = await processImageFile(fullPath) - if (imageResult) { - results.push(`File: ${relPath}\n[Image file - content processed for vision model]`) - } - } else { - results.push(`File: ${relPath}\nError: Cannot read binary file`) - } + // Request approval for single file + const isOutsideWorkspace = await cancellation.run(() => + ReadFileTarget.isOutsideWorkspace(target!.fullPath), + ) + let lineSnippet = "" + if (entry.lineRanges && entry.lineRanges.length > 0) { + const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`) + lineSnippet = ranges.join(", ") + } + + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, target!.fullPath), + isOutsideWorkspace, + content: target!.fullPath, + reason: lineSnippet || undefined, + } satisfies ClineSayTool) + + cancellation.throwIfCancelled() + const { response, text, images } = await task.ask("tool", completeMessage, false) + approvalResults.push({ + path: relPath, + status: "pending", + approvalStatus: response === "yesButtonClicked" ? "approved" : "denied", + feedbackText: text, + feedbackImages: images, + }) + cancellation.throwIfCancelled() + + if (response !== "yesButtonClicked") { + if (text) await cancellation.run(() => task.say("user_feedback", text, images)) + task.didRejectTool = true + results.push(`File: ${relPath}\nStatus: Denied by user`) continue } - // Read text file - const rawContent = await fs.readFile(fullPath, "utf8") + if (text) await cancellation.run(() => task.say("user_feedback", text, images)) - // Handle line ranges if specified - let content: string - if (entry.lineRanges && entry.lineRanges.length > 0) { - const lines = rawContent.split("\n") - const selectedLines: string[] = [] + try { + await target!.withHandle(async (file, stats) => { + // Check if the path is a directory + if (stats.isDirectory()) { + const errorMsg = `Cannot read '${relPath}' because it is a directory.` + results.push(`File: ${relPath}\nError: ${errorMsg}`) + await cancellation.run(() => + task.say("error", `Error reading file ${relPath}: ${errorMsg}`), + ) + // Mirror the native path: a failed read marks the tool turn as failed. + task.didToolFailInCurrentTurn = true + return + } - for (const range of entry.lineRanges) { - // Convert to 0-based index, ranges are 1-based inclusive - const startIdx = Math.max(0, range.start - 1) - const endIdx = Math.min(lines.length - 1, range.end - 1) + let isBinary = false + try { + const probe = Buffer.alloc(512) + const { bytesRead } = await cancellation.run(() => file.read(probe, 0, probe.length, 0)) + isBinary = await cancellation.run(() => isBinaryFile(probe, bytesRead)) + } catch (error) { + if (cancellation.isCancelled()) throw error + // Preserve the historical legacy text fallback when binary probing fails. + } - for (let i = startIdx; i <= endIdx; i++) { - selectedLines.push(`${i + 1} | ${lines[i]}`) + if (isBinary) { + // Handle binary files (images) + const fileExtension = path.extname(relPath).toLowerCase() + if (supportsImages && isSupportedImageFormat(fileExtension)) { + const state = await cancellation.run(async () => task.providerRef.deref()?.getState()) + const { + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } = state ?? {} + const validation = await cancellation.run(() => + validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + 0, // Legacy path doesn't track cumulative memory + file, + ), + ) + if (!validation.isValid) { + results.push( + `File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`, + ) + return + } + const imageResult = await cancellation.run(() => + processImageFile(fullPath, file, cancellation), + ) + if (imageResult) { + results.push(`File: ${relPath}\n[Image file - content processed for vision model]`) + } + } else { + results.push(`File: ${relPath}\nError: Cannot read binary file`) + } + return } - } - content = selectedLines.join("\n") - } else { - // Read with default limits using slice mode - const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) - content = result.content - if (result.wasTruncated) { - content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` - } - } - results.push(`File: ${relPath}\n${content}`) + // Read text file + const rawContent = await cancellation.run(() => file.readFile("utf8")) + + // Handle line ranges if specified + let content: string + if (entry.lineRanges && entry.lineRanges.length > 0) { + const lines = rawContent.split("\n") + const selectedLines: string[] = [] + + for (const range of entry.lineRanges) { + // Convert to 0-based index, ranges are 1-based inclusive + const startIdx = Math.max(0, range.start - 1) + const endIdx = Math.min(lines.length - 1, range.end - 1) + + for (let i = startIdx; i <= endIdx; i++) { + selectedLines.push(`${i + 1} | ${lines[i]}`) + } + } + content = selectedLines.join("\n") + } else { + // Read with default limits using slice mode + const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) + content = result.content + if (result.wasTruncated) { + content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + } + } - // Track file in context - await task.fileContextTracker.trackFileContext(relPath, "read_tool") - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - // Mirror the native path: a failed read marks the tool turn as failed. - task.didToolFailInCurrentTurn = true + // Track file in context + await cancellation.run(() => task.fileContextTracker.trackFileContext(relPath, "read_tool")) + results.push(`File: ${relPath}\n${content}`) + }) + } catch (error) { + if (cancellation.isCancelled()) throw error + const errorMsg = error instanceof Error ? error.message : String(error) + results.push(`File: ${relPath}\nError: ${errorMsg}`) + await cancellation.run(() => task.say("error", `Error reading file ${relPath}: ${errorMsg}`)) + // Mirror the native path: a failed read marks the tool turn as failed. + task.didToolFailInCurrentTurn = true + } } - } - // Push combined results - pushToolResult(results.join("\n\n---\n\n")) + // Push combined results + cancellation.throwIfCancelled() + pushToolResult(results.join("\n\n---\n\n")) + } catch (error) { + if (cancellation.isCancelled()) { + this.buildAndPushResult(task, approvalResults, pushToolResult, cancellation) + return + } + throw error + } } } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index a457ff87bc..5678c4d5a3 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -22,7 +22,7 @@ import { isBinaryFile } from "isbinaryfile" import { readFileTool } from "../ReadFileTool" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" -import { extractTextFromFile, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" +import { extractTextFromBuffer, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -41,12 +41,23 @@ vi.mock("path", async () => { vi.mock("fs/promises", () => ({ readFile: vi.fn(), stat: vi.fn(), + realpath: vi.fn(async (file: string) => file), + lstat: vi.fn(async () => ({ dev: 1n, ino: 1n, isSymbolicLink: () => false })), + open: vi.fn(async (file: string) => { + const mocked = await import("fs/promises") + return { + stat: async () => ({ ...(await mocked.stat(file)), dev: 1n, ino: 1n }), + read: async () => ({ bytesRead: 0 }), + readFile: async (encoding?: "utf8") => (encoding ? mocked.readFile(file, encoding) : mocked.readFile(file)), + close: vi.fn().mockResolvedValue(undefined), + } + }), })) vi.mock("isbinaryfile") vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromFile: vi.fn(), + extractTextFromBuffer: vi.fn(), addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { if (!text) return "" const lines = text.split("\n") @@ -120,7 +131,7 @@ const mockedFsReadFile = vi.mocked(fsPromises.readFile) const mockedFsStat = vi.mocked(fsPromises.stat) const mockedIsBinaryFile = vi.mocked(isBinaryFile) -const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) +const mockedExtractTextFromBuffer = vi.mocked(extractTextFromBuffer) const mockedReadWithSlice = vi.mocked(readWithSlice) const mockedReadWithIndentation = vi.mocked(readWithIndentation) const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) @@ -420,24 +431,34 @@ describe("ReadFileTool", () => { it("should extract text from PDF files", async () => { const mockTask = createMockTask() const callbacks = createMockCallbacks() + const source = Buffer.from("%PDF-1.7\nsynthetic PDF bytes") - mockedExtractTextFromFile.mockResolvedValue("PDF content here") + mockedFsReadFile.mockResolvedValue(source) + mockedExtractTextFromBuffer.mockResolvedValue("PDF content here") await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks) - expect(mockedExtractTextFromFile).toHaveBeenCalled() + expect(mockedExtractTextFromBuffer).toHaveBeenCalledExactlyOnceWith( + source, + path.resolve(mockTask.cwd, "document.pdf"), + ) expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("PDF content here")) }) it("should extract text from DOCX files", async () => { const mockTask = createMockTask() const callbacks = createMockCallbacks() + const source = Buffer.from("PK\u0003\u0004synthetic DOCX bytes") - mockedExtractTextFromFile.mockResolvedValue("DOCX content here") + mockedFsReadFile.mockResolvedValue(source) + mockedExtractTextFromBuffer.mockResolvedValue("DOCX content here") await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks) - expect(mockedExtractTextFromFile).toHaveBeenCalled() + expect(mockedExtractTextFromBuffer).toHaveBeenCalledExactlyOnceWith( + source, + path.resolve(mockTask.cwd, "document.docx"), + ) expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("DOCX content here")) }) @@ -457,7 +478,7 @@ describe("ReadFileTool", () => { const mockTask = createMockTask() const callbacks = createMockCallbacks() - mockedExtractTextFromFile.mockRejectedValue(new Error("Extraction failed")) + mockedExtractTextFromBuffer.mockRejectedValue(new Error("Extraction failed")) await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks) @@ -1008,7 +1029,7 @@ describe("ReadFileTool", () => { ) expect(mockedFsReadFile).not.toHaveBeenCalled() expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromBuffer).not.toHaveBeenCalled() }, ) @@ -1033,7 +1054,7 @@ describe("ReadFileTool", () => { ) expect(mockedFsReadFile).not.toHaveBeenCalled() expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromBuffer).not.toHaveBeenCalled() }) it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( diff --git a/src/core/tools/file-reading/ReadFileCancellation.ts b/src/core/tools/file-reading/ReadFileCancellation.ts new file mode 100644 index 0000000000..1f97b6a0e9 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileCancellation.ts @@ -0,0 +1,33 @@ +import type { Task } from "../../task/Task" + +import { ReadFileCancelledError } from "./ReadFileCancelledError" + +/** Observe existing task cancellation flags; never change lifecycle state. */ +export class ReadFileCancellation { + constructor(private readonly task: Pick) {} + + isCancelled(): boolean { + return this.task.abort || this.task.abandoned + } + + throwIfCancelled(): void { + if (this.isCancelled()) throw new ReadFileCancelledError() + } + + /** Guard value-producing operations; resource acquisition needs its own cleanup scope. */ + async run(operation: () => Promise): Promise { + this.throwIfCancelled() + + let result: T + try { + result = await operation() + } catch (error) { + if (error instanceof ReadFileCancelledError) throw error + this.throwIfCancelled() + throw error + } + + this.throwIfCancelled() + return result + } +} diff --git a/src/core/tools/file-reading/ReadFileCancelledError.ts b/src/core/tools/file-reading/ReadFileCancelledError.ts new file mode 100644 index 0000000000..a71035060c --- /dev/null +++ b/src/core/tools/file-reading/ReadFileCancelledError.ts @@ -0,0 +1,6 @@ +export class ReadFileCancelledError extends Error { + constructor() { + super("File read cancelled") + this.name = "ReadFileCancelledError" + } +} diff --git a/src/core/tools/file-reading/ReadFileTarget.ts b/src/core/tools/file-reading/ReadFileTarget.ts new file mode 100644 index 0000000000..fad8136c32 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTarget.ts @@ -0,0 +1,69 @@ +import * as fs from "fs/promises" +import { constants, type BigIntStats } from "fs" +import path from "path" +import * as vscode from "vscode" + +import type { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" + +/** Metadata only until approval; content consumers must never reopen fullPath. */ +export class ReadFileTarget { + private constructor( + readonly fullPath: string, + private readonly cancellation: ReadFileCancellation, + private readonly identity?: BigIntStats, + private readonly missingError?: unknown, + ) {} + + static async resolve(lexicalPath: string, cancellation: ReadFileCancellation): Promise { + try { + const fullPath = await cancellation.run(() => fs.realpath(lexicalPath)) + const identity = await cancellation.run(() => fs.lstat(fullPath, { bigint: true })) + if (identity.isSymbolicLink()) { + throw new Error(`File target changed before approval: ${lexicalPath}`) + } + const revalidatedPath = await cancellation.run(() => fs.realpath(fullPath)) + if (revalidatedPath !== fullPath) { + throw new Error(`File target changed before approval: ${lexicalPath}`) + } + return new ReadFileTarget(fullPath, cancellation, identity) + } catch (error) { + if (error instanceof ReadFileCancelledError) throw error + // Filesystem diagnostics, including inaccessible parents, require approval. + // Code-less identity races and programmer errors must still fail closed. + if (error instanceof Error && "code" in error && typeof error.code === "string") { + return new ReadFileTarget(lexicalPath, cancellation, undefined, error) + } + throw error + } + } + + static async isOutsideWorkspace(fullPath: string): Promise { + const roots = await Promise.all( + (vscode.workspace.workspaceFolders ?? []).map((folder) => + fs.realpath(folder.uri.fsPath).catch(() => undefined), + ), + ) + return !roots.some((root) => { + if (!root) return false + const relative = path.relative(root, fullPath) + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) + }) + } + + async withHandle(read: (file: fs.FileHandle, stats: BigIntStats) => Promise): Promise { + return this.cancellation.run(async () => { + if (!this.identity) throw this.missingError + const file = await fs.open(this.fullPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const stats = await this.cancellation.run(() => file.stat({ bigint: true })) + if (stats.dev !== this.identity.dev || stats.ino !== this.identity.ino) { + throw new Error(`File target changed after approval: ${this.fullPath}`) + } + return await read(file, stats) + } finally { + await file.close() + } + }) + } +} diff --git a/src/core/tools/file-reading/__tests__/ReadFileCancellation.spec.ts b/src/core/tools/file-reading/__tests__/ReadFileCancellation.spec.ts new file mode 100644 index 0000000000..e8353dfe09 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/ReadFileCancellation.spec.ts @@ -0,0 +1,132 @@ +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileCancelledError } from "../ReadFileCancelledError" + +describe("ReadFileCancellation", () => { + it.each([ + { abort: false, abandoned: false, cancelled: false }, + { abort: true, abandoned: false, cancelled: true }, + { abort: false, abandoned: true, cancelled: true }, + { abort: true, abandoned: true, cancelled: true }, + ])("observes abort=$abort and abandoned=$abandoned without changing them", ({ abort, abandoned, cancelled }) => { + const task = { abort, abandoned } + const cancellation = new ReadFileCancellation(task) + + expect(cancellation.isCancelled()).toBe(cancelled) + if (cancelled) { + expect(() => cancellation.throwIfCancelled()).toThrow(ReadFileCancelledError) + } else { + expect(() => cancellation.throwIfCancelled()).not.toThrow() + } + expect(task).toEqual({ abort, abandoned }) + }) + + it.each(["abort", "abandoned"] as const)( + "observes later changes to %s rather than capturing a snapshot", + (flag) => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + + expect(cancellation.isCancelled()).toBe(false) + task[flag] = true + expect(cancellation.isCancelled()).toBe(true) + expect(() => cancellation.throwIfCancelled()).toThrow("File read cancelled") + task[flag] = false + expect(cancellation.isCancelled()).toBe(false) + expect(() => cancellation.throwIfCancelled()).not.toThrow() + }, + ) + + describe("checked async operations", () => { + it("returns a successful result without changing task flags", async () => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + const result = { fullPath: "/workspace/file.txt" } + const operation = vi.fn(async () => result) + + await expect(cancellation.run(operation)).resolves.toBe(result) + expect(operation).toHaveBeenCalledOnce() + expect(task).toEqual({ abort: false, abandoned: false }) + }) + + it.each(["abort", "abandoned"] as const)("does not start an operation when %s is already set", async (flag) => { + const task = { abort: false, abandoned: false } + task[flag] = true + const operation = vi.fn(async () => "unexpected content") + + await expect(new ReadFileCancellation(task).run(operation)).rejects.toBeInstanceOf(ReadFileCancelledError) + expect(operation).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "discards a result when %s changes during the operation", + async (flag) => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + + await expect( + cancellation.run(async () => { + task[flag] = true + return "late content" + }), + ).rejects.toBeInstanceOf(ReadFileCancelledError) + }, + ) + + it.each(["abort", "abandoned"] as const)( + "prioritizes cancellation over a late rejection after %s", + async (flag) => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + const filesystemError = Object.assign(new Error("late filesystem failure"), { code: "ENOENT" }) + + await expect( + cancellation.run(async () => { + task[flag] = true + throw filesystemError + }), + ).rejects.toBeInstanceOf(ReadFileCancelledError) + }, + ) + + it("preserves the original cancellation error and stack without checking cancellation again", async () => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + const error = new ReadFileCancelledError() + const originalStack = error.stack + const check = vi.spyOn(cancellation, "throwIfCancelled") + + await expect( + cancellation.run(async () => { + task.abort = true + throw error + }), + ).rejects.toBe(error) + expect(error.stack).toBe(originalStack) + expect(check).toHaveBeenCalledOnce() + }) + + it.each([new Error("filesystem failure"), "non-Error failure"])( + "preserves an unrelated rejection when the task is active: %s", + async (error) => { + const cancellation = new ReadFileCancellation({ abort: false, abandoned: false }) + + await expect( + cancellation.run(async () => { + throw error + }), + ).rejects.toBe(error) + }, + ) + + it("handles synchronous operation failures without replacing the original error", async () => { + const cancellation = new ReadFileCancellation({ abort: false, abandoned: false }) + const error = new Error("synchronous operation failure") + + await expect( + cancellation.run(() => { + throw error + }), + ).rejects.toBe(error) + }) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/ReadFileTarget.spec.ts b/src/core/tools/file-reading/__tests__/ReadFileTarget.spec.ts new file mode 100644 index 0000000000..66572185a7 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/ReadFileTarget.spec.ts @@ -0,0 +1,69 @@ +import * as fs from "fs/promises" + +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileCancelledError } from "../ReadFileCancelledError" +import { ReadFileTarget } from "../ReadFileTarget" + +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn(), lstat: vi.fn(), open: vi.fn() } +}) + +describe("ReadFileTarget resolution errors", () => { + beforeEach(() => vi.clearAllMocks()) + + it("preserves an existing cancellation error without replacing it in the target's catch", async () => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + const error = new ReadFileCancelledError() + const check = vi.spyOn(cancellation, "throwIfCancelled") + vi.mocked(fs.realpath).mockImplementationOnce(async () => { + task.abort = true + throw error + }) + + await expect(ReadFileTarget.resolve("/workspace/file.txt", cancellation)).rejects.toBe(error) + expect(check).toHaveBeenCalledOnce() + expect(fs.lstat).not.toHaveBeenCalled() + }) + + it("does not defer a cancellation error even if it carries a filesystem-like code", async () => { + const cancellation = new ReadFileCancellation({ abort: false, abandoned: false }) + const error = Object.assign(new ReadFileCancelledError(), { code: "ECANCELED" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(error) + + await expect(ReadFileTarget.resolve("/workspace/file.txt", cancellation)).rejects.toBe(error) + expect(fs.lstat).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "does not defer a late filesystem error when %s changes during resolution", + async (flag) => { + const task = { abort: false, abandoned: false } + const cancellation = new ReadFileCancellation(task) + vi.mocked(fs.realpath).mockImplementationOnce(async () => { + task[flag] = true + throw Object.assign(new Error("late missing file"), { code: "ENOENT" }) + }) + + await expect(ReadFileTarget.resolve("/workspace/file.txt", cancellation)).rejects.toBeInstanceOf( + ReadFileCancelledError, + ) + expect(fs.lstat).not.toHaveBeenCalled() + }, + ) + + it("keeps ordinary filesystem diagnostics deferred until approved handle access", async () => { + const cancellation = new ReadFileCancellation({ abort: false, abandoned: false }) + const error = Object.assign(new Error("missing file"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(error) + const read = vi.fn(async () => "unexpected content") + + const target = await ReadFileTarget.resolve("/workspace/file.txt", cancellation) + + expect(target.fullPath).toBe("/workspace/file.txt") + await expect(target.withHandle(read)).rejects.toBe(error) + expect(fs.open).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readApprovalStrategy.contract.spec.ts b/src/core/tools/file-reading/__tests__/readApprovalStrategy.contract.spec.ts new file mode 100644 index 0000000000..ee1df5e0d3 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readApprovalStrategy.contract.spec.ts @@ -0,0 +1,46 @@ +import { isFileMatchedByPatterns } from "../../../auto-approval/filePatterns" +import { WorkspaceRootStrategy } from "../../../auto-approval/strategies/WorkspaceRootStrategy" + +// Reader-owned coverage keeps the copied approval prerequisite patch unchanged. +describe("reader approval strategy contracts", () => { + afterEach(() => vi.restoreAllMocks()) + + it("keeps a drive-shaped UNC server in the exact Windows network namespace", () => { + const strategy = WorkspaceRootStrategy.forPlatform(true) + + expect(strategy.toMatcherAbsolutePath("//C/share/notes.md", "/C")).toBe("/UNC/C/share/notes.md") + expect(strategy.toMatcherAbsolutePath("C:/share/notes.md", "/C")).toBe("/C/share/notes.md") + }) + + it("compares the read candidate and both normalized workspace roots with the selected strategy", () => { + const candidate = "/storage/project/docs/notes.md" + const root = "/aliases/project" + const canonicalRoot = "/storage/project" + const comparisonKey = vi.fn((value: string) => value) + const normalizeAbsoluteRoot = vi + .fn() + .mockReturnValueOnce(root) + .mockReturnValueOnce(canonicalRoot) + const strategy: WorkspaceRootStrategy = { + toMatcherAbsolutePath: vi.fn((value: string) => value), + normalizeAbsoluteRoot, + comparisonKey, + } + vi.spyOn(WorkspaceRootStrategy, "forPlatform").mockReturnValue(strategy) + + const matched = isFileMatchedByPatterns({ + filePath: candidate, + cwd: root, + canonicalCwd: canonicalRoot, + patterns: ["docs/**", "!/aliases/project/docs/private.md", "/aliases/project/docs/notes.md"], + isWindows: false, + }) + + expect(matched).toBe(true) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(1, root) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(2, canonicalRoot) + expect(comparisonKey).toHaveBeenCalledWith(candidate) + expect(comparisonKey).toHaveBeenCalledWith(root) + expect(comparisonKey).toHaveBeenCalledWith(canonicalRoot) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts new file mode 100644 index 0000000000..ce72895e2a --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts @@ -0,0 +1,1545 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import * as vscode from "vscode" +import ExcelJS from "exceljs" +import { isBinaryFile } from "isbinaryfile" +import mammoth from "mammoth" +import type { ClineSayTool, ReadFileToolParams } from "@roo-code/types" +import type { Task } from "../../../task/Task" +import { checkAutoApproval } from "../../../auto-approval" +import { readFileTool } from "../../ReadFileTool" +import * as imageHelpers from "../../helpers/imageHelpers" +import { RooIgnoreController } from "../../../ignore/RooIgnoreController" +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileCancelledError } from "../ReadFileCancelledError" +import { ReadFileTarget } from "../ReadFileTarget" + +// Instrument host I/O without replacing real filesystem behavior. +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: vi.fn(actual.open), + readFile: vi.fn(actual.readFile), + realpath: vi.fn(actual.realpath), + lstat: vi.fn(actual.lstat), + } +}) + +vi.mock("vscode", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + RelativePattern: class { + constructor( + readonly base: string, + readonly pattern: string, + ) {} + }, + } +}) + +vi.mock("isbinaryfile", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isBinaryFile: vi.fn(actual.isBinaryFile) } +}) + +// Keep decoder protocols at the library boundary; approval and descriptor I/O remain real. +vi.mock("pdf-parse/lib/pdf-parse", () => ({ default: async (source: Buffer) => ({ text: source.toString() }) })) +vi.mock("mammoth", () => ({ + default: { + extractRawText: async (options: { buffer?: Buffer; path?: string }) => ({ + value: options.buffer?.toString() ?? (await fs.readFile(options.path!, "utf8")), + }), + }, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, resolve, reject } +} + +describe("public file reader safety", () => { + let directory: string + let workspace: string + let external: string + let task: ReturnType + let approvalMessages: ClineSayTool[] + let manualApproval: ReturnType> + let openedFiles: fs.FileHandle[] + const callbacks = { pushToolResult: vi.fn(), askApproval: vi.fn(), handleError: vi.fn() } + + function createTask(settings: Partial[0]["state"]>> = {}) { + return { + cwd: workspace, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + rooIgnoreController: { validateAccess: vi.fn<(file: string) => boolean>().mockReturnValue(true) }, + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + api: { getModel: () => ({ info: { supportsImages: true } }) }, + providerRef: { deref: () => ({ getState: async () => ({}) }) }, + ask: vi.fn(async (ask, text) => { + approvalMessages.push(JSON.parse(text ?? "{}")) + const policy = await checkAutoApproval({ + cwd: task.cwd, + ask, + text, + state: { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + ...settings, + }, + }) + return policy.decision === "approve" ? { response: "yesButtonClicked" } : manualApproval(ask, text) + }), + } + } + + async function read(params: ReadFileToolParams) { + // This host double intentionally omits Task members unrelated to file reading. + await readFileTool.execute(params, task as unknown as Task, callbacks) + return JSON.stringify(callbacks.pushToolResult.mock.calls) + } + + async function replaceAfterOpening(filePath: string, replacement: string) { + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.open) + .mockReset() + .mockImplementation(async (...args) => { + const file = await actual.open(...args) + openedFiles.push(file) + if (args[0] === filePath) { + await fs.rename(filePath, `${filePath}.original`) + await fs.symlink(replacement, filePath) + } + return file + }) + } + + async function inspectOpenedFile(inspect: (file: fs.FileHandle) => void) { + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + openedFiles.push(file) + inspect(file) + return file + }) + } + + beforeEach(async () => { + vi.clearAllMocks() + const actual = await vi.importActual("fs/promises") + openedFiles = [] + vi.mocked(fs.open) + .mockReset() + .mockImplementation(async (...args) => { + const file = await actual.open(...args) + openedFiles.push(file) + return file + }) + vi.mocked(fs.readFile).mockReset().mockImplementation(actual.readFile) + vi.mocked(fs.realpath).mockReset().mockImplementation(actual.realpath) + vi.mocked(fs.lstat).mockReset().mockImplementation(actual.lstat) + const binary = await vi.importActual("isbinaryfile") + vi.mocked(isBinaryFile).mockReset().mockImplementation(binary.isBinaryFile) + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-file-safety-"))) + workspace = path.join(directory, "workspace") + external = path.join(directory, "external.txt") + await fs.mkdir(workspace) + await fs.writeFile(external, "synthetic external secret") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(workspace), name: "workspace", index: 0 }, + ]) + approvalMessages = [] + manualApproval = vi.fn().mockResolvedValue({ response: "noButtonClicked" }) + task = createTask() + }) + + afterEach(async () => { + expect(openedFiles.every((file) => file.fd === -1)).toBe(true) + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("requires manual external approval for an internal symlink under the real policy", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + + const result = await read({ path: "linked.txt" }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(result).toContain("Denied by user") + expect(result).not.toContain("synthetic external secret") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("blocks an ignored canonical target before approval or content I/O", async () => { + const ignored = path.join(workspace, "ignored.txt") + await fs.writeFile(ignored, "ignored secret") + await fs.symlink(ignored, path.join(workspace, "alias.txt")) + task.rooIgnoreController.validateAccess.mockImplementation((file) => file !== ignored) + + const result = await read({ path: "alias.txt" }) + + expect(result).not.toContain("ignored secret") + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).toHaveBeenCalledWith("rooignore_error", "alias.txt") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("rejects a regular-file replacement during approval without disclosing replacement bytes", async () => { + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.writeFile(external, "unapproved replacement secret") + return { response: "yesButtonClicked" } + }) + + const result = await read({ path: external }) + + expect(result).not.toContain("unapproved replacement secret") + expect(result).toContain("File target changed after approval") + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("rejects a different device even when the approved inode is unchanged", async () => { + const actual = await vi.importActual("fs/promises") + const otherDeviceStats = await actual.lstat(external, { bigint: true }) + otherDeviceStats.dev += 1n + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + await inspectOpenedFile((file) => { + vi.spyOn(file, "stat").mockResolvedValueOnce(otherDeviceStats) + vi.spyOn(file, "read") + vi.spyOn(file, "readFile") + }) + + const result = await read({ path: external }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).toContain("File target changed after approval") + expect(result).not.toContain("synthetic external secret") + expect(openedFiles[0].read).not.toHaveBeenCalled() + expect(openedFiles[0].readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("legacy requires external target approval and retains its string result", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + + const result = await read({ files: [{ path: "linked.txt" }], _legacyFormat: true }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: linked.txt\nStatus: Denied by user") + expect(result).not.toContain("synthetic external secret") + }) + + it.each((["abort", "abandoned"] as const).flatMap((flag) => [false, true].map((approved) => ({ flag, approved }))))( + "legacy stops when $flag changes during approval without treating the response as rejection (approved=$approved)", + async ({ flag, approved }) => { + manualApproval.mockImplementation(async () => { + task[flag] = true + return { response: approved ? "yesButtonClicked" : "noButtonClicked" } + }) + + const result = await read({ files: [{ path: external }], _legacyFormat: true }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).not.toContain("synthetic external secret") + expect(fs.open).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.didRejectTool).toBe(false) + }, + ) + + it.each(["abort", "abandoned"] as const)( + "legacy closes a descriptor returned after %s without starting metadata or content reads", + async (flag) => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked" }) + await inspectOpenedFile((file) => { + vi.spyOn(file, "stat") + vi.spyOn(file, "read") + vi.spyOn(file, "readFile") + task[flag] = true + }) + + await read({ files: [{ path: external }], _legacyFormat: true }) + + expect(openedFiles).toHaveLength(1) + expect(openedFiles[0].fd).toBe(-1) + expect(openedFiles[0].stat).not.toHaveBeenCalled() + expect(openedFiles[0].read).not.toHaveBeenCalled() + expect(openedFiles[0].readFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }, + ) + + it("legacy discards completed content and does not approve another file after cancellation during cleanup", async () => { + task.ask.mockResolvedValue({ response: "yesButtonClicked" }) + await inspectOpenedFile((file) => { + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + await close() + task.abort = true + }) + }) + + const result = await read({ files: [{ path: external }, { path: external }], _legacyFormat: true }) + + expect(result).not.toContain("synthetic external secret") + expect(task.ask).toHaveBeenCalledOnce() + expect(fs.open).toHaveBeenCalledOnce() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it.each( + (["abort", "abandoned"] as const).flatMap((flag) => + ["reading", "closing"].map((boundary) => ({ flag, boundary })), + ), + )( + "verified handle rejects a late result after $flag during $boundary and closes the descriptor", + async ({ flag, boundary }) => { + const target = await ReadFileTarget.resolve(external, new ReadFileCancellation(task)) + if (boundary === "closing") { + await inspectOpenedFile((file) => { + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + await close() + task[flag] = true + }) + }) + } + const readContent = vi.fn(async () => { + if (boundary === "reading") task[flag] = true + return "late content" + }) + + await expect(target.withHandle(readContent)).rejects.toBeInstanceOf(ReadFileCancelledError) + + expect(readContent).toHaveBeenCalledOnce() + expect(openedFiles).toHaveLength(1) + expect(openedFiles[0].fd).toBe(-1) + }, + ) + + it("verified handle preserves a read error and still closes the descriptor", async () => { + const target = await ReadFileTarget.resolve(external, new ReadFileCancellation(task)) + const error = new Error("read failure") + + await expect( + target.withHandle(async () => { + throw error + }), + ).rejects.toBe(error) + + expect(openedFiles).toHaveLength(1) + expect(openedFiles[0].fd).toBe(-1) + }) + + it("legacy rejects canonical symlink substitution during approval", async () => { + const replacement = path.join(directory, "unapproved.txt") + await fs.writeFile(replacement, "legacy replacement secret") + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.symlink(replacement, external) + return { response: "yesButtonClicked" } + }) + + const result = await read({ files: [{ path: external }], _legacyFormat: true }) + + expect(result).not.toContain("legacy replacement secret") + expect(result).toContain("Error:") + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("images use only approved descriptor bytes after filename replacement", async () => { + const image = path.join(workspace, "image.png") + const replacement = path.join(directory, "other.png") + const approved = Buffer.from([137, 80, 78, 71, 0, 1, 2, 3]) + const unapproved = Buffer.from([137, 80, 78, 71, 0, 9, 8, 7]) + await fs.writeFile(image, approved) + await fs.writeFile(replacement, unapproved) + await replaceAfterOpening(image, replacement) + + const result = await read({ path: "image.png" }) + + expect(result).toContain(approved.toString("base64")) + expect(result).not.toContain(unapproved.toString("base64")) + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("XLSX extraction preserves formatting and consumes approved bytes after filename replacement", async () => { + const spreadsheet = path.join(workspace, "book.xlsx") + const replacement = path.join(directory, "other.xlsx") + for (const [file, value] of [ + [spreadsheet, "approved cell"], + [replacement, "unapproved cell"], + ]) { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet("Data").getCell("A1").value = value + await workbook.xlsx.writeFile(file) + } + await replaceAfterOpening(spreadsheet, replacement) + + const result = await read({ path: "book.xlsx" }) + + expect(result).toContain("1 | --- Sheet: Data ---") + expect(result).toContain("2 | approved cell") + expect(result).not.toContain("unapproved cell") + }) + + it("notebook extraction uses approved bytes without changing historical line numbering", async () => { + const notebook = path.join(workspace, "book.ipynb") + const replacement = path.join(directory, "other.ipynb") + for (const [file, value] of [ + [notebook, "approved code"], + [replacement, "unapproved code"], + ]) { + await fs.writeFile(file, JSON.stringify({ cells: [{ cell_type: "code", source: [value] }] })) + } + await replaceAfterOpening(notebook, replacement) + // The old reader extracts notebooks only when binary detection chooses that branch. + vi.mocked(isBinaryFile).mockResolvedValue(true) + + const result = await read({ path: "book.ipynb" }) + + expect(result).toContain("1 | 1 | approved code") + expect(result).not.toContain("unapproved code") + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("PDF decoding consumes approved bytes after filename replacement", async () => { + const document = path.join(workspace, "document.pdf") + const replacement = path.join(directory, "other.pdf") + await fs.writeFile(document, "approved PDF text") + await fs.writeFile(replacement, "unapproved PDF text") + await replaceAfterOpening(document, replacement) + vi.mocked(isBinaryFile).mockResolvedValue(true) + + const result = await read({ path: "document.pdf" }) + + expect(result).toContain("1 | 1 | approved PDF text") + expect(result).not.toContain("unapproved PDF text") + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("DOCX decoding consumes approved bytes after filename replacement", async () => { + const document = path.join(workspace, "document.docx") + const replacement = path.join(directory, "other.docx") + await fs.writeFile(document, "approved DOCX text") + await fs.writeFile(replacement, "unapproved DOCX text") + await replaceAfterOpening(document, replacement) + vi.mocked(isBinaryFile).mockResolvedValue(true) + + const result = await read({ path: "document.docx" }) + + expect(result).toContain("1 | 1 | approved DOCX text") + expect(result).not.toContain("unapproved DOCX text") + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("reads the original canonical target when the requested alias changes during approval", async () => { + const alias = path.join(workspace, "alias.txt") + const replacement = path.join(directory, "replacement.txt") + await fs.writeFile(replacement, "unapproved alias bytes") + await fs.symlink(external, alias) + manualApproval.mockImplementation(async () => { + await fs.unlink(alias) + await fs.symlink(replacement, alias) + return { response: "yesButtonClicked" } + }) + + const result = await read({ path: "alias.txt" }) + + expect(result).toContain("1 | synthetic external secret") + expect(result).not.toContain("unapproved alias bytes") + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it.each(["final symlink", "parent symlink", "legacy regular file"])( + "rejects approved identity replacement: %s", + async (kind) => { + const parent = path.join(directory, "approved") + const other = path.join(directory, "other") + await fs.mkdir(parent) + await fs.mkdir(other) + const approved = path.join(parent, "file.txt") + await fs.writeFile(approved, "approved bytes") + await fs.writeFile(path.join(other, "file.txt"), "unapproved bytes") + manualApproval.mockImplementation(async () => { + if (kind === "parent symlink") { + await fs.rename(parent, `${parent}.original`) + await fs.symlink(other, parent) + } else { + await fs.rename(approved, `${approved}.original`) + if (kind === "final symlink") await fs.symlink(path.join(other, "file.txt"), approved) + else await fs.writeFile(approved, "unapproved bytes") + } + return { response: "yesButtonClicked" } + }) + + const result = await read( + kind === "legacy regular file" + ? { files: [{ path: approved }], _legacyFormat: true } + : { path: approved }, + ) + + expect(result).not.toContain("unapproved bytes") + expect(result).toContain("Error:") + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it("a symlinked workspace root remains internal under the real approval policy", async () => { + const rootAlias = path.join(directory, "root-alias") + await fs.symlink(workspace, rootAlias) + await fs.writeFile(path.join(workspace, "internal.txt"), "first line\nsecond line") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(rootAlias), name: "alias", index: 0 }, + ]) + task.cwd = rootAlias + + await read({ path: "internal.txt" }) + + expect(manualApproval).not.toHaveBeenCalled() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: false }) + expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: internal.txt\n1 | first line\n2 | second line") + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it.each( + (["relative", "alias-absolute", "canonical-absolute"] as const).flatMap((spelling) => + [false, true].map((legacy) => ({ spelling, legacy })), + ), + )( + "auto-approves a canonical internal target with $spelling allowlist (legacy=$legacy)", + async ({ spelling, legacy }) => { + const rootAlias = path.join(directory, "root-alias") + await fs.symlink(workspace, rootAlias) + await fs.mkdir(path.join(workspace, "docs")) + const canonicalFile = path.join(workspace, "docs", "notes.md") + await fs.writeFile(canonicalFile, "allowed internal bytes") + const pattern = + spelling === "relative" ? "docs/**" : `${spelling === "alias-absolute" ? rootAlias : workspace}/docs/**` + task = createTask({ alwaysAllowReadOnly: false, allowedReadFiles: [pattern] }) + task.cwd = rootAlias + + const result = await read( + legacy ? { files: [{ path: "docs/notes.md" }], _legacyFormat: true } : { path: "docs/notes.md" }, + ) + + expect(manualApproval).not.toHaveBeenCalled() + expect(approvalMessages[0]).toMatchObject({ + path: canonicalFile.toPosix(), + content: canonicalFile, + isOutsideWorkspace: false, + }) + expect(result).toContain("1 | allowed internal bytes") + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it.each([false, true])( + "an external file symlink needs an explicit external pattern (explicit=%s)", + async (explicit) => { + const rootAlias = path.join(directory, "root-alias") + await fs.symlink(workspace, rootAlias) + await fs.mkdir(path.join(workspace, "docs")) + await fs.symlink(external, path.join(workspace, "docs", "alias.md")) + task = createTask({ + alwaysAllowReadOnly: false, + allowedReadFiles: explicit ? ["docs/**", external] : ["docs/**"], + }) + task.cwd = rootAlias + + const result = await read({ path: "docs/alias.md" }) + + expect(approvalMessages[0]).toMatchObject({ + path: external.toPosix(), + content: external, + isOutsideWorkspace: true, + }) + if (explicit) { + expect(manualApproval).not.toHaveBeenCalled() + expect(result).toContain("1 | synthetic external secret") + } else { + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).toContain("Denied by user") + expect(result).not.toContain("synthetic external secret") + expect(fs.open).not.toHaveBeenCalled() + } + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it("uses the task root rather than the first workspace folder for multi-root read matching", async () => { + const focusedRoot = path.join(directory, "focused-workspace") + const rootAlias = path.join(directory, "task-alias") + await fs.mkdir(focusedRoot) + await fs.symlink(workspace, rootAlias) + await fs.writeFile(path.join(workspace, "notes.md"), "task-local bytes") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(focusedRoot), name: "focused", index: 0 }, + { uri: vscode.Uri.file(rootAlias), name: "task", index: 1 }, + ]) + task = createTask({ alwaysAllowReadOnly: false, allowedReadFiles: ["notes.md"] }) + task.cwd = rootAlias + + const result = await read({ path: "notes.md" }) + + expect(manualApproval).not.toHaveBeenCalled() + expect(approvalMessages[0]).toMatchObject({ + isOutsideWorkspace: false, + content: path.join(workspace, "notes.md"), + }) + expect(result).toContain("1 | task-local bytes") + }) + + it("does not apply a task-relative allowlist to another open workspace root", async () => { + const otherRoot = path.join(directory, "other-workspace") + const rootAlias = path.join(directory, "task-alias") + await fs.mkdir(otherRoot) + await fs.symlink(workspace, rootAlias) + const otherFile = path.join(otherRoot, "notes.md") + await fs.writeFile(otherFile, "unapproved other-root bytes") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(otherRoot), name: "other", index: 0 }, + { uri: vscode.Uri.file(rootAlias), name: "task", index: 1 }, + ]) + task = createTask({ alwaysAllowReadOnly: false, allowedReadFiles: ["notes.md"] }) + task.cwd = rootAlias + + const result = await read({ path: otherFile }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ + path: otherFile.toPosix(), + content: otherFile, + isOutsideWorkspace: false, + }) + expect(result).toContain("Denied by user") + expect(result).not.toContain("unapproved other-root bytes") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it.each(["absent", "unresolvable", "sibling"])( + "does not grant blanket internal permission with %s workspace containment", + async (kind) => { + const sibling = `${workspace}-sibling` + await fs.mkdir(sibling) + const filePath = path.join(kind === "sibling" ? sibling : workspace, "notes.md") + await fs.writeFile(filePath, "unapproved containment bytes") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue( + kind === "absent" + ? undefined + : [ + { + uri: vscode.Uri.file( + kind === "unresolvable" ? path.join(directory, "missing-root") : workspace, + ), + name: "root", + index: 0, + }, + ], + ) + + const result = await read({ path: filePath }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: filePath }) + expect(result).toContain("Denied by user") + expect(result).not.toContain("unapproved containment bytes") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it.each(["unset", "empty"].flatMap((kind) => [false, true].map((legacy) => ({ kind, legacy }))))( + "reads only after manual external approval with $kind workspace folders (legacy=$legacy)", + async ({ kind, legacy }) => { + const filePath = path.join(workspace, "notes.md") + await fs.writeFile(filePath, "approved no-workspace bytes") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue(kind === "unset" ? undefined : []) + manualApproval.mockImplementation(async () => { + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + return { response: "yesButtonClicked" } + }) + + const result = await read( + legacy ? { files: [{ path: filePath }], _legacyFormat: true } : { path: filePath }, + ) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages).toHaveLength(1) + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: filePath }) + expect(fs.open).toHaveBeenCalledOnce() + expect(fs.readFile).not.toHaveBeenCalled() + expect(result).toContain("1 | approved no-workspace bytes") + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.didRejectTool).toBe(false) + }, + ) + + it("does not disclose an existing external file through ENOTDIR when approval is denied", async () => { + const invalidChild = path.join(external, "child.txt") + manualApproval.mockImplementation(async () => { + expect(task.say).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + return { response: "noButtonClicked" } + }) + + const result = await read({ path: invalidChild }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: invalidChild }) + expect(result).toContain("Denied by user") + expect(result).not.toContain("ENOTDIR") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it.each( + ["ENOTDIR", "EACCES", "ELOOP"].flatMap((code) => + (["realpath", "lstat"] as const).flatMap((operation) => + [false, true].flatMap((legacy) => + [false, true].map((approved) => ({ code, operation, legacy, approved })), + ), + ), + ), + )( + "defers $operation $code diagnostics (legacy=$legacy, approved=$approved)", + async ({ code, operation, legacy, approved }) => { + const lexicalPath = path.join(directory, "outside-alias.txt") + await fs.symlink(external, lexicalPath) + vi.mocked(fs[operation]).mockRejectedValueOnce( + Object.assign(new Error(`${code}: resolution inaccessible`), { code }), + ) + manualApproval.mockImplementation(async () => { + expect(task.say).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + return { response: approved ? "yesButtonClicked" : "noButtonClicked" } + }) + + const result = await read( + legacy ? { files: [{ path: lexicalPath }], _legacyFormat: true } : { path: lexicalPath }, + ) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: lexicalPath }) + if (approved) expect(result).toContain(`${code}: resolution inaccessible`) + else { + expect(result).toContain("Denied by user") + expect(result).not.toContain(code) + } + expect(task.didToolFailInCurrentTurn).toBe(approved) + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it.each([undefined, 123])( + "does not defer unexpected resolution errors with a non-string errno (%s)", + async (code) => { + vi.mocked(fs.realpath).mockRejectedValueOnce( + Object.assign(new TypeError("unexpected resolution failure"), { code }), + ) + + const result = await read({ path: external }) + + expect(result).toContain("unexpected resolution failure") + expect(task.ask).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it.each([false, true])("missing-file diagnostics stay after approval (legacy=%s)", async (legacy) => { + const missing = path.join(directory, "missing.txt") + manualApproval.mockImplementation(async () => { + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + return { response: "yesButtonClicked" } + }) + + const result = await read(legacy ? { files: [{ path: missing }], _legacyFormat: true } : { path: missing }) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).toContain("ENOENT") + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(fs.open).not.toHaveBeenCalled() + }) + + it.each((["abort", "abandoned"] as const).flatMap((flag) => [false, true].map((legacy) => ({ flag, legacy }))))( + "pre-cancelled reads start no access UI, approval or filesystem work ($flag, legacy=$legacy)", + async ({ flag, legacy }) => { + task[flag] = true + + const result = await read( + legacy ? { files: [{ path: external }], _legacyFormat: true } : { path: external }, + ) + + expect(task.rooIgnoreController.validateAccess).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(result).not.toContain("synthetic external secret") + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + }, + ) + + it("abandonment while realpath is pending stops identity capture without error UI", async () => { + const started = deferred() + const resolved = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return resolved.promise + }) + const pending = read({ path: external }) + await started.promise + task.abandoned = true + resolved.resolve(external) + + await pending + + expect(fs.lstat).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("abort during identity capture prevents canonical revalidation and target access", async () => { + const actual = await vi.importActual("fs/promises") + const identity = await actual.lstat(external, { bigint: true }) + const started = deferred() + const release = deferred() + vi.mocked(fs.realpath).mockClear() + vi.mocked(fs.lstat).mockImplementationOnce(async () => { + started.resolve() + await release.promise + return identity + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(fs.realpath).toHaveBeenCalledOnce() + expect(task.rooIgnoreController.validateAccess).toHaveBeenCalledOnce() + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("abort during final canonical revalidation prevents resolved-target access", async () => { + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.realpath) + .mockImplementationOnce(actual.realpath) + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + return external + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(task.rooIgnoreController.validateAccess).toHaveBeenCalledOnce() + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("abort during workspace classification does not start approval", async () => { + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + if (args[0] === workspace) { + started.resolve() + await release.promise + } + return actual.realpath(...args) + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didRejectTool).toBe(false) + }) + + it("late denied approval retains received feedback but does not become a rejection after cancellation", async () => { + const started = deferred() + const approval = deferred>>() + task.ask.mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read({ path: external }) + await started.promise + task.abandoned = true + approval.resolve({ + response: "noButtonClicked", + text: "Inspect only", + images: ["data:image/png;base64,aW1hZ2U="], + }) + const result = await pending + + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.say).not.toHaveBeenCalled() + expect(result).toContain("Inspect only") + expect(result).toContain("aW1hZ2U=") + expect(result).not.toContain("Denied by user") + expect(fs.open).not.toHaveBeenCalled() + }) + + it("a descriptor opened after abort closes without starting metadata or content I/O", async () => { + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + openedFiles.push(file) + vi.spyOn(file, "stat") + vi.spyOn(file, "read") + vi.spyOn(file, "readFile") + started.resolve(file) + await release.promise + return file + }) + const pending = read({ path: external }) + const file = await started.promise + task.abort = true + release.resolve() + await pending + + expect(file.stat).not.toHaveBeenCalled() + expect(file.read).not.toHaveBeenCalled() + expect(file.readFile).not.toHaveBeenCalled() + expect(file.fd).toBe(-1) + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("abandonment during fstat stops the descriptor probe and closes the handle", async () => { + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + const started = deferred() + const release = deferred() + await inspectOpenedFile((file) => { + const stat = file.stat.bind(file) + vi.spyOn(file, "stat").mockImplementationOnce(async (options) => { + started.resolve(file) + await release.promise + return stat(options) + }) + vi.spyOn(file, "read") + }) + const pending = read({ path: external }) + const file = await started.promise + task.abandoned = true + release.resolve() + await pending + + expect(file.read).not.toHaveBeenCalled() + expect(file.fd).toBe(-1) + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("abort during the descriptor probe prevents binary detection", async () => { + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + const started = deferred() + const release = deferred() + await inspectOpenedFile((file) => { + vi.spyOn(file, "read").mockImplementationOnce(async () => { + started.resolve() + await release.promise + return { bytesRead: 0, buffer: Buffer.alloc(0) } + }) + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(isBinaryFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("abandonment during binary detection prevents whole-file I/O", async () => { + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + const started = deferred() + const detected = deferred() + await inspectOpenedFile((file) => { + vi.spyOn(file, "readFile") + }) + vi.mocked(isBinaryFile).mockImplementationOnce(() => { + started.resolve() + return detected.promise + }) + const pending = read({ path: external }) + await started.promise + task.abandoned = true + detected.resolve(false) + await pending + + expect(openedFiles[0].readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("late ordinary text bytes are not decoded or tracked after abandonment", async () => { + manualApproval.mockResolvedValue({ response: "yesButtonClicked" }) + const started = deferred() + const buffer = Buffer.from("late text secret") + const bytes = deferred() + const decode = vi.spyOn(buffer, "toString") + await inspectOpenedFile((file) => { + vi.spyOn(file, "readFile").mockImplementationOnce(() => { + started.resolve() + return bytes.promise + }) + }) + const pending = read({ path: external }) + await started.promise + task.abandoned = true + bytes.resolve(buffer) + const result = await pending + + expect(decode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(result).not.toContain("late text secret") + }) + + it("document bytes arriving after abort do not start extraction", async () => { + const document = path.join(workspace, "late.docx") + await fs.writeFile(document, "approved document") + vi.mocked(isBinaryFile).mockResolvedValue(true) + const extract = vi.spyOn(mammoth, "extractRawText") + const started = deferred() + const buffer = Buffer.from("late document secret") + const bytes = deferred() + await inspectOpenedFile((file) => { + vi.spyOn(file, "readFile").mockImplementationOnce(() => { + started.resolve() + return bytes.promise + }) + }) + const pending = read({ path: "late.docx" }) + await started.promise + task.abort = true + bytes.resolve(buffer) + const result = await pending + + expect(extract).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(result).not.toContain("late document secret") + }) + + it("late extraction output is discarded without starting context tracking", async () => { + await fs.writeFile(path.join(workspace, "late.docx"), "approved document") + vi.mocked(isBinaryFile).mockResolvedValue(true) + const started = deferred() + const extraction = deferred>>() + vi.spyOn(mammoth, "extractRawText").mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + const pending = read({ path: "late.docx" }) + await started.promise + task.abandoned = true + extraction.resolve({ value: "late extraction secret", messages: [] }) + const result = await pending + + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(result).not.toContain("late extraction secret") + }) + + it.each([2, 3])("abort during image metadata step %s prevents image byte reads", async (step) => { + await fs.writeFile(path.join(workspace, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const started = deferred() + const release = deferred() + await inspectOpenedFile((file) => { + const stat = file.stat.bind(file) + let calls = 0 + vi.spyOn(file, "stat").mockImplementation(async (options) => { + if (++calls === step) { + started.resolve() + await release.promise + } + return stat(options) + }) + vi.spyOn(file, "readFile") + }) + const pending = read({ path: "image.png" }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(openedFiles[0].readFile).not.toHaveBeenCalled() + expect(openedFiles[0].stat).toHaveBeenCalledTimes(step) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("image bytes arriving after abort are not base64 encoded or tracked", async () => { + await fs.writeFile(path.join(workspace, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const started = deferred() + const buffer = Buffer.from([137, 80, 78, 71, 0, 9]) + const bytes = deferred() + const encode = vi.spyOn(buffer, "toString") + await inspectOpenedFile((file) => { + vi.spyOn(file, "readFile").mockImplementationOnce(() => { + started.resolve() + return bytes.promise + }) + }) + const pending = read({ path: "image.png" }) + await started.promise + task.abort = true + bytes.resolve(buffer) + await pending + + expect(encode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("processed images arriving after abandonment do not start context tracking", async () => { + await fs.writeFile(path.join(workspace, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const started = deferred() + const image = deferred() + vi.spyOn(imageHelpers, "processImageFile").mockImplementationOnce(() => { + started.resolve() + return image.promise + }) + const pending = read({ path: "image.png" }) + await started.promise + task.abandoned = true + image.resolve({ + dataUrl: "data:image/png;base64,bGF0ZQ==", + buffer: Buffer.from("late"), + sizeInKB: 1, + sizeInMB: 0.001, + notice: "late image", + }) + const result = await pending + + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(result).not.toContain("bGF0ZQ==") + expect(task.say).not.toHaveBeenCalled() + }) + + it("cancellation during an already-started diagnostic retains approval feedback without marking failure", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + vi.mocked(fs.open).mockRejectedValueOnce(new Error("read diagnostic")) + const started = deferred() + const release = deferred() + task.say.mockImplementation(async (type) => { + if (type === "error") { + started.resolve() + await release.promise + } + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + release.resolve() + const result = await pending + + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.didRejectTool).toBe(false) + expect(result).toContain("Inspect only") + expect(result).not.toContain("read diagnostic") + }) + + it.each([false, true])("abort during the outer diagnostic does not publish failure (reject=%s)", async (reject) => { + task.ask.mockRejectedValueOnce(new Error("approval technical failure")) + const started = deferred() + const release = deferred() + task.say.mockImplementationOnce(async () => { + started.resolve() + await release.promise + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + if (reject) release.reject(new Error("late diagnostic rejection")) + else release.resolve() + await pending + + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + }) + + it.each([false, true])( + "handle reads pinned text after opening and preserves ranges (legacy=%s)", + async (legacy) => { + const file = path.join(workspace, "pinned.txt") + const replacement = path.join(directory, "other.txt") + await fs.writeFile(file, "first\nsecond\nthird") + await fs.writeFile(replacement, "unapproved replacement text") + await replaceAfterOpening(file, replacement) + // This host double intentionally omits Task members unrelated to file reading. + await readFileTool.handle( + task as unknown as Task, + { + type: "tool_use", + name: "read_file", + params: {}, + partial: false, + nativeArgs: legacy + ? { files: [{ path: "pinned.txt", lineRanges: [{ start: 2, end: 3 }] }], _legacyFormat: true } + : { path: "pinned.txt", offset: 2, limit: 2 }, + }, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: pinned.txt\n2 | second\n3 | third") + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }, + ) + + it.each( + [ + "approval", + "realpath", + "fstat", + "probe", + "binary", + "provider", + "text", + "image", + "document", + "tracking", + "close", + ].flatMap((boundary) => + (boundary === "document" ? [false] : [false, true]).map((legacy) => ({ boundary, legacy })), + ), + )( + "late rejection at $boundary is suppressed after cancellation and descriptors are released (legacy=$legacy)", + async ({ boundary, legacy }) => { + const started = deferred() + const failure = deferred() + const fail = () => { + started.resolve() + return failure.promise + } + const filePath = + boundary === "image" || boundary === "provider" + ? "late.png" + : boundary === "document" + ? "late.docx" + : external + if (filePath !== external) await fs.writeFile(path.join(workspace, filePath), "approved bytes") + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + if (boundary === "approval") task.ask.mockReset().mockImplementationOnce(fail) + if (boundary === "realpath") vi.mocked(fs.realpath).mockImplementationOnce(fail) + if (boundary === "binary") vi.mocked(isBinaryFile).mockImplementationOnce(fail) + if (boundary === "provider") { + vi.mocked(isBinaryFile).mockResolvedValue(true) + vi.spyOn(task.providerRef, "deref").mockReturnValue({ getState: fail }) + } + if (boundary === "image") { + vi.mocked(isBinaryFile).mockResolvedValue(true) + vi.spyOn(imageHelpers, "processImageFile").mockImplementationOnce(fail) + } + if (boundary === "document") { + vi.mocked(isBinaryFile).mockResolvedValue(true) + vi.spyOn(mammoth, "extractRawText").mockImplementationOnce(fail) + } + if (boundary === "tracking") task.fileContextTracker.trackFileContext.mockImplementationOnce(fail) + await inspectOpenedFile((file) => { + if (boundary === "fstat") vi.spyOn(file, "stat").mockImplementationOnce(fail) + if (boundary === "probe") vi.spyOn(file, "read").mockImplementationOnce(fail) + if (boundary === "text") vi.spyOn(file, "readFile").mockImplementationOnce(fail) + if (boundary === "close") { + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + await close() + return fail() + }) + } + }) + const pending = read(legacy ? { files: [{ path: filePath }], _legacyFormat: true } : { path: filePath }) + await started.promise + task.abort = true + failure.reject(new Error("late technical rejection")) + const result = await pending + + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.didRejectTool).toBe(false) + expect(task.say.mock.calls.some(([type]) => type === "error")).toBe(false) + expect(result).not.toContain("late technical rejection") + if (boundary !== "approval" && boundary !== "realpath") expect(result).toContain("Inspect only") + }, + ) + + it.each( + ["ENOENT", "ENOTDIR", "EACCES", "ELOOP"].flatMap((code) => [false, true].map((legacy) => ({ code, legacy }))), + )("cancelled $code resolution does not start access or approval (legacy=$legacy)", async ({ code, legacy }) => { + const started = deferred() + const resolution = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return resolution.promise + }) + const pending = read(legacy ? { files: [{ path: external }], _legacyFormat: true } : { path: external }) + await started.promise + task.abort = true + resolution.reject(Object.assign(new Error("resolution failed after cancellation"), { code })) + await pending + + expect(fs.lstat).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it.each( + ["approved feedback", "denied feedback", "rejected feedback", "tracking", "closing"].flatMap((boundary) => + [false, true].map((legacy) => ({ boundary, legacy })), + ), + )( + "cancellation during $boundary discards content while retaining received approval feedback (legacy=$legacy)", + async ({ boundary, legacy }) => { + task.ask.mockResolvedValueOnce({ + response: boundary === "denied feedback" ? "noButtonClicked" : "yesButtonClicked", + text: "Inspect only", + }) + const started = deferred() + const release = deferred() + const wait = () => { + started.resolve() + return release.promise + } + if (boundary.includes("feedback")) + task.say.mockImplementationOnce(async () => { + await wait() + return undefined + }) + if (boundary === "tracking") task.fileContextTracker.trackFileContext.mockImplementationOnce(wait) + if (boundary === "closing") { + await inspectOpenedFile((file) => { + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + await wait() + await close() + }) + }) + } + const pending = read(legacy ? { files: [{ path: external }], _legacyFormat: true } : { path: external }) + await started.promise + task.abort = true + if (boundary === "rejected feedback") release.reject(new Error("late feedback rejection")) + else release.resolve() + const result = await pending + + expect(result).toContain("Inspect only") + expect(result).not.toContain("synthetic external secret") + expect(result).not.toContain("late feedback rejection") + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.didRejectTool).toBe(false) + if (boundary.includes("feedback")) expect(fs.open).not.toHaveBeenCalled() + }, + ) + + it.each([false, true])( + "reports a parent retarget during identity capture before approval (legacy=%s)", + async (legacy) => { + const parent = path.join(directory, "parent") + const replacement = path.join(directory, "replacement") + await fs.mkdir(parent) + await fs.mkdir(replacement) + const file = path.join(parent, "file.txt") + await fs.writeFile(file, "approved bytes") + await fs.writeFile(path.join(replacement, "file.txt"), "unapproved parent bytes") + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.lstat).mockImplementationOnce(async (...args) => { + await fs.rename(parent, `${parent}.original`) + await fs.symlink(replacement, parent) + return actual.lstat(...args) + }) + + const result = await read(legacy ? { files: [{ path: file }], _legacyFormat: true } : { path: file }) + + expect(result).toContain("File target changed before approval") + expect(result).not.toContain("unapproved parent bytes") + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it("the real ignore controller blocks aliases to ignored internal files", async () => { + await fs.writeFile(path.join(workspace, ".rooignore"), "ignored.txt\n") + await fs.writeFile(path.join(workspace, "ignored.txt"), "ignored real secret") + await fs.symlink(path.join(workspace, "ignored.txt"), path.join(workspace, "alias.txt")) + const controller = new RooIgnoreController(workspace) + try { + await controller.initialize() + vi.mocked(fs.readFile).mockClear() + task.rooIgnoreController.validateAccess.mockImplementation((file) => controller.validateAccess(file)) + + const result = await read({ path: "alias.txt" }) + + expect(result).not.toContain("ignored real secret") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + } finally { + controller.dispose() + } + }) + + it("failed real notebook extraction closes the descriptor and keeps uncancelled errors visible", async () => { + await fs.writeFile(path.join(workspace, "invalid.ipynb"), "invalid notebook JSON") + vi.mocked(isBinaryFile).mockResolvedValue(true) + + const result = await read({ path: "invalid.ipynb" }) + + expect(result).toContain("Error:") + expect(task.say.mock.calls.some(([type]) => type === "error")).toBe(true) + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(openedFiles).toHaveLength(1) + expect(openedFiles[0].fd).toBe(-1) + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("legacy binary probe failures retain the historical pinned text fallback", async () => { + await fs.writeFile(path.join(workspace, "fallback.txt"), "fallback content") + await inspectOpenedFile((file) => { + vi.spyOn(file, "read").mockRejectedValueOnce(new Error("transient probe failure")) + }) + + await read({ files: [{ path: "fallback.txt" }], _legacyFormat: true }) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: fallback.txt\n1 | fallback content") + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("cancelled approval retains received image-only feedback", async () => { + const started = deferred() + const approval = deferred>>() + task.ask.mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read({ path: external }) + await started.promise + task.abort = true + approval.resolve({ response: "yesButtonClicked", images: ["data:image/png;base64,dXNlciBmZWVkYmFjaw=="] }) + const result = await pending + + expect(result).toContain("dXNlciBmZWVkYmFjaw==") + expect(result).not.toContain("synthetic external secret") + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("external approval remains pending without content I/O and then returns the approved bytes", async () => { + const started = deferred() + const approval = deferred>>() + manualApproval.mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read({ path: external }) + await started.promise + + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + approval.resolve({ response: "yesButtonClicked" }) + const result = await pending + + expect(result).toContain("1 | synthetic external secret") + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("provider state arriving after abort does not start verified I/O or image processing", async () => { + await fs.writeFile(path.join(workspace, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const started = deferred() + const release = deferred() + task.providerRef.deref = () => ({ + getState: async () => { + started.resolve() + await release.promise + return {} + }, + }) + const validation = vi.spyOn(imageHelpers, "validateImageForProcessing") + const pending = read({ path: "image.png" }) + await started.promise + task.abort = true + release.resolve() + await pending + + expect(fs.open).not.toHaveBeenCalled() + expect(validation).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) +}) diff --git a/src/core/tools/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts index a1adb078e6..9dd4163d4e 100644 --- a/src/core/tools/helpers/imageHelpers.ts +++ b/src/core/tools/helpers/imageHelpers.ts @@ -3,6 +3,8 @@ import * as fs from "fs/promises" import { t } from "../../../i18n" import prettyBytes from "pretty-bytes" +import type { ReadFileCancellation } from "../file-reading/ReadFileCancellation" + /** * Default maximum allowed image file size in bytes (5MB) */ @@ -72,8 +74,13 @@ export interface ImageProcessingResult { /** * Reads an image file and returns both the data URL and buffer */ -export async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ dataUrl: string; buffer: Buffer }> { - const fileBuffer = await fs.readFile(filePath) +export async function readImageAsDataUrlWithBuffer( + filePath: string, + file?: fs.FileHandle, + cancellation?: ReadFileCancellation, +): Promise<{ dataUrl: string; buffer: Buffer }> { + const fileBuffer = file ? await file.readFile() : await fs.readFile(filePath) + cancellation?.throwIfCancelled() const base64 = fileBuffer.toString("base64") const ext = path.extname(filePath).toLowerCase() @@ -99,6 +106,7 @@ export async function validateImageForProcessing( maxImageFileSize: number, maxTotalImageSize: number, currentTotalMemoryUsed: number, + file?: fs.FileHandle, ): Promise { // Check if model supports images if (!supportsImages) { @@ -109,7 +117,7 @@ export async function validateImageForProcessing( } } - const imageStats = await fs.stat(fullPath) + const imageStats = file ? await file.stat() : await fs.stat(fullPath) const imageSizeInMB = imageStats.size / (1024 * 1024) // Check individual file size limit @@ -147,9 +155,14 @@ export async function validateImageForProcessing( /** * Processes an image file and returns the result */ -export async function processImageFile(fullPath: string): Promise { - const imageStats = await fs.stat(fullPath) - const { dataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) +export async function processImageFile( + fullPath: string, + file?: fs.FileHandle, + cancellation?: ReadFileCancellation, +): Promise { + const imageStats = file ? await file.stat() : await fs.stat(fullPath) + cancellation?.throwIfCancelled() + const { dataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath, file, cancellation) const imageSizeInKB = Math.round(imageStats.size / 1024) const imageSizeInMB = imageStats.size / (1024 * 1024) const noticeText = t("tools:readFile.imageWithSize", { size: imageSizeInKB }) From b4e9d4bb3f15b4eaafce5c0242dcb89733aeed58 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 14 Oct 2026 18:53:13 +0300 Subject: [PATCH 3/3] refactor(read-file): layer reusable foundation on finalized safety Retain the exact approval and safety prerequisite commits from PR #1999. Reuse verified target/cancellation and image I/O helpers, preserve request policy snapshots and presenter ownership, and keep the residual constructor-composed reader strategies, block formatting, raw-document bounds and numeric validation. Port all safety regressions through the new boundary; add exact decoder, ignored-target display and late-diagnostic tests without new lint suppressions. --- .../providers/__tests__/base-provider.spec.ts | 32 + .../__tests__/NativeToolCallParser.spec.ts | 32 + ...resentAssistantMessage-custom-tool.spec.ts | 4 +- ...ntAssistantMessage-tool-repetition.spec.ts | 8 +- ...tantMessage-tool-usage-attribution.spec.ts | 122 + .../presentAssistantMessage.ts | 20 +- src/core/mentions/index.ts | 2 +- .../native-tools/__tests__/read_file.spec.ts | 39 +- .../prompts/tools/native-tools/read_file.ts | 26 +- src/core/task/__tests__/Task.spec.ts | 8 +- src/core/tools/ReadFileTool.ts | 937 ----- src/core/tools/__tests__/readFileTool.spec.ts | 1575 -------- .../tools/file-reading/LegacyFileReader.ts | 201 ++ .../tools/file-reading/ModernFileReader.ts | 70 + src/core/tools/file-reading/ReadFileAccess.ts | 108 + .../file-reading/ReadFileContentReader.ts | 93 + .../file-reading/ReadFileErrorReporter.ts | 16 + .../file-reading/ReadFileResultFormatter.ts | 49 + .../file-reading/ReadFileTextProcessor.ts | 57 + src/core/tools/file-reading/ReadFileTool.ts | 113 + .../readFileCancellation.integration.spec.ts | 847 +++++ .../readFileDocument.integration.spec.ts | 172 + .../readFileImage.integration.spec.ts | 314 ++ .../__tests__/readFileResultFormatter.spec.ts | 219 ++ .../readFileSafety.integration.spec.ts | 22 +- .../readFileSymlink.integration.spec.ts | 415 +++ .../__tests__/readFileTextProcessor.spec.ts | 63 + .../__tests__/readFileTool.spec.ts | 3178 +++++++++++++++++ .../tools/file-reading/readFileConstants.ts | 8 + .../tools/file-reading/readFileValidation.ts | 49 + .../strategies/ReadFileBinaryReader.ts | 26 + .../strategies/ReadFileDocumentReader.ts | 54 + .../strategies/ReadFileImageReader.ts | 89 + .../strategies/ReadFileStrategy.ts | 6 + .../strategies/ReadFileTextReader.ts | 52 + src/core/tools/file-reading/types.ts | 41 + src/eslint-suppressions.json | 10 - .../misc/__tests__/extract-raw-text.spec.ts | 92 + .../indentation-reader-unicode.spec.ts | 3 +- .../misc/__tests__/indentation-reader.spec.ts | 90 +- src/integrations/misc/extract-text.ts | 47 +- src/integrations/misc/indentation-reader.ts | 46 +- 42 files changed, 6766 insertions(+), 2589 deletions(-) delete mode 100644 src/core/tools/ReadFileTool.ts delete mode 100644 src/core/tools/__tests__/readFileTool.spec.ts create mode 100644 src/core/tools/file-reading/LegacyFileReader.ts create mode 100644 src/core/tools/file-reading/ModernFileReader.ts create mode 100644 src/core/tools/file-reading/ReadFileAccess.ts create mode 100644 src/core/tools/file-reading/ReadFileContentReader.ts create mode 100644 src/core/tools/file-reading/ReadFileErrorReporter.ts create mode 100644 src/core/tools/file-reading/ReadFileResultFormatter.ts create mode 100644 src/core/tools/file-reading/ReadFileTextProcessor.ts create mode 100644 src/core/tools/file-reading/ReadFileTool.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts create mode 100644 src/core/tools/file-reading/__tests__/readFileTool.spec.ts create mode 100644 src/core/tools/file-reading/readFileConstants.ts create mode 100644 src/core/tools/file-reading/readFileValidation.ts create mode 100644 src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts create mode 100644 src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts create mode 100644 src/core/tools/file-reading/strategies/ReadFileImageReader.ts create mode 100644 src/core/tools/file-reading/strategies/ReadFileStrategy.ts create mode 100644 src/core/tools/file-reading/strategies/ReadFileTextReader.ts create mode 100644 src/core/tools/file-reading/types.ts create mode 100644 src/integrations/misc/__tests__/extract-raw-text.spec.ts diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts index 998b08e3f4..4cea362bf7 100644 --- a/src/api/providers/__tests__/base-provider.spec.ts +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -5,6 +5,7 @@ import type { ModelInfo } from "@roo-code/types" import { BaseProvider } from "../base-provider" import type { ApiStream } from "../../transform/stream" import { expectNoLoneSurrogates } from "../../../test-utils/surrogates" +import { createReadFileTool } from "../../../core/prompts/tools/native-tools/read_file" // Create a concrete implementation for testing class TestProvider extends BaseProvider { @@ -270,6 +271,37 @@ describe("BaseProvider", () => { expect(result.properties.only.type).toBe("null") }) describe("convertToolsForOpenAI", () => { + it("preserves read_file integer minima while requiring properties for strict mode", () => { + const result = provider.testConvertToolsForOpenAI([createReadFileTool()]) + + expect(result?.[0]).toMatchObject({ + function: { + strict: true, + parameters: { + required: ["path", "mode", "offset", "limit", "indentation"], + properties: { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + indentation: { + required: [ + "anchor_line", + "max_levels", + "include_siblings", + "include_header", + "max_lines", + ], + properties: { + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, + }, + }, + }, + }, + }, + }) + }) + it("should return undefined for undefined input", () => { const result = provider.testConvertToolsForOpenAI(undefined) expect(result).toBeUndefined() diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 67a7953159..5714644022 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -3,6 +3,38 @@ import { NativeToolCallParser, type ToolCallStreamEvent } from "../NativeToolCal describe("NativeToolCallParser", () => { describe("parseToolCall", () => { describe("read_file tool", () => { + it("coerces numeric strings before reader-owned validation", () => { + expect( + NativeToolCallParser.parseToolCall({ + id: "numeric_strings", + name: "read_file", + arguments: JSON.stringify({ + path: "test.ts", + offset: "2", + limit: "1", + indentation: { anchor_line: "3", max_levels: "0", max_lines: "4" }, + }), + }), + ).toMatchObject({ + nativeArgs: { + path: "test.ts", + offset: 2, + limit: 1, + indentation: { anchor_line: 3, max_levels: 0, max_lines: 4 }, + }, + }) + }) + + it.each([0, -1, 1.5])("retains numeric limit %s for precise execution diagnostics", (limit) => { + expect( + NativeToolCallParser.parseToolCall({ + id: "reader_validation", + name: "read_file", + arguments: JSON.stringify({ path: "test.ts", limit }), + }), + ).toMatchObject({ nativeArgs: { path: "test.ts", limit } }) + }) + it("should parse minimal single-file read_file args", () => { const toolCall = { id: "toolu_123", diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 340eb44dd5..05ef18dc4b 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" -import { readFileTool } from "../../tools/ReadFileTool" +import { ReadFileTool } from "../../tools/file-reading/ReadFileTool" import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" // Mock dependencies @@ -399,7 +399,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // longer re-reads provider state before dispatch (issue #1642), so the // dispatch-time throw this test pins is triggered by the dispatched tool // handler itself. - const handleSpy = vi.spyOn(readFileTool, "handle").mockRejectedValue(new Error("dispatch failed")) + const handleSpy = vi.spyOn(ReadFileTool.prototype, "handle").mockRejectedValue(new Error("dispatch failed")) try { await expect(presentAssistantMessage(mockTask, baseSnapshot)).rejects.toThrow("dispatch failed") diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts index cbd3000e78..0583ad4f56 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts @@ -65,10 +65,10 @@ const { readFileHandle, attemptCompletionHandle } = vi.hoisted(() => ({ attemptCompletionHandle: vi.fn(async () => {}), })) -vi.mock("../../tools/ReadFileTool", () => ({ - readFileTool: { - handle: readFileHandle, - getReadFileToolDescription: vi.fn(() => "[read_file]"), +vi.mock("../../tools/file-reading/ReadFileTool", () => ({ + ReadFileTool: class { + handle = readFileHandle + getReadFileToolDescription = vi.fn(() => "[read_file]") }, })) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 00350b79c3..db4eac1a88 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -5,6 +5,7 @@ import { TelemetryEventName } from "@roo-code/types" import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" +import { ReadFileTool } from "../../tools/file-reading/ReadFileTool" import { getModeBySlug } from "../../../shared/modes" import type { Task } from "../../task/Task" import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" @@ -168,6 +169,127 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") }) + describe("shared read_file tool", () => { + it("reuses one instance for partial and complete calls across tasks", async () => { + const handle = vi.spyOn(ReadFileTool.prototype, "handle").mockResolvedValue(undefined) + const otherTask = { ...mockTask, taskId: "other-task-id", instanceId: "other-instance" } + + try { + for (const task of [mockTask, otherTask]) { + for (const partial of [true, false]) { + task.assistantMessageContent = [ + { + type: "tool_use", + id: `call_${task.taskId}`, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial, + }, + ] + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(task as unknown as Task, baseSnapshot) + } + } + + expect(handle).toHaveBeenCalledTimes(4) + const sharedInstance = handle.mock.contexts[0] + expect(sharedInstance).toBeInstanceOf(ReadFileTool) + for (const instance of handle.mock.contexts) { + expect(instance).toBe(sharedInstance) + } + } finally { + handle.mockRestore() + } + }) + + it("preserves the injected reader when recursively processing tool calls", async () => { + const reader = { + handle: vi.fn().mockResolvedValue(undefined), + getReadFileToolDescription: vi.fn(() => ""), + } + mockTask.assistantMessageContent = ["first.txt", "second.txt"].map((path, index) => ({ + type: "tool_use", + id: `call_read_${index}`, + name: "read_file", + params: { path }, + nativeArgs: { path }, + partial: false, + })) + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, baseSnapshot, reader) + + expect(reader.handle).toHaveBeenCalledTimes(2) + for (const instance of reader.handle.mock.contexts) { + expect(instance).toBe(reader) + } + }) + + it("preserves the injected reader when processing pending streaming updates", async () => { + const reader = { + handle: vi + .fn() + .mockResolvedValue(undefined) + .mockImplementationOnce(async (task) => { + task.presentAssistantMessageHasPendingUpdates = true + }), + getReadFileToolDescription: vi.fn(() => ""), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_streaming_read", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: true, + }, + ] + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, baseSnapshot, reader) + + expect(reader.handle).toHaveBeenCalledTimes(2) + for (const instance of reader.handle.mock.contexts) { + expect(instance).toBe(reader) + } + }) + + it.each([ + { nativeArgs: { path: "native.txt" }, expectedPath: "native.txt" }, + { nativeArgs: undefined, expectedPath: "legacy.txt" }, + ])("uses the injected reader to describe $expectedPath", async ({ nativeArgs, expectedPath }) => { + const reader = new ReadFileTool() + const describeTool = vi.spyOn(reader, "getReadFileToolDescription") + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_rejected_read", + name: "read_file", + params: { path: "legacy.txt" }, + nativeArgs, + partial: false, + }, + ] + + try { + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, baseSnapshot, reader) + + expect(describeTool).toHaveBeenCalledExactlyOnceWith("read_file", { path: expectedPath }) + expect(describeTool.mock.contexts[0]).toBe(reader) + expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining(expectedPath) }), + ) + } finally { + describeTool.mockRestore() + } + }) + }) + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { mockTask.assistantMessageContent = [ { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 68cc7a3dd0..7841e66d80 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,7 +17,7 @@ import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" import { listFilesTool } from "../tools/ListFilesTool" -import { readFileTool } from "../tools/ReadFileTool" +import { ReadFileTool } from "../tools/file-reading/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" import { writeToFileTool } from "../tools/WriteToFileTool" import { editTool } from "../tools/EditTool" @@ -44,6 +44,8 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +const defaultReadFileTool = new ReadFileTool() + /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. * Never returns the raw name unless it is a known static tool, so an @@ -86,7 +88,11 @@ export function toTelemetryToolName( * as it becomes available. */ -export async function presentAssistantMessage(cline: Task, policySnapshot: RequestPolicySnapshot) { +export async function presentAssistantMessage( + cline: Task, + policySnapshot: RequestPolicySnapshot, + readFileTool: Pick = defaultReadFileTool, +) { if (cline.abort) { return } @@ -113,7 +119,7 @@ export async function presentAssistantMessage(cline: Task, policySnapshot: Reque // The last pending check and the release below run in the same // synchronous step, so no update can be stranded behind the lock. do { - await presentAssistantMessageBlock(cline, policySnapshot) + await presentAssistantMessageBlock(cline, policySnapshot, readFileTool) } while (!cline.abort && cline.presentAssistantMessageHasPendingUpdates) } finally { // Tool handlers and provider-state reads can reject. Never strand the @@ -122,7 +128,11 @@ export async function presentAssistantMessage(cline: Task, policySnapshot: Reque } } -async function presentAssistantMessageBlock(cline: Task, policySnapshot: RequestPolicySnapshot): Promise { +async function presentAssistantMessageBlock( + cline: Task, + policySnapshot: RequestPolicySnapshot, + readFileTool: Pick, +): Promise { if (cline.abort) { return } @@ -1140,7 +1150,7 @@ async function presentAssistantMessageBlock(cline: Task, policySnapshot: Request if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { // There are already more content blocks to stream, so we'll call // this function ourselves. - return await presentAssistantMessageBlock(cline, policySnapshot) + return await presentAssistantMessageBlock(cline, policySnapshot, readFileTool) } else { // CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady // This handles the case where assistantMessageContent is empty or becomes empty after processing diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 23a27f39b5..12e2b5e5c6 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -11,7 +11,7 @@ import { getCommitInfo, getWorkingState } from "../../utils/git" import { openFile } from "../../integrations/misc/open-file" import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../integrations/misc/extract-text" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT } from "../tools/file-reading/readFileConstants" import { FileContextTracker } from "../context-tracking/FileContextTracker" diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts index dded7fba50..023c72a6cb 100644 --- a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -1,5 +1,6 @@ import type OpenAI from "openai" import { createReadFileTool } from "../read_file" +import { normalizeToolSchema, ToolInputSchema } from "../../../../../utils/json-schema" // Helper type to access function tools type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } @@ -8,6 +9,31 @@ type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function describe("createReadFileTool", () => { + it("keeps integer lower bounds in the native and normalized strict schemas", () => { + const schema = getFunctionDef(createReadFileTool()).parameters + if (!schema) throw new Error("read_file parameters are required") + const expected = { + properties: { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + indentation: { + properties: { + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, + }, + additionalProperties: false, + }, + }, + required: ["path"], + additionalProperties: false, + } + + expect(schema).toMatchObject(expected) + expect(ToolInputSchema.parse(schema)).toMatchObject(expected) + expect(normalizeToolSchema(schema)).toMatchObject(expected) + }) + describe("single-file-per-call documentation", () => { it("should indicate single-file-per-call and suggest parallel tool calls", () => { const tool = createReadFileTool() @@ -52,22 +78,21 @@ describe("createReadFileTool", () => { }) describe("supportsImages option", () => { - it("should include image format documentation when supportsImages is true", () => { + it("should advertise only shared-contract image formats when supportsImages is true", () => { const tool = createReadFileTool({ supportsImages: true }) const description = getFunctionDef(tool).description expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, WEBP) for visual analysis", ) + expect(description).toContain("Other binary image formats are not supported") }) it("should not include image format documentation when supportsImages is false", () => { const tool = createReadFileTool({ supportsImages: false }) const description = getFunctionDef(tool).description - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) + expect(description).not.toContain("Automatically processes and returns image files") expect(description).toContain("may not handle other binary files properly") }) @@ -75,9 +100,7 @@ describe("createReadFileTool", () => { const tool = createReadFileTool({}) const description = getFunctionDef(tool).description - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) + expect(description).not.toContain("Automatically processes and returns image files") }) it("should always include PDF and DOCX support in description", () => { diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 35d6a0d53a..1ed89548c2 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,15 +1,9 @@ import type OpenAI from "openai" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../../../tools/file-reading/readFileConstants" +import { READ_FILE_NUMERIC_CONSTRAINTS } from "../../../tools/file-reading/readFileValidation" -// ─── Constants ──────────────────────────────────────────────────────────────── - -/** Default maximum lines to return per file (Codex-inspired predictable limit) */ -export const DEFAULT_LINE_LIMIT = 2000 - -/** Maximum characters per line before truncation */ -export const MAX_LINE_LENGTH = 2000 - -/** Default indentation levels to include above anchor (0 = unlimited) */ -export const DEFAULT_MAX_LEVELS = 0 +// Preserve older imports without making runtime readers depend on this tool definition. +export { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH, DEFAULT_MAX_LEVELS } from "../../../tools/file-reading/readFileConstants" // ─── Helper Functions ───────────────────────────────────────────────────────── @@ -21,7 +15,7 @@ export const DEFAULT_MAX_LEVELS = 0 */ function getReadFileSupportsNote(supportsImages: boolean): string { if (supportsImages) { - return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis. May not handle other binary files properly.` + return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, WEBP) for visual analysis. Other binary image formats are not supported by the shared image-block contract. May not handle other binary files properly.` } return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` } @@ -83,12 +77,12 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch const indentationProperties: Record = { anchor_line: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.anchor_line, description: "1-based line number to anchor the extraction. REQUIRED for meaningful indentation mode results. The extractor finds the semantic block (function, method, class) containing this line and returns it completely. Without anchor_line, indentation mode defaults to line 1 and returns only imports/header content. Obtain anchor_line from: search results, error stack traces, definition lookups, codebase_search results, or condensed file summaries (e.g., '14--28 | export class UserService' means anchor_line=14).", }, max_levels: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.max_levels, description: `Maximum indentation levels to include above the anchor (indentation mode, 0 = unlimited (default)). Higher values include more parent context.`, }, include_siblings: { @@ -102,7 +96,7 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch "Include file header content (imports, module-level comments) at the top of output (indentation mode, default: true).", }, max_lines: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.max_lines, description: "Hard cap on lines returned for indentation mode. Acts as a separate limit from the top-level 'limit' parameter.", }, @@ -120,11 +114,11 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch "Reading mode. 'slice' (default): read lines sequentially with offset/limit - use for general file exploration or when you don't have a target line number (may truncate code mid-function). 'indentation': extract complete semantic code blocks containing anchor_line - PREFERRED when you have a line number because it guarantees complete, valid code blocks. WARNING: Do not use indentation mode without specifying indentation.anchor_line, or you will only get header content.", }, offset: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.offset, description: "1-based line offset to start reading from (slice mode, default: 1)", }, limit: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.limit, description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`, }, indentation: { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 85325b39ff..dc6720dc06 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -38,7 +38,7 @@ import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-p import { asyncStreamFrom } from "../../../test-utils/stream" import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" -import { readFileTool } from "../../tools/ReadFileTool" +import { ReadFileTool } from "../../tools/file-reading/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" type TaskTestAccess = { @@ -840,7 +840,7 @@ describe("Cline", () => { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult(`File: ${block.params.path}\nfinished`) }) @@ -941,7 +941,7 @@ describe("Cline", () => { handlerPushedResult = resolve }) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (block.partial) return callbacks.pushToolResult("File: README.md\nfinished") @@ -1004,7 +1004,7 @@ describe("Cline", () => { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult("File: README.md\nfinished") }) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts deleted file mode 100644 index a85ffc8c8d..0000000000 --- a/src/core/tools/ReadFileTool.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * ReadFileTool - Codex-inspired file reading with indentation mode support. - * - * Supports two modes: - * 1. Slice mode (default): Read contiguous lines with offset/limit - * 2. Indentation mode: Extract semantic code blocks based on indentation hierarchy - * - * Also supports legacy format for backward compatibility: - * - Legacy format: { files: [{ path: string, lineRanges?: [...] }] } - */ -import path from "path" -import * as fs from "fs/promises" -import { isBinaryFile } from "isbinaryfile" - -import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineRange } from "@roo-code/types" -import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" - -import { Task } from "../task/Task" -import { formatResponse } from "../prompts/responses" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { getReadablePath } from "../../utils/path" -import { extractTextFromBuffer, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" -import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" -import type { ToolUse, PushToolResult } from "../../shared/tools" - -import { - DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - isSupportedImageFormat, - validateImageForProcessing, - processImageFile, - ImageMemoryTracker, -} from "./helpers/imageHelpers" -import { BaseTool, ToolCallbacks } from "./BaseTool" -import { ReadFileTarget } from "./file-reading/ReadFileTarget" -import { ReadFileCancellation } from "./file-reading/ReadFileCancellation" - -// ─── Types ──────────────────────────────────────────────────────────────────── - -/** - * Internal entry structure for tracking file read parameters. - */ -interface InternalFileEntry { - path: string - mode?: ReadFileMode - offset?: number - limit?: number - anchor_line?: number - max_levels?: number - include_siblings?: boolean - include_header?: boolean - max_lines?: number -} - -interface FileResult { - path: string - status: "approved" | "denied" | "blocked" | "error" | "pending" - content?: string - error?: string - notice?: string - nativeContent?: string - imageDataUrl?: string - feedbackText?: string - feedbackImages?: string[] - approvalStatus?: "approved" | "denied" - // Store the original entry for mode processing - entry?: InternalFileEntry - target?: ReadFileTarget -} - -// ─── Tool Implementation ────────────────────────────────────────────────────── - -export class ReadFileTool extends BaseTool<"read_file"> { - readonly name = "read_file" as const - - async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise { - // Dispatch to legacy or new execution path based on format - if (isLegacyReadFileParams(params)) { - return this.executeLegacy(params.files, task, callbacks) - } - - return this.executeNew(params, task, callbacks) - } - - /** - * Execute new single-file format with slice/indentation mode support. - */ - private async executeNew(params: ReadFileParams, task: Task, callbacks: ToolCallbacks): Promise { - const cancellation = new ReadFileCancellation(task) - if (cancellation.isCancelled()) return - const { pushToolResult } = callbacks - const modelInfo = task.api.getModel().info - const filePath = params.path - - // Validate input - if (!filePath) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "path") - pushToolResult(`Error: ${errorMsg}`) - return - } - - const supportsImages = modelInfo.supportsImages ?? false - - // Initialize file results tracking - // Validate line number parameters (must be 1-indexed positive integers) - if (params.offset !== undefined && params.offset < 1) { - const errorMsg = `offset must be a 1-indexed line number (got ${params.offset}). Line numbers start at 1.` - pushToolResult(`Error: ${errorMsg}`) - return - } - if (params.indentation?.anchor_line !== undefined && params.indentation.anchor_line < 1) { - const errorMsg = `anchor_line must be a 1-indexed line number (got ${params.indentation.anchor_line}). Line numbers start at 1.` - pushToolResult(`Error: ${errorMsg}`) - return - } - - const fileEntry: InternalFileEntry = { - path: filePath, - mode: params.mode, - offset: params.offset, - limit: params.limit, - anchor_line: params.indentation?.anchor_line, - max_levels: params.indentation?.max_levels, - include_siblings: params.indentation?.include_siblings, - include_header: params.indentation?.include_header, - max_lines: params.indentation?.max_lines, - } - - const fileResults: FileResult[] = [ - { - path: filePath, - status: "pending" as const, - entry: fileEntry, - }, - ] - - const updateFileResult = (filePath: string, updates: Partial) => { - const index = fileResults.findIndex((result) => result.path === filePath) - if (index !== -1) { - fileResults[index] = { ...fileResults[index], ...updates } - } - } - - try { - // Phase 1: Validate and filter files for approval - const filesToApprove: FileResult[] = [] - - for (const fileResult of fileResults) { - const relPath = fileResult.path - - // RooIgnore validation - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (accessAllowed) { - fileResult.target = await ReadFileTarget.resolve(path.resolve(task.cwd, relPath), cancellation) - } - if (!accessAllowed || !task.rooIgnoreController?.validateAccess(fileResult.target!.fullPath)) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - continue - } - - filesToApprove.push(fileResult) - } - - // Phase 2: Request user approval - await this.requestApproval(task, filesToApprove, updateFileResult, cancellation) - cancellation.throwIfCancelled() - - // Phase 3: Process approved files - const imageMemoryTracker = new ImageMemoryTracker() - const state = await task.providerRef.deref()?.getState() - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - - for (const fileResult of fileResults) { - if (fileResult.status !== "approved") continue - - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const entry = fileResult.entry! - - try { - await fileResult.target!.withHandle(async (file, stats) => { - // Check if path is a directory - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.` - updateFileResult(relPath, { - status: "error", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - return - } - - // Check for binary file - const probe = Buffer.alloc(512) - const { bytesRead } = await file.read(probe, 0, probe.length, 0) - cancellation.throwIfCancelled() - const isBinary = await isBinaryFile(probe, bytesRead) - cancellation.throwIfCancelled() - - if (isBinary) { - await this.handleBinaryFile( - task, - relPath, - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker, - updateFileResult, - file, - cancellation, - ) - return - } - - // Read text file content with lossy UTF-8 conversion - // Reading as Buffer first allows graceful handling of non-UTF8 bytes - // (they become U+FFFD replacement characters instead of throwing) - const buffer = await file.readFile() - cancellation.throwIfCancelled() - const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, - }) - }) - } catch (error) { - if (cancellation.isCancelled()) throw error - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - } - } - - // Phase 4: Build and return result - cancellation.throwIfCancelled() - const hasErrors = fileResults.some((r) => r.status === "error" || r.status === "blocked") - if (hasErrors) { - task.didToolFailInCurrentTurn = true - } - - this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) - } catch (error) { - if (cancellation.isCancelled()) { - this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) - return - } - const relPath = filePath || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) - - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - - try { - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - } catch (diagnosticError) { - if (!cancellation.isCancelled()) throw diagnosticError - } - if (cancellation.isCancelled()) { - this.buildAndPushResult(task, fileResults, pushToolResult, cancellation) - return - } - task.didToolFailInCurrentTurn = true - - const errorResult = fileResults - .filter((r) => r.nativeContent) - .map((r) => r.nativeContent) - .join("\n\n---\n\n") - - pushToolResult(errorResult || `Error: ${errorMsg}`) - } - } - - /** - * Process a text file according to the requested mode. - */ - private processTextFile(content: string, entry: InternalFileEntry): string { - const mode = entry.mode || "slice" - - if (mode === "indentation") { - // Indentation mode: semantic block extraction - // When anchor_line is not provided, default to offset (which defaults to 1) - const anchorLine = entry.anchor_line ?? entry.offset ?? 1 - const result = readWithIndentation(content, { - anchorLine, - maxLevels: entry.max_levels, - includeSiblings: entry.include_siblings, - includeHeader: entry.include_header, - limit: entry.limit ?? DEFAULT_LINE_LIMIT, - maxLines: entry.max_lines, - }) - - let output = result.content - - if (result.wasTruncated && result.includedRanges.length > 0) { - const [start, end] = result.includedRanges[0] - const nextOffset = end + 1 - const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT - // Put truncation warning at TOP (before content) to match @ mention format - output = `IMPORTANT: File content truncated. - Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. - To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - - ${result.content}` - } else if (result.includedRanges.length > 0) { - const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") - output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` - } - - return output - } - - // Slice mode (default): simple offset/limit reading - // NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice. - const offset1 = entry.offset ?? 1 - const offset0 = Math.max(0, offset1 - 1) - const limit = entry.limit ?? DEFAULT_LINE_LIMIT - - const result = readWithSlice(content, offset0, limit) - - let output = result.content - - if (result.wasTruncated) { - const startLine = offset1 - const endLine = offset1 + result.returnedLines - 1 - const nextOffset = endLine + 1 - // Put truncation warning at TOP (before content) to match @ mention format - output = `IMPORTANT: File content truncated. - Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. - To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - - ${result.content}` - } else if (result.returnedLines === 0) { - output = "Note: File is empty" - } - - return output - } - - /** - * Handle binary file processing (images, PDF, DOCX, etc.). - */ - private async handleBinaryFile( - task: Task, - relPath: string, - fullPath: string, - supportsImages: boolean, - maxImageFileSize: number, - maxTotalImageSize: number, - imageMemoryTracker: ImageMemoryTracker, - updateFileResult: (path: string, updates: Partial) => void, - file: fs.FileHandle, - cancellation: ReadFileCancellation, - ): Promise { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() - - // Handle image files - if (isSupportedImageFormat(fileExtension)) { - try { - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker.getTotalMemoryUsed(), - file, - ) - cancellation.throwIfCancelled() - - if (!validationResult.isValid) { - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`, - }) - return - } - - const imageResult = await processImageFile(fullPath, file, cancellation) - cancellation.throwIfCancelled() - imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`, - imageDataUrl: imageResult.dataUrl, - }) - return - } catch (error) { - if (cancellation.isCancelled()) throw error - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading image file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`) - return - } - } - - // Handle other supported binary formats (PDF, DOCX, etc.) - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - try { - const source = await file.readFile() - cancellation.throwIfCancelled() - const content = await extractTextFromBuffer(source, fullPath) - cancellation.throwIfCancelled() - const numberedContent = addLineNumbers(content) - const lineCount = content.split("\n").length - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: - lineCount > 0 - ? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}` - : `File: ${relPath}\nNote: File is empty`, - }) - return - } catch (error) { - if (cancellation.isCancelled()) throw error - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error extracting text: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`) - return - } - } - - // Unsupported binary format - const fileFormat = fileExtension.slice(1) || "bin" - updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, - }) - } - - /** - * Request user approval for file reads. - */ - private async requestApproval( - task: Task, - filesToApprove: FileResult[], - updateFileResult: (path: string, updates: Partial) => void, - cancellation: ReadFileCancellation, - ): Promise { - if (filesToApprove.length === 0) return - cancellation.throwIfCancelled() - - if (filesToApprove.length > 1) { - // Batch approval - const batchFiles = filesToApprove.map((fileResult) => { - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const readablePath = getReadablePath(task.cwd, relPath) - - const lineSnippet = this.getLineSnippet(fileResult.entry!) - const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` - - return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } - }) - - const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response === "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) - }) - } else if (response === "noButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { - status: "denied", - nativeContent: `File: ${fr.path}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - }) - } else { - // Individual permissions - try { - const individualPermissions = JSON.parse(text || "{}") - let hasAnyDenial = false - - batchFiles.forEach((batchFile, index) => { - const fileResult = filesToApprove[index] - const approved = individualPermissions[batchFile.key] === true - - if (approved) { - updateFileResult(fileResult.path, { status: "approved" }) - } else { - hasAnyDenial = true - updateFileResult(fileResult.path, { - status: "denied", - nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, - }) - } - }) - - if (hasAnyDenial) task.didRejectTool = true - } catch { - task.didRejectTool = true - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { - status: "denied", - nativeContent: `File: ${fr.path}\nStatus: Denied by user`, - }) - }) - } - } - } else { - // Single file approval - const fileResult = filesToApprove[0] - const relPath = fileResult.path - const fullPath = fileResult.target!.fullPath - const isOutsideWorkspace = await ReadFileTarget.isOutsideWorkspace(fullPath) - cancellation.throwIfCancelled() - const lineSnippet = this.getLineSnippet(fileResult.entry!) - - const startLine = this.getStartLine(fileResult.entry!) - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, fullPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - startLine, - } satisfies ClineSayTool) - - const { response, text, images } = await task.ask("tool", completeMessage, false) - - updateFileResult(relPath, { - approvalStatus: response === "yesButtonClicked" ? "approved" : "denied", - feedbackText: text, - feedbackImages: images, - }) - cancellation.throwIfCancelled() - if (text) await task.say("user_feedback", text, images) - cancellation.throwIfCancelled() - - if (response !== "yesButtonClicked") { - task.didRejectTool = true - updateFileResult(relPath, { - status: "denied", - nativeContent: `File: ${relPath}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - } else { - updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) - } - } - } - - /** - * Get the starting line number for navigation purposes. - */ - private getStartLine(entry: InternalFileEntry): number | undefined { - if (entry.mode === "indentation") { - // For indentation mode, always return the effective anchor line - return entry.anchor_line ?? entry.offset ?? 1 - } - const offset = entry.offset ?? 1 - return offset > 1 ? offset : undefined - } - - /** - * Generate a human-readable line snippet for approval messages. - */ - private getLineSnippet(entry: InternalFileEntry): string { - if (entry.mode === "indentation") { - // Always show indentation mode with the effective anchor line - const effectiveAnchor = entry.anchor_line ?? entry.offset ?? 1 - return `(indentation mode at line ${effectiveAnchor})` - } - - const limit = entry.limit ?? DEFAULT_LINE_LIMIT - const offset1 = entry.offset ?? 1 - - if (offset1 > 1) { - return `(lines ${offset1}-${offset1 + limit - 1})` - } - - // Always show the line limit, even when using the default - return `(up to ${limit} lines)` - } - - /** - * Build and push the final result to the tool output. - */ - private buildAndPushResult( - task: Task, - fileResults: FileResult[], - pushToolResult: PushToolResult, - cancellation: ReadFileCancellation, - ): void { - const cancelled = cancellation.isCancelled() - if (cancelled) { - fileResults = fileResults.map((result) => ({ - ...result, - status: result.approvalStatus ?? "pending", - nativeContent: undefined, - imageDataUrl: undefined, - })) - if (!fileResults.some((result) => result.feedbackText || result.feedbackImages?.length)) return - } - const finalResult = fileResults - .filter((r) => r.nativeContent) - .map((r) => r.nativeContent) - .join("\n\n---\n\n") - - const fileImageUrls = fileResults.filter((r) => r.imageDataUrl).map((r) => r.imageDataUrl as string) - - let statusMessage = "" - let feedbackImages: string[] = cancelled ? fileResults.flatMap((result) => result.feedbackImages ?? []) : [] - - const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) - - if (deniedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - feedbackImages = deniedWithFeedback.feedbackImages || [] - } else if (task.didRejectTool) { - statusMessage = formatResponse.toolDenied() - } else { - const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText) - if (approvedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] - } - } - - const allImages = [...feedbackImages, ...fileImageUrls] - const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] - - if (statusMessage || imagesToInclude.length > 0) { - const result = formatResponse.toolResult( - statusMessage || finalResult, - imagesToInclude.length > 0 ? imagesToInclude : undefined, - ) - - if (typeof result === "string") { - pushToolResult(statusMessage ? `${result}\n${finalResult}` : result) - } else { - if (statusMessage) { - const textBlock = { type: "text" as const, text: finalResult } - pushToolResult([...result, textBlock] as any) - } else { - pushToolResult(result as any) - } - } - } else { - pushToolResult(finalResult) - } - } - - getReadFileToolDescription(blockName: string, blockParams: { path?: string }): string - getReadFileToolDescription(blockName: string, nativeArgs: ReadFileParams): string - getReadFileToolDescription(blockName: string, second: unknown): string { - // If native typed args were provided - if (second && typeof second === "object" && "path" in second && typeof (second as any).path === "string") { - return `[${blockName} for '${(second as any).path}']` - } - - const blockParams = second as Record - if (blockParams?.path) { - return `[${blockName} for '${blockParams.path}']` - } - return `[${blockName} with missing path]` - } - - override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { - // Handle both legacy and new format for partial display - let filePath = "" - if (block.nativeArgs) { - if (isLegacyReadFileParams(block.nativeArgs)) { - // Legacy format - show first file - filePath = block.nativeArgs.files[0]?.path ?? "" - } else { - filePath = block.nativeArgs.path ?? "" - } - } - - const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: filePath ? getReadablePath(task.cwd, filePath) : "", - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) - } - - /** - * Execute legacy multi-file format for backward compatibility. - * This handles the old format: { files: [{ path: string, lineRanges?: [...] }] } - */ - private async executeLegacy(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { - const cancellation = new ReadFileCancellation(task) - if (cancellation.isCancelled()) return - const { pushToolResult } = callbacks - const modelInfo = task.api.getModel().info - const results: string[] = [] - const approvalResults: FileResult[] = [] - - try { - if (!fileEntries || fileEntries.length === 0) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await cancellation.run(() => task.sayAndCreateMissingParamError("read_file", "files")) - pushToolResult(`Error: ${errorMsg}`) - return - } - - const supportsImages = modelInfo.supportsImages ?? false - - // Process each file sequentially (legacy behavior). - for (const entry of fileEntries) { - cancellation.throwIfCancelled() - const relPath = entry.path - const fullPath = path.resolve(task.cwd, relPath) - - // RooIgnore validation - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - let target: ReadFileTarget | undefined - try { - if (accessAllowed) { - target = await ReadFileTarget.resolve(fullPath, cancellation) - } - } catch (error) { - if (cancellation.isCancelled()) throw error - const errorMsg = error instanceof Error ? error.message : String(error) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await cancellation.run(() => task.say("error", `Error reading file ${relPath}: ${errorMsg}`)) - task.didToolFailInCurrentTurn = true - continue - } - if (!accessAllowed || !task.rooIgnoreController?.validateAccess(target!.fullPath)) { - await cancellation.run(() => task.say("rooignore_error", relPath)) - const errorMsg = formatResponse.rooIgnoreError(relPath) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - // Mirror the native path: a blocked file marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - continue - } - - // Request approval for single file - const isOutsideWorkspace = await cancellation.run(() => - ReadFileTarget.isOutsideWorkspace(target!.fullPath), - ) - let lineSnippet = "" - if (entry.lineRanges && entry.lineRanges.length > 0) { - const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`) - lineSnippet = ranges.join(", ") - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, target!.fullPath), - isOutsideWorkspace, - content: target!.fullPath, - reason: lineSnippet || undefined, - } satisfies ClineSayTool) - - cancellation.throwIfCancelled() - const { response, text, images } = await task.ask("tool", completeMessage, false) - approvalResults.push({ - path: relPath, - status: "pending", - approvalStatus: response === "yesButtonClicked" ? "approved" : "denied", - feedbackText: text, - feedbackImages: images, - }) - cancellation.throwIfCancelled() - - if (response !== "yesButtonClicked") { - if (text) await cancellation.run(() => task.say("user_feedback", text, images)) - task.didRejectTool = true - results.push(`File: ${relPath}\nStatus: Denied by user`) - continue - } - - if (text) await cancellation.run(() => task.say("user_feedback", text, images)) - - try { - await target!.withHandle(async (file, stats) => { - // Check if the path is a directory - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory.` - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await cancellation.run(() => - task.say("error", `Error reading file ${relPath}: ${errorMsg}`), - ) - // Mirror the native path: a failed read marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - return - } - - let isBinary = false - try { - const probe = Buffer.alloc(512) - const { bytesRead } = await cancellation.run(() => file.read(probe, 0, probe.length, 0)) - isBinary = await cancellation.run(() => isBinaryFile(probe, bytesRead)) - } catch (error) { - if (cancellation.isCancelled()) throw error - // Preserve the historical legacy text fallback when binary probing fails. - } - - if (isBinary) { - // Handle binary files (images) - const fileExtension = path.extname(relPath).toLowerCase() - if (supportsImages && isSupportedImageFormat(fileExtension)) { - const state = await cancellation.run(async () => task.providerRef.deref()?.getState()) - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - const validation = await cancellation.run(() => - validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - 0, // Legacy path doesn't track cumulative memory - file, - ), - ) - if (!validation.isValid) { - results.push( - `File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`, - ) - return - } - const imageResult = await cancellation.run(() => - processImageFile(fullPath, file, cancellation), - ) - if (imageResult) { - results.push(`File: ${relPath}\n[Image file - content processed for vision model]`) - } - } else { - results.push(`File: ${relPath}\nError: Cannot read binary file`) - } - return - } - - // Read text file - const rawContent = await cancellation.run(() => file.readFile("utf8")) - - // Handle line ranges if specified - let content: string - if (entry.lineRanges && entry.lineRanges.length > 0) { - const lines = rawContent.split("\n") - const selectedLines: string[] = [] - - for (const range of entry.lineRanges) { - // Convert to 0-based index, ranges are 1-based inclusive - const startIdx = Math.max(0, range.start - 1) - const endIdx = Math.min(lines.length - 1, range.end - 1) - - for (let i = startIdx; i <= endIdx; i++) { - selectedLines.push(`${i + 1} | ${lines[i]}`) - } - } - content = selectedLines.join("\n") - } else { - // Read with default limits using slice mode - const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) - content = result.content - if (result.wasTruncated) { - content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` - } - } - - // Track file in context - await cancellation.run(() => task.fileContextTracker.trackFileContext(relPath, "read_tool")) - results.push(`File: ${relPath}\n${content}`) - }) - } catch (error) { - if (cancellation.isCancelled()) throw error - const errorMsg = error instanceof Error ? error.message : String(error) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await cancellation.run(() => task.say("error", `Error reading file ${relPath}: ${errorMsg}`)) - // Mirror the native path: a failed read marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - } - } - - // Push combined results - cancellation.throwIfCancelled() - pushToolResult(results.join("\n\n---\n\n")) - } catch (error) { - if (cancellation.isCancelled()) { - this.buildAndPushResult(task, approvalResults, pushToolResult, cancellation) - return - } - throw error - } - } -} - -export const readFileTool = new ReadFileTool() diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts deleted file mode 100644 index 5678c4d5a3..0000000000 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ /dev/null @@ -1,1575 +0,0 @@ -/** - * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support. - * - * These tests cover: - * - Input validation (missing path parameter) - * - RooIgnore blocking - * - Directory read error handling - * - Binary file handling (images, PDF, DOCX, unsupported) - * - Image memory limits - * - Approval flow (approve, deny, feedback) - * - Text file processing (slice and indentation modes) - * - Output structure formatting - */ - -import path from "path" - -import type { ToolUse } from "../../../shared/tools" -import type { Task } from "../../task/Task" - -import { isBinaryFile } from "isbinaryfile" - -import { readFileTool } from "../ReadFileTool" -import { formatResponse } from "../../prompts/responses" -import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" -import { extractTextFromBuffer, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" -import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" - -// ─── Mocks ──────────────────────────────────────────────────────────────────── - -vi.mock("path", async () => { - const originalPath = await vi.importActual("path") - return { - default: originalPath, - ...originalPath, - resolve: vi.fn().mockImplementation((...args) => { - return args.join("/") - }), - } -}) - -vi.mock("fs/promises", () => ({ - readFile: vi.fn(), - stat: vi.fn(), - realpath: vi.fn(async (file: string) => file), - lstat: vi.fn(async () => ({ dev: 1n, ino: 1n, isSymbolicLink: () => false })), - open: vi.fn(async (file: string) => { - const mocked = await import("fs/promises") - return { - stat: async () => ({ ...(await mocked.stat(file)), dev: 1n, ino: 1n }), - read: async () => ({ bytesRead: 0 }), - readFile: async (encoding?: "utf8") => (encoding ? mocked.readFile(file, encoding) : mocked.readFile(file)), - close: vi.fn().mockResolvedValue(undefined), - } - }), -})) - -vi.mock("isbinaryfile") - -vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromBuffer: vi.fn(), - addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { - if (!text) return "" - const lines = text.split("\n") - return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n") - }), - getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), -})) - -vi.mock("../../../integrations/misc/indentation-reader", () => ({ - readWithIndentation: vi.fn(), - readWithSlice: vi.fn(), -})) - -vi.mock("../helpers/imageHelpers", () => ({ - DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, - isSupportedImageFormat: vi.fn(), - validateImageForProcessing: vi.fn(), - processImageFile: vi.fn(), - ImageMemoryTracker: vi.fn().mockImplementation(function () { - return { - getTotalMemoryUsed: vi.fn().mockReturnValue(0), - addMemoryUsage: vi.fn(), - } - }), -})) - -vi.mock("../../prompts/responses", () => ({ - formatResponse: { - toolDenied: vi.fn(() => "The user denied this operation."), - toolDeniedWithFeedback: vi.fn( - (feedback?: string) => - `The user denied this operation and responded with the message:\n\n${feedback}\n`, - ), - toolApprovedWithFeedback: vi.fn( - (feedback?: string) => - `The user approved this operation and responded with the message:\n\n${feedback}\n`, - ), - rooIgnoreError: vi.fn( - (filePath: string) => - `Access to ${filePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, - ), - toolResult: vi.fn((text: string, images?: string[]) => { - if (images && images.length > 0) { - return [ - { type: "text", text }, - ...images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }), - ] - } - return text - }), - imageBlocks: vi.fn((images?: string[]) => { - return images - ? images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }) - : [] - }), - }, -})) - -// Mock fs/promises -const fsPromises = await import("fs/promises") -const mockedFsReadFile = vi.mocked(fsPromises.readFile) -const mockedFsStat = vi.mocked(fsPromises.stat) - -const mockedIsBinaryFile = vi.mocked(isBinaryFile) -const mockedExtractTextFromBuffer = vi.mocked(extractTextFromBuffer) -const mockedReadWithSlice = vi.mocked(readWithSlice) -const mockedReadWithIndentation = vi.mocked(readWithIndentation) -const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) -const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing) -const mockedProcessImageFile = vi.mocked(processImageFile) - -// ─── Test Helpers ───────────────────────────────────────────────────────────── - -interface MockTaskOptions { - supportsImages?: boolean - rooIgnoreAllowed?: boolean - maxImageFileSize?: number - maxTotalImageSize?: number -} - -function createMockTask(options: MockTaskOptions = {}) { - const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options - - return { - cwd: "/test/workspace", - api: { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages }, - }), - }, - consecutiveMistakeCount: 0, - didToolFailInCurrentTurn: false, - didRejectTool: false, - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), - say: vi.fn().mockResolvedValue(undefined), - sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), - recordToolError: vi.fn(), - rooIgnoreController: { - validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed), - }, - fileContextTracker: { - trackFileContext: vi.fn().mockResolvedValue(undefined), - }, - providerRef: { - deref: vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - maxImageFileSize, - maxTotalImageSize, - }), - }), - }, - } -} - -function createMockCallbacks() { - return { - pushToolResult: vi.fn(), - askApproval: vi.fn(), - handleError: vi.fn(), - } -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("ReadFileTool", () => { - beforeEach(() => { - vi.clearAllMocks() - - // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | test content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - }) - - describe("input validation", () => { - it("should return error when path is missing", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "" } as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") - expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should return error when path is undefined", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({} as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should return error when offset is 0 or negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("offset must be a 1-indexed line number"), - ) - }) - - it("should return error when offset is negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("offset must be a 1-indexed line number"), - ) - }) - - it("should return error when anchor_line is 0 or negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute( - { - path: "test.txt", - mode: "indentation", - indentation: { anchor_line: 0 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("anchor_line must be a 1-indexed line number"), - ) - }) - - it("should return error when anchor_line is negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute( - { - path: "test.txt", - mode: "indentation", - indentation: { anchor_line: -10 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("anchor_line must be a 1-indexed line number"), - ) - }) - }) - - describe("RooIgnore handling", () => { - it("should block access to rooignore-protected files", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "secret.env" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") - expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) - }) - }) - - describe("directory handling", () => { - it("should return error when trying to read a directory", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ path: "src/utils" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith( - "error", - expect.stringContaining("Cannot read 'src/utils' because it is a directory"), - ) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("image handling", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - }) - - it("should process image file when model supports images", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockResolvedValue({ - dataUrl: "data:image/png;base64,abc123", - buffer: Buffer.from("test"), - sizeInKB: 512, - sizeInMB: 0.5, - notice: "Image processed successfully", - }) - - await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - - expect(mockedValidateImageForProcessing).toHaveBeenCalled() - expect(mockedProcessImageFile).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - - it("should skip image when model does not support images", async () => { - const mockTask = createMockTask({ supportsImages: false }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "unsupported_model", - notice: "Model does not support image processing", - }) - - await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - - expect(mockedValidateImageForProcessing).toHaveBeenCalled() - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("Model does not support image processing"), - ) - }) - - it("should skip image when file exceeds size limit", async () => { - const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "size_limit", - notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)", - }) - - await readFileTool.execute({ path: "large-image.png" }, mockTask as any, callbacks) - - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("exceeds the maximum allowed"), - ) - }) - - it("should skip image when total memory limit exceeded", async () => { - const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "memory_limit", - notice: "Skipping image: would exceed total memory limit", - }) - - await readFileTool.execute({ path: "another-image.png" }, mockTask as any, callbacks) - - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("would exceed total memory")) - }) - - it("should handle image read errors gracefully", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image")) - - await readFileTool.execute({ path: "corrupt.png" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) - }) - }) - - describe("binary file handling", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(false) - }) - - it("should extract text from PDF files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - const source = Buffer.from("%PDF-1.7\nsynthetic PDF bytes") - - mockedFsReadFile.mockResolvedValue(source) - mockedExtractTextFromBuffer.mockResolvedValue("PDF content here") - - await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks) - - expect(mockedExtractTextFromBuffer).toHaveBeenCalledExactlyOnceWith( - source, - path.resolve(mockTask.cwd, "document.pdf"), - ) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("PDF content here")) - }) - - it("should extract text from DOCX files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - const source = Buffer.from("PK\u0003\u0004synthetic DOCX bytes") - - mockedFsReadFile.mockResolvedValue(source) - mockedExtractTextFromBuffer.mockResolvedValue("DOCX content here") - - await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks) - - expect(mockedExtractTextFromBuffer).toHaveBeenCalledExactlyOnceWith( - source, - path.resolve(mockTask.cwd, "document.docx"), - ) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("DOCX content here")) - }) - - it("should handle unsupported binary formats", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - // Return empty array to indicate .exe is not supported - vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"]) - - await readFileTool.execute({ path: "program.exe" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Binary file")) - }) - - it("should handle extraction errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedExtractTextFromBuffer.mockRejectedValue(new Error("Extraction failed")) - - await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("text file processing", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should read text file with slice mode (default)", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - const content = "line 1\nline 2\nline 3" - mockedFsReadFile.mockResolvedValue(Buffer.from(content)) - mockedReadWithSlice.mockReturnValue({ - content: "1 | line 1\n2 | line 2\n3 | line 3", - returnedLines: 3, - totalLines: 3, - wasTruncated: false, - includedRanges: [[1, 3]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockedReadWithSlice).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("line 1")) - }) - - it("should read text file with offset and limit", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5")) - mockedReadWithSlice.mockReturnValue({ - content: "2 | line 2\n3 | line 3", - returnedLines: 2, - totalLines: 5, - wasTruncated: true, - includedRanges: [[2, 3]], - }) - - await readFileTool.execute( - { path: "test.ts", mode: "slice", offset: 2, limit: 2 }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based - }) - - it("should read text file with indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - const content = "class Foo {\n method() {\n return 42\n }\n}" - mockedFsReadFile.mockResolvedValue(Buffer.from(content)) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }", - returnedLines: 5, - totalLines: 5, - wasTruncated: false, - includedRanges: [[1, 5]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 3 }, - }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - content, - expect.objectContaining({ - anchorLine: 3, - }), - ) - }) - - it("should show truncation notice when content is truncated", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content...")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | truncated content", - returnedLines: 100, - totalLines: 5000, - wasTruncated: true, - includedRanges: [[1, 100]], - }) - - await readFileTool.execute({ path: "large.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("truncated")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) - }) - - it("should handle empty files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("")) - mockedReadWithSlice.mockReturnValue({ - content: "", - returnedLines: 0, - totalLines: 0, - wasTruncated: false, - includedRanges: [], - }) - - await readFileTool.execute({ path: "empty.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("empty")) - }) - }) - - describe("approval flow", () => { - it("should approve file read when user clicks yes", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false) - expect(mockTask.didRejectTool).toBe(false) - }) - - it("should deny file read when user clicks no", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.didRejectTool).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) - }) - - it("should include user feedback when provided with approval", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "yesButtonClicked", - text: "Please be careful with this file", - images: undefined, - }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) - expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") - }) - - it("should include user feedback when provided with denial", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "noButtonClicked", - text: "This file contains secrets", - images: undefined, - }) - - await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) - expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") - }) - }) - - describe("output structure", () => { - it("should include file path in output", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "src/app.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: src/app.ts")) - }) - - it("should track file context after successful read", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool") - }) - }) - - describe("error handling", () => { - it("should handle file read errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) - - await readFileTool.execute({ path: "nonexistent.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle stat errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockRejectedValue(new Error("Permission denied")) - - await readFileTool.execute({ path: "protected.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("getReadFileToolDescription", () => { - it("should return description with path when nativeArgs provided", () => { - const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - - expect(description).toBe("[read_file for 'src/app.ts']") - }) - - it("should return description with path when params provided", () => { - const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - - expect(description).toBe("[read_file for 'src/app.ts']") - }) - - it("should return description indicating missing path", () => { - const description = readFileTool.getReadFileToolDescription("read_file", {}) - - expect(description).toBe("[read_file with missing path]") - }) - }) - - describe("legacy format handling", () => { - it("should detect legacy format and use backward compatibility path", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("legacy content")) - - await readFileTool.execute({ files: [{ path: "legacy.ts" }] } as any, mockTask as any, callbacks) - - // The legacy path emits "File: " entries — proof the backward-compat branch ran. - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: legacy.ts")) - }) - - it("should return error when legacy files array is empty", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ files: [] } as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should handle multiple files in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file1 content")) - mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file2 content")) - - // Override readWithSlice to return content that reflects the actual file data - mockedReadWithSlice - .mockReturnValueOnce({ - content: "1 | file1 content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - .mockReturnValueOnce({ - content: "1 | file2 content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { files: [{ path: "file1.ts" }, { path: "file2.ts" }] } as any, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file1 content")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file2 content")) - }) - - it("should block rooignore-protected files in legacy format", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ files: [{ path: "secret.env" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) - // Consistent with the native path: a blocked file fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should deny legacy file read when user clicks no", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ files: [{ path: "protected.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.didRejectTool).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) - }) - - it("should handle directory path in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ files: [{ path: "src/utils" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) - // Consistent with the native path: a failed read fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle line ranges in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) - - await readFileTool.execute( - { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("2 | line2")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("4 | line4")) - }) - - it("should handle binary image files in legacy format with image support", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockResolvedValue({ - dataUrl: "data:image/png;base64,abc123", - buffer: Buffer.from("test"), - sizeInKB: 512, - sizeInMB: 0.5, - notice: "Image processed successfully", - }) - - await readFileTool.execute({ files: [{ path: "image.png" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("Image file - content processed"), - ) - }) - - it("should handle binary image validation failure in legacy format", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "size_limit", - notice: "Image too large", - }) - - await readFileTool.execute({ files: [{ path: "large.png" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Image too large")) - }) - - it("should handle unsupported binary files in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(false) - - await readFileTool.execute({ files: [{ path: "program.exe" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot read binary file")) - }) - - it("should handle file read errors in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) - - await readFileTool.execute({ files: [{ path: "missing.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("ENOENT")) - // Consistent with the native path: a failed read fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle user feedback on approval in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "yesButtonClicked", - text: "Read carefully", - images: undefined, - }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - - await readFileTool.execute({ files: [{ path: "test.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) - }) - - it("should handle user feedback on denial in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "noButtonClicked", - text: "Not allowed", - images: undefined, - }) - - await readFileTool.execute({ files: [{ path: "secret.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) - }) - - it("should handle truncation in legacy format when no line ranges", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 100, - totalLines: 5000, - wasTruncated: true, - includedRanges: [[1, 100]], - }) - - await readFileTool.execute({ files: [{ path: "large.ts" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File truncated")) - }) - - it("should track file context in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - - await readFileTool.execute({ files: [{ path: "tracked.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("tracked.ts", "read_tool") - }) - }) - - describe("handlePartial", () => { - it.each([{}, { path: "" }, undefined])( - "renders streamed native arguments before their path arrives: %j", - async (nativeArgs) => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - const streamedBlock = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs, - partial: true, - } - - // Streaming JSON is incomplete even though finalized native arguments require a path. - await Reflect.apply(readFileTool.handlePartial, readFileTool, [mockTask, streamedBlock]) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), - true, - ) - expect(mockedFsReadFile).not.toHaveBeenCalled() - expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromBuffer).not.toHaveBeenCalled() - }, - ) - - it("renders incomplete legacy arguments without a file entry or outside-workspace warning", async () => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - const block: ToolUse<"read_file"> = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs: { files: [], _legacyFormat: true }, - partial: true, - } - - // This reader-boundary double omits Task fields unrelated to file reading. - await readFileTool.handlePartial(mockTask as unknown as Task, block) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), - true, - ) - expect(mockedFsReadFile).not.toHaveBeenCalled() - expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromBuffer).not.toHaveBeenCalled() - }) - - it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( - "should preserve partial display for %s", - async (filePath) => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block: ToolUse<"read_file"> = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs: { path: filePath }, - partial: true, - } - - // This reader-boundary double omits Task fields unrelated to file reading. - await readFileTool.handlePartial(mockTask as unknown as Task, block) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), - true, - ) - }, - ) - - it("should handle partial display for legacy format", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: { files: [{ path: "legacy.ts" }] }, - partial: false, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("legacy.ts"), false) - }) - - it("should handle partial display with empty path", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: { path: "" }, - partial: true, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - - it("should handle partial display with no nativeArgs", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: undefined, - partial: true, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - - it.each(["Cancelled", "superseded"])("should gracefully handle %s ask rejection in partial", async (reason) => { - const mockTask = createMockTask() - mockTask.ask.mockRejectedValue(new Error(reason)) - - const block = { - nativeArgs: { path: "test.ts" }, - partial: true, - } - - // Should not throw - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - }) - - describe("processTextFile indentation mode edge cases", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should use offset as fallback when anchor_line is not provided in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "5 | line 5 content", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[5, 5]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - offset: 5, - } as any, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ anchorLine: 5 }), - ) - }) - - it("should default anchorLine to 1 when neither anchor_line nor offset in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | first line", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - } as any, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ anchorLine: 1 }), - ) - }) - - it("should show truncation notice in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | truncated block", - returnedLines: 50, - totalLines: 200, - wasTruncated: true, - includedRanges: [[1, 50]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 1 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File content truncated")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) - }) - - it("should include range information in indentation mode when not truncated", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "5 | function foo() { ... }", - returnedLines: 10, - totalLines: 100, - wasTruncated: false, - includedRanges: [[5, 14]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 5 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Included ranges: 5-14")) - }) - - it("should pass all indentation options to readWithIndentation", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { - anchor_line: 10, - max_levels: 2, - include_siblings: true, - include_header: false, - max_lines: 200, - }, - limit: 500, - }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - anchorLine: 10, - maxLevels: 2, - includeSiblings: true, - includeHeader: false, - limit: 500, - maxLines: 200, - }), - ) - }) - }) - - describe("slice mode edge cases", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should convert 1-based offset to 0-based for readWithSlice", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3")) - mockedReadWithSlice.mockReturnValue({ - content: "4 | line4", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[4, 4]], - }) - - await readFileTool.execute( - { path: "test.ts", mode: "slice", offset: 4, limit: 1 }, - mockTask as any, - callbacks, - ) - - // offset=4 (1-based) should become 3 (0-based) passed to readWithSlice - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 3, 1) - }) - - it("should use default offset of 1 when not specified", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Default offset=1 -> 0-based = 0 - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 0, expect.any(Number)) - }) - - it("should use default limit when not specified", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Should use DEFAULT_LINE_LIMIT (which is typically 2000) - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number)) - }) - - it("should show correct line range in truncation notice", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content")) - mockedReadWithSlice.mockReturnValue({ - content: "5 | partial", - returnedLines: 100, - totalLines: 500, - wasTruncated: true, - includedRanges: [[5, 104]], - }) - - await readFileTool.execute({ path: "test.ts", offset: 5, limit: 100 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Showing lines 5-104")) - }) - }) - - describe("getLineSnippet and getStartLine", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should show indentation mode snippet in approval message", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 42 }, - }, - mockTask as any, - callbacks, - ) - - // The approval message should contain the indentation mode info - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("indentation mode at line 42") - }) - - it("should show line range in approval when offset > 1", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts", offset: 10, limit: 50 }, mockTask as any, callbacks) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("lines 10-59") - expect(message.startLine).toBe(10) - }) - - it("should show up to N lines in approval when offset is default", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("up to") - expect(message.reason).toContain("lines") - expect(message.startLine).toBeUndefined() - }) - - it("should show indentation mode snippet with offset fallback", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - offset: 7, - } as any, - mockTask as any, - callbacks, - ) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("indentation mode at line 7") - }) - }) - - describe("provider state handling", () => { - it("should use default image limits when state is null", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - // Make providerRef return null - mockTask.providerRef.deref.mockReturnValue(null) - - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Should still work - uses default limits - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - - it("should use default limits when state has no image config", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({}), - }) - - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - }) - - describe("error handling edge cases", () => { - it("should handle unknown error types (non-Error)", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockRejectedValue("string error") - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) - }) - - it("should set didToolFailInCurrentTurn on rooignore block", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "blocked.ts" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should set didToolFailInCurrentTurn on directory read", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ path: "src/" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) -}) diff --git a/src/core/tools/file-reading/LegacyFileReader.ts b/src/core/tools/file-reading/LegacyFileReader.ts new file mode 100644 index 0000000000..a8dc79b3c1 --- /dev/null +++ b/src/core/tools/file-reading/LegacyFileReader.ts @@ -0,0 +1,201 @@ +import path from "path" +import type { FileHandle } from "fs/promises" +import { isBinaryFile } from "isbinaryfile" +import type { ClineSayTool, FileEntry, LineRange } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" +import { DEFAULT_LINE_LIMIT } from "./readFileConstants" +import { getReadablePath } from "../../../utils/path" +import { readWithSlice } from "../../../integrations/misc/indentation-reader" +import { ReadFileTarget } from "./ReadFileTarget" +import { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" +import type { FileResult } from "./types" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, +} from "../helpers/imageHelpers" + +/** Compatibility-only reader. Do not use it for advertised native batches. */ +export class LegacyFileReader { + async read(entries: FileEntry[], task: Task, onApproval?: (result: FileResult) => void): Promise { + const cancellation = new ReadFileCancellation(task) + const supportsImages = task.api.getModel().info.supportsImages ?? false + const results: string[] = [] + try { + for (const entry of entries) { + cancellation.throwIfCancelled() + const result = await this.readLegacyEntry(entry, task, supportsImages, onApproval) + cancellation.throwIfCancelled() + results.push(result) + } + } catch (error) { + if (!cancellation.isCancelled() && !(error instanceof ReadFileCancelledError)) throw error + return "" + } + return results.join("\n\n---\n\n") + } + + private async readLegacyEntry( + entry: FileEntry, + task: Task, + supportsImages: boolean, + onApproval?: (result: FileResult) => void, + ): Promise { + const cancellation = new ReadFileCancellation(task) + const relPath = entry.path + if (!task.rooIgnoreController?.validateAccess(relPath)) { + await cancellation.run(() => task.say("rooignore_error", relPath)) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${formatResponse.rooIgnoreError(relPath)}` + } + let target: ReadFileTarget + try { + target = await ReadFileTarget.resolve(path.resolve(task.cwd, relPath), cancellation) + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) throw error + return this.reportLegacyReadError(relPath, task, error) + } + if (!task.rooIgnoreController?.validateAccess(target.fullPath)) { + await cancellation.run(() => task.say("rooignore_error", relPath)) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${formatResponse.rooIgnoreError(relPath)}` + } + if (!(await this.approveLegacyFile(entry, task, target.fullPath, onApproval))) + return `File: ${relPath}\nStatus: Denied by user` + try { + return await target.withHandle(async (file, stats) => { + cancellation.throwIfCancelled() + if (stats.isDirectory()) { + return await this.reportLegacyReadError( + relPath, + task, + `Cannot read '${relPath}' because it is a directory.`, + ) + } + if (await cancellation.run(() => this.detectLegacyBinary(file))) { + return await this.readLegacyBinary(relPath, target.fullPath, task, supportsImages, file) + } + const content = this.formatLegacyText( + await cancellation.run(() => file.readFile("utf8")), + entry.lineRanges, + ) + return await this.trackLegacyResult(relPath, `File: ${relPath}\n${content}`, task) + }) + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) throw error + return this.reportLegacyReadError(relPath, task, error) + } + } + + private async approveLegacyFile( + entry: FileEntry, + task: Task, + fullPath: string, + onApproval?: (result: FileResult) => void, + ): Promise { + const cancellation = new ReadFileCancellation(task) + const isOutsideWorkspace = await cancellation.run(() => ReadFileTarget.isOutsideWorkspace(fullPath)) + const reason = entry.lineRanges?.map((range) => `(lines ${range.start}-${range.end})`).join(", ") + const message = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, fullPath), + isOutsideWorkspace, + content: fullPath, + reason: reason || undefined, + } satisfies ClineSayTool) + const { response, text, images } = await task.ask("tool", message, false) + onApproval?.({ + path: entry.path, + status: "cancelled", + approvalStatus: response === "yesButtonClicked" ? "approved" : "denied", + feedbackText: text, + feedbackImages: images, + }) + cancellation.throwIfCancelled() + if (text) await cancellation.run(() => task.say("user_feedback", text, images)) + if (response === "yesButtonClicked") return true + task.didRejectTool = true + return false + } + + private async detectLegacyBinary(file: FileHandle): Promise { + try { + const sample = Buffer.alloc(512) + const { bytesRead } = await file.read(sample, 0, sample.length, 0) + return await isBinaryFile(sample.subarray(0, bytesRead), bytesRead) + } catch { + return false + } + } + + private async readLegacyBinary( + relPath: string, + fullPath: string, + task: Task, + supportsImages: boolean, + file: FileHandle, + ): Promise { + const cancellation = new ReadFileCancellation(task) + if (!supportsImages || !isSupportedImageFormat(path.extname(relPath).toLowerCase())) { + return `File: ${relPath}\nError: Cannot read binary file` + } + const state = await cancellation.run(async () => task.providerRef.deref()?.getState()) + const validation = await cancellation.run(() => + validateImageForProcessing( + fullPath, + supportsImages, + state?.maxImageFileSize ?? DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + state?.maxTotalImageSize ?? DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, // Preserve the historical non-cumulative image validation contract. + file, + ), + ) + if (!validation.isValid) return `File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}` + await cancellation.run(() => processImageFile(fullPath, file, cancellation)) + return `File: ${relPath}\n[Image file - content processed for vision model]` + } + + private async reportLegacyReadError(relPath: string, task: Task, error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error) + await new ReadFileCancellation(task).run(() => task.say("error", `Error reading file ${relPath}: ${message}`)) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${message}` + } + + private async trackLegacyResult(relPath: string, content: string, task: Task): Promise { + try { + await new ReadFileCancellation(task).run(() => + task.fileContextTracker.trackFileContext(relPath, "read_tool"), + ) + return content + } catch (error) { + if (new ReadFileCancellation(task).isCancelled() || error instanceof ReadFileCancelledError) throw error + // Historically successful content was pushed before tracking, so retain it. + return `${content}\n\n---\n\n${await this.reportLegacyReadError(relPath, task, error)}` + } + } + + private formatLegacyText(content: string, ranges?: LineRange[]): string { + if (!ranges?.length) { + const result = readWithSlice(content, 0, DEFAULT_LINE_LIMIT) + const notice = result.wasTruncated + ? `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + : "" + return result.content + notice + } + const lines = content.split("\n") + return ranges + .flatMap((range) => { + const selected: string[] = [] + const start = Math.max(0, range.start - 1) + const end = Math.min(lines.length - 1, range.end - 1) + for (let index = start; index <= end; index++) selected.push(`${index + 1} | ${lines[index]}`) + return selected + }) + .join("\n") + } +} diff --git a/src/core/tools/file-reading/ModernFileReader.ts b/src/core/tools/file-reading/ModernFileReader.ts new file mode 100644 index 0000000000..6f65622fc7 --- /dev/null +++ b/src/core/tools/file-reading/ModernFileReader.ts @@ -0,0 +1,70 @@ +import path from "path" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { ReadFileAccess } from "./ReadFileAccess" +import { ReadFileContentReader } from "./ReadFileContentReader" +import { ReadFileErrorReporter } from "./ReadFileErrorReporter" +import { ReadFileTarget } from "./ReadFileTarget" +import { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" +import { validateReadFileNumbers } from "./readFileValidation" +import type { FileResult, ReadEntryOptions } from "./types" + +/** Shared modern reader. Approval belongs to each entry; callers own aggregation. */ +export class ModernFileReader { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly access: ReadFileAccess = new ReadFileAccess(), + private readonly contentReader: ReadFileContentReader = new ReadFileContentReader(errorReporter), + ) {} + + async read(params: ReadFileParams, task: Task, options: ReadEntryOptions = {}): Promise { + const cancellation = new ReadFileCancellation(task) + let approval: FileResult | undefined + const cancelled = (): FileResult => ({ + path: params.path, + status: "cancelled", + ...(approval + ? { + feedbackText: approval.feedbackText, + feedbackImages: approval.feedbackImages, + ...(approval.approvalStatus ? { approvalStatus: approval.approvalStatus } : {}), + } + : {}), + }) + try { + if (cancellation.isCancelled()) return cancelled() + const validationError = validateReadFileNumbers(params) + if (validationError) throw new Error(validationError) + const blocked = await cancellation.run(() => this.access.check(params.path, task)) + if (blocked) return blocked + const lexicalPath = path.resolve(task.cwd, params.path) + const target = await ReadFileTarget.resolve(lexicalPath, cancellation) + const fullPath = target.fullPath + const targetBlocked = await cancellation.run(() => this.access.check(fullPath, task, params.path)) + if (targetBlocked) return targetBlocked + approval = await this.access.requestApproval(params, task, options, fullPath) + if (approval.status === "approved") { + approval = { ...approval, approvalStatus: approval.status } + } + if (cancellation.isCancelled()) return cancelled() + if (approval.status !== "approved") return approval + const result = await this.contentReader.read(params, task, options, target) + return { + ...approval, + ...(cancellation.isCancelled() ? cancelled() : result), + } + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) return cancelled() + let result: FileResult + try { + result = await cancellation.run(() => this.errorReporter.report(params.path || "unknown", task, error)) + } catch (diagnosticError) { + if (cancellation.isCancelled() || diagnosticError instanceof ReadFileCancelledError) return cancelled() + throw diagnosticError + } + task.didToolFailInCurrentTurn = true + return approval ? { ...approval, ...result } : result + } + } +} diff --git a/src/core/tools/file-reading/ReadFileAccess.ts b/src/core/tools/file-reading/ReadFileAccess.ts new file mode 100644 index 0000000000..3ba5ba3e87 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileAccess.ts @@ -0,0 +1,108 @@ +import path from "path" +import type { ReadFileParams, ClineSayTool } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { AskIgnoredError } from "../../task/AskIgnoredError" +import { formatResponse } from "../../prompts/responses" +import { getReadablePath } from "../../../utils/path" +import { DEFAULT_LINE_LIMIT } from "./readFileConstants" +import { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" +import { ReadFileTarget } from "./ReadFileTarget" +import type { FileResult, ReadEntryOptions } from "./types" + +export class ReadFileAccess { + async check(relPath: string, task: Task, displayPath = relPath): Promise { + if (task.rooIgnoreController?.validateAccess(relPath)) return undefined + await task.say("rooignore_error", displayPath) + const error = formatResponse.rooIgnoreError(displayPath) + return { path: displayPath, status: "blocked", error, nativeContent: `File: ${displayPath}\nError: ${error}` } + } + + async requestApproval( + params: ReadFileParams, + task: Task, + options: ReadEntryOptions, + fullPath = path.resolve(task.cwd, params.path), + ): Promise { + const cancellation = new ReadFileCancellation(task) + const isOutsideWorkspace = await cancellation.run(() => ReadFileTarget.isOutsideWorkspace(fullPath)) + const message = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, fullPath), + content: fullPath, + isOutsideWorkspace, + reason: this.getLineSnippet(params), + startLine: this.getStartLine(params), + } satisfies ClineSayTool) + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + let approval: Awaited> + try { + approval = await task.ask("tool", message, false) + } catch (error) { + // Withdrawn approvals cancel batches even before task flags propagate. + if ( + cancellation.isCancelled() || + error instanceof ReadFileCancelledError || + (options.textOnly && error instanceof AskIgnoredError) + ) { + return { path: params.path, status: "cancelled" } + } + throw error + } + const { response, text, images } = approval + const approvalStatus: NonNullable = + response === "yesButtonClicked" ? "approved" : "denied" + if (cancellation.isCancelled()) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + try { + if (text) await cancellation.run(() => task.say("user_feedback", text, images)) + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + throw error + } + if (cancellation.isCancelled()) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + const feedback = { + path: params.path, + entry: params, + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + if (response === "yesButtonClicked") return { ...feedback, status: "approved" } + task.didRejectTool = true + return { ...feedback, status: "denied", nativeContent: `File: ${params.path}\nStatus: Denied by user` } + } + + private getStartLine(params: ReadFileParams): number | undefined { + if (params.mode === "indentation") return params.indentation?.anchor_line ?? params.offset ?? 1 + const offset = params.offset ?? 1 + return offset > 1 ? offset : undefined + } + + private getLineSnippet(params: ReadFileParams): string { + if (params.mode === "indentation") return `(indentation mode at line ${this.getStartLine(params)})` + const offset = params.offset ?? 1 + const limit = params.limit ?? DEFAULT_LINE_LIMIT + return offset > 1 ? `(lines ${offset}-${offset + limit - 1})` : `(up to ${limit} lines)` + } +} diff --git a/src/core/tools/file-reading/ReadFileContentReader.ts b/src/core/tools/file-reading/ReadFileContentReader.ts new file mode 100644 index 0000000000..ea2dbae173 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileContentReader.ts @@ -0,0 +1,93 @@ +import path from "path" +import { isBinaryFile } from "isbinaryfile" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + isSupportedImageFormat, +} from "../helpers/imageHelpers" +import { ReadFileErrorReporter } from "./ReadFileErrorReporter" +import { ReadFileTextReader } from "./strategies/ReadFileTextReader" +import { ReadFileBinaryReader } from "./strategies/ReadFileBinaryReader" +import { ReadFileImageReader } from "./strategies/ReadFileImageReader" +import { ReadFileDocumentReader } from "./strategies/ReadFileDocumentReader" +import { ReadFileTarget } from "./ReadFileTarget" +import type { ReadFileStrategy } from "./strategies/ReadFileStrategy" +import { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" +import type { FileResult, ReadEntryOptions, ReadFileContext } from "./types" + +export class ReadFileContentReader { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly strategies: readonly ReadFileStrategy[] = [ + new ReadFileTextReader(), + new ReadFileImageReader(errorReporter), + new ReadFileDocumentReader(errorReporter), + new ReadFileBinaryReader(), + ], + ) {} + + async read( + params: ReadFileParams, + task: Task, + options: ReadEntryOptions, + target?: ReadFileTarget, + ): Promise { + const cancellation = new ReadFileCancellation(task) + try { + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + const state = await cancellation.run(async () => task.providerRef.deref()?.getState()) + const imageLimits = { + maxImageFileSize: state?.maxImageFileSize ?? DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize: state?.maxTotalImageSize ?? DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } + const resolved = target ?? (await ReadFileTarget.resolve(path.resolve(task.cwd, params.path), cancellation)) + const result = await resolved.withHandle(async (file, stats) => { + const fullPath = resolved.fullPath + cancellation.throwIfCancelled() + if (stats.isDirectory()) { + return cancellation.run(() => + this.errorReporter.report( + params.path, + task, + `Cannot read '${params.path}' because it is a directory. Use list_files tool instead.`, + { prefix: "" }, + ), + ) + } + const extension = path.extname(params.path).toLowerCase() + if (options.textOnly && isSupportedImageFormat(extension)) { + return { + path: params.path, + status: "unsupported", + error: "Batch images are not supported; use read_file.", + } + } + const sample = Buffer.alloc(512) + const { bytesRead } = await cancellation.run(() => file.read(sample, 0, sample.length, 0)) + const binary = await cancellation.run(() => isBinaryFile(sample.subarray(0, bytesRead), bytesRead)) + const context: ReadFileContext = { + params, + task, + fullPath, + extension, + binary, + options, + file, + imageLimits, + } + const strategy = this.strategies.find((candidate) => candidate.canRead(context)) + if (!strategy) throw new Error(`No file-reading strategy supports '${params.path}'.`) + return await cancellation.run(() => strategy.read(context)) + }) + // Cleanup may itself settle after cancellation; never deliver its late payload. + return cancellation.isCancelled() ? { path: params.path, status: "cancelled" } : result + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) + return { path: params.path, status: "cancelled" } + return cancellation.run(() => this.errorReporter.report(params.path, task, error)) + } + } +} diff --git a/src/core/tools/file-reading/ReadFileErrorReporter.ts b/src/core/tools/file-reading/ReadFileErrorReporter.ts new file mode 100644 index 0000000000..6c42af1fa0 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileErrorReporter.ts @@ -0,0 +1,16 @@ +import type { Task } from "../../task/Task" +import type { FileResult, ReadErrorContext } from "./types" + +export class ReadFileErrorReporter { + async report(relPath: string, task: Task, error: unknown, context: ReadErrorContext = {}): Promise { + const message = error instanceof Error ? error.message : String(error) + const { prefix = "Error reading file: ", action = "Error reading file" } = context + await task.say("error", `${action} ${relPath}: ${message}`) + return { + path: relPath, + status: "error", + error: prefix + message, + nativeContent: `File: ${relPath}\nError: ${message}`, + } + } +} diff --git a/src/core/tools/file-reading/ReadFileResultFormatter.ts b/src/core/tools/file-reading/ReadFileResultFormatter.ts new file mode 100644 index 0000000000..be6b69713b --- /dev/null +++ b/src/core/tools/file-reading/ReadFileResultFormatter.ts @@ -0,0 +1,49 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { FileResult } from "./types" +import { formatResponse } from "../../prompts/responses" + +type ReadFileResponse = Array + +export class ReadFileResultFormatter { + format(result: FileResult, supportsImages: boolean): ReadFileResponse { + let feedbackMessage: string | undefined + const approvalStatus = result.approvalStatus ?? result.status + switch (approvalStatus) { + case "denied": + feedbackMessage = result.feedbackText + ? formatResponse.toolDeniedWithFeedback(result.feedbackText) + : formatResponse.toolDenied() + break + case "blocked": + case "error": + case "cancelled": + case "unsupported": + case "pending": + feedbackMessage = undefined + break + case "approved": + feedbackMessage = result.feedbackText + ? formatResponse.toolApprovedWithFeedback(result.feedbackText) + : undefined + break + default: { + const unhandledStatus: never = approvalStatus + throw new Error(`Unhandled read file status: ${unhandledStatus}`) + } + } + + const content = result.nativeContent + const imageBlocks = supportsImages ? this.getReadFileImageBlocks(result) : undefined + return [ + ...(feedbackMessage ? [{ type: "text" as const, text: feedbackMessage }] : []), + ...(imageBlocks ?? []), + ...(content ? [{ type: "text" as const, text: content }] : []), + ] + } + + private getReadFileImageBlocks(result: FileResult): Anthropic.ImageBlockParam[] { + const images = [...(result.feedbackImages ?? [])] + if (result.status === "approved" && result.imageDataUrl) images.push(result.imageDataUrl) + return formatResponse.imageBlocks(images) + } +} diff --git a/src/core/tools/file-reading/ReadFileTextProcessor.ts b/src/core/tools/file-reading/ReadFileTextProcessor.ts new file mode 100644 index 0000000000..e55a558aea --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTextProcessor.ts @@ -0,0 +1,57 @@ +/** Text slicing, structural reading, and continuation formatting for file-reading tools. */ +import type { ReadFileParams } from "@roo-code/types" +import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "./readFileConstants" + +export class ReadFileTextProcessor { + process(content: string, params: ReadFileParams): string { + const limit = params.limit ?? DEFAULT_LINE_LIMIT + if (params.mode === "indentation") { + const options = params.indentation + const result = readWithIndentation(content, { + anchorLine: options?.anchor_line ?? params.offset ?? 1, + maxLevels: options?.max_levels, + includeSiblings: options?.include_siblings, + includeHeader: options?.include_header, + limit, + maxLines: options?.max_lines, + }) + if (!result.includedRanges.length) return result.content + if (result.wasTruncated) { + const [start, end] = result.includedRanges[0] + return this.formatTruncatedText(result.content, start, end, result.totalLines, limit) + } + const ranges = result.includedRanges.map(([start, end]) => `${start}-${end}`).join(", ") + return `${result.content}\n\nIncluded ranges: ${ranges} (total: ${result.totalLines} lines)` + } + + const offset = params.offset ?? 1 + const result = readWithSlice(content, Math.max(0, offset - 1), limit) + if (result.content.startsWith("Error:")) return result.content + if (result.wasTruncated) { + return this.formatTruncatedText( + result.content, + offset, + offset + result.returnedLines - 1, + result.totalLines, + limit, + ) + } + return result.returnedLines === 0 ? "Note: File is empty" : result.content + } + + hasClippedLines(source: string, output: string): boolean { + const lines = source.split("\n") + return [...output.matchAll(/^\s*(\d+)\s*\|/gm)].some( + (match) => (lines[Number(match[1]) - 1]?.length ?? 0) > MAX_LINE_LENGTH, + ) + } + + private formatTruncatedText(content: string, start: number, end: number, total: number, limit: number): string { + return `IMPORTANT: File content truncated. + Status: Showing lines ${start}-${end} of ${total} total lines. + To read more: Use the read_file tool with offset=${end + 1} and limit=${limit}. +\t + ${content}` + } +} diff --git a/src/core/tools/file-reading/ReadFileTool.ts b/src/core/tools/file-reading/ReadFileTool.ts new file mode 100644 index 0000000000..03f238a28f --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTool.ts @@ -0,0 +1,113 @@ +/** Modern single-file reading, with a separate legacy conversation adapter. */ +import path from "path" +import type { ReadFileParams, ReadFileToolParams, ClineSayTool } from "@roo-code/types" +import { isLegacyReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import { getReadablePath } from "../../../utils/path" +import type { ToolUse } from "../../../shared/tools" +import { ReadFileResultFormatter } from "./ReadFileResultFormatter" +import { LegacyFileReader } from "./LegacyFileReader" +import { ModernFileReader } from "./ModernFileReader" +import { validateReadFileNumbers } from "./readFileValidation" +import { ReadFileCancellation } from "./ReadFileCancellation" +import { ReadFileCancelledError } from "./ReadFileCancelledError" +import type { FileResult, ReadEntryOptions } from "./types" +import { BaseTool, type ToolCallbacks } from "../BaseTool" + +export class ReadFileTool extends BaseTool<"read_file"> { + readonly name = "read_file" as const + + constructor( + private readonly resultFormatter: ReadFileResultFormatter = new ReadFileResultFormatter(), + private readonly fileReader: ModernFileReader = new ModernFileReader(), + private readonly legacyFileReader: LegacyFileReader = new LegacyFileReader(), + ) { + super() + } + + async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise { + const cancellation = new ReadFileCancellation(task) + if (cancellation.isCancelled()) return + if (isLegacyReadFileParams(params)) { + if (await this.reportMissingParameter(!params.files?.length, "files", task, callbacks)) return + const approvals: FileResult[] = [] + const result = await this.legacyFileReader.read(params.files, task, (approval) => approvals.push(approval)) + if (cancellation.isCancelled()) { + const feedback = approvals.filter( + (approval) => approval.feedbackText || approval.feedbackImages?.length, + ) + if (feedback.length) + callbacks.pushToolResult( + feedback.flatMap((approval) => + this.resultFormatter.format(approval, task.api.getModel().info.supportsImages ?? false), + ), + ) + return + } + callbacks.pushToolResult(result) + return + } + if (await this.reportMissingParameter(!params.path, "path", task, callbacks)) return + const validationError = validateReadFileNumbers(params) + if (validationError) { + callbacks.pushToolResult(`Error: ${validationError}`) + return + } + let result = await this.readEntry(params, task) + if (cancellation.isCancelled()) { + result = { ...result, status: "cancelled", nativeContent: undefined, imageDataUrl: undefined } + } + if (result.status === "cancelled" && !result.feedbackText && !result.feedbackImages?.length) return + if (result.status === "error" || result.status === "blocked") task.didToolFailInCurrentTurn = true + callbacks.pushToolResult(this.resultFormatter.format(result, task.api.getModel().info.supportsImages ?? false)) + } + + private async reportMissingParameter( + missing: boolean, + parameter: "path" | "files", + task: Task, + callbacks: ToolCallbacks, + ): Promise { + if (!missing) return false + task.consecutiveMistakeCount++ + task.recordToolError(this.name) + const cancellation = new ReadFileCancellation(task) + try { + const error = await cancellation.run(() => task.sayAndCreateMissingParamError(this.name, parameter)) + callbacks.pushToolResult(`Error: ${error}`) + } catch (error) { + if (!cancellation.isCancelled() && !(error instanceof ReadFileCancelledError)) throw error + } + return true + } + + /** Shared modern reader. Approval belongs to each entry; callers own aggregation. */ + async readEntry(params: ReadFileParams, task: Task, options: ReadEntryOptions = {}): Promise { + return this.fileReader.read(params, task, options) + } + + getReadFileToolDescription(blockName: string, params: { path?: string }): string + getReadFileToolDescription(blockName: string, params: ReadFileToolParams): string + getReadFileToolDescription(blockName: string, params: unknown): string { + if (params && typeof params === "object" && "path" in params && params.path) + return `[${blockName} for '${String(params.path)}']` + return `[${blockName} with missing path]` + } + + override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { + const args = block.nativeArgs + const filePath = args ? (isLegacyReadFileParams(args) ? (args.files[0]?.path ?? "") : (args.path ?? "")) : "" + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" + const message = JSON.stringify({ + tool: "readFile", + path: filePath ? getReadablePath(task.cwd, filePath) : "", + isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, + } satisfies ClineSayTool) + try { + await task.ask("tool", message, block.partial) + } catch { + /* Streaming asks may be replaced by a newer update. */ + } + } +} diff --git a/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts new file mode 100644 index 0000000000..0bcf25a4c0 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts @@ -0,0 +1,847 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { isBinaryFile } from "isbinaryfile" +import type { Task } from "../../../task/Task" +import { ModernFileReader } from "../ModernFileReader" +import { ReadFileContentReader } from "../ReadFileContentReader" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { ReadFileAccess } from "../ReadFileAccess" +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileTarget } from "../ReadFileTarget" +import { extractRawTextFromFile } from "../../../../integrations/misc/extract-text" +import { processImageFile, validateImageForProcessing } from "../../helpers/imageHelpers" +import type { FileResult, ReadEntryOptions } from "../types" + +// Instrument real filesystem calls; descriptors and their cleanup remain real. +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + realpath: vi.fn(actual.realpath), + lstat: vi.fn(actual.lstat), + open: vi.fn(actual.open), + } +}) + +vi.mock("isbinaryfile", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isBinaryFile: vi.fn(actual.isBinaryFile) } +}) + +vi.mock("../../../../integrations/misc/extract-text", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extractRawTextFromFile: vi.fn(actual.extractRawTextFromFile) } +}) + +vi.mock("../../helpers/imageHelpers", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateImageForProcessing: vi.fn(actual.validateImageForProcessing), + processImageFile: vi.fn(actual.processImageFile), + } +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + return { promise, resolve, reject } +} + +function createTask(cwd: string) { + const getState = vi.fn(async () => ({ maxImageFileSize: 5, maxTotalImageSize: 20 })) + return { + cwd, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + rooIgnoreController: { validateAccess: vi.fn(() => true) }, + fileContextTracker: { trackFileContext: vi.fn(async () => {}) }, + api: { getModel: () => ({ info: { supportsImages: true } }) }, + providerRef: { deref: () => ({ getState }) }, + } +} + +describe("ordinary read cancellation boundaries", () => { + let directory: string + let task: ReturnType + + function read(file = "source.txt", options: ReadEntryOptions = {}) { + // This public-boundary host double omits Task members unrelated to file reading. + return new ModernFileReader().read({ path: file }, task as unknown as Task, options) + } + + beforeEach(async () => { + vi.restoreAllMocks() + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.realpath).mockImplementation(actual.realpath) + vi.mocked(fs.lstat).mockImplementation(actual.lstat) + vi.mocked(fs.open).mockImplementation(actual.open) + directory = await actual.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-read-cancel-"))) + await fs.writeFile(path.join(directory, "source.txt"), "ordinary content") + task = createTask(directory) + vi.clearAllMocks() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("does no access UI, approval or filesystem work for a pre-aborted ordinary read", async () => { + task.abort = true + task.rooIgnoreController.validateAccess.mockReturnValue(false) + + expect(await read()).toEqual({ path: "source.txt", status: "cancelled" }) + expect(task.rooIgnoreController.validateAccess).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.realpath).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("stops canonical inspection after realpath settles for an abandoned read", async () => { + const canonical = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return canonical.promise + }) + const pending = read() + await started.promise + task.abandoned = true + canonical.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.lstat).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not revalidate the canonical path after cancellation during identity capture", async () => { + const identity = await fs.lstat(path.join(directory, "source.txt"), { bigint: true }) + const inspection = deferred() + const started = deferred() + vi.mocked(fs.lstat).mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + const pending = read() + await started.promise + task.abort = true + inspection.resolve(identity) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.realpath).toHaveBeenCalledOnce() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not check target access when final canonical revalidation finishes after abort", async () => { + const actual = await vi.importActual("fs/promises") + const revalidation = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementation((...args) => { + if (vi.mocked(fs.realpath).mock.calls.length === 2) { + started.resolve() + return revalidation.promise + } + return actual.realpath(...args) + }) + const pending = read() + await started.promise + task.abort = true + revalidation.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.rooIgnoreController.validateAccess).toHaveBeenCalledExactlyOnceWith("source.txt") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not start approval after target access checking resolves for an abandoned task", async () => { + const original = ReadFileAccess.prototype.check + const access = deferred() + const started = deferred() + vi.spyOn(ReadFileAccess.prototype, "check").mockImplementation(function (this: ReadFileAccess, relPath, host) { + if (path.isAbsolute(relPath)) { + started.resolve() + return access.promise + } + return original.call(this, relPath, host) + }) + const pending = read() + await started.promise + task.abandoned = true + access.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not ask when workspace classification resolves after abort", async () => { + const classification = deferred() + const started = deferred() + vi.spyOn(ReadFileTarget, "isOutsideWorkspace").mockImplementationOnce(() => { + started.resolve() + return classification.promise + }) + const pending = read() + await started.promise + task.abort = true + classification.resolve(false) + + expect(await pending).toEqual({ path: "source.txt", status: "cancelled" }) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("closes a real descriptor opened after abort without starting fstat or content I/O", async () => { + const actual = await vi.importActual("fs/promises") + const opened = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + opened.resolve(file) + await release.promise + return file + }) + const pending = read() + const file = await opened.promise + const stat = vi.spyOn(file, "stat") + const probe = vi.spyOn(file, "read") + const load = vi.spyOn(file, "readFile") + const close = vi.spyOn(file, "close") + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(stat).not.toHaveBeenCalled() + expect(probe).not.toHaveBeenCalled() + expect(load).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledOnce() + expect(file.fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not detect binary content after the descriptor probe settles for an abandoned read", async () => { + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const probe = file.read.bind(file) + vi.spyOn(file, "read").mockImplementationOnce(async (...readArgs) => { + const result = await probe(...readArgs) + started.resolve() + await release.promise + return result + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(isBinaryFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not start ordinary document extraction after descriptor bytes arrive following abort", async () => { + await fs.writeFile( + path.join(directory, "document.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source: ["document content"] }] }), + ) + vi.mocked(isBinaryFile).mockResolvedValueOnce(true) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const load = file.readFile.bind(file) + vi.spyOn(file, "readFile").mockImplementationOnce(async (...readArgs) => { + const bytes = await load(...readArgs) + started.resolve() + await release.promise + return bytes + }) + opened.resolve(file) + return file + }) + const pending = read("document.ipynb") + await started.promise + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(extractRawTextFromFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not start text-only extraction after document bytes arrive following abandonment", async () => { + await fs.writeFile( + path.join(directory, "document.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source: ["document content"] }] }), + ) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const load = file.readFile.bind(file) + vi.spyOn(file, "readFile").mockImplementationOnce(async (...readArgs) => { + const bytes = await load(...readArgs) + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read("document.ipynb", { textOnly: true }) + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(extractRawTextFromFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not validate or process an image after provider state arrives following abort", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const getState = task.providerRef.deref().getState + const state = deferred>>() + const started = deferred() + getState.mockImplementationOnce(() => { + started.resolve() + return state.promise + }) + const pending = read("image.png") + await started.promise + task.abort = true + state.resolve({ maxImageFileSize: 5, maxTotalImageSize: 20 }) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(validateImageForProcessing).not.toHaveBeenCalled() + expect(processImageFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards a processed image arriving after abandonment without registering context", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + task.abandoned = true + processing.resolve({ + dataUrl: "data:image/png;base64,bGF0ZQ==", + buffer: Buffer.from("late"), + sizeInKB: 1, + sizeInMB: 0.001, + notice: "late image", + }) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not publish an error when pending image processing rejects after abort", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + task.abort = true + processing.reject(new Error("late processing failure")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not load image bytes after processing metadata settles following abandonment", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const inspect = file.stat.bind(file) + const stat = vi.spyOn(file, "stat").mockImplementation(async (...statArgs) => { + const result = await inspect(...statArgs) + // B1 identity inspection, image validation, then processing metadata. + if (stat.mock.calls.length === 3) { + started.resolve() + await release.promise + } + return result + }) + vi.spyOn(file, "readFile") + opened.resolve(file) + return file + }) + const pending = read("image.png") + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect((await opened.promise).readFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not base64-encode image bytes arriving after abort", async () => { + const bytes = Buffer.from([137, 80, 78, 71, 0]) + await fs.writeFile(path.join(directory, "image.png"), bytes) + const encode = vi.spyOn(bytes, "toString") + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "readFile").mockImplementationOnce(async () => { + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read("image.png") + await started.promise + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(encode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards read content after abort during descriptor closing but retains existing approval feedback", async () => { + task.ask.mockResolvedValueOnce({ + response: "yesButtonClicked", + text: "Inspect only", + images: ["data:image/png;base64,aW1hZ2U="], + }) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + // Registration already started before cancellation; it cannot be rolled back. + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledOnce() + task.abort = true + release.resolve() + + const result = await pending + expect(result).toMatchObject({ + status: "cancelled", + feedbackText: "Inspect only", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + }) + expect(result.nativeContent).toBeUndefined() + expect(result.imageDataUrl).toBeUndefined() + expect((await opened.promise).fd).toBe(-1) + expect(task.say).toHaveBeenCalledExactlyOnceWith("user_feedback", "Inspect only", [ + "data:image/png;base64,aW1hZ2U=", + ]) + }) + + it("retains approval feedback if its already-started publication settles after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abort = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(fs.open).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not begin feedback publication for an approval arriving after abandonment", async () => { + const approval = deferred>>() + const started = deferred() + task.ask.mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read() + await started.promise + task.abandoned = true + approval.resolve({ response: "yesButtonClicked", text: "Inspect only" }) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("discards a late content-reader result at final delivery while retaining approval feedback", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const content = deferred() + const started = deferred() + vi.spyOn(ReadFileContentReader.prototype, "read").mockImplementationOnce(() => { + started.resolve() + return content.promise + }) + const pending = read() + await started.promise + task.abandoned = true + content.resolve({ path: "source.txt", status: "approved", nativeContent: "late content" }) + + const result = await pending + expect(result).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(result.nativeContent).toBeUndefined() + }) + + it("retains approval feedback when the content reader rejects after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const content = deferred() + const started = deferred() + vi.spyOn(ReadFileContentReader.prototype, "read").mockImplementationOnce(() => { + started.resolve() + return content.promise + }) + const pending = read() + await started.promise + task.abort = true + content.reject(new Error("late read failure")) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledExactlyOnceWith("user_feedback", "Inspect only", undefined) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("discards an error result if its already-started diagnostic settles after abort", async () => { + vi.mocked(fs.realpath).mockRejectedValueOnce(new Error("canonical lookup failed")) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file source.txt: canonical lookup failed", + ) + task.abort = true + publication.resolve(undefined) + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.error).toBeUndefined() + expect(result.nativeContent).toBeUndefined() + expect(task.say).toHaveBeenCalledOnce() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not open a target whose resolution completes after abort at the reusable content boundary", async () => { + const target = await ReadFileTarget.resolve(path.join(directory, "source.txt"), new ReadFileCancellation(task)) + vi.clearAllMocks() + const resolution = deferred() + const started = deferred() + vi.spyOn(ReadFileTarget, "resolve").mockImplementationOnce(() => { + started.resolve() + return resolution.promise + }) + // This public-boundary host double omits Task members unrelated to file reading. + const pending = new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + await started.promise + task.abort = true + resolution.resolve(target) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.open).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops canonical metadata work inside the reusable content reader after abandonment", async () => { + const canonical = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return canonical.promise + }) + // This public-boundary host double omits Task members unrelated to file reading. + const pending = new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + await started.promise + task.abandoned = true + canonical.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.lstat).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does no canonical I/O for a pre-aborted reusable content read", async () => { + task.abort = true + // This public-boundary host double omits Task members unrelated to file reading. + const result = await new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + + expect(result.status).toBe("cancelled") + expect(fs.realpath).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not classify cancellation during denied-feedback publication as a user rejection", async () => { + task.ask.mockResolvedValueOnce({ response: "noButtonClicked", text: "Do not read" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abandoned = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Do not read" }) + expect(task.didRejectTool).toBe(false) + expect(fs.open).not.toHaveBeenCalled() + }) + + it("retains received feedback when its pending publication rejects after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abort = true + publication.reject(new Error("late publication failure")) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledOnce() + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(fs.open).not.toHaveBeenCalled() + }) + + it("closes the real descriptor when pending fstat rejects after abandonment without error UI", async () => { + const actual = await vi.importActual("fs/promises") + const inspection = deferred>>() + const started = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "stat").mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + inspection.reject(new Error("late fstat failure")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect((await opened.promise).fd).toBe(-1) + expect(task.say).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps uncancelled image-processing rejection visible as a technical error", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + processing.reject(new Error("technical processing failure")) + + expect(await pending).toMatchObject({ + status: "error", + error: "Error reading image file: technical processing failure", + }) + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading image file image.png: technical processing failure", + ) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards content after abandonment during already-started context persistence", async () => { + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read() + await started.promise + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("source.txt", "read_tool") + task.abandoned = true + registration.resolve() + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.nativeContent).toBeUndefined() + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledOnce() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not publish tracking errors when already-started context persistence rejects after abort", async () => { + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read() + await started.promise + task.abort = true + registration.reject(new Error("late tracking failure")) + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.nativeContent).toBeUndefined() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not calculate clipping metadata after text-only tracking settles following abort", async () => { + const clipped = vi.spyOn(ReadFileTextProcessor.prototype, "hasClippedLines") + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read("source.txt", { textOnly: true }) + await started.promise + task.abort = true + registration.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(clipped).not.toHaveBeenCalled() + }) + + it("does not decode ordinary text bytes arriving after abandonment", async () => { + const bytes = Buffer.from("late ordinary content") + const decode = vi.spyOn(bytes, "toString") + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "readFile").mockImplementationOnce(async () => { + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(decode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("retains approval feedback if a technical read diagnostic completes after cancellation", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + vi.spyOn(ReadFileContentReader.prototype, "read").mockRejectedValueOnce(new Error("read failure")) + const publication = deferred() + const started = deferred() + task.say.mockImplementation((type) => { + if (type === "error") { + started.resolve() + return publication.promise + } + return Promise.resolve(undefined) + }) + const pending = read() + await started.promise + task.abort = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledTimes(2) + }) + + it("cancellation wins over a late denied approval result without dropping its feedback", async () => { + const approval = deferred() + const started = deferred() + vi.spyOn(ReadFileAccess.prototype, "requestApproval").mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read() + await started.promise + task.abandoned = true + approval.resolve({ + path: "source.txt", + status: "denied", + feedbackText: "Do not read", + nativeContent: "Denied by user", + }) + + const result = await pending + expect(result).toMatchObject({ status: "cancelled", feedbackText: "Do not read" }) + expect(result.nativeContent).toBeUndefined() + expect(fs.open).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts new file mode 100644 index 0000000000..ac4eb15730 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts @@ -0,0 +1,172 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import ExcelJS from "exceljs" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../../task/Task" +import { extractRawTextFromFile, extractTextFromFile } from "../../../../integrations/misc/extract-text" +import { ModernFileReader } from "../ModernFileReader" +import { MAX_LINE_LENGTH } from "../readFileConstants" +import type { ReadEntryOptions } from "../types" + +function createReadingTask(cwd: string) { + return { + providerRef: { deref: () => ({ getState: async () => ({}) }) }, + cwd, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + } +} + +describe("text-only document extraction and structural reading", () => { + let directory: string + let reader: ModernFileReader + let task: ReturnType + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-document-reading-")) + reader = new ModernFileReader() + task = createReadingTask(directory) + }) + + afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }) + }) + + async function writeNotebook(source: string[]) { + await fs.writeFile( + path.join(directory, "example.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source }] }), + ) + } + + function readDocument(params: ReadFileParams, options: ReadEntryOptions = { textOnly: true }) { + // This boundary double omits Task fields unrelated to file reading. + return reader.read(params, task as unknown as Task, options) + } + + it("preserves the function declaration and indentation without numbering extracted notebook text twice", async () => { + await writeNotebook([ + "def first():", + " value = 1", + " doubled = value * 2", + " return doubled", + "", + "def second():", + " return 9", + ]) + + const result = await readDocument({ + path: "example.ipynb", + mode: "indentation", + indentation: { anchor_line: 3, max_levels: 1, include_header: false }, + }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe( + "File: example.ipynb\n1 | def first():\n2 | value = 1\n3 | doubled = value * 2\n4 | return doubled\n5 | \n6 | def second():\n7 | return 9\n\nIncluded ranges: 1-7 (total: 7 lines)", + ) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("example.ipynb", "read_tool") + }) + + it("applies slice limits and continuation offsets to raw extracted notebook lines", async () => { + await writeNotebook(["first", " second", "third"]) + + const result = await readDocument({ path: "example.ipynb", offset: 2, limit: 1 }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("Status: Showing lines 2-2 of 3 total lines.") + expect(result.nativeContent).toContain("offset=3 and limit=1") + expect(result.nativeContent?.endsWith("2 | second")).toBe(true) + }) + + it("preserves source text resembling line-number prefixes and the existing formatted extraction API", async () => { + await writeNotebook(["12 | actual content", " 3 | indented content"]) + const file = path.join(directory, "example.ipynb") + + expect(await extractRawTextFromFile(file)).toBe("12 | actual content\n 3 | indented content") + expect(await extractTextFromFile(file)).toBe("1 | 12 | actual content\n2 | 3 | indented content\n") + const result = await readDocument({ path: "example.ipynb" }) + expect(result.nativeContent).toBe("File: example.ipynb\n1 | 12 | actual content\n2 | 3 | indented content") + }) + + it("reads XLSX sheet text without stripping legitimate number-like cell prefixes", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + sheet.addRow(["12 | actual content", "second column"]) + await workbook.xlsx.writeFile(path.join(directory, "example.xlsx")) + + const result = await readDocument({ path: "example.xlsx" }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe( + "File: example.xlsx\n1 | --- Sheet: Data ---\n2 | 12 | actual content\tsecond column", + ) + }) + + it("clips raw document lines without spending the source budget on extraction prefixes", async () => { + await writeNotebook(["x".repeat(MAX_LINE_LENGTH + 10)]) + + const result = await readDocument({ path: "example.ipynb" }) + + expect(result.nativeContent).toBe(`File: example.ipynb\n1 | ${"x".repeat(MAX_LINE_LENGTH - 3)}...`) + expect(result.longLinesTruncated).toBe(true) + }) + + it("keeps empty notebooks empty in both raw and existing formatted extraction APIs", async () => { + const file = path.join(directory, "example.ipynb") + await fs.writeFile(file, JSON.stringify({ cells: [] })) + + expect(await extractRawTextFromFile(file)).toBe("") + expect(await extractTextFromFile(file)).toBe("") + }) + + it("rejects an out-of-range ordinary document read instead of returning the entire large document", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2101; index++) sheet.addRow([`row ${index}`]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx", offset: 2500, limit: 1 }, {}) + + expect(result).toMatchObject({ + status: "error", + nativeContent: "File: large.xlsx\nError: offset 2499 is beyond file end (2102 lines)", + }) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("limits ordinary document output to the requested clipped line without returning unrelated rows", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2100; index++) sheet.addRow([`row ${index}`]) + sheet.addRow(["x".repeat(5000)]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx", offset: 2102, limit: 1 }, {}) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe(`File: large.xlsx\n2102 | ${"x".repeat(MAX_LINE_LENGTH - 3)}...`) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("large.xlsx", "read_tool") + }) + + it("applies the default line limit and continuation guidance to ordinary documents", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2101; index++) sheet.addRow([`row ${index}`]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx" }, {}) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("Status: Showing lines 1-2000 of 2102 total lines.") + expect(result.nativeContent).toContain("offset=2001 and limit=2000") + expect(result.nativeContent?.endsWith("2000 | row 1999")).toBe(true) + expect(result.nativeContent).not.toContain("row 2101") + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts new file mode 100644 index 0000000000..673157caf3 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts @@ -0,0 +1,314 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import type { Task } from "../../../task/Task" +import type { ToolCallbacks } from "../../BaseTool" +import { ReadFileTool } from "../ReadFileTool" +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import * as imageHelpers from "../../helpers/imageHelpers" + +const pngBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aO1sAAAAASUVORK5CYII=", + "base64", +) +// Minimal baseline grayscale JPEG: unit quantization, zero DC, end-of-block AC. +const jpegBytes = Buffer.concat([ + Buffer.from("ffd8ffdb004300", "hex"), + Buffer.alloc(64, 1), + Buffer.from("ffc0000b080001000101011100ffc4002600", "hex"), + Buffer.from([1, ...Array(15).fill(0), 0, 16, 1, ...Array(15).fill(0), 0]), + Buffer.from("ffda0008010100003f003fffd9", "hex"), +]) +const supportedImages = [ + { extension: ".png", mimeType: "image/png", bytes: pngBytes }, + { extension: ".jpg", mimeType: "image/jpeg", bytes: jpegBytes }, + { extension: ".jpeg", mimeType: "image/jpeg", bytes: jpegBytes }, + { + extension: ".gif", + mimeType: "image/gif", + bytes: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), + }, + { + extension: ".webp", + mimeType: "image/webp", + bytes: Buffer.from("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA", "base64"), + }, +] + +// A complete synthetic 1x1, 24-bit BMP, including its padded pixel row. +function createBitmap(): Buffer { + const bytes = Buffer.alloc(58) + bytes.write("BM") + bytes.writeUInt32LE(bytes.length, 2) + bytes.writeUInt32LE(54, 10) + bytes.writeUInt32LE(40, 14) + bytes.writeInt32LE(1, 18) + bytes.writeInt32LE(1, 22) + bytes.writeUInt16LE(1, 26) + bytes.writeUInt16LE(24, 28) + bytes.writeUInt32LE(4, 34) + bytes[54] = 255 + return bytes +} + +describe("public file-read image payload contract", () => { + let directory: string + + function createTask(supportsImages = true, maxImageFileSize?: number) { + return { + cwd: directory, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + api: { getModel: () => ({ info: { supportsImages } }) }, + providerRef: { deref: () => ({ getState: async () => ({ maxImageFileSize }) }) }, + } + } + + async function read(file: string, task = createTask()) { + const tool = new ReadFileTool() + const entry = vi.spyOn(tool, "readEntry") + const callbacks = { + pushToolResult: vi.fn(), + askApproval: vi.fn(), + handleError: vi.fn(), + } + // The host double omits Task members unrelated to reading; all readers and formatters are real. + await tool.execute({ path: file }, task as unknown as Task, callbacks) + return { result: await entry.mock.results[0].value, blocks: callbacks.pushToolResult.mock.calls[0][0], task } + } + + beforeEach(async () => { + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-image-reading-"))) + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("rejects a real BMP without adding unsupported image blocks or file context", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "image.bmp"), bytes) + + const { result, blocks, task } = await read("image.bmp") + + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(result.status).toBe("unsupported") + expect(result.imageDataUrl).toBeUndefined() + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not let a PNG-named alias hide an unsupported canonical BMP target", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "target.bmp"), bytes) + await fs.symlink(path.join(directory, "target.bmp"), path.join(directory, "alias.png")) + + const { result, blocks, task } = await read("alias.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not label an unknown canonical image format as PNG through an alias", async () => { + const bytes = Buffer.from("00000018667479706865696300000000686569636d696631", "hex") + await fs.writeFile(path.join(directory, "target.heic"), bytes) + await fs.symlink(path.join(directory, "target.heic"), path.join(directory, "alias.png")) + + const { result, blocks, task } = await read("alias.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: unknown") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("checks the actual processed data URL even when both filename and target are PNG", async () => { + await fs.writeFile(path.join(directory, "image.png"), pngBytes) + const processImage = imageHelpers.processImageFile + // Simulate a processor returning an encoding different from its requested filename. + vi.spyOn(imageHelpers, "processImageFile").mockImplementation(async (...args) => { + const image = await processImage(...args) + return { ...image, dataUrl: image.dataUrl.replace("image/png", "image/tiff") } + }) + + const { result, blocks, task } = await read("image.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/tiff") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(pngBytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(supportedImages)( + "preserves $extension bytes in final $mimeType blocks", + async ({ extension, mimeType, bytes }) => { + const file = `image${extension}` + await fs.writeFile(path.join(directory, file), bytes) + + const { result, blocks, task } = await read(file) + + expect(result.status).toBe("approved") + expect(blocks).toEqual([ + { type: "image", source: { type: "base64", media_type: mimeType, data: bytes.toString("base64") } }, + { type: "text", text: result.nativeContent }, + ]) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(file, "read_tool") + }, + ) + + // Binary signature fixtures suffice: unsupported formats must be rejected before decoding. + it.each([ + { extension: ".ico", mimeType: "image/x-icon", bytes: Buffer.from("000001000000", "hex") }, + { extension: ".tiff", mimeType: "image/tiff", bytes: Buffer.from("49492a0008000000000000000000", "hex") }, + { extension: ".tif", mimeType: "image/tiff", bytes: Buffer.from("4d4d002a00000008000000000000", "hex") }, + { + extension: ".avif", + mimeType: "image/avif", + bytes: Buffer.from("0000001466747970617669660000000061766966", "hex"), + }, + ])("rejects $extension without returning bytes or registering context", async ({ extension, mimeType, bytes }) => { + await fs.writeFile(path.join(directory, `image${extension}`), bytes) + + const { result, blocks, task } = await read(`image${extension}`) + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain(`Unsupported image MIME type: ${mimeType}`) + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each([ + { file: "image.png", bytes: pngBytes }, + { file: "image.bmp", bytes: createBitmap() }, + ])("preserves the non-image-capable model notice for $file", async ({ file, bytes }) => { + await fs.writeFile(path.join(directory, file), bytes) + + const { result, blocks, task } = await read(file, createTask(false)) + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBeUndefined() + expect(result.nativeContent).toContain("current model does not support images") + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(file, "read_tool") + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it.each([ + { file: "image.png", bytes: pngBytes }, + { file: "image.bmp", bytes: createBitmap() }, + ])("keeps text-only entry reads of $file unsupported and content-free", async ({ file, bytes }) => { + await fs.writeFile(path.join(directory, file), bytes) + const task = createTask() + // The host double intentionally omits Task members unrelated to file reading. + const result = await new ReadFileTool().readEntry({ path: file }, task as unknown as Task, { textOnly: true }) + + expect(result.status).toBe("unsupported") + expect(result.error).toBe("Batch images are not supported; use read_file.") + expect(result.imageDataUrl).toBeUndefined() + expect(new ReadFileResultFormatter().format(result, true)).toEqual([]) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps legacy BMP reads string-only without creating an image block", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "image.bmp"), bytes) + const task = createTask() + const pushToolResult = vi.fn() + // Legacy reads use the same intentionally partial host double as modern reads. + await new ReadFileTool().execute( + { files: [{ path: "image.bmp" }], _legacyFormat: true }, + task as unknown as Task, + { + pushToolResult, + askApproval: vi.fn(), + handleError: vi.fn(), + }, + ) + + expect(pushToolResult).toHaveBeenCalledWith( + "File: image.bmp\n[Image file - content processed for vision model]", + ) + expect(JSON.stringify(pushToolResult.mock.calls)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("labels supported alias reads with the canonical encoding rather than the alias extension", async () => { + await fs.writeFile(path.join(directory, "target.png"), pngBytes) + await fs.symlink(path.join(directory, "target.png"), path.join(directory, "alias.jpg")) + + const { result, blocks } = await read("alias.jpg") + + expect(result.status).toBe("approved") + expect(blocks).toEqual([ + { type: "image", source: { type: "base64", media_type: "image/png", data: pngBytes.toString("base64") } }, + { type: "text", text: result.nativeContent }, + ]) + }) + + it("rejects unsupported BMP before size validation can register it as a successful notice", async () => { + await fs.writeFile(path.join(directory, "image.bmp"), createBitmap()) + + const { result, blocks, task } = await read("image.bmp", createTask(true, 0)) + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("preserves approval feedback and supported feedback images for a supported file read", async () => { + await fs.writeFile(path.join(directory, "image.png"), pngBytes) + const task = createTask() + const imageDataUrl = `data:image/png;base64,${pngBytes.toString("base64")}` + task.ask.mockResolvedValue({ response: "yesButtonClicked", text: "Inspect only", images: [imageDataUrl] }) + + const { result, blocks } = await read("image.png", task) + + expect(result.feedbackText).toBe("Inspect only") + expect(task.say).toHaveBeenCalledWith("user_feedback", "Inspect only", [imageDataUrl]) + const image = { + type: "image", + source: { type: "base64", media_type: "image/png", data: pngBytes.toString("base64") }, + } + expect(blocks).toEqual([ + { type: "text", text: JSON.stringify({ status: "approved", feedback: "Inspect only" }) }, + image, + image, + { type: "text", text: result.nativeContent }, + ]) + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("keeps ordinary SVG source readable as text rather than creating an unsupported image block", async () => { + const source = '' + await fs.writeFile(path.join(directory, "image.svg"), source) + + const { result, blocks } = await read("image.svg") + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).toEqual([{ type: "text", text: `File: image.svg\n1 | ${source}` }]) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts b/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts new file mode 100644 index 0000000000..d97f7c6c68 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts @@ -0,0 +1,219 @@ +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import type { FileResult } from "../types" +import { formatResponse } from "../../../prompts/responses" + +const image = "data:image/png;base64,aW1hZ2U=" + +describe("ReadFileResultFormatter", () => { + let formatter: ReadFileResultFormatter + + beforeEach(() => { + formatter = new ReadFileResultFormatter() + }) + + it.each([ + { path: "a", status: "approved", nativeContent: "File: a\n1 | content" }, + { path: "a", status: "error", nativeContent: "File: a\nError: missing" }, + { path: "a", status: "blocked", nativeContent: "File: a\nError: blocked" }, + ] satisfies FileResult[])("returns $status file text unchanged", (file) => { + expect(formatter.format(file, false)).toEqual([{ type: "text", text: file.nativeContent }]) + }) + + it("returns an empty array when the file has no content or feedback", () => { + expect(formatter.format({ path: "a", status: "cancelled" }, false)).toEqual([]) + }) + + it("places feedback and images before the file text without losing content", () => { + const result = formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a\n1 | content", + feedbackText: "inspect carefully", + feedbackImages: [image], + imageDataUrl: image, + }, + true, + ) + expect(result.map((block) => block.type)).toEqual(["text", "image", "image", "text"]) + expect(result[0]).toMatchObject({ type: "text", text: expect.stringContaining("inspect carefully") }) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a\n1 | content" }) + }) + + it("keeps user feedback images before the image read from the file", () => { + const result = formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a", + feedbackImages: [image], + imageDataUrl: "data:image/jpeg;base64,ZmlsZQ==", + }, + true, + ) + expect(result.filter((block) => block.type === "image")).toMatchObject([ + { source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + { source: { type: "base64", media_type: "image/jpeg", data: "ZmlsZQ==" } }, + ]) + }) + + it("includes a file image without duplicating its text when there is no feedback", () => { + const result = formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", imageDataUrl: image }, + true, + ) + expect(result.map((block) => block.type)).toEqual(["image", "text"]) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + }) + + it("omits file images for text-only models", () => { + expect( + formatter.format({ path: "a", status: "approved", nativeContent: "File: a", imageDataUrl: image }, false), + ).toEqual([{ type: "text", text: "File: a" }]) + }) + + it("does not format image blocks for text-only models", () => { + const imageBlocks = vi.spyOn(formatResponse, "imageBlocks") + try { + expect( + formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a", + feedbackImages: [image], + imageDataUrl: image, + }, + false, + ), + ).toEqual([{ type: "text", text: "File: a" }]) + expect(imageBlocks).not.toHaveBeenCalled() + } finally { + imageBlocks.mockRestore() + } + }) + + it("retains approval feedback as text when there are no images", () => { + const result = formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", feedbackText: "inspect carefully" }, + false, + ) + expect(result).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback("inspect carefully") }, + { type: "text", text: "File: a" }, + ]) + }) + + it("retains denial feedback and omits images for text-only models", () => { + const result = formatter.format( + { + path: "a", + status: "denied", + nativeContent: "File: a\nStatus: Denied by user", + feedbackText: "private file", + feedbackImages: [image], + }, + false, + ) + expect(result).toEqual([ + { type: "text", text: formatResponse.toolDeniedWithFeedback("private file") }, + { type: "text", text: "File: a\nStatus: Denied by user" }, + ]) + }) + + it.each(["error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "does not label a %s result as approval or denial", + (status) => { + const file: FileResult = { path: "a", status, nativeContent: "File: a", feedbackText: "inspect carefully" } + expect(formatter.format(file, false)).toEqual([{ type: "text", text: "File: a" }]) + }, + ) + + it.each(["error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "does not invent feedback for a %s result without content", + (status) => { + expect(formatter.format({ path: "a", status, feedbackText: "inspect carefully" }, false)).toEqual([]) + }, + ) + + it.each(["denied", "error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "returns the %s response without including file images", + (status) => { + const file: FileResult = { + path: "a", + status, + nativeContent: `File: a\nStatus: ${status}`, + feedbackImages: [image], + imageDataUrl: "data:image/jpeg;base64,ZmlsZQ==", + } + const result = formatter.format(file, true) + expect(result.filter((block) => block.type === "image")).toMatchObject([ + { source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + ]) + expect(result.filter((block) => block.type === "text").map((block) => block.text)).toContain( + file.nativeContent, + ) + }, + ) + + it.each(["approved", "denied"] as const)("retains image-only user feedback for %s results", (status) => { + const result = formatter.format({ path: "a", status, nativeContent: "File: a", feedbackImages: [image] }, true) + expect(result.filter((block) => block.type === "image")).toHaveLength(1) + if (status === "denied") { + expect(result[0]).toEqual({ type: "text", text: formatResponse.toolDenied() }) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + } else { + expect(result.map((block) => block.type)).toEqual(["image", "text"]) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + } + }) + + it("omits image-only user feedback for text-only models", () => { + expect( + formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", feedbackImages: [image] }, + false, + ), + ).toEqual([{ type: "text", text: "File: a" }]) + }) + + it("includes a generic denial when the user rejects without feedback", () => { + expect(formatter.format({ path: "a", status: "denied", nativeContent: "File: a" }, false)).toEqual([ + { type: "text", text: formatResponse.toolDenied() }, + { type: "text", text: "File: a" }, + ]) + }) + + it("formats denial feedback from the file result alone", () => { + expect( + formatter.format( + { path: "a", status: "denied", nativeContent: "File: a", feedbackText: "private file" }, + false, + ), + ).toEqual([ + { type: "text", text: formatResponse.toolDeniedWithFeedback("private file") }, + { type: "text", text: "File: a" }, + ]) + }) + + it("returns feedback without an empty content block", () => { + expect(formatter.format({ path: "a", status: "approved", feedbackText: "inspect carefully" }, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback("inspect carefully") }, + ]) + }) + + it("returns image-only content without an empty text block", () => { + const result = formatter.format({ path: "a", status: "approved", imageDataUrl: image }, true) + expect(result).toMatchObject([ + { type: "image", source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + ]) + }) + + it("rejects an unrecognized runtime status instead of silently formatting a successful response", () => { + const malformedResult: unknown = { path: "a", status: "unrecognized", nativeContent: "File: a" } + // An injected JavaScript implementation can violate the TypeScript discriminator contract. + expect(() => Reflect.apply(formatter.format, formatter, [malformedResult, false])).toThrow( + "Unhandled read file status: unrecognized", + ) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts index ce72895e2a..23345ca114 100644 --- a/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts +++ b/src/core/tools/file-reading/__tests__/readFileSafety.integration.spec.ts @@ -8,7 +8,9 @@ import mammoth from "mammoth" import type { ClineSayTool, ReadFileToolParams } from "@roo-code/types" import type { Task } from "../../../task/Task" import { checkAutoApproval } from "../../../auto-approval" -import { readFileTool } from "../../ReadFileTool" +import { ReadFileTool } from "../ReadFileTool" + +const readFileTool = new ReadFileTool() import * as imageHelpers from "../../helpers/imageHelpers" import { RooIgnoreController } from "../../../ignore/RooIgnoreController" import { ReadFileCancellation } from "../ReadFileCancellation" @@ -417,7 +419,7 @@ describe("public file reader safety", () => { expect(result).not.toContain("unapproved cell") }) - it("notebook extraction uses approved bytes without changing historical line numbering", async () => { + it("notebook extraction uses approved bytes without double-numbering raw document text", async () => { const notebook = path.join(workspace, "book.ipynb") const replacement = path.join(directory, "other.ipynb") for (const [file, value] of [ @@ -432,7 +434,7 @@ describe("public file reader safety", () => { const result = await read({ path: "book.ipynb" }) - expect(result).toContain("1 | 1 | approved code") + expect(result).toContain("1 | approved code") expect(result).not.toContain("unapproved code") expect(fs.readFile).not.toHaveBeenCalled() }) @@ -447,7 +449,7 @@ describe("public file reader safety", () => { const result = await read({ path: "document.pdf" }) - expect(result).toContain("1 | 1 | approved PDF text") + expect(result).toContain("1 | approved PDF text") expect(result).not.toContain("unapproved PDF text") expect(fs.readFile).not.toHaveBeenCalled() }) @@ -462,7 +464,7 @@ describe("public file reader safety", () => { const result = await read({ path: "document.docx" }) - expect(result).toContain("1 | 1 | approved DOCX text") + expect(result).toContain("1 | approved DOCX text") expect(result).not.toContain("unapproved DOCX text") expect(fs.readFile).not.toHaveBeenCalled() }) @@ -533,7 +535,9 @@ describe("public file reader safety", () => { expect(manualApproval).not.toHaveBeenCalled() expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: false }) - expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: internal.txt\n1 | first line\n2 | second line") + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: internal.txt\n1 | first line\n2 | second line" }, + ]) expect(fs.readFile).not.toHaveBeenCalled() }) @@ -1251,7 +1255,11 @@ describe("public file reader safety", () => { callbacks, ) - expect(callbacks.pushToolResult).toHaveBeenCalledWith("File: pinned.txt\n2 | second\n3 | third") + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + legacy + ? "File: pinned.txt\n2 | second\n3 | third" + : [{ type: "text", text: "File: pinned.txt\n2 | second\n3 | third" }], + ) expect(fs.readFile).not.toHaveBeenCalled() expect(task.didToolFailInCurrentTurn).toBe(false) }, diff --git a/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts new file mode 100644 index 0000000000..a303705145 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts @@ -0,0 +1,415 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import * as vscode from "vscode" +import ExcelJS from "exceljs" +import type { ClineSayTool } from "@roo-code/types" +import type { Task } from "../../../task/Task" +import { checkAutoApproval } from "../../../auto-approval" +import { ReadFileTool } from "../ReadFileTool" +import { LegacyFileReader } from "../LegacyFileReader" +import { RooIgnoreController } from "../../../ignore/RooIgnoreController" + +// Call-through host instrumentation; filesystem behavior and approval policy remain real. +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, open: vi.fn(actual.open), readFile: vi.fn(actual.readFile), lstat: vi.fn(actual.lstat) } +}) + +vi.mock("vscode", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + RelativePattern: class { + constructor( + readonly base: string, + readonly pattern: string, + ) {} + }, + } +}) + +describe("file reader resolved-target permission boundary", () => { + let directory: string + let workspace: string + let external: string + let task: ReturnType + let manualApproval: ReturnType Promise>>>> + let approvalMessages: ClineSayTool[] + + function createTask() { + return { + cwd: workspace, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + api: { getModel: () => ({ info: { supportsImages: true } }) }, + providerRef: { deref: () => ({ getState: async () => ({}) }) }, + ask: vi.fn(async (ask, text) => { + approvalMessages.push(JSON.parse(text ?? "{}")) + const policy = await checkAutoApproval({ + cwd: workspace, + ask, + text, + state: { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + }, + }) + return policy.decision === "approve" ? { response: "yesButtonClicked" } : manualApproval() + }), + } + } + + function read(file: string, textOnly = true) { + // The host double intentionally omits Task members unrelated to file reading. + return new ReadFileTool().readEntry({ path: file }, task as unknown as Task, { textOnly }) + } + + async function replaceAfterOpening(filePath: string, replacement: string) { + const { open } = await vi.importActual("fs/promises") + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const file = await open(...args) + if (args[0] === filePath) { + await fs.rename(filePath, `${filePath}.original`) + await fs.symlink(replacement, filePath) + } + return file + }) + } + + beforeEach(async () => { + vi.clearAllMocks() + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.open).mockImplementation(actual.open) + vi.mocked(fs.readFile).mockImplementation(actual.readFile) + vi.mocked(fs.lstat).mockImplementation(actual.lstat) + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-symlink-reading-"))) + workspace = path.join(directory, "workspace") + external = path.join(directory, "external.txt") + await fs.mkdir(workspace) + await fs.writeFile(external, "synthetic external secret") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(workspace), name: "workspace", index: 0 }, + ]) + approvalMessages = [] + manualApproval = vi.fn().mockResolvedValue({ response: "noButtonClicked" }) + task = createTask() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("requires manual approval for an internal symlink to an external file under the real policy", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + + const result = await read("linked.txt") + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(result.status).toBe("denied") + expect(JSON.stringify(result)).not.toContain("synthetic external secret") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not disclose a new symlink target replacing the approved canonical filename while approval waits", async () => { + const unapproved = path.join(directory, "unapproved.txt") + await fs.writeFile(unapproved, "unapproved replacement secret") + await fs.symlink(external, path.join(workspace, "linked.txt")) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.symlink(unapproved, external) + return { response: "yesButtonClicked" } + }) + + const result = await read("linked.txt") + + expect(manualApproval).toHaveBeenCalledOnce() + expect(JSON.stringify(result)).not.toContain("unapproved replacement secret") + expect(result.status).toBe("error") + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("extracts notebook bytes from the verified file even when its canonical name is replaced after opening", async () => { + const notebook = path.join(workspace, "example.ipynb") + const unapproved = path.join(directory, "unapproved.ipynb") + await fs.writeFile(notebook, JSON.stringify({ cells: [{ cell_type: "code", source: ["approved source"] }] })) + await fs.writeFile( + unapproved, + JSON.stringify({ cells: [{ cell_type: "code", source: ["unapproved source"] }] }), + ) + await replaceAfterOpening(notebook, unapproved) + + const result = await read("example.ipynb") + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("approved source") + expect(result.nativeContent).not.toContain("unapproved source") + }) + + it("ordinary XLSX extraction consumes only the approved descriptor bytes after a filename replacement", async () => { + const spreadsheet = path.join(workspace, "example.xlsx") + const unapproved = path.join(directory, "unapproved.xlsx") + for (const [file, text] of [ + [spreadsheet, "original sheet"], + [unapproved, "unapproved sheet"], + ]) { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet("Data").addRow([text]) + await workbook.xlsx.writeFile(file) + } + await replaceAfterOpening(spreadsheet, unapproved) + + const result = await read("example.xlsx", false) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("original sheet") + expect(result.nativeContent).not.toContain("unapproved sheet") + }) + + it("returns image bytes only from the approved descriptor after a filename replacement", async () => { + const image = path.join(workspace, "image.png") + const replacement = path.join(directory, "unapproved.png") + const original = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aO1sAAAAASUVORK5CYII=", + "base64", + ) + await fs.writeFile(image, original) + await fs.writeFile(replacement, "unapproved image bytes") + await replaceAfterOpening(image, replacement) + + const result = await read("image.png", false) + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBe(`data:image/png;base64,${original.toString("base64")}`) + }) + + it("requires the same manual external-target approval in the legacy reader without changing its string contract", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + // The host double intentionally omits Task members unrelated to file reading. + const result = await new LegacyFileReader().read([{ path: "linked.txt" }], task as unknown as Task) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(result).toBe("File: linked.txt\nStatus: Denied by user") + expect(fs.open).not.toHaveBeenCalled() + }) + + it("keeps ordinary internal files auto-approved under the real policy", async () => { + await fs.writeFile(path.join(workspace, "normal.txt"), "normal workspace content") + + const result = await read("normal.txt") + + expect(manualApproval).not.toHaveBeenCalled() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: false, path: "normal.txt" }) + expect(result.nativeContent).toBe("File: normal.txt\n1 | normal workspace content") + }) + + it("does no content I/O while a direct external read awaits manual approval, then reads the approved file", async () => { + let release!: (value: Awaited>) => void + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + manualApproval.mockImplementation(() => { + entered() + return new Promise((resolve) => { + release = resolve + }) + }) + const pending = read(external) + await waiting + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + release({ response: "yesButtonClicked" }) + + const result = await pending + + expect(result.nativeContent).toContain("synthetic external secret") + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + }) + + it("reads the approved original target if the requested symlink is retargeted while manual approval waits", async () => { + const linked = path.join(workspace, "linked.txt") + const unapproved = path.join(directory, "unapproved.txt") + await fs.writeFile(unapproved, "unapproved retargeted secret") + await fs.symlink(external, linked) + let release!: (value: Awaited>) => void + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + manualApproval.mockImplementation(() => { + entered() + return new Promise((resolve) => { + release = resolve + }) + }) + const pending = read("linked.txt") + await waiting + expect(fs.open).not.toHaveBeenCalled() + await fs.unlink(linked) + await fs.symlink(unapproved, linked) + release({ response: "yesButtonClicked" }) + + const result = await pending + + expect(result.nativeContent).toBe("File: linked.txt\n1 | synthetic external secret") + expect(JSON.stringify(result)).not.toContain("unapproved retargeted secret") + }) + + it("keeps a symlink to an ignored internal target blocked without approval or content I/O", async () => { + await fs.writeFile(path.join(workspace, "private.txt"), "ignored synthetic secret") + await fs.writeFile(path.join(workspace, ".rooignore"), "private.txt\n") + await fs.symlink(path.join(workspace, "private.txt"), path.join(workspace, "linked.txt")) + const ignore = new RooIgnoreController(workspace) + try { + await ignore.initialize() + task.rooIgnoreController.validateAccess.mockImplementation((file: string) => ignore.validateAccess(file)) + vi.mocked(fs.readFile).mockClear() + + const result = await read("linked.txt") + + expect(result.status).toBe("blocked") + expect(result.path).toBe("linked.txt") + expect(result.nativeContent).toContain("File: linked.txt") + expect(task.say).toHaveBeenCalledExactlyOnceWith("rooignore_error", "linked.txt") + expect(JSON.stringify(result)).not.toContain(path.join(workspace, "private.txt")) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(JSON.stringify(result)).not.toContain("ignored synthetic secret") + } finally { + ignore.dispose() + } + }) + + it("rejects a replacement regular file before reading bytes and closes the mismatched descriptor", async () => { + let opened: fs.FileHandle | undefined + const { open } = await vi.importActual("fs/promises") + const readBytes = vi.fn() + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + opened = await open(...args) + vi.spyOn(opened, "read").mockImplementation(readBytes) + vi.spyOn(opened, "readFile").mockImplementation(readBytes) + return opened + }) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.writeFile(external, "unapproved regular replacement") + return { response: "yesButtonClicked" } + }) + + const result = await read(external) + + expect(result.status).toBe("error") + expect(result.error).toContain("File target changed after approval") + expect(readBytes).not.toHaveBeenCalled() + expect(opened?.fd).toBe(-1) + expect(JSON.stringify(result)).not.toContain("unapproved regular replacement") + }) + + it("legacy reading rejects a canonical filename replaced with a symlink during manual approval", async () => { + const replacement = path.join(directory, "replacement.txt") + await fs.writeFile(replacement, "unapproved legacy secret") + await fs.symlink(external, path.join(workspace, "linked.txt")) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.symlink(replacement, external) + return { response: "yesButtonClicked" } + }) + // The host double intentionally omits Task members unrelated to file reading. + const result = await new LegacyFileReader().read([{ path: "linked.txt" }], task as unknown as Task) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).toContain("File: linked.txt\nError:") + expect(result).not.toContain("unapproved legacy secret") + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("preserves missing-file diagnostics without opening or reading content", async () => { + const result = await read("missing.txt") + + expect(result.status).toBe("error") + expect(result.error).toContain("ENOENT") + expect(result.error).toContain("missing.txt") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps normal internal files auto-approved when the workspace folder itself is a symlink", async () => { + await fs.writeFile(path.join(workspace, "normal.txt"), "normal workspace content") + const alias = path.join(directory, "workspace-alias") + await fs.symlink(workspace, alias, "dir") + workspace = alias + task.cwd = alias + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(alias), name: "workspace", index: 0 }, + ]) + + const result = await read("normal.txt") + + expect(manualApproval).not.toHaveBeenCalled() + expect(result.nativeContent).toBe("File: normal.txt\n1 | normal workspace content") + expect(approvalMessages[0].isOutsideWorkspace).toBe(false) + }) + + it("fails closed if a canonical parent directory is retargeted between resolution and identity capture", async () => { + const parent = path.join(workspace, "nested") + const outside = path.join(directory, "outside") + await fs.mkdir(parent) + await fs.mkdir(outside) + await fs.writeFile(path.join(parent, "file.txt"), "original internal source") + await fs.writeFile(path.join(outside, "file.txt"), "unapproved parent-retargeted secret") + const { lstat } = await vi.importActual("fs/promises") + vi.spyOn(fs, "lstat").mockImplementation(async (...args) => { + if (args[0] === path.join(parent, "file.txt")) { + await fs.rename(parent, `${parent}.original`) + await fs.symlink(outside, parent, "dir") + } + return lstat(...args) + }) + + const result = await read("nested/file.txt") + + expect(result.status).toBe("error") + expect(JSON.stringify(result)).not.toContain("unapproved parent-retargeted secret") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("releases the approved descriptor when real notebook extraction fails", async () => { + await fs.writeFile(path.join(workspace, "broken.ipynb"), "invalid notebook JSON") + let opened: fs.FileHandle | undefined + const { open } = await vi.importActual("fs/promises") + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + opened = await open(...args) + return opened + }) + + const result = await read("broken.ipynb") + + expect(result.status).toBe("error") + expect(opened?.fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts b/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts new file mode 100644 index 0000000000..f077b4d41e --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts @@ -0,0 +1,63 @@ +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" + +describe("ReadFileTextProcessor", () => { + let processor: ReadFileTextProcessor + + beforeEach(() => { + processor = new ReadFileTextProcessor() + }) + + it("keeps 1-based slice offsets and continuation guidance", () => { + const output = processor.process("first\nsecond\nthird", { path: "a", offset: 2, limit: 1 }) + expect(output).toBe( + [ + "IMPORTANT: File content truncated.", + "\tStatus: Showing lines 2-2 of 3 total lines.", + "\tTo read more: Use the read_file tool with offset=3 and limit=1.", + "\t", + "\t2 | second", + ].join("\n"), + ) + expect(output).toContain("Showing lines 2-2 of 3 total lines") + expect(output).toContain("offset=3 and limit=1") + expect(output).toContain("2 | second") + expect(output).not.toContain("1 | first") + }) + + it("preserves the slice reader's existing representation of an empty source", () => { + expect(processor.process("", { path: "a" })).toBe("1 | ") + }) + + it("preserves an out-of-range error rather than reporting a nonempty file as empty", () => { + const output = processor.process("first\nsecond", { path: "a", offset: 50, limit: 1 }) + expect(output).toBe("Error: offset 49 is beyond file end (2 lines)") + }) + + it("uses the anchor and preserves structural range summaries", () => { + const output = processor.process("function sample() {\n return 1\n}\nconst tail = 2", { + path: "a", + mode: "indentation", + indentation: { anchor_line: 2, include_header: false }, + }) + expect(output).toContain("function sample()") + expect(output).toContain("return 1") + expect(output).toMatch(/Included ranges:|IMPORTANT: File content truncated/) + }) + + it("preserves indentation anchor errors without appending a range summary", () => { + const output = processor.process("first\nsecond", { + path: "a", + mode: "indentation", + indentation: { anchor_line: 50 }, + }) + expect(output).toBe("Error: anchor_line 50 is out of range (1-2)") + }) + + it("detects long-line clipping only for returned lines", () => { + const source = "short\n" + "x".repeat(3000) + expect(processor.hasClippedLines(source, "1 | short")).toBe(false) + expect(processor.hasClippedLines(source, "2 | xxx...")).toBe(true) + expect(processor.hasClippedLines(source, "Error: no lines returned")).toBe(false) + expect(processor.hasClippedLines(source, "3 | missing line")).toBe(false) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileTool.spec.ts b/src/core/tools/file-reading/__tests__/readFileTool.spec.ts new file mode 100644 index 0000000000..50e7739ffa --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileTool.spec.ts @@ -0,0 +1,3178 @@ +/** + * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support. + * + * These tests cover: + * - Input validation (missing path parameter) + * - RooIgnore blocking + * - Directory read error handling + * - Binary file handling (images, PDF, DOCX, unsupported) + * - Image memory limits + * - Approval flow (approve, deny, feedback) + * - Text file processing (slice and indentation modes) + * - Output structure formatting + */ + +import path from "path" +import { statSync } from "fs" +import { ReadFileCancellation } from "../ReadFileCancellation" +import type { ReadFileParams } from "@roo-code/types" +import { DEFAULT_LINE_LIMIT } from "../readFileConstants" +import type { Task } from "../../../task/Task" +import { AskIgnoredError } from "../../../task/AskIgnoredError" +import type { ToolUse } from "../../../../shared/tools" + +import { isBinaryFile } from "isbinaryfile" + +import { ReadFileTool } from "../ReadFileTool" +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { LegacyFileReader } from "../LegacyFileReader" +import { ModernFileReader } from "../ModernFileReader" +import { ReadFileContentReader } from "../ReadFileContentReader" +import { ReadFileTextReader } from "../strategies/ReadFileTextReader" +import { ReadFileAccess } from "../ReadFileAccess" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { ReadFileBinaryReader } from "../strategies/ReadFileBinaryReader" +import { ReadFileImageReader } from "../strategies/ReadFileImageReader" +import { ReadFileDocumentReader } from "../strategies/ReadFileDocumentReader" +import { ReadFileStrategy } from "../strategies/ReadFileStrategy" +import type { FileResult, ReadEntryOptions, ReadFileContext } from "../types" +import { formatResponse } from "../../../prompts/responses" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + validateImageForProcessing, + processImageFile, + isSupportedImageFormat, +} from "../../helpers/imageHelpers" +import { extractRawTextFromFile, getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { readWithIndentation, readWithSlice } from "../../../../integrations/misc/indentation-reader" + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("path", async () => { + const originalPath = await vi.importActual("path") + const platformPath = process.env.READ_FILE_TEST_PATH_STYLE === "win32" ? originalPath.win32 : originalPath + const mockedPath = { ...platformPath, resolve: vi.fn(platformPath.resolve) } + return { + ...mockedPath, + default: mockedPath, + } +}) + +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), + stat: vi.fn(), + realpath: vi.fn(async (file: string) => file), + lstat: vi.fn(async () => ({ dev: 1n, ino: 1n, isSymbolicLink: () => false })), + open: vi.fn(async (file: string) => { + const fs = await import("fs/promises") + return { + stat: async () => ({ ...(await fs.stat(file)), dev: 1n, ino: 1n }), + read: async () => ({ bytesRead: 0 }), + readFile: async (encoding?: BufferEncoding) => (encoding ? fs.readFile(file, encoding) : fs.readFile(file)), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +vi.mock("isbinaryfile") + +vi.mock("../../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn(), + extractRawTextFromFile: vi.fn(), + addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { + if (!text) return "" + const lines = text.split("\n") + return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n") + }), + getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), +})) + +vi.mock("../../../../integrations/misc/indentation-reader", () => ({ + readWithIndentation: vi.fn(), + readWithSlice: vi.fn(), +})) + +vi.mock("../../helpers/imageHelpers", async (importOriginal) => ({ + IMAGE_MIME_TYPES: (await importOriginal()).IMAGE_MIME_TYPES, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, + isSupportedImageFormat: vi.fn(), + validateImageForProcessing: vi.fn(), + processImageFile: vi.fn(), + ImageMemoryTracker: vi.fn().mockImplementation(function () { + return { + getTotalMemoryUsed: vi.fn().mockReturnValue(0), + addMemoryUsage: vi.fn(), + } + }), +})) + +vi.mock("../../../prompts/responses", () => ({ + formatResponse: { + toolDenied: vi.fn(() => "The user denied this operation."), + toolDeniedWithFeedback: vi.fn( + (feedback?: string) => + `The user denied this operation and responded with the message:\n\n${feedback}\n`, + ), + toolApprovedWithFeedback: vi.fn( + (feedback?: string) => + `The user approved this operation and responded with the message:\n\n${feedback}\n`, + ), + rooIgnoreError: vi.fn( + (filePath: string) => + `Access to ${filePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, + ), + toolResult: vi.fn((text: string, images?: string[]) => { + if (images && images.length > 0) { + return [ + { type: "text", text }, + ...images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }), + ] + } + return text + }), + imageBlocks: vi.fn((images?: string[]) => { + return images + ? images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }) + : [] + }), + }, +})) + +// Mock fs/promises +const fsPromises = await import("fs/promises") +const nativePath = await vi.importActual("path") +const nativeTextReader = await vi.importActual( + "../../../../integrations/misc/indentation-reader", +) +const mockedFsReadFile = vi.mocked(fsPromises.readFile) +const mockedFsStat = vi.mocked(fsPromises.stat) + +const mockedIsBinaryFile = vi.mocked(isBinaryFile) +const mockedExtractRawTextFromFile = vi.mocked(extractRawTextFromFile) +const mockedReadWithSlice = vi.mocked(readWithSlice) +const mockedReadWithIndentation = vi.mocked(readWithIndentation) +const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) +const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing) +const mockedProcessImageFile = vi.mocked(processImageFile) + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +interface MockTaskOptions { + supportsImages?: boolean + rooIgnoreAllowed?: boolean + maxImageFileSize?: number + maxTotalImageSize?: number +} + +function createMockTask(options: MockTaskOptions = {}) { + const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options + + const task = { + abort: false, + abandoned: false, + cwd: "/test/workspace", + api: { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages }, + }), + }, + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + didRejectTool: false, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), + say: vi.fn().mockResolvedValue(undefined), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed), + }, + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + maxImageFileSize, + maxTotalImageSize, + }), + }), + }, + } + // Reader host double supplies approval, provider, tracking and diagnostics only. + return task as typeof task & Task +} + +// Task's private members are irrelevant to these reader-only structural doubles. +function asTask(task: T): T & Task { + return task as T & Task +} + +function createMockCallbacks() { + return { + pushToolResult: vi.fn(), + askApproval: vi.fn(), + handleError: vi.fn(), + } +} + +function expectNoReadSideEffects(task: ReturnType) { + expect(task.ask).not.toHaveBeenCalled() + expect(task.rooIgnoreController.validateAccess).not.toHaveBeenCalled() + expect(path.resolve).not.toHaveBeenCalled() + expect(fsPromises.realpath).not.toHaveBeenCalled() + expect(fsPromises.lstat).not.toHaveBeenCalled() + expect(fsPromises.open).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() +} + +function textResultContaining(text: string) { + return expect.arrayContaining([expect.objectContaining({ type: "text", text: expect.stringContaining(text) })]) +} + +class TestReadStrategy extends ReadFileStrategy { + canRead = vi.fn().mockReturnValue(false) + read = vi.fn() +} + +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ReadFileTool", () => { + let readFileTool: ReadFileTool + beforeEach(() => { + readFileTool = new ReadFileTool() + vi.clearAllMocks() + vi.mocked(path.resolve).mockImplementation( + process.env.READ_FILE_TEST_PATH_STYLE === "win32" ? path.win32.resolve : nativePath.resolve, + ) + + // Default mock implementations + mockedFsStat.mockResolvedValue( + Object.assign(statSync(__filename, { bigint: true }), { isDirectory: () => false }), + ) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | test content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + }) + + it("reuses the injected formatter for successive reads", async () => { + const formatter = new ReadFileResultFormatter() + const formatted = [{ type: "text" as const, text: "formatted file response" }] + const format = vi.spyOn(formatter, "format").mockReturnValue(formatted) + const tool = new ReadFileTool(formatter) + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(format).toHaveBeenCalledTimes(2) + expect(format).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ path: "first.ts", status: "approved" }), + true, + ) + expect(format).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ path: "second.ts", status: "approved" }), + true, + ) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, formatted) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, formatted) + }) + + it("reuses the injected text processor for successive reads", async () => { + const processor = new ReadFileTextProcessor() + const process = vi.spyOn(processor, "process").mockReturnValue("1 | processed content") + const contentReader = new ReadFileContentReader(undefined, [new ReadFileTextReader(processor)]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, contentReader)) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(process).toHaveBeenCalledTimes(2) + expect(process).toHaveBeenNthCalledWith(1, "test content", { path: "first.ts" }) + expect(process).toHaveBeenNthCalledWith(2, "test content", { path: "second.ts" }) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, [ + { type: "text", text: "File: first.ts\n1 | processed content" }, + ]) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, [ + { type: "text", text: "File: second.ts\n1 | processed content" }, + ]) + }) + + it("uses the injected text processor to detect clipped lines in text-only reads", async () => { + const processor = new ReadFileTextProcessor() + vi.spyOn(processor, "process").mockReturnValue("1 | clipped content") + const hasClippedLines = vi.spyOn(processor, "hasClippedLines").mockReturnValue(true) + const contentReader = new ReadFileContentReader(undefined, [new ReadFileTextReader(processor)]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, contentReader)) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await tool.readEntry({ path: "test.ts" }, mockTask, { textOnly: true }) + + expect(hasClippedLines).toHaveBeenCalledExactlyOnceWith("test content", "1 | clipped content") + expect(result).toMatchObject({ status: "approved", longLinesTruncated: true }) + }) + + it("delegates readEntry parameters and options without changing the result", async () => { + const reader = new ModernFileReader() + const result: FileResult = { path: "test.ts", status: "approved", longLinesTruncated: true } + const read = vi.spyOn(reader, "read").mockResolvedValue(result) + const tool = new ReadFileTool(undefined, reader) + const params = { path: "test.ts", offset: 2, limit: 1 } + const options: ReadEntryOptions = { textOnly: true } + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await tool.readEntry(params, task, options)).toBe(result) + expect(read).toHaveBeenCalledExactlyOnceWith(params, task, options) + expect(mockTask.ask).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "discards an injected reader's late content after %s while retaining approval feedback", + async (flag) => { + const mockTask = createMockTask() + const reader = new ModernFileReader() + vi.spyOn(reader, "read").mockImplementationOnce(async () => { + mockTask[flag] = true + return { + path: "late.ts", + status: "approved", + approvalStatus: "approved", + feedbackText: "Inspect only", + nativeContent: "late secret", + imageDataUrl: "data:image/png;base64,c2VjcmV0", + } + }) + const callbacks = createMockCallbacks() + + await new ReadFileTool(undefined, reader).execute({ path: "late.ts" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: formatResponse.toolApprovedWithFeedback("Inspect only") }, + ]) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }, + ) + + it.each( + (["abort", "abandoned"] as const).flatMap((flag) => + [false, true].flatMap((reject) => [false, true].map((legacy) => ({ flag, reject, legacy }))), + ), + )( + "does not deliver a late missing-parameter diagnostic (flag=$flag, reject=$reject, legacy=$legacy)", + async ({ flag, reject, legacy }) => { + const mockTask = createMockTask() + mockTask.sayAndCreateMissingParamError.mockImplementationOnce(async () => { + mockTask[flag] = true + if (reject) throw new Error("late diagnostic rejection") + return "missing parameter" + }) + const callbacks = createMockCallbacks() + + await readFileTool.execute(legacy ? { _legacyFormat: true, files: [] } : { path: "" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }, + ) + + it("rejects a zero limit at readEntry before approval or filesystem access", async () => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("one\ntwo\nthree\nfour")) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", limit: 0 }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "error", error: expect.stringContaining("limit") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([-1, 1.5, NaN, Infinity, -Infinity, "1", true, [], {}])( + "rejects invalid runtime limit %j at readEntry", + async (limit) => { + const mockTask = createMockTask() + const params = { path: "test.ts" } + // Deliberately inject values that untyped runtime callers can supply. + Object.defineProperty(params, "limit", { value: limit }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry(params, mockTask) + + expect(result).toMatchObject({ + status: "error", + error: expect.stringContaining("limit must be a positive integer"), + }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }, + ) + + it("rejects an unused indentation max_lines of zero in slice-mode document reads", async () => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { path: "test.ipynb", mode: "slice", indentation: { max_lines: 0 } }, + mockTask, + ) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("max_lines") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it("rejects a fractional offset at readEntry without clamping it", async () => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", offset: 1.5 }, mockTask) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("offset must be a 1-indexed") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([ + { indentation: { anchor_line: 0 }, diagnostic: "anchor_line must be a 1-indexed" }, + { indentation: { max_levels: -1 }, diagnostic: "max_levels must be a nonnegative integer" }, + ])("rejects malformed unused indentation knobs: $diagnostic", async ({ indentation, diagnostic }) => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", indentation }, mockTask) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining(diagnostic) }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each( + ["offset", "anchor_line", "max_levels", "max_lines"].flatMap((name) => + [-1, 1.5, NaN, Infinity, "1", true, [], {}].map((value) => ({ name, value })), + ), + )("validates supplied $name=$value independently of reading mode", async ({ name, value }) => { + const mockTask = createMockTask() + const indentation = {} + const params = { path: "test.ipynb", indentation } + // Deliberately inject values that untyped runtime callers can supply. + Object.defineProperty(name === "offset" ? params : indentation, name, { value }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry(params, mockTask, { textOnly: true }) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining(name) }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([false, true])("retains defaults/nulls and one-line slice reads (textOnly=%s)", async (textOnly) => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("one\ntwo")) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + const params = { path: "test.ts" } + Object.defineProperties(params, { + offset: { value: null }, + limit: { value: null }, + indentation: { value: { anchor_line: null, max_levels: null, max_lines: null } }, + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + const defaults = await readFileTool.readEntry({ path: "test.ts" }, task, { textOnly }) + const nulls = await readFileTool.readEntry(params, task, { textOnly }) + const singleLine = await readFileTool.readEntry({ path: "test.ts", offset: 1, limit: 1 }, task, { textOnly }) + + expect(defaults).toMatchObject({ + status: "approved", + approvalStatus: "approved", + nativeContent: "File: test.ts\n1 | one\n2 | two", + }) + expect(nulls.nativeContent).toBe(defaults.nativeContent) + expect(singleLine).toMatchObject({ status: "approved", approvalStatus: "approved" }) + expect(singleLine.nativeContent).toContain("1 | one") + expect(singleLine.nativeContent).not.toContain("2 | two") + }) + + it("allows one-line indentation limits and zero unlimited depth", async () => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("function example() {\n return true\n}")) + mockedReadWithIndentation.mockImplementation(nativeTextReader.readWithIndentation) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { + path: "test.ts", + mode: "indentation", + limit: 1, + indentation: { anchor_line: 2, max_levels: 0, max_lines: 1 }, + }, + mockTask, + { textOnly: true }, + ) + + expect(result).toMatchObject({ status: "approved", approvalStatus: "approved" }) + expect(result.nativeContent).toContain("2 | return true") + expect(result.nativeContent).not.toContain("1 | function example()") + }) + + it.each(["slice", "indentation"] as const)( + "rejects a top-level zero limit despite a valid hard cap in %s mode", + async (mode) => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { path: "test.ipynb", mode, limit: 0, indentation: { anchor_line: 1, max_lines: 1 } }, + mockTask, + ) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("limit") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }, + ) + + it("keeps missing-path adapter diagnostics ahead of numeric validation", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "", limit: 0 }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith("Error: Missing required parameter: path") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledExactlyOnceWith("read_file", "path") + expect(mockTask.recordToolError).toHaveBeenCalledExactlyOnceWith("read_file") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expectNoReadSideEffects(mockTask) + }) + + it("reuses the injected modern reader for successive tool calls", async () => { + const reader = new ModernFileReader() + const firstResult: FileResult = { path: "first.ts", status: "approved", nativeContent: "first content" } + const secondResult: FileResult = { path: "second.ts", status: "approved", nativeContent: "second content" } + const read = vi.spyOn(reader, "read").mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult) + const tool = new ReadFileTool(undefined, reader) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(read).toHaveBeenCalledTimes(2) + expect(read).toHaveBeenNthCalledWith(1, { path: "first.ts" }, task, {}) + expect(read).toHaveBeenNthCalledWith(2, { path: "second.ts" }, task, {}) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, [{ type: "text", text: "first content" }]) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, [{ type: "text", text: "second content" }]) + }) + + it.each([ + { status: "approved", failed: false }, + { status: "denied", failed: false }, + { status: "blocked", failed: true }, + { status: "error", failed: true }, + { status: "pending", failed: false }, + { status: "cancelled", failed: false }, + { status: "unsupported", failed: false }, + ] as const)( + "formats the delegated $status result and preserves the tool failure flag", + async ({ status, failed }) => { + const result: FileResult = { path: "test.ts", status, nativeContent: "reader result" } + const reader = new ModernFileReader() + vi.spyOn(reader, "read").mockResolvedValue(result) + const formatter = new ReadFileResultFormatter() + const formatted = [{ type: "text" as const, text: "formatted result" }] + const format = vi.spyOn(formatter, "format").mockReturnValue(formatted) + const tool = new ReadFileTool(formatter, reader) + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await tool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(failed) + if (status === "cancelled") { + expect(format).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + return + } + expect(format).toHaveBeenCalledExactlyOnceWith(result, true) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith(formatted) + }, + ) + + describe("ModernFileReader composition", () => { + it("reports pre-read failures with an unknown path when no usable path is available", async () => { + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockRejectedValue(new Error("Access check failed")) + const reader = new ModernFileReader(undefined, access) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "" }, mockTask) + + expect(result).toEqual({ + path: "unknown", + status: "error", + error: "Error reading file: Access check failed", + nativeContent: "File: unknown\nError: Access check failed", + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file unknown: Access check failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("reports ordinary approval failures as errors rather than silently cancelling", async () => { + const mockTask = createMockTask() + mockTask.ask.mockRejectedValue(new Error("Approval ask failed")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask) + + expect(result).toEqual({ + path: "test.ts", + status: "error", + error: "Error reading file: Approval ask failed", + nativeContent: "File: test.ts\nError: Approval ask failed", + }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file test.ts: Approval ask failed", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("treats an access failure after cancellation as cancellation rather than a tool error", async () => { + const access = new ReadFileAccess() + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + vi.spyOn(access, "check").mockImplementationOnce(async () => { + mockTask.abort = true + throw new Error("Access check interrupted") + }) + const errorReporter = new ReadFileErrorReporter() + const report = vi.spyOn(errorReporter, "report") + const reader = new ModernFileReader(errorReporter, access) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(report).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it.each(["blocked", "denied"] as const)("does not read content after %s", async (status) => { + const access = new ReadFileAccess() + const stopped: FileResult = { path: "test.ts", status } + const check = vi.spyOn(access, "check").mockResolvedValue(status === "blocked" ? stopped : undefined) + const approval = vi.spyOn(access, "requestApproval").mockResolvedValue(stopped) + const contentReader = new ReadFileContentReader() + const readContent = vi.spyOn(contentReader, "read") + const reader = new ModernFileReader(undefined, access, contentReader) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read({ path: "test.ts" }, task)).toBe(stopped) + expect(check).toHaveBeenNthCalledWith(1, "test.ts", task) + if (status !== "blocked") { + expect(check).toHaveBeenNthCalledWith(2, path.resolve(task.cwd, "test.ts"), task, "test.ts") + } + expect(approval).toHaveBeenCalledTimes(status === "blocked" ? 0 : 1) + expect(readContent).not.toHaveBeenCalled() + }) + + it.each(["approved", "error", "cancelled"] as const)( + "preserves approval feedback when content reading returns %s", + async (status) => { + const params = { path: "test.ts" } + const options: ReadEntryOptions = { textOnly: true } + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockResolvedValue(undefined) + vi.spyOn(access, "requestApproval").mockResolvedValue({ + path: params.path, + status: "approved", + entry: params, + feedbackText: "inspect carefully", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + }) + const contentReader = new ReadFileContentReader() + const readContent = vi.spyOn(contentReader, "read").mockResolvedValue({ + path: params.path, + status, + nativeContent: "content result", + }) + const reader = new ModernFileReader(undefined, access, contentReader) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read(params, task, options)).toEqual({ + path: params.path, + status, + approvalStatus: "approved", + entry: params, + feedbackText: "inspect carefully", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + nativeContent: "content result", + }) + expect(readContent).toHaveBeenCalledExactlyOnceWith( + params, + task, + options, + expect.objectContaining({ fullPath: path.resolve(task.cwd, params.path) }), + ) + }, + ) + + it("retains approval feedback when the content-reader boundary rejects rather than returning a result", async () => { + const instruction = "Inspect without changing files" + const mockTask = createMockTask() + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: instruction, images: undefined }) + const contentReader = new ReadFileContentReader() + vi.spyOn(contentReader, "read").mockRejectedValueOnce(new Error("Reader operation failed")) + const reader = new ModernFileReader(undefined, undefined, contentReader) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "source.ts" }, mockTask) + + expect(result).toMatchObject({ + status: "error", + approvalStatus: "approved", + feedbackText: instruction, + }) + expect(new ReadFileResultFormatter().format(result, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + { type: "text", text: "File: source.ts\nError: Reader operation failed" }, + ]) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("uses the injected error reporter when access checking fails", async () => { + const error = new Error("Access check failed") + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockRejectedValue(error) + const errorReporter = new ReadFileErrorReporter() + const result: FileResult = { path: "test.ts", status: "error", error: error.message } + const report = vi.spyOn(errorReporter, "report").mockResolvedValue(result) + const reader = new ModernFileReader(errorReporter, access) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read({ path: "test.ts" }, task)).toBe(result) + expect(report).toHaveBeenCalledExactlyOnceWith("test.ts", task, error) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("ReadFileContentReader routing", () => { + it("describes extensionless binary files without loading their payload", async () => { + const mockTask = createMockTask() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "binary" }, mockTask) + + expect(result).toMatchObject({ + path: "binary", + status: "approved", + notice: "Binary file format: bin", + nativeContent: "File: binary\nBinary file (bin) - content not displayed", + }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + }) + + it("reports empty extracted documents without inventing a line range", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockResolvedValueOnce("") + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "document.pdf" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: document.pdf\nNote: File is empty" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "document.pdf", + "read_tool", + ) + }) + + it.each([ + { path: "source.ts", binary: false, textOnly: false, expected: "text" }, + { path: "image.png", binary: true, textOnly: false, expected: "image" }, + { path: "document.pdf", binary: true, textOnly: false, expected: "document" }, + { path: "document.docx", binary: true, textOnly: false, expected: "document" }, + { path: "notebook.ipynb", binary: true, textOnly: false, expected: "document" }, + { path: "archive.bin", binary: true, textOnly: false, expected: "binary" }, + { path: "binary", binary: true, textOnly: false, expected: "binary" }, + { path: "document.pdf", binary: true, textOnly: true, expected: "text" }, + { path: "archive.bin", binary: true, textOnly: true, expected: "text" }, + ] as const)( + "selects only the $expected strategy for $path, textOnly=$textOnly", + async ({ path: relPath, binary, textOnly, expected }) => { + mockedIsBinaryFile.mockResolvedValue(binary) + mockedIsSupportedImageFormat.mockImplementation((extension) => extension === ".png") + const strategies = { + text: new ReadFileTextReader(), + image: new ReadFileImageReader(), + document: new ReadFileDocumentReader(), + binary: new ReadFileBinaryReader(), + } + const result: FileResult = { path: relPath, status: "approved", nativeContent: "strategy result" } + const readText = vi.spyOn(strategies.text, "read").mockResolvedValue(result) + const readImage = vi.spyOn(strategies.image, "read").mockResolvedValue(result) + const readDocument = vi.spyOn(strategies.document, "read").mockResolvedValue(result) + const readBinary = vi.spyOn(strategies.binary, "read").mockResolvedValue(result) + const readers = { text: readText, image: readImage, document: readDocument, binary: readBinary } + const reader = new ReadFileContentReader(undefined, Object.values(strategies)) + const params = { path: relPath } + const options: ReadEntryOptions = { textOnly } + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, relPath), + extension: path.extname(relPath), + binary, + options, + } + expect( + Object.entries(strategies) + .filter(([, strategy]) => strategy.canRead(context)) + .map(([name]) => name), + ).toEqual([expected]) + expect(await reader.read(params, task, options)).toBe(result) + expect(readers[expected]).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + for (const [name, read] of Object.entries(readers)) { + if (name !== expected) expect(read).not.toHaveBeenCalled() + } + }, + ) + + it.each([ + { binary: false, textOnly: false }, + { binary: false, textOnly: true }, + { binary: true, textOnly: false }, + { binary: true, textOnly: true }, + ] as const)("routes binary=$binary, textOnly=$textOnly to the correct reader", async ({ binary, textOnly }) => { + mockedIsBinaryFile.mockResolvedValue(binary) + mockedIsSupportedImageFormat.mockReturnValue(false) + const result: FileResult = { path: "test.ts", status: "approved", nativeContent: "delegated content" } + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read").mockResolvedValue(result) + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read").mockResolvedValue(result) + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const mockTask = createMockTask() + const params = { path: "test.ts" } + const options: ReadEntryOptions = { textOnly } + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read(params, task, options)).toBe(result) + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + extension: ".ts", + binary, + options, + } + if (binary && !textOnly) { + expect(readBinary).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + expect(readText).not.toHaveBeenCalled() + } else { + expect(readText).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + expect(readBinary).not.toHaveBeenCalled() + } + }) + + it("routes text-only documents through text extraction instead of binary rendering", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const result: FileResult = { path: "document.pdf", status: "approved" } + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read").mockResolvedValue(result) + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read") + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const mockTask = createMockTask() + const params = { path: "document.pdf" } + const options: ReadEntryOptions = { textOnly: true } + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read(params, task, options)).toBe(result) + expect(readText).toHaveBeenCalledExactlyOnceWith({ + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + extension: ".pdf", + binary: true, + options, + }) + expect(readBinary).not.toHaveBeenCalled() + }) + + it("rejects unsupported text-only binaries without reading their contents", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read") + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read") + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, reader)) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await tool.readEntry({ path: "program.exe" }, mockTask, { + textOnly: true, + }) + + expect(result).toEqual({ + path: "program.exe", + status: "unsupported", + approvalStatus: "approved", + error: "Unsupported batch binary format; use read_file.", + entry: { path: "program.exe" }, + feedbackText: undefined, + feedbackImages: undefined, + }) + expect(readText).toHaveBeenCalledTimes(1) + expect(readBinary).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("selects the first applicable injected strategy through the common contract", async () => { + const skipped = new TestReadStrategy() + const selected = new TestReadStrategy() + selected.canRead.mockReturnValue(true) + const result: FileResult = { path: "test.ts", status: "approved", nativeContent: "custom content" } + selected.read.mockResolvedValue(result) + const unused = new TestReadStrategy() + unused.canRead.mockReturnValue(true) + const reader = new ReadFileContentReader(undefined, [skipped, selected, unused]) + const params = { path: "test.ts" } + const options: ReadEntryOptions = {} + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + extension: ".ts", + binary: false, + options, + } + expect(await reader.read(params, task, options)).toBe(result) + expect(skipped.canRead).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + expect(selected.canRead).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + expect(selected.read).toHaveBeenCalledExactlyOnceWith({ + ...context, + imageLimits: { maxImageFileSize: 5, maxTotalImageSize: 20 }, + file: expect.any(Object), + }) + expect(skipped.read).not.toHaveBeenCalled() + expect(unused.canRead).not.toHaveBeenCalled() + expect(unused.read).not.toHaveBeenCalled() + }) + + it("reports an error when no injected strategy can read the file", async () => { + const strategy = new TestReadStrategy() + const reader = new ReadFileContentReader(undefined, [strategy]) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, {}) + + expect(result).toMatchObject({ + status: "error", + error: "Error reading file: No file-reading strategy supports 'test.ts'.", + }) + expect(strategy.read).not.toHaveBeenCalled() + }) + + it("reports errors thrown by the selected strategy", async () => { + const strategy = new TestReadStrategy() + strategy.canRead.mockReturnValue(true) + strategy.read.mockRejectedValue(new Error("Strategy read failed")) + const reader = new ReadFileContentReader(undefined, [strategy]) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, {}) + + expect(result).toMatchObject({ status: "error", error: "Error reading file: Strategy read failed" }) + }) + + it("shares the injected error reporter with binary image handling", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + const error = new Error("Image validation failed") + mockedValidateImageForProcessing.mockRejectedValue(error) + const errorReporter = new ReadFileErrorReporter() + const result: FileResult = { path: "image.png", status: "error", error: error.message } + const report = vi.spyOn(errorReporter, "report").mockResolvedValue(result) + const reader = new ReadFileContentReader(errorReporter) + const mockTask = createMockTask({ supportsImages: true }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + expect(await reader.read({ path: "image.png" }, task, {})).toBe(result) + expect(report).toHaveBeenCalledExactlyOnceWith("image.png", task, error, { + prefix: "Error reading image file: ", + action: "Error reading image file", + }) + }) + }) + + describe("ModernFileReader ordinary cancellation", () => { + it("does not load content or register context when cancellation occurs during descriptor inspection", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + const stats = await mockedFsStat("fixture") + mockedFsStat.mockClear() + const inspection = createDeferred() + const started = createDeferred() + mockedFsStat.mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "source.ts" }, mockTask) + await started.promise + expect(mockedFsReadFile).not.toHaveBeenCalled() + mockTask.abort = true + inspection.resolve(stats) + + expect(await pending).toMatchObject({ path: "source.ts", status: "cancelled" }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not process an image when cancellation occurs while validation is pending", async () => { + const mockTask = asTask({ ...createMockTask({ supportsImages: true }), abort: false, abandoned: false }) + mockedIsBinaryFile.mockResolvedValueOnce(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + const validation = createDeferred>>() + const started = createDeferred() + mockedValidateImageForProcessing.mockImplementationOnce(() => { + started.resolve() + return validation.promise + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,aW1hZ2U=", + buffer: Buffer.from("image"), + sizeInKB: 1, + sizeInMB: 0.001, + notice: "Image", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "image.png" }, mockTask) + await started.promise + mockTask.abandoned = true + validation.resolve({ isValid: true }) + + expect(await pending).toMatchObject({ path: "image.png", status: "cancelled" }) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards ordinary document extraction completed after cancellation without formatting or tracking", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockedIsBinaryFile.mockResolvedValueOnce(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const extraction = createDeferred() + const started = createDeferred() + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "document.pdf" }, mockTask) + await started.promise + mockTask.abort = true + extraction.resolve("Late document content") + + expect(await pending).toMatchObject({ path: "document.pdf", status: "cancelled" }) + expect(mockedReadWithSlice).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + }) + + describe("ModernFileReader text-only reads", () => { + it("reports technical approval failures in text-only mode instead of falsely cancelling", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockRejectedValueOnce(new Error("Provider state unavailable")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ + path: "source.ts", + status: "error", + error: "Error reading file: Provider state unavailable", + nativeContent: "File: source.ts\nError: Provider state unavailable", + }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file source.ts: Provider state unavailable", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("reports feedback-publication failures after approval instead of treating them as cancellation", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Read only", images: undefined }) + mockTask.say.mockRejectedValueOnce(new Error("Feedback publication failed")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ status: "error", error: "Error reading file: Feedback publication failed" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenNthCalledWith(1, "user_feedback", "Read only", undefined) + expect(mockTask.say).toHaveBeenNthCalledWith( + 2, + "error", + "Error reading file source.ts: Feedback publication failed", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "preserves genuine cancellation when approval rejects after %s", + async (flag) => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockImplementationOnce(async () => { + mockTask[flag] = true + throw new Error("Task is ending") + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toEqual({ path: "source.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it("does not report a directory error when inspection finishes after cancellation", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + const stats = await mockedFsStat("fixture") + stats.isDirectory = () => true + mockedFsStat.mockClear() + mockedFsStat.mockImplementationOnce(async () => { + mockTask.abort = true + return stats + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "folder" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "folder", status: "cancelled" }) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }) + + it("keeps pending extraction failures visible when the task is not cancelled", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + const extraction = createDeferred() + const started = createDeferred() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + const params = { path: "document.pdf" } + + // This reader-boundary double omits Task fields unrelated to file reading. + const pendingRead = readFileTool.readEntry(params, mockTask, { textOnly: true }) + await started.promise + expect(mockTask.say).not.toHaveBeenCalled() + extraction.reject(new Error("Document extraction failed")) + + expect(await pendingRead).toEqual({ + path: "document.pdf", + status: "error", + approvalStatus: "approved", + entry: params, + feedbackText: undefined, + feedbackImages: undefined, + error: "Error reading file: Document extraction failed", + nativeContent: "File: document.pdf\nError: Document extraction failed", + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file document.pdf: Document extraction failed", + ) + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "preserves cancellation when pending document extraction rejects after %s", + async (flag) => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + const extraction = createDeferred() + const started = createDeferred() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pendingRead = readFileTool.readEntry({ path: "document.pdf" }, mockTask, { + textOnly: true, + }) + await started.promise + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + mockTask[flag] = true + extraction.reject(new Error("Extractor interrupted")) + + expect(await pendingRead).toMatchObject({ path: "document.pdf", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it("extracts and slices document text from pinned bytes in text-only mode", async () => { + const mockTask = createMockTask() + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedIsBinaryFile.mockResolvedValue(true) + mockedExtractRawTextFromFile.mockResolvedValue("first\nsecond\nthird") + mockedReadWithSlice.mockReturnValue({ + content: "2 | second", + returnedLines: 1, + totalLines: 3, + wasTruncated: false, + includedRanges: [[2, 2]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "document.pdf", offset: 2, limit: 1 }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ + path: "document.pdf", + status: "approved", + nativeContent: "File: document.pdf\n2 | second", + longLinesTruncated: false, + }) + expect(mockedExtractRawTextFromFile).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "document.pdf"), + Buffer.from("test content"), + ) + expect(mockedReadWithSlice).toHaveBeenCalledExactlyOnceWith("first\nsecond\nthird", 1, 1) + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "document.pdf", + "read_tool", + ) + }) + + it("discards late file content after cancellation without processing or tracking it", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedFsReadFile.mockImplementationOnce(async () => { + mockTask.abort = true + return Buffer.from("late content") + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "test.ts")) + expect(mockedReadWithSlice).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops before content reading when cancellation arrives during binary detection", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedIsBinaryFile.mockImplementationOnce(async () => { + mockTask.abandoned = true + return false + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops before binary detection when cancellation arrives during file inspection", async () => { + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + const stats = await mockedFsStat("fixture") + mockedFsStat.mockClear() + mockedFsStat.mockImplementationOnce(async () => { + mockTask.abort = true + return stats + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("rejects images in text-only reads before inspecting or reading their contents", async () => { + const mockTask = createMockTask({ supportsImages: true }) + mockedIsSupportedImageFormat.mockImplementation((extension) => extension === ".png") + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "image.png" }, mockTask, { + textOnly: true, + }) + + expect(result).toMatchObject({ + path: "image.png", + status: "unsupported", + error: "Batch images are not supported; use read_file.", + }) + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "does not request approval for a text-only read after %s", + async (flag) => { + const reader = new ModernFileReader() + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask[flag] = true + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }, + ) + + it("cancels a text-only read when its approval is withdrawn", async () => { + const reader = new ModernFileReader() + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockRejectedValueOnce(new AskIgnoredError("superseded")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not read a file when cancellation arrives during approval", async () => { + const reader = new ModernFileReader() + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockImplementation(async () => { + mockTask.abort = true + return { response: "yesButtonClicked", text: undefined, images: undefined } + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask, { textOnly: true }) + + expect(result).toEqual({ + path: "test.ts", + status: "cancelled", + approvalStatus: "approved", + feedbackText: undefined, + feedbackImages: undefined, + }) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }) + }) + + describe("input validation", () => { + it("should return error when path is missing", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "" }, mockTask, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should return error when path is undefined", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({} as ReadFileParams, mockTask, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should return error when offset is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) + }) + + it("should return error when offset is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) + }) + + it("should return error when anchor_line is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute( + { + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: 0 }, + }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) + }) + + it("should return error when anchor_line is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute( + { + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: -10 }, + }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) + }) + }) + + describe("RooIgnore handling", () => { + it("blocks access before approval or file inspection when the ignore controller is unavailable", async () => { + const mockTask = asTask({ ...createMockTask(), rooIgnoreController: undefined }) + const error = + "Access to secret.env is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file." + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "secret.env" }, mockTask) + + expect(result).toEqual({ + path: "secret.env", + status: "blocked", + error, + nativeContent: `File: secret.env\nError: ${error}`, + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith("rooignore_error", "secret.env") + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should block access to rooignore-protected files", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "secret.env" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("blocked by the .rooignore")) + }) + }) + + describe("directory handling", () => { + it("should return error when trying to read a directory", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue( + Object.assign(statSync(__filename, { bigint: true }), { isDirectory: () => true }), + ) + + await readFileTool.execute({ path: "src/utils" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("Cannot read 'src/utils' because it is a directory"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("it is a directory")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("image handling", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + }) + + it("should process image file when model supports images", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", + }) + + await readFileTool.execute({ path: "image.png" }, mockTask, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + true, + 5, + 20, + 0, + expect.any(Object), + ) + expect(mockedProcessImageFile).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + expect.any(Object), + expect.any(ReadFileCancellation), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, + { type: "text", text: "File: image.png\nNote: Image processed successfully" }, + ]) + }) + + it("should skip image when model does not support images", async () => { + const mockTask = createMockTask({ supportsImages: false }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "unsupported_model", + notice: "Model does not support image processing", + }) + + await readFileTool.execute({ path: "image.png" }, mockTask, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + textResultContaining("Model does not support image processing"), + ) + }) + + it("should skip image when file exceeds size limit", async () => { + const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)", + }) + + await readFileTool.execute({ path: "large-image.png" }, mockTask, callbacks) + + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("exceeds the maximum allowed")) + }) + + it("should skip image when total memory limit exceeded", async () => { + const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "memory_limit", + notice: "Skipping image: would exceed total memory limit", + }) + + await readFileTool.execute({ path: "another-image.png" }, mockTask, callbacks) + + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("would exceed total memory")) + }) + + it("should handle image read errors gracefully", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image")) + + await readFileTool.execute({ path: "corrupt.png" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Error")) + }) + }) + + describe("binary file handling", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + }) + + it("should extract text from PDF files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockResolvedValueOnce("PDF content here") + + const source = Buffer.from("approved PDF bytes") + mockedFsReadFile.mockResolvedValueOnce(source) + await readFileTool.execute({ path: "document.pdf" }, mockTask, callbacks) + + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "document.pdf")) + expect(mockedExtractRawTextFromFile).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "document.pdf"), + source, + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("PDF content here")) + }) + + it("should extract text from DOCX files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockResolvedValueOnce("DOCX content here") + + const source = Buffer.from("approved DOCX bytes") + mockedFsReadFile.mockResolvedValueOnce(source) + await readFileTool.execute({ path: "document.docx" }, mockTask, callbacks) + + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "document.docx")) + expect(mockedExtractRawTextFromFile).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "document.docx"), + source, + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("DOCX content here")) + }) + + it("rejects an out-of-range empty document instead of bypassing range processing", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedExtractRawTextFromFile.mockResolvedValueOnce("") + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "empty.pdf", offset: 2 }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: empty.pdf\nError: offset 1 is beyond file end (1 lines)" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle unsupported binary formats", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // Return empty array to indicate .exe is not supported + vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"]) + + await readFileTool.execute({ path: "program.exe" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Binary file")) + }) + + it("should handle extraction errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockRejectedValueOnce(new Error("Extraction failed")) + + await readFileTool.execute({ path: "corrupt.pdf" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("text file processing", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should read text file with slice mode (default)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + const content = "line 1\nline 2\nline 3" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithSlice.mockReturnValue({ + content: "1 | line 1\n2 | line 2\n3 | line 3", + returnedLines: 3, + totalLines: 3, + wasTruncated: false, + includedRanges: [[1, 3]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockedReadWithSlice).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("line 1")) + }) + + it("should read text file with offset and limit", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5")) + mockedReadWithSlice.mockReturnValue({ + content: "2 | line 2\n3 | line 3", + returnedLines: 2, + totalLines: 5, + wasTruncated: true, + includedRanges: [[2, 3]], + }) + + await readFileTool.execute({ path: "test.ts", mode: "slice", offset: 2, limit: 2 }, mockTask, callbacks) + + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based + }) + + it("should read text file with indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + const content = "class Foo {\n method() {\n return 42\n }\n}" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }", + returnedLines: 5, + totalLines: 5, + wasTruncated: false, + includedRanges: [[1, 5]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 3 }, + }, + mockTask, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + content, + expect.objectContaining({ + anchorLine: 3, + }), + ) + }) + + it("should show truncation notice when content is truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content...")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | truncated content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) + + await readFileTool.execute({ path: "large.ts" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("To read more")) + }) + + it("should handle empty files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("")) + mockedReadWithSlice.mockReturnValue({ + content: "", + returnedLines: 0, + totalLines: 0, + wasTruncated: false, + includedRanges: [], + }) + + await readFileTool.execute({ path: "empty.ts" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("empty")) + }) + }) + + describe("approval flow", () => { + it("preserves received approval instructions when cancellation wins immediately after the response", async () => { + const instruction = "Do not modify any files; only inspect" + const mockTask = asTask({ ...createMockTask(), abort: false, abandoned: false }) + mockTask.ask.mockImplementationOnce(async () => { + mockTask.abort = true + return { response: "yesButtonClicked", text: instruction, images: undefined } + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask) + + expect(result.status).toBe("cancelled") + expect(new ReadFileResultFormatter().format(result, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + ]) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps approval instructions in the final model-facing blocks when file reading fails", async () => { + const instruction = "Do not modify any files; only inspect" + const mockTask = createMockTask() + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: instruction, images: undefined }) + mockedFsReadFile.mockRejectedValueOnce(new Error("Missing source file")) + const formatter = new ReadFileResultFormatter() + const format = vi.spyOn(formatter, "format") + const tool = new ReadFileTool(formatter) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await tool.execute({ path: "source.ts" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + { type: "text", text: "File: source.ts\nError: Missing source file" }, + ]) + expect(format).toHaveBeenCalledWith( + expect.objectContaining({ status: "error", feedbackText: instruction }), + false, + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should approve file read when user clicks yes", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false) + expect(mockTask.didRejectTool).toBe(false) + }) + + it("should deny file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Denied by user")) + }) + + it("should include user feedback when provided with approval", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Please be careful with this file", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) + expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") + }) + + it("should include user feedback when provided with denial", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "This file contains secrets", + images: undefined, + }) + + await readFileTool.execute({ path: "secrets.env" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) + expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") + }) + }) + + describe("output structure", () => { + it("should include file path in output", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "src/app.ts" }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("File: src/app.ts")) + }) + + it("should track file context after successful read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool") + }) + }) + + describe("error handling", () => { + it("should handle file read errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) + + await readFileTool.execute({ path: "nonexistent.ts" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle stat errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockRejectedValue(new Error("Permission denied")) + + await readFileTool.execute({ path: "protected.ts" }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("getReadFileToolDescription", () => { + it("should return description with path when nativeArgs provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) + + expect(description).toBe("[read_file for 'src/app.ts']") + }) + + it("should return description with path when params provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) + + expect(description).toBe("[read_file for 'src/app.ts']") + }) + + it("should return description indicating missing path", () => { + const description = readFileTool.getReadFileToolDescription("read_file", {}) + + expect(description).toBe("[read_file with missing path]") + }) + }) + + describe("legacy format handling", () => { + it("reports failed legacy image processing without dropping the file or stopping subsequent reads", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValueOnce({ isValid: true }) + mockedProcessImageFile.mockRejectedValueOnce(new Error("Image decoding failed")) + mockedFsReadFile.mockResolvedValueOnce("legacy content") + mockedReadWithSlice.mockReturnValueOnce({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { _legacyFormat: true, files: [{ path: "image.png" }, { path: "legacy.ts" }] }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nError: Image decoding failed\n\n---\n\nFile: legacy.ts\n1 | legacy content", + ) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file image.png: Image decoding failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockedProcessImageFile).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + expect.any(Object), + expect.any(ReadFileCancellation), + ) + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "legacy.ts"), "utf8") + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "legacy.ts", + "read_tool", + ) + }) + + it("does not process legacy images when the model does not declare image support", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.api.getModel.mockReturnValue({ info: {} }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "image.png" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nError: Cannot read binary file", + ) + expect(mockedValidateImageForProcessing).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("uses legacy image limits and a useful notice when provider config and validation notice are absent", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockTask.providerRef.deref.mockReturnValue(undefined) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ isValid: false }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "image.png" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nNotice: Image validation failed", + ) + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + true, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("retains legacy file content when context tracking fails afterward", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.fileContextTracker.trackFileContext.mockRejectedValue(new Error("Context tracking failed")) + mockedFsReadFile.mockResolvedValue("legacy content") + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "legacy.ts" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: legacy.ts\n1 | legacy content\n\n---\n\nFile: legacy.ts\nError: Context tracking failed", + ) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file legacy.ts: Context tracking failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("falls back to legacy text reading when binary detection fails", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockRejectedValueOnce(new Error("Binary detection unavailable")) + mockedFsReadFile.mockResolvedValue("legacy content") + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "legacy.ts" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith("File: legacy.ts\n1 | legacy content") + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "legacy.ts"), "utf8") + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "legacy.ts", + "read_tool", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("reuses the injected legacy reader for successive calls", async () => { + const legacyReader = new LegacyFileReader() + const read = vi.spyOn(legacyReader, "read").mockResolvedValue("legacy file response") + const tool = new ReadFileTool(undefined, undefined, legacyReader) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + const firstFiles = [{ path: "first.ts" }] + const secondFiles = [{ path: "second.ts" }] + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask + await tool.execute({ _legacyFormat: true, files: firstFiles }, task, callbacks) + await tool.execute({ _legacyFormat: true, files: secondFiles }, task, callbacks) + + expect(read).toHaveBeenCalledTimes(2) + expect(read).toHaveBeenNthCalledWith(1, firstFiles, task, expect.any(Function)) + expect(read).toHaveBeenNthCalledWith(2, secondFiles, task, expect.any(Function)) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, "legacy file response") + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, "legacy file response") + }) + + it("should detect legacy format and use backward compatibility path", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("legacy content")) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "legacy.ts" }] }, mockTask, callbacks) + + // The legacy path emits "File: " entries — proof the backward-compat branch ran. + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: legacy.ts")) + }) + + it("should return error when legacy files array is empty", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ _legacyFormat: true, files: [] }, mockTask, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should handle multiple files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file1 content")) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file2 content")) + + // Override readWithSlice to return content that reflects the actual file data + mockedReadWithSlice + .mockReturnValueOnce({ + content: "1 | file1 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + .mockReturnValueOnce({ + content: "1 | file2 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { _legacyFormat: true, files: [{ path: "file1.ts" }, { path: "file2.ts" }] }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file1 content")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file2 content")) + }) + + it("should block rooignore-protected files in legacy format", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "secret.env" }] }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) + // Consistent with the native path: a blocked file fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should deny legacy file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "protected.ts" }] }, mockTask, callbacks) + + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) + }) + + it("should handle directory path in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsStat.mockResolvedValue( + Object.assign(statSync(__filename, { bigint: true }), { isDirectory: () => true }), + ) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "src/utils" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle line ranges in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + // fs.readFile with "utf8" encoding returns a string, not a Buffer + mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5") + + await readFileTool.execute( + { _legacyFormat: true, files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("2 | line2")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("4 | line4")) + }) + + it("should handle binary image files in legacy format with image support", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", + }) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "image.png" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Image file - content processed"), + ) + }) + + it("should handle binary image validation failure in legacy format", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image too large", + }) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "large.png" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Image too large")) + }) + + it("should handle unsupported binary files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "program.exe" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot read binary file")) + }) + + it("should handle file read errors in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "missing.ts" }] }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("ENOENT")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle user feedback on approval in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Read carefully", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "test.ts" }] }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) + }) + + it("should handle user feedback on denial in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "Not allowed", + images: undefined, + }) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "secret.ts" }] }, mockTask, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) + }) + + it("should handle truncation in legacy format when no line ranges", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "large.ts" }] }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File truncated")) + }) + + it("should track file context in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ _legacyFormat: true, files: [{ path: "tracked.ts" }] }, mockTask, callbacks) + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("tracked.ts", "read_tool") + }) + }) + + describe("handlePartial", () => { + it.each([{}, { path: "" }, undefined])( + "renders streamed native arguments before their path arrives: %j", + async (nativeArgs) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + const streamedBlock = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs, + partial: true, + } + + // Streaming JSON is incomplete even though finalized native arguments require a path. + await Reflect.apply(readFileTool.handlePartial, readFileTool, [mockTask, streamedBlock]) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + }, + ) + + it("renders incomplete legacy arguments without a file entry or outside-workspace warning", async () => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { _legacyFormat: true, files: [] }, + partial: true, + } + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask, block) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + }) + + it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( + "should preserve partial display for %s", + async (filePath) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { path: filePath }, + partial: true, + } + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask, block) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), + true, + ) + }, + ) + + it("should handle partial display for legacy format", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { _legacyFormat: true, files: [{ path: "legacy.ts" }] }, + partial: false, + } + + await readFileTool.handlePartial(mockTask, block as ToolUse<"read_file">) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("legacy.ts"), false) + }) + + it("should handle partial display with empty path", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { path: "" }, + partial: true, + } + + await readFileTool.handlePartial(mockTask, block as ToolUse<"read_file">) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it("should handle partial display with no nativeArgs", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: undefined, + partial: true, + } + + await readFileTool.handlePartial(mockTask, block as ToolUse<"read_file">) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it.each(["Cancelled", "superseded"])("should gracefully handle %s ask rejection in partial", async (reason) => { + const mockTask = createMockTask() + mockTask.ask.mockRejectedValue(new Error(reason)) + + const block = { + nativeArgs: { path: "test.ts" }, + partial: true, + } + + // Should not throw + await readFileTool.handlePartial(mockTask, block as ToolUse<"read_file">) + + expect(mockTask.ask).toHaveBeenCalled() + }) + }) + + describe("processTextFile indentation mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should use offset as fallback when anchor_line is not provided in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | line 5 content", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[5, 5]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 5, + }, + mockTask, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 5 }), + ) + }) + + it("should default anchorLine to 1 when neither anchor_line nor offset in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | first line", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + }, + mockTask, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 1 }), + ) + }) + + it("should show truncation notice in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | truncated block", + returnedLines: 50, + totalLines: 200, + wasTruncated: true, + includedRanges: [[1, 50]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 1 }, + }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("File content truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("To read more")) + }) + + it("should include range information in indentation mode when not truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | function foo() { ... }", + returnedLines: 10, + totalLines: 100, + wasTruncated: false, + includedRanges: [[5, 14]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 5 }, + }, + mockTask, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Included ranges: 5-14")) + }) + + it("should pass all indentation options to readWithIndentation", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { + anchor_line: 10, + max_levels: 2, + include_siblings: true, + include_header: false, + max_lines: 200, + }, + limit: 500, + }, + mockTask, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + anchorLine: 10, + maxLevels: 2, + includeSiblings: true, + includeHeader: false, + limit: 500, + maxLines: 200, + }), + ) + }) + }) + + describe("slice mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should convert 1-based offset to 0-based for readWithSlice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | line4", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[4, 4]], + }) + + await readFileTool.execute({ path: "test.ts", mode: "slice", offset: 4, limit: 1 }, mockTask, callbacks) + + // offset=4 (1-based) should become 3 (0-based) passed to readWithSlice + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 3, 1) + }) + + it("should use default offset of 1 when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + // Default offset=1 -> 0-based = 0 + expect(mockedReadWithSlice).toHaveBeenCalledExactlyOnceWith("content", 0, DEFAULT_LINE_LIMIT) + }) + + it("should use default limit when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + // Should use DEFAULT_LINE_LIMIT (which is typically 2000) + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number)) + }) + + it("should show correct line range in truncation notice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content")) + mockedReadWithSlice.mockReturnValue({ + content: "5 | partial", + returnedLines: 100, + totalLines: 500, + wasTruncated: true, + includedRanges: [[5, 104]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 5, limit: 100 }, mockTask, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Showing lines 5-104")) + }) + }) + + describe("getLineSnippet and getStartLine", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should show indentation mode snippet in approval message", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 42 }, + }, + mockTask, + callbacks, + ) + + // The approval message should contain the indentation mode info + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 42") + }) + + it("should show line range in approval when offset > 1", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 10, limit: 50 }, mockTask, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("lines 10-59") + expect(message.startLine).toBe(10) + }) + + it("should show up to N lines in approval when offset is default", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("up to") + expect(message.reason).toContain("lines") + expect(message.startLine).toBeUndefined() + }) + + it("should show indentation mode snippet with offset fallback", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 7, + }, + mockTask, + callbacks, + ) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 7") + }) + }) + + describe("provider state handling", () => { + it("should use default image limits when state is null", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockTask.providerRef.deref.mockReturnValue(null) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + notice: "Default image limits applied", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "image.png" }, mockTask, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + true, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: image.png\nNote: Default image limits applied" }, + ]) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("uses safe image defaults when provider config and model capability are unset", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.api.getModel.mockReturnValue({ info: {} }) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + notice: "Model lacks image capability", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "image.png" }, mockTask, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + false, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: image.png\nNote: Model lacks image capability" }, + ]) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + }) + + describe("error handling edge cases", () => { + it("returns structured range errors and marks the tool turn as failed", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedReadWithSlice.mockReturnValue({ + content: "Error: offset 99 is beyond file end (2 lines)", + returnedLines: 0, + totalLines: 2, + wasTruncated: false, + includedRanges: [], + }) + // Task lifecycle fields are irrelevant to this reader-boundary double. + await readFileTool.execute({ path: "test.ts", offset: 100 }, mockTask, callbacks) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith([ + { type: "text", text: "File: test.ts\nError: offset 99 is beyond file end (2 lines)" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should handle unknown error types (non-Error)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockRejectedValue("string error") + + await readFileTool.execute({ path: "test.ts" }, mockTask, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Error")) + }) + + it("should set didToolFailInCurrentTurn on rooignore block", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "blocked.ts" }, mockTask, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should set didToolFailInCurrentTurn on directory read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue( + Object.assign(statSync(__filename, { bigint: true }), { isDirectory: () => true }), + ) + + await readFileTool.execute({ path: "src/" }, mockTask, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) +}) diff --git a/src/core/tools/file-reading/readFileConstants.ts b/src/core/tools/file-reading/readFileConstants.ts new file mode 100644 index 0000000000..f3067c671c --- /dev/null +++ b/src/core/tools/file-reading/readFileConstants.ts @@ -0,0 +1,8 @@ +/** Default maximum lines returned by a modern single-file read. */ +export const DEFAULT_LINE_LIMIT = 2000 + +/** Maximum characters displayed per source line. */ +export const MAX_LINE_LENGTH = 2000 + +/** Default indentation levels above the anchor (0 means unlimited). */ +export const DEFAULT_MAX_LEVELS = 0 diff --git a/src/core/tools/file-reading/readFileValidation.ts b/src/core/tools/file-reading/readFileValidation.ts new file mode 100644 index 0000000000..fd9b0f33db --- /dev/null +++ b/src/core/tools/file-reading/readFileValidation.ts @@ -0,0 +1,49 @@ +/** Numeric constraints shared by runtime validation and the native tool schema. */ +export const READ_FILE_NUMERIC_CONSTRAINTS = { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, +} as const + +export function validateReadFileNumber( + name: keyof typeof READ_FILE_NUMERIC_CONSTRAINTS, + value: unknown, + minimum: number = READ_FILE_NUMERIC_CONSTRAINTS[name].minimum, + allowUnset = true, +): string | undefined { + // Strict provider schemas use null for omitted optional values. + if (allowUnset && (value === undefined || value === null)) return undefined + if (typeof value === "number" && Number.isInteger(value) && value >= minimum) return undefined + if (minimum === 1 && (name === "offset" || name === "anchor_line")) { + return `${name} must be a 1-indexed line number (got ${String(value)}). Line numbers start at 1 and must be positive integers.` + } + return `${name} must be a ${minimum === 0 ? "nonnegative" : "positive"} integer (got ${String(value)}).` +} + +export function validateReadFileNumbers(params: { + offset?: unknown + limit?: unknown + indentation?: { anchor_line?: unknown; max_levels?: unknown; max_lines?: unknown } | null +}): string | undefined { + return ( + validateReadFileNumber("offset", params.offset, READ_FILE_NUMERIC_CONSTRAINTS.offset.minimum) ?? + validateReadFileNumber("limit", params.limit, READ_FILE_NUMERIC_CONSTRAINTS.limit.minimum) ?? + validateReadFileNumber( + "anchor_line", + params.indentation?.anchor_line, + READ_FILE_NUMERIC_CONSTRAINTS.anchor_line.minimum, + ) ?? + validateReadFileNumber( + "max_levels", + params.indentation?.max_levels, + READ_FILE_NUMERIC_CONSTRAINTS.max_levels.minimum, + ) ?? + validateReadFileNumber( + "max_lines", + params.indentation?.max_lines, + READ_FILE_NUMERIC_CONSTRAINTS.max_lines.minimum, + ) + ) +} diff --git a/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts b/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts new file mode 100644 index 0000000000..6fc5cfc524 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts @@ -0,0 +1,26 @@ +import { getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { isSupportedImageFormat } from "../../helpers/imageHelpers" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileBinaryReader extends ReadFileStrategy { + canRead({ binary, extension, options }: ReadFileContext): boolean { + return ( + binary && + options.textOnly !== true && + !isSupportedImageFormat(extension) && + !getSupportedBinaryFormats().includes(extension) + ) + } + + async read({ params, extension }: ReadFileContext): Promise { + const relPath = params.path + const format = extension.slice(1) || "bin" + return { + path: relPath, + status: "approved", + notice: `Binary file format: ${format}`, + nativeContent: `File: ${relPath}\nBinary file (${format}) - content not displayed`, + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts b/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts new file mode 100644 index 0000000000..3c4f4c8138 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts @@ -0,0 +1,54 @@ +import { extractRawTextFromFile, getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { isSupportedImageFormat } from "../../helpers/imageHelpers" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileCancelledError } from "../ReadFileCancelledError" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileDocumentReader extends ReadFileStrategy { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly textProcessor: ReadFileTextProcessor = new ReadFileTextProcessor(), + ) { + super() + } + + canRead({ binary, extension, options }: ReadFileContext): boolean { + return ( + binary && + options.textOnly !== true && + !isSupportedImageFormat(extension) && + getSupportedBinaryFormats().includes(extension) + ) + } + + async read({ params, task, fullPath, file }: ReadFileContext): Promise { + const relPath = params.path + const cancellation = new ReadFileCancellation(task) + try { + const bytes = file ? await cancellation.run(() => file.readFile()) : undefined + if (cancellation.isCancelled()) return { path: relPath, status: "cancelled" } + const content = await cancellation.run(() => extractRawTextFromFile(fullPath, bytes)) + if (cancellation.isCancelled()) return { path: relPath, status: "cancelled" } + const text = this.textProcessor.process(content, params) + if (text.startsWith("Error:")) { + return { path: relPath, status: "error", error: text, nativeContent: `File: ${relPath}\n${text}` } + } + await cancellation.run(() => task.fileContextTracker.trackFileContext(relPath, "read_tool")) + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\n${content.length === 0 ? "Note: File is empty" : text}`, + } + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) + return { path: relPath, status: "cancelled" } + return this.errorReporter.report(relPath, task, error, { + prefix: "Error extracting text: ", + action: "Error extracting text from", + }) + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileImageReader.ts b/src/core/tools/file-reading/strategies/ReadFileImageReader.ts new file mode 100644 index 0000000000..75bd1d04d6 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileImageReader.ts @@ -0,0 +1,89 @@ +import path from "path" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + IMAGE_MIME_TYPES, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, + ImageMemoryTracker, +} from "../../helpers/imageHelpers" +import { getImageMimeType, isSupportedImageMimeType } from "../../../../utils/imageMime" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { ReadFileCancellation } from "../ReadFileCancellation" +import { ReadFileCancelledError } from "../ReadFileCancelledError" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileImageReader extends ReadFileStrategy { + constructor(private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter()) { + super() + } + + canRead({ binary, extension, options }: ReadFileContext): boolean { + return binary && options.textOnly !== true && isSupportedImageFormat(extension) + } + + async read({ params, task, fullPath, file, imageLimits }: ReadFileContext): Promise { + const relPath = params.path + const cancellation = new ReadFileCancellation(task) + try { + const state = imageLimits ?? (await cancellation.run(async () => task.providerRef.deref()?.getState())) + if (cancellation.isCancelled()) return { path: relPath, status: "cancelled" } + const tracker = new ImageMemoryTracker() + const supportsImages = task.api.getModel().info.supportsImages ?? false + const targetMimeType = IMAGE_MIME_TYPES[path.extname(fullPath).toLowerCase()] + if (supportsImages && !isSupportedImageMimeType(targetMimeType)) + return this.unsupported(relPath, targetMimeType) + const validation = await cancellation.run(() => + validateImageForProcessing( + fullPath, + supportsImages, + state?.maxImageFileSize ?? DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + state?.maxTotalImageSize ?? DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + tracker.getTotalMemoryUsed(), + file, + ), + ) + if (cancellation.isCancelled()) return { path: relPath, status: "cancelled" } + if (!validation.isValid) { + await cancellation.run(() => task.fileContextTracker.trackFileContext(relPath, "read_tool")) + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\nNote: ${validation.notice}`, + } + } + // Label the verified target, not a symlink alias with a different extension. + const image = await cancellation.run(() => processImageFile(fullPath, file, cancellation)) + if (cancellation.isCancelled()) return { path: relPath, status: "cancelled" } + const mimeType = getImageMimeType(image.dataUrl) + if (!isSupportedImageMimeType(mimeType)) return this.unsupported(relPath, mimeType) + tracker.addMemoryUsage(image.sizeInMB) + await cancellation.run(() => task.fileContextTracker.trackFileContext(relPath, "read_tool")) + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\nNote: ${image.notice}`, + imageDataUrl: image.dataUrl, + } + } catch (error) { + if (cancellation.isCancelled() || error instanceof ReadFileCancelledError) + return { path: relPath, status: "cancelled" } + return this.errorReporter.report(relPath, task, error, { + prefix: "Error reading image file: ", + action: "Error reading image file", + }) + } + } + + private unsupported(relPath: string, mimeType: string | undefined): FileResult { + const notice = `Unsupported image MIME type: ${mimeType ?? "unknown"}. Use JPEG, PNG, GIF, or WebP.` + return { + path: relPath, + status: "unsupported", + error: notice, + nativeContent: `File: ${relPath}\nNote: ${notice}`, + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileStrategy.ts b/src/core/tools/file-reading/strategies/ReadFileStrategy.ts new file mode 100644 index 0000000000..9f2c39a587 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileStrategy.ts @@ -0,0 +1,6 @@ +import type { FileResult, ReadFileContext } from "../types" + +export abstract class ReadFileStrategy { + abstract canRead(context: ReadFileContext): boolean + abstract read(context: ReadFileContext): Promise +} diff --git a/src/core/tools/file-reading/strategies/ReadFileTextReader.ts b/src/core/tools/file-reading/strategies/ReadFileTextReader.ts new file mode 100644 index 0000000000..37243060dd --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileTextReader.ts @@ -0,0 +1,52 @@ +import * as fs from "fs/promises" +import { extractRawTextFromFile, getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { ReadFileCancellation } from "../ReadFileCancellation" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileTextReader extends ReadFileStrategy { + constructor(private readonly textProcessor: ReadFileTextProcessor = new ReadFileTextProcessor()) { + super() + } + + canRead({ binary, options }: ReadFileContext): boolean { + return options.textOnly === true || !binary + } + + async read({ params, task, fullPath, extension, binary, options, file }: ReadFileContext): Promise { + const cancellation = new ReadFileCancellation(task) + const extract = options.textOnly === true && getSupportedBinaryFormats().includes(extension) + if (options.textOnly && binary && !extract) { + return { + path: params.path, + status: "unsupported", + error: "Unsupported batch binary format; use read_file.", + } + } + // Buffer decoding deliberately tolerates non-UTF8 bytes, as before. + let content: string + if (extract) { + const bytes = file ? await cancellation.run(() => file.readFile()) : undefined + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + content = await cancellation.run(() => extractRawTextFromFile(fullPath, bytes)) + } else { + const bytes = await cancellation.run(() => (file ? file.readFile() : fs.readFile(fullPath))) + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + content = bytes.toString("utf-8") + } + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + const text = this.textProcessor.process(content, params) + if (text.startsWith("Error:")) { + return { path: params.path, status: "error", error: text, nativeContent: `File: ${params.path}\n${text}` } + } + await cancellation.run(() => task.fileContextTracker.trackFileContext(params.path, "read_tool")) + if (cancellation.isCancelled()) return { path: params.path, status: "cancelled" } + return { + path: params.path, + status: "approved", + nativeContent: `File: ${params.path}\n${text}`, + longLinesTruncated: options.textOnly ? this.textProcessor.hasClippedLines(content, text) : undefined, + } + } +} diff --git a/src/core/tools/file-reading/types.ts b/src/core/tools/file-reading/types.ts new file mode 100644 index 0000000000..0eeb9e924c --- /dev/null +++ b/src/core/tools/file-reading/types.ts @@ -0,0 +1,41 @@ +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import type { FileHandle } from "fs/promises" + +export interface FileResult { + path: string + status: "approved" | "denied" | "blocked" | "error" | "pending" | "cancelled" | "unsupported" + /** User decision is independent of the subsequent content-read outcome. */ + approvalStatus?: "approved" | "denied" + content?: string + error?: string + notice?: string + nativeContent?: string + imageDataUrl?: string + feedbackText?: string + feedbackImages?: string[] + longLinesTruncated?: boolean + entry?: ReadFileParams +} + +export interface ReadEntryOptions { + textOnly?: boolean +} + +export interface ReadFileContext { + params: ReadFileParams + task: Task + fullPath: string + extension: string + binary: boolean + options: ReadEntryOptions + /** Captured before descriptor acquisition so cancelled provider reads cannot start I/O. */ + imageLimits?: { maxImageFileSize: number; maxTotalImageSize: number } + /** Present at the public reader boundary; strategies must not reopen a pathname when supplied. */ + file?: FileHandle +} + +export interface ReadErrorContext { + prefix?: string + action?: string +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f1b2590f7f..e0da1e75b8 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -889,11 +889,6 @@ "count": 1 } }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "core/tools/ToolRepetitionDetector.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -964,11 +959,6 @@ "count": 26 } }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 96 - } - }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/src/integrations/misc/__tests__/extract-raw-text.spec.ts b/src/integrations/misc/__tests__/extract-raw-text.spec.ts new file mode 100644 index 0000000000..d232e8f1c6 --- /dev/null +++ b/src/integrations/misc/__tests__/extract-raw-text.spec.ts @@ -0,0 +1,92 @@ +import fs from "fs/promises" +import { extractRawTextFromBuffer, extractRawTextFromFile, extractTextFromFile } from "../extract-text" + +const { parsePdf, parseDocx } = vi.hoisted(() => ({ + parsePdf: vi.fn<(buffer: Buffer) => Promise<{ text: string }>>(), + parseDocx: vi.fn(), +})) + +vi.mock("pdf-parse/lib/pdf-parse", () => ({ default: parsePdf })) +vi.mock("mammoth", () => ({ default: { extractRawText: parseDocx } })) +vi.mock("fs/promises", () => ({ default: { access: vi.fn(), readFile: vi.fn() } })) + +describe("raw document extraction compatibility", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("document bytes")) + }) + + it.each(["pdf", "docx"])( + "decodes only supplied %s bytes, with exact decoder arguments and no pathname I/O", + async (extension) => { + const source = Buffer.from(`approved ${extension} bytes`) + parsePdf.mockResolvedValue({ text: "raw PDF" }) + parseDocx.mockResolvedValue({ value: "raw DOCX", messages: [] }) + + expect(await extractRawTextFromBuffer(source, `/missing/document.${extension}`)).toBe( + extension === "pdf" ? "raw PDF" : "raw DOCX", + ) + if (extension === "pdf") { + expect(parsePdf).toHaveBeenCalledExactlyOnceWith(source) + expect(parseDocx).not.toHaveBeenCalled() + } else { + expect(parseDocx).toHaveBeenCalledExactlyOnceWith({ buffer: source }) + expect(parsePdf).not.toHaveBeenCalled() + } + expect(fs.access).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it.each(["pdf", "docx"])( + "the %s compatibility wrapper does not reopen a pathname when verified bytes are supplied", + async (extension) => { + const source = Buffer.alloc(0) + parsePdf.mockResolvedValue({ text: "" }) + parseDocx.mockResolvedValue({ value: "", messages: [] }) + + expect(await extractRawTextFromFile(`/missing/empty.${extension}`, source)).toBe("") + expect(fs.access).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }, + ) + + it("returns raw PDF text while preserving the existing formatted PDF API", async () => { + parsePdf.mockResolvedValue({ text: "12 | genuine text\n indented text" }) + + expect(await extractRawTextFromFile("document.pdf")).toBe("12 | genuine text\n indented text") + expect(await extractTextFromFile("document.pdf")).toBe("1 | 12 | genuine text\n2 | indented text\n") + }) + + it("preserves raw DOCX whitespace and trailing newlines without changing formatted DOCX output", async () => { + parseDocx.mockResolvedValue({ value: "12 | genuine text\n indented text\n\n", messages: [] }) + + expect(await extractRawTextFromFile("document.docx")).toBe("12 | genuine text\n indented text\n\n") + expect(await extractTextFromFile("document.docx")).toBe("1 | 12 | genuine text\n2 | indented text\n3 | \n") + }) + + it("does not replace a document parser failure with a missing-file or formatting error", async () => { + const failure = new Error("Corrupt DOCX archive") + parseDocx.mockRejectedValueOnce(failure) + + await expect(extractRawTextFromFile("document.docx")).rejects.toBe(failure) + }) + + it("reports a missing document before invoking its parser", async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")) + + await expect(extractRawTextFromFile("missing.pdf")).rejects.toThrow("File not found: missing.pdf") + expect(parsePdf).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("rejects unsupported document formats without trying to interpret their bytes", async () => { + await expect(extractRawTextFromFile("archive.bin")).rejects.toThrow( + "Cannot extract document text for file type: .bin", + ) + expect(fs.readFile).not.toHaveBeenCalled() + expect(parsePdf).not.toHaveBeenCalled() + expect(parseDocx).not.toHaveBeenCalled() + }) +}) diff --git a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts index 90d5007f16..f678f74673 100644 --- a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts @@ -134,7 +134,8 @@ describe("Unicode clipping through the existing readers", () => { wasTruncated: false, }) expect(readWithIndentation(longLine, { anchorLine: 0 })).toEqual({ - content: "Error: anchor_line 0 is out of range (1-1)", + content: + "Error: anchor_line must be a 1-indexed line number (got 0). Line numbers start at 1 and must be positive integers.", includedRanges: [], totalLines: 1, returnedLines: 0, diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index b6c28365aa..d9a80e8864 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -254,6 +254,38 @@ describe("formatWithLineNumbers", () => { // ─── readWithSlice Tests ────────────────────────────────────────────────────── describe("readWithSlice", () => { + it("returns an error result for a directly supplied zero limit", () => { + const result = readWithSlice(SIMPLE_CODE, 0, 0) + + expect(result).toMatchObject({ + content: expect.stringContaining("Error: limit must be a positive integer"), + includedRanges: [], + totalLines: 7, + returnedLines: 0, + wasTruncated: false, + }) + }) + + it.each([-1, 0.5, NaN, Infinity])("rejects a malformed zero-based offset %s without clamping", (offset) => { + const result = readWithSlice(SIMPLE_CODE, offset, 1) + + expect(result.content).toContain("Error: offset must be a nonnegative integer") + expect(result.includedRanges).toEqual([]) + expect(result.returnedLines).toBe(0) + }) + + it("treats null optional slice numbers as defaults for untyped callers", () => { + expect(Reflect.apply(readWithSlice, undefined, [SIMPLE_CODE, null, null])).toEqual(readWithSlice(SIMPLE_CODE)) + }) + + it.each([-1, 1.5, NaN, Infinity])("returns an error result for invalid limit %s", (limit) => { + expect(readWithSlice(SIMPLE_CODE, 0, limit)).toMatchObject({ + content: expect.stringContaining("Error: limit"), + includedRanges: [], + returnedLines: 0, + }) + }) + it("should read from beginning with default offset", () => { const result = readWithSlice(SIMPLE_CODE, 0, 10) @@ -285,17 +317,69 @@ describe("readWithSlice", () => { expect(result.content).toContain("Error") }) - it("should handle negative offset", () => { + it("should return an error for negative offset", () => { const result = readWithSlice(SIMPLE_CODE, -5, 10) - // Should normalize to 0 - expect(result.includedRanges[0][0]).toBe(1) + expect(result.content).toContain("Error: offset must be a nonnegative integer") + expect(result.includedRanges).toEqual([]) }) }) // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("returns an error result instead of throwing when a direct caller omits the required anchor", () => { + const options = { anchorLine: 1 } + Object.defineProperty(options, "anchorLine", { value: undefined }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toMatchObject({ + content: expect.stringContaining("Error: anchor_line"), + includedRanges: [], + returnedLines: 0, + }) + }) + + it("returns an error for a zero hard cap even when the expansion limit is valid", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 3, limit: 10, maxLines: 0 }) + + expect(result).toMatchObject({ + content: expect.stringContaining("Error: max_lines must be a positive integer"), + includedRanges: [], + totalLines: 7, + returnedLines: 0, + wasTruncated: false, + }) + }) + + it("treats null optional indentation numbers as defaults", () => { + const options = { anchorLine: 3, includeHeader: false } + Object.defineProperties(options, { + maxLevels: { value: null }, + limit: { value: null }, + maxLines: { value: null }, + }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toEqual( + readWithIndentation(SIMPLE_CODE, { anchorLine: 3, includeHeader: false, maxLevels: 0 }), + ) + }) + + it.each( + ["anchorLine", "maxLevels", "limit", "maxLines"].flatMap((name) => + [-1, 1.5, NaN, Infinity, "1"].map((value) => ({ name, value })), + ), + )("returns an error result for malformed $name=$value without throwing", ({ name, value }) => { + const options = { anchorLine: 3 } + Object.defineProperty(options, name, { value }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toMatchObject({ + content: expect.stringContaining("Error:"), + includedRanges: [], + returnedLines: 0, + wasTruncated: false, + }) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index aa645dda67..bf3236db08 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -8,27 +8,29 @@ import { extractTextFromXLSX } from "./extract-text-from-xlsx" import { readWithSlice } from "./indentation-reader" import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file" -async function extractTextFromPDF(source: Buffer): Promise { +async function extractTextFromPDF(source: Buffer, lineNumbers = true): Promise { const data = await pdf(source) - return addLineNumbers(data.text) + return lineNumbers ? addLineNumbers(data.text) : data.text } -async function extractTextFromDOCX(source: Buffer): Promise { +async function extractTextFromDOCX(source: Buffer, lineNumbers = true): Promise { const result = await mammoth.extractRawText({ buffer: source }) - return addLineNumbers(result.value) + return lineNumbers ? addLineNumbers(result.value) : result.value } -async function extractTextFromIPYNB(source: Buffer): Promise { +async function extractTextFromIPYNB(source: Buffer, lineNumbers = true): Promise { const notebook = JSON.parse(source.toString("utf8")) let extractedText = "" + const rawCells: string[] = [] for (const cell of notebook.cells) { if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { extractedText += cell.source.join("\n") + "\n" + rawCells.push(cell.source.join("\n")) } } - return addLineNumbers(extractedText) + return lineNumbers ? addLineNumbers(extractedText) : rawCells.join("\n") } /** @@ -150,6 +152,39 @@ export async function extractTextFromBuffer(source: Buffer, filePath: string): P return readWithSlice(source.toString("utf8"), 0, DEFAULT_LINE_LIMIT).content } +/** Raw document decoding consumes required bytes; paths are format metadata only. */ +export async function extractRawTextFromBuffer(source: Buffer, filePath: string): Promise { + const extension = path.extname(filePath).toLowerCase() + switch (extension) { + case ".pdf": + return extractTextFromPDF(source, false) + case ".docx": + return extractTextFromDOCX(source, false) + case ".ipynb": + return extractTextFromIPYNB(source, false) + case ".xlsx": + return extractTextFromXLSX(source) + default: + throw new Error(`Cannot extract document text for file type: ${extension}`) + } +} + +/** Compatibility wrapper; a supplied verified buffer never triggers pathname I/O. */ +export async function extractRawTextFromFile(filePath: string, source?: Buffer): Promise { + if (!getSupportedBinaryFormats().includes(path.extname(filePath).toLowerCase())) { + throw new Error(`Cannot extract document text for file type: ${path.extname(filePath).toLowerCase()}`) + } + if (source === undefined) { + try { + await fs.access(filePath) + } catch { + throw new Error(`File not found: ${filePath}`) + } + source = await fs.readFile(filePath) + } + return extractRawTextFromBuffer(source, filePath) +} + export function addLineNumbers(content: string, startLine: number = 1): string { // If content is empty, return empty string - empty files should not have line numbers // If content is empty but startLine > 1, return "startLine | " because we know the file is not empty diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index 47ff4593bf..9eb2161d97 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -17,7 +17,8 @@ import { DEFAULT_LINE_LIMIT, DEFAULT_MAX_LEVELS, MAX_LINE_LENGTH, -} from "../../core/prompts/tools/native-tools/read_file" +} from "../../core/tools/file-reading/readFileConstants" +import { validateReadFileNumber, validateReadFileNumbers } from "../../core/tools/file-reading/readFileValidation" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -224,18 +225,30 @@ function computeIncludedRanges(lines: LineRecord[]): Array<[number, number]> { * @returns The extracted content with metadata */ export function readWithIndentation(content: string, options: IndentationReadOptions): IndentationReadResult { - const { - anchorLine, - maxLevels = DEFAULT_MAX_LEVELS, - includeSiblings = false, - includeHeader = true, - limit = DEFAULT_LINE_LIMIT, - maxLines, - } = options + const { anchorLine, includeSiblings = false, includeHeader = true, maxLines } = options + const maxLevels = options.maxLevels ?? DEFAULT_MAX_LEVELS + const limit = options.limit ?? DEFAULT_LINE_LIMIT const lines = parseLines(content) const totalLines = lines.length + // Unlike the optional read_file anchor, this numerical API requires an anchor. + const validationError = + validateReadFileNumber("anchor_line", anchorLine, undefined, false) ?? + validateReadFileNumbers({ + limit, + indentation: { max_levels: maxLevels, max_lines: maxLines }, + }) + if (validationError) { + return { + content: `Error: ${validationError}`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + // Validate anchor line if (anchorLine < 1 || anchorLine > totalLines) { return { @@ -374,11 +387,24 @@ export function readWithSlice( offset: number = 0, limit: number = DEFAULT_LINE_LIMIT, ): IndentationReadResult { + offset = offset ?? 0 + limit = limit ?? DEFAULT_LINE_LIMIT const lines = parseLines(content) const totalLines = lines.length + // This lower-level slice API uses a zero-based offset, unlike read_file. + const validationError = validateReadFileNumber("offset", offset, 0) ?? validateReadFileNumbers({ limit }) + if (validationError) { + return { + content: `Error: ${validationError}`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + // Validate offset - if (offset < 0) offset = 0 if (offset >= totalLines) { return { content: `Error: offset ${offset} is beyond file end (${totalLines} lines)`,