diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 28478c9f34ab..0b10fcc63ae3 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -28,16 +28,6 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 const MAX_LINE_LENGTH = 2_000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` -export class ReadLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`File exceeds ${maximumBytes} byte read limit: ${resource}`) - this.name = "ReadLimitError" - } -} - export class BinaryFileError extends Error { constructor(readonly resource: string) { super(`Cannot read binary file: ${resource}`) @@ -137,12 +127,9 @@ export class TextPage extends Schema.Class("FileSystem.TextPage")({ next: PositiveInt.pipe(Schema.optional), }) {} -export class ReadTarget extends Schema.Class("FileSystem.ReadTarget")({ - real: Schema.String, +export class ReadPath extends Schema.Class("FileSystem.ReadPath")({ + type: Schema.Literals(["file", "directory"]), resource: Schema.String, - size: NonNegativeInt, - dev: Schema.Number, - ino: Schema.Number.pipe(Schema.optional), }) {} export const ListInput = Schema.Struct({ @@ -179,10 +166,6 @@ export class RootTarget extends Schema.Class("FileSystem.RootTarget" ino: Schema.Number.pipe(Schema.optional), }) {} -export type ReadPathTarget = - | { readonly type: "file"; readonly target: ReadTarget } - | { readonly type: "directory"; readonly target: ListTarget } - export class Entry extends Schema.Class("FileSystem.Entry")({ path: RelativePath, uri: Schema.String, @@ -235,12 +218,8 @@ export const Event = { export interface Interface { readonly read: (input: ReadInput) => Effect.Effect - readonly resolveReadPath: (input: ReadInput) => Effect.Effect - readonly resolveRead: (input: ReadInput) => Effect.Effect - readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect - readonly readSampleResolved: (target: ReadTarget, maximumBytes: number) => Effect.Effect - readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect - readonly readToolResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect + readonly resolveReadPath: (input: ReadInput) => Effect.Effect + readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect /** Select a contained canonical read root without asserting leaf policy. */ readonly resolveRoot: (input?: ListInput) => Effect.Effect @@ -361,33 +340,27 @@ export const layer = Layer.effect( }) const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) { - const file = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(file.real).pipe(Effect.orDie) - const relative = path.relative(file.root, file.real).replaceAll("\\", "/") - const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}` - if (info.type === "File") { - return { - type: "file" as const, - target: new ReadTarget({ - real: file.real, - resource, - size: Number(info.size), - dev: info.dev, - ino: Option.getOrUndefined(info.ino), - }), - } - } - if (info.type === "Directory") { - return { type: "directory" as const, target: new ListTarget({ ...file, resource }) } - } - return yield* Effect.die(new Error("Path is not a file or directory")) + const target = yield* resolve(input.path, input.reference) + const info = yield* fs.stat(target.real).pipe(Effect.orDie) + const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined + if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." + return new ReadPath({ + type, + resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, + }) }) - const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) { - const resolved = yield* resolveReadPath(input) - if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file")) - return resolved.target + const resolveFile = Effect.fnUntraced(function* (input: ReadInput) { + const target = yield* resolve(input.path, input.reference) + const info = yield* fs.stat(target.real).pipe(Effect.orDie) + if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." + return { + real: target.real, + resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, + } }) - const content = (target: ReadTarget, bytes: Uint8Array) => + const content = (target: { readonly real: string }, bytes: Uint8Array) => Effect.gen(function* () { const mime = FSUtil.mimeType(target.real) if (!bytes.includes(0)) { @@ -403,143 +376,13 @@ export const layer = Layer.effect( mime, }) }) - const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) { - if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie)) - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) - if (info.size > maximumBytes) return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes)) - const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie) - if (bytes._tag === "Some" && bytes.value.length > maximumBytes) - return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes)) - return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array()) - }), - ) - }) - const readSampleResolved = Effect.fn("FileSystem.readSampleResolved")(function* ( - target: ReadTarget, - maximumBytes: number, - ) { - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) - return Option.getOrElse(yield* file.readAlloc(maximumBytes).pipe(Effect.orDie), () => new Uint8Array()) - }), - ) - }) - const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* ( - target: ReadTarget, - page: TextPageInput = {}, - ) { - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) - - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) - const lines: string[] = [] - const decoder = new TextDecoder("utf-8", { fatal: true }) - let pending = "" - let discard = false - let line = 1 - let bytes = 0 - let found = false - let truncated = false - let next: number | undefined - - const append = (input: string) => { - if (line < offset) { - line++ - return true - } - if (lines.length >= limit) { - truncated = true - next = line - return false - } - found = true - const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input - const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) - if (bytes + size > MAX_READ_BYTES) { - truncated = true - next = line - return false - } - lines.push(text) - bytes += size - line++ - return true - } - - let done = false - while (!done) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) - if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource)) - let text = decoder.decode(chunk.value, { stream: true }) - while (true) { - const index = text.indexOf("\n") - if (index === -1) { - if (!discard) { - pending += text - if (pending.length > MAX_LINE_LENGTH) { - pending = pending.slice(0, MAX_LINE_LENGTH + 1) - discard = true - } - } - break - } - const current = pending + (discard ? "" : text.slice(0, index)) - pending = "" - discard = false - text = text.slice(index + 1) - if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) { - done = true - break - } - } - } - if (!done) { - const tail = decoder.decode() - if (!discard) pending += tail - if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true - } - if (!done && !found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) - - return new TextPage({ - type: "text-page", - content: lines.join("\n"), - mime: FSUtil.mimeType(target.real), - offset, - truncated, - ...(next === undefined ? {} : { next }), - }) - }), - ) - }) - const readToolResolved = Effect.fn("FileSystem.readToolResolved")(function* ( - target: ReadTarget, - page: TextPageInput = {}, - ) { + const readTool = Effect.fn("FileSystem.readTool")(function* (input: ReadInput, page: TextPageInput = {}) { + const target = yield* resolveFile(input) return yield* Effect.scoped( Effect.gen(function* () { const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) const info = yield* file.stat.pipe(Effect.orDie) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) const first = Option.getOrElse( yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie), @@ -761,14 +604,11 @@ export const layer = Layer.effect( return Service.of({ read: Effect.fn("FileSystem.read")(function* (input) { - return yield* readResolved(yield* resolveRead(input)) + const target = yield* resolveFile(input) + return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie)) }), resolveReadPath, - resolveRead, - readResolved, - readSampleResolved, - readTextPageResolved, - readToolResolved, + readTool, list: Effect.fn("FileSystem.list")(function* (input) { return yield* listResolved(yield* resolveList(input)) }), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index bf4520cdd581..8e0d0b81ef50 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -91,33 +91,20 @@ export const layer = Layer.effectDiscard( yield* registry.contribute((editor) => editor.set(name, { tool: definition, - execute: ({ parameters, sessionID, assertPermission }) => { + execute: ({ parameters, assertPermission }) => { const input = parameters return Effect.gen(function* () { const resolved = yield* filesystem.resolveReadPath(input) if (resolved.type === "directory") { - const { offset, limit } = input - const target = resolved.target - yield* assertPermission({ action: name, resources: [target.resource], save: ["*"] }) - const final = yield* filesystem.resolveReadPath(input) - if ( - final.type !== "directory" || - final.target.resource !== target.resource || - final.target.real !== target.real - ) - return yield* Effect.die(new Error("Directory changed after permission approval")) - return yield* filesystem.listPageResolved(final.target, { offset, limit }) + yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] }) + return yield* filesystem.listPage(input) } - const target = resolved.target yield* assertPermission({ action: name, - resources: [target.resource], + resources: [resolved.resource], save: ["*"], }) - const final = yield* filesystem.resolveReadPath(input) - if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real) - return yield* Effect.die(new Error("File changed after permission approval")) - const content = yield* filesystem.readToolResolved(final.target, { + const content = yield* filesystem.readTool(input, { offset: input.offset, limit: input.limit, }) @@ -139,7 +126,7 @@ export const layer = Layer.effectDiscard( const photon = yield* loadPhoton const decoded = yield* Effect.try({ try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")), - catch: () => new ImageDecodeError(final.target.resource), + catch: () => new ImageDecodeError(resolved.resource), }) try { const width = decoded.get_width() @@ -150,7 +137,7 @@ export const layer = Layer.effectDiscard( if (!limits.autoResize) return yield* Effect.die( new ImageSizeError( - final.target.resource, + resolved.resource, width, height, bytes, @@ -199,7 +186,7 @@ export const layer = Layer.effectDiscard( } return yield* Effect.die( new ImageSizeError( - final.target.resource, + resolved.resource, width, height, bytes, @@ -212,8 +199,7 @@ export const layer = Layer.effectDiscard( decoded.free() } } - if (content.type === "binary") - return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource)) + if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource)) return content }).pipe( Effect.catchCause((cause) => @@ -221,7 +207,6 @@ export const layer = Layer.effectDiscard( const error = Cause.squash(cause) const message = error instanceof FileSystem.BinaryFileError || - error instanceof FileSystem.ReadLimitError || error instanceof FileSystem.MediaIngestLimitError || error instanceof ImageDecodeError || error instanceof ImageSizeError diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index af3ad24d5295..56d2fa3dfb5f 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" -import { Effect, Exit, Fiber, Layer, Schema } from "effect" +import { Effect, Exit, Layer, Schema } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -91,26 +91,9 @@ describe("FileSystem", () => { encoding: "base64", mime: "application/octet-stream", }) - const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") }) - expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true) - }).pipe(provide(directory)), - ), - ) - - it.live("revalidates file identity before sampled classification", () => - withTmp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "image.png") - yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47]))) - const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("image.png") }) - - yield* Effect.promise(() => fs.rename(file, path.join(directory, "original.png"))) - yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0xff, 0xd8, 0xff]))) - - expect( - Exit.isFailure(yield* service.readSampleResolved(target, FileSystem.READ_SAMPLE_BYTES).pipe(Effect.exit)), - ).toBe(true) + expect(Exit.isFailure(yield* service.readTool({ path: RelativePath.make("data.bin") }).pipe(Effect.exit))).toBe( + true, + ) }).pipe(provide(directory)), ), ) @@ -121,17 +104,18 @@ describe("FileSystem", () => { const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}-é`.padEnd(2_000, "x")) yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n"))) const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("large.txt") }) + const input = { path: RelativePath.make("large.txt") } - const first = yield* service.readTextPageResolved(target) - expect(first).toMatchObject({ + const result = yield* service.readTool(input) + expect(result).toMatchObject({ type: "text-page", offset: 1, truncated: true, }) + const first = result.type === "text-page" ? result : yield* Effect.die(new Error("Expected a text page")) expect(first.next).toBeDefined() const next = first.next! - expect(yield* service.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({ + expect(yield* service.readTool(input, { offset: next, limit: 1 })).toEqual({ type: "text-page", content: lines[next - 1], mime: "text/plain", @@ -139,7 +123,7 @@ describe("FileSystem", () => { truncated: true, next: next + 1, }) - expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({ + expect(yield* service.readTool(input, { offset: 30 })).toEqual({ type: "text-page", content: lines[29], mime: "text/plain", @@ -161,8 +145,11 @@ describe("FileSystem", () => { ), ) const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("late-binary.txt") }) - expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure( + yield* service.readTool({ path: RelativePath.make("late-binary.txt") }, { limit: 1 }).pipe(Effect.exit), + ), + ).toBe(true) }).pipe(provide(directory)), ), ) @@ -178,8 +165,11 @@ describe("FileSystem", () => { ), ) const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("invalid-utf8.txt") }) - expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure( + yield* service.readTool({ path: RelativePath.make("invalid-utf8.txt") }, { limit: 1 }).pipe(Effect.exit), + ), + ).toBe(true) }).pipe(provide(directory)), ), ) @@ -194,11 +184,17 @@ describe("FileSystem", () => { fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])), ) const service = yield* FileSystem.Service - const smallTarget = yield* service.resolveRead({ path: RelativePath.make("small.pdf") }) - const largeTarget = yield* service.resolveRead({ path: RelativePath.make("large.pdf") }) - expect(Exit.isFailure(yield* service.readToolResolved(smallTarget).pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* service.readToolResolved(largeTarget).pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* service.readToolResolved(largeTarget, { limit: 1 }).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure(yield* service.readTool({ path: RelativePath.make("small.pdf") }).pipe(Effect.exit)), + ).toBe(true) + expect( + Exit.isFailure(yield* service.readTool({ path: RelativePath.make("large.pdf") }).pipe(Effect.exit)), + ).toBe(true) + expect( + Exit.isFailure( + yield* service.readTool({ path: RelativePath.make("large.pdf") }, { limit: 1 }).pipe(Effect.exit), + ), + ).toBe(true) }).pipe(provide(directory)), ), ) @@ -217,42 +213,14 @@ describe("FileSystem", () => { } }) const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("huge.png") }) - const exit = yield* service.readToolResolved(target).pipe(Effect.exit) + const exit = yield* service.readTool({ path: RelativePath.make("huge.png") }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("Media exceeds") }).pipe(provide(directory)), ), ) - it.live("never mixes a sampled image with replacement-path content", () => - withTmp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "race.png") - const moved = path.join(directory, "original.png") - const original = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.alloc(4 * 1024 * 1024, 0x11), - ]) - const replacement = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(1024, 0x22)]) - yield* Effect.promise(() => fs.writeFile(file, original)) - const service = yield* FileSystem.Service - const target = yield* service.resolveRead({ path: RelativePath.make("race.png") }) - const reading = yield* service.readToolResolved(target).pipe(Effect.forkChild) - yield* Effect.promise(async () => { - await fs.rename(file, moved) - await fs.writeFile(file, replacement) - }) - const exit = yield* Fiber.join(reading).pipe(Effect.exit) - if (Exit.isSuccess(exit)) { - expect(exit.value).toMatchObject({ type: "binary", mime: "image/png" }) - if (exit.value.type === "binary") expect(exit.value.content).toBe(original.toString("base64")) - } - }).pipe(provide(directory)), - ), - ) - - it.live("closes validated descriptors after successful and failed reads", () => + it.live("closes descriptors after successful and failed reads", () => withTmp((directory) => { let active = 0 const filesystem = Layer.effect( @@ -280,10 +248,8 @@ describe("FileSystem", () => { ? undefined : yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length)) for (let index = 0; index < 50; index++) { - yield* service.readToolResolved(yield* service.resolveRead({ path: RelativePath.make("text.txt") })) - yield* service - .readToolResolved(yield* service.resolveRead({ path: RelativePath.make("binary.pdf") })) - .pipe(Effect.exit) + yield* service.readTool({ path: RelativePath.make("text.txt") }) + yield* service.readTool({ path: RelativePath.make("binary.pdf") }).pipe(Effect.exit) } expect(active).toBe(0) if (before !== undefined) { diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index f092b2fe64ff..2949ec64bef4 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -37,11 +37,7 @@ const filesystem = Layer.succeed( FileSystem.Service.of({ read: () => Effect.die("unused"), resolveReadPath: () => Effect.die("unused"), - resolveRead: () => Effect.die("unused"), - readResolved: () => Effect.die("unused"), - readSampleResolved: () => Effect.die("unused"), - readTextPageResolved: () => Effect.die("unused"), - readToolResolved: () => Effect.die("unused"), + readTool: () => Effect.die("unused"), list: () => Effect.die("unused"), resolveRoot: (input = {}) => Effect.sync(() => { diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index 7812c3dfdd3b..2e289cabbfa1 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -32,11 +32,7 @@ const filesystem = Layer.succeed( FileSystem.Service.of({ read: () => Effect.die("unused"), resolveReadPath: () => Effect.die("unused"), - resolveRead: () => Effect.die("unused"), - readResolved: () => Effect.die("unused"), - readSampleResolved: () => Effect.die("unused"), - readTextPageResolved: () => Effect.die("unused"), - readToolResolved: () => Effect.die("unused"), + readTool: () => Effect.die("unused"), list: () => Effect.die("unused"), resolveRoot: (input = {}) => Effect.succeed( diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index be7f04950338..be8e456ffb66 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { beforeEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" @@ -7,28 +7,21 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ReadTool } from "@opencode-ai/core/tool/read" -import { RelativePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" const assertions: PermissionV2.AssertInput[] = [] -const reads: FileSystem.ReadInput[] = [] -const samples: number[] = [] -const textPageInputs: FileSystem.TextPageInput[] = [] -const pages: FileSystem.ListTarget[] = [] -const pageInputs: Pick[] = [] -let resolvedInput: FileSystem.ReadInput | undefined +const readCalls: { + input: FileSystem.ReadInput & FileSystem.TextPageInput + page: FileSystem.TextPageInput +}[] = [] +const listCalls: FileSystem.ListPageInput[] = [] +let resolvedType: "file" | "directory" = "file" let resolveFailure: unknown -let listResolveFailure: unknown = new Error("not a directory") -let listReal = "/project/src" -let size = 5 -let real = "/project/README.md" -let afterApproval = () => {} -let readContent: FileSystem.Content = new FileSystem.TextContent({ +let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain", }) -let sample = new TextEncoder().encode("hello") let readFailure: unknown let configEntries: Config.Entry[] = [] const filesystem = Layer.succeed( @@ -37,121 +30,29 @@ const filesystem = Layer.succeed( read: () => Effect.die("unused"), resolveReadPath: (input) => resolveFailure === undefined - ? Effect.succeed({ - type: "file" as const, - target: new FileSystem.ReadTarget({ - real, + ? Effect.succeed( + new FileSystem.ReadPath({ + type: resolvedType, resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`, - size, - dev: 1, }), - }) - : listResolveFailure === undefined - ? Effect.succeed({ - type: "directory" as const, - target: new FileSystem.ListTarget({ - absolute: `/project/${input.path ?? "."}`, - real: listReal, - directory: "/project", - root: "/project", - resource: input.path ?? ".", - }), - }) - : Effect.die(resolveFailure), - resolveRead: (input) => - Effect.sync(() => { - resolvedInput = input - }).pipe( - Effect.andThen( - resolveFailure === undefined - ? Effect.succeed( - new FileSystem.ReadTarget({ - real, - resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`, - size, - dev: 1, - }), - ) - : Effect.die(resolveFailure), - ), - ), - readResolved: () => - readFailure === undefined - ? Effect.sync(() => { - reads.push({ path: RelativePath.make("README.md") }) - return readContent - }) - : Effect.die(readFailure), - readSampleResolved: (_target, maximumBytes) => - Effect.sync(() => { - samples.push(maximumBytes) - return sample.slice(0, maximumBytes) - }), - readTextPageResolved: (_target, page = {}) => - readFailure === undefined - ? Effect.sync(() => { - textPageInputs.push(page) - return new FileSystem.TextPage({ - type: "text-page", - content: "hello", - mime: "text/plain", - offset: page.offset ?? 1, - truncated: true, - next: (page.offset ?? 1) + 1, - }) - }) - : Effect.die(readFailure), - readToolResolved: (_target, page = {}) => { - samples.push(FileSystem.READ_SAMPLE_BYTES) + ) + : Effect.die(resolveFailure), + readTool: (input, page = {}) => { + readCalls.push({ input, page }) if (readFailure !== undefined) return Effect.die(readFailure) - if (sample[0] === 0x89 && sample[1] === 0x50 && sample[2] === 0x4e && sample[3] === 0x47) - return Effect.succeed( - readContent.type === "binary" - ? new FileSystem.BinaryContent({ ...readContent, mime: "image/png" }) - : readContent, - ) - if (FileSystem.isBinary(real.split("/").at(-1) ?? real, sample)) - return Effect.die(new FileSystem.BinaryFileError(real.split("/").at(-1) ?? real)) - if (size > FileSystem.MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined) - return Effect.sync(() => { - textPageInputs.push(page) - return new FileSystem.TextPage({ - type: "text-page", - content: "hello", - mime: "text/plain", - offset: page.offset ?? 1, - truncated: true, - next: (page.offset ?? 1) + 1, - }) - }) - return Effect.sync(() => { - reads.push({ path: RelativePath.make("README.md") }) - return readContent - }) + return Effect.succeed(readResult) }, resolveRoot: () => Effect.die("unused"), revalidateRoot: Effect.succeed, list: () => Effect.die("unused"), - resolveList: (input = {}) => - listResolveFailure === undefined - ? Effect.succeed( - new FileSystem.ListTarget({ - absolute: `/project/${input.path ?? "."}`, - real: listReal, - directory: "/project", - root: "/project", - resource: input.path ?? ".", - }), - ) - : Effect.die(listResolveFailure), + resolveList: () => Effect.die("unused"), listResolved: () => Effect.die("unused"), - listPage: () => Effect.die("unused"), - listPageResolved: (target, page = {}) => + listPage: (input = {}) => Effect.sync(() => { - pages.push(target) - pageInputs.push(page) + listCalls.push(input) return new FileSystem.ListPage({ entries: [], truncated: false }) }), + listPageResolved: () => Effect.die("unused"), find: () => Effect.die("unused"), grep: () => Effect.die("unused"), isIgnored: () => false, @@ -164,7 +65,6 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => { assertions.push(input) - if (allow) afterApproval() }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), @@ -185,21 +85,20 @@ const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, r const sessionID = SessionV2.ID.make("ses_read_tool_test") describe("ReadTool", () => { + beforeEach(() => { + assertions.length = 0 + readCalls.length = 0 + listCalls.length = 0 + allow = true + resolvedType = "file" + resolveFailure = undefined + readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" }) + readFailure = undefined + configEntries = [] + }) + it.effect("registers, authorizes, and reads through the location filesystem", () => Effect.gen(function* () { - assertions.length = 0 - reads.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/README.md" - afterApproval = () => {} - readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" }) - sample = new TextEncoder().encode("hello") - readFailure = undefined - configEntries = [] - resolvedInput = undefined const registry = yield* ToolRegistry.Service expect(yield* registry.definitions()).toMatchObject([{ name: "read" }]) @@ -210,30 +109,19 @@ describe("ReadTool", () => { }), ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(reads).toEqual([{ path: RelativePath.make("README.md") }]) + expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }]) }), ) it.effect("returns a small PNG as native media instead of durable base64 text", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - reads.length = 0 - samples.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = Buffer.from(png, "base64").length - real = "/project/pixel.png" - afterApproval = () => {} - sample = Buffer.from(png, "base64") - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: png, encoding: "base64", mime: "image/png", }) - readFailure = undefined - configEntries = [] const registry = yield* ToolRegistry.Service expect( @@ -248,8 +136,7 @@ describe("ReadTool", () => { { type: "media", mediaType: "image/png", data: png, filename: "pixel.png" }, ], }) - expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES]) - expect(reads).toHaveLength(0) + expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }]) const settled = yield* registry.settle({ sessionID, @@ -264,23 +151,14 @@ describe("ReadTool", () => { }), ) - it.effect("rejects invalid or truncated image data after signature classification", () => + it.effect("rejects invalid image data returned by the filesystem", () => Effect.gen(function* () { - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 8 - real = "/project/truncated.png" - afterApproval = () => {} - sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", - content: Buffer.from(sample).toString("base64"), + content: "iVBORw0KGgo=", encoding: "base64", mime: "image/png", }) - readFailure = undefined - configEntries = [] const registry = yield* ToolRegistry.Service expect( @@ -298,20 +176,12 @@ describe("ReadTool", () => { const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) const base64 = Buffer.from(source.get_bytes()).toString("base64") source.free() - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = Buffer.from(base64, "base64").length - real = "/project/wide.png" - afterApproval = () => {} - sample = Buffer.from(base64, "base64") - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime: "image/png", }) - readFailure = undefined configEntries = [ new Config.Document({ type: "document", @@ -339,20 +209,12 @@ describe("ReadTool", () => { const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) const base64 = Buffer.from(source.get_bytes()).toString("base64") source.free() - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = Buffer.from(base64, "base64").length - real = "/project/wide.png" - afterApproval = () => {} - sample = Buffer.from(base64, "base64") - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime: "image/png", }) - readFailure = undefined configEntries = [ new Config.Document({ type: "document", @@ -382,20 +244,12 @@ describe("ReadTool", () => { it.effect("enforces max base64 bytes after resize attempts", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = Buffer.from(png, "base64").length - real = "/project/pixel.png" - afterApproval = () => {} - sample = Buffer.from(png, "base64") - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: png, encoding: "base64", mime: "image/png", }) - readFailure = undefined configEntries = [ new Config.Document({ type: "document", @@ -417,24 +271,15 @@ describe("ReadTool", () => { }), ) - it.effect("classifies supported image contents before a misleading binary extension", () => + it.effect("returns supported image contents despite a misleading binary extension", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = Buffer.from(png, "base64").length - real = "/project/pixel.bin" - afterApproval = () => {} - sample = Buffer.from(png, "base64") - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: png, encoding: "base64", - mime: "application/octet-stream", + mime: "image/png", }) - readFailure = undefined - configEntries = [] const registry = yield* ToolRegistry.Service expect( @@ -449,19 +294,9 @@ describe("ReadTool", () => { }), ) - it.effect("rejects unsupported binary before direct reads or paging", () => + it.effect("preserves unsupported binary errors from the filesystem", () => Effect.gen(function* () { - reads.length = 0 - textPageInputs.length = 0 - samples.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = FileSystem.MAX_READ_BYTES + 1 - real = "/project/archive.dat" - afterApproval = () => {} - sample = new Uint8Array([0, 1, 2, 3]) - readFailure = undefined + readFailure = new FileSystem.BinaryFileError("archive.dat") const registry = yield* ToolRegistry.Service expect( @@ -475,23 +310,15 @@ describe("ReadTool", () => { }, }), ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) - expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES]) - expect(reads).toEqual([]) - expect(textPageInputs).toEqual([]) + expect(readCalls).toEqual([ + { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }, + ]) }), ) it.effect("does not read when permission is denied", () => Effect.gen(function* () { - assertions.length = 0 - reads.length = 0 allow = false - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/README.md" - afterApproval = () => {} - resolvedInput = undefined const registry = yield* ToolRegistry.Service expect( @@ -500,20 +327,13 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), ).toEqual({ type: "error", value: "Unable to read README.md" }) - expect(reads).toEqual([]) + expect(readCalls).toEqual([]) }), ) it.effect("lists a bounded directory page through read", () => Effect.gen(function* () { - assertions.length = 0 - pages.length = 0 - pageInputs.length = 0 - allow = true - resolveFailure = new Error("Path is not a file") - listResolveFailure = undefined - listReal = "/project/src" - afterApproval = () => {} + resolvedType = "directory" const registry = yield* ToolRegistry.Service expect( @@ -528,18 +348,14 @@ describe("ReadTool", () => { }), ).toEqual({ type: "json", value: { entries: [], truncated: false } }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) - expect(pageInputs).toEqual([{ offset: 2, limit: 10 }]) + expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }]) }), ) it.effect("does not list a directory when permission is denied", () => Effect.gen(function* () { - pages.length = 0 allow = false - resolveFailure = new Error("Path is not a file") - listResolveFailure = undefined - listReal = "/project/src" - afterApproval = () => {} + resolvedType = "directory" const registry = yield* ToolRegistry.Service expect( @@ -548,43 +364,12 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, }), ).toEqual({ type: "error", value: "Unable to read src" }) - expect(pages).toEqual([]) - }), - ) - - it.effect("does not list when the directory changes after permission approval", () => - Effect.gen(function* () { - pages.length = 0 - allow = true - resolveFailure = new Error("Path is not a file") - listResolveFailure = undefined - listReal = "/project/src" - afterApproval = () => { - listReal = "/outside/src" - } - const registry = yield* ToolRegistry.Service - - expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } }, - }), - ).toEqual({ type: "error", value: "Unable to read src" }) - expect(pages).toEqual([]) + expect(listCalls).toEqual([]) }), ) it.effect("authorizes project references with their canonical identity", () => Effect.gen(function* () { - assertions.length = 0 - reads.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/README.md" - afterApproval = () => {} - resolvedInput = undefined const registry = yield* ToolRegistry.Service yield* registry.execute({ @@ -598,14 +383,9 @@ describe("ReadTool", () => { it.effect("settles missing files as typed tool errors", () => Effect.gen(function* () { - allow = true - reads.length = 0 - real = "/project/README.md" - afterApproval = () => {} const registry = yield* ToolRegistry.Service resolveFailure = new Error("missing") - listResolveFailure = new Error("missing") expect( yield* registry.execute({ sessionID, @@ -613,21 +393,20 @@ describe("ReadTool", () => { }), ).toEqual({ type: "error", value: "Unable to read missing.txt" }) - expect(reads).toEqual([]) + expect(readCalls).toEqual([]) }), ) - it.effect("reads large UTF-8 text files as bounded pages with continuation", () => + it.effect("forwards pagination and returns bounded text pages with continuation", () => Effect.gen(function* () { - textPageInputs.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = FileSystem.MAX_READ_BYTES + 1 - real = "/project/large.txt" - afterApproval = () => {} - sample = new TextEncoder().encode("hello") - readFailure = undefined + readResult = new FileSystem.TextPage({ + type: "text-page", + content: "hello", + mime: "text/plain", + offset: 2, + truncated: true, + next: 3, + }) const registry = yield* ToolRegistry.Service expect( @@ -644,66 +423,13 @@ describe("ReadTool", () => { type: "json", value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) - expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }]) - }), - ) - - it.effect("preserves safe read limit errors", () => - Effect.gen(function* () { - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/changed.txt" - afterApproval = () => {} - sample = new TextEncoder().encode("hello") - readFailure = new FileSystem.ReadLimitError("changed.txt", FileSystem.MAX_READ_BYTES) - const registry = yield* ToolRegistry.Service - - expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-read-limit", name: "read", input: { path: "changed.txt" } }, - }), - ).toEqual({ - type: "error", - value: `File exceeds ${FileSystem.MAX_READ_BYTES} byte read limit: changed.txt`, - }) - }), - ) - - it.effect("preserves binary errors discovered after the sample", () => - Effect.gen(function* () { - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = FileSystem.MAX_READ_BYTES + 1 - real = "/project/late-binary" - afterApproval = () => {} - sample = new TextEncoder().encode("text prefix") - readFailure = new FileSystem.BinaryFileError("late-binary") - const registry = yield* ToolRegistry.Service - - expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-late-binary", name: "read", input: { path: "late-binary" } }, - }), - ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) + expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }]) }), ) it.effect("rejects unsupported binary discovered by a direct read", () => Effect.gen(function* () { - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/late-binary" - afterApproval = () => {} - sample = new TextEncoder().encode("text prefix") - readFailure = undefined - readContent = new FileSystem.BinaryContent({ + readResult = new FileSystem.BinaryContent({ type: "binary", content: "AAECAw==", encoding: "base64", @@ -719,29 +445,4 @@ describe("ReadTool", () => { ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) }), ) - - it.effect("does not read when the file changes after permission approval", () => - Effect.gen(function* () { - assertions.length = 0 - reads.length = 0 - allow = true - resolveFailure = undefined - listResolveFailure = new Error("not a directory") - size = 5 - real = "/project/README.md" - sample = new TextEncoder().encode("hello") - readFailure = undefined - afterApproval = () => { - real = "/outside/README.md" - } - const registry = yield* ToolRegistry.Service - expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } }, - }), - ).toEqual({ type: "error", value: "Unable to read README.md" }) - expect(reads).toEqual([]) - }), - ) })