fix(core): make V2 reads media-aware and binary-safe (#31038)

This commit is contained in:
Kit Langton 2026-06-05 19:48:34 -04:00 committed by GitHub
commit 83dca45dd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1709 additions and 120 deletions

View file

@ -91,6 +91,7 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",

View file

@ -23,9 +23,89 @@ export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const READ_SAMPLE_BYTES = 4 * 1024
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}`)
this.name = "BinaryFileError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
export const isBinary = (resource: string, bytes: Uint8Array) => {
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const supportedImageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
@ -158,7 +238,9 @@ export interface Interface {
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
readonly readSampleResolved: (target: ReadTarget, maximumBytes: number) => Effect.Effect<Uint8Array>
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
readonly readToolResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<Content | TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Select a contained canonical read root without asserting leaf policy. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
@ -330,15 +412,29 @@ export const layer = Layer.effect(
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 Error(`File exceeds ${maximumBytes} byte read limit`))
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 Error(`File exceeds ${maximumBytes} byte read limit`))
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 = {},
@ -391,7 +487,7 @@ export const layer = Layer.effect(
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 Error("Cannot page binary file"))
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")
@ -433,6 +529,148 @@ export const layer = Layer.effect(
}),
)
})
const readToolResolved = Effect.fn("FileSystem.readToolResolved")(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 first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = supportedImageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
return new BinaryContent({
type: "binary",
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64",
mime,
})
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first))
return yield* Effect.die(new BinaryFileError(target.resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
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))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) })
}
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
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
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
line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(target.resource)
let text = decoder.decode(chunk, { 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)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
const text = lines.join("\n")
return new TextPage({
type: "text-page",
content: text,
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
@ -528,7 +766,9 @@ export const layer = Layer.effect(
resolveReadPath,
resolveRead,
readResolved,
readSampleResolved,
readTextPageResolved,
readToolResolved,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
}),

View file

@ -81,6 +81,13 @@ const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
.map((item) =>
item.type === "text" ? item.text : `[Attached ${item.mime}${item.name === undefined ? "" : `: ${item.name}`}]`,
)
.join("\n")
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
@ -95,7 +102,7 @@ const serialize = (message: SessionMessage.Message) => {
if (part.state.status === "completed")
return [
`[Assistant tool call]: ${part.name}(${input})`,
`[Tool result]: ${truncate(JSON.stringify(part.state.content))}`,
`[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
]
if (part.state.status === "error")
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]

View file

@ -350,7 +350,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
...result,
outputPaths,
result: event.result,
...(provider.executed ? { result: event.result } : {}),
provider,
})
return

View file

@ -1,12 +1,46 @@
export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Cause, Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_WIDTH = 2_000
const MAX_IMAGE_HEIGHT = 2_000
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
class ImageDecodeError extends Error {
constructor(readonly resource: string) {
super(`Image could not be decoded: ${resource}`)
this.name = "ImageDecodeError"
}
}
class ImageSizeError extends Error {
constructor(
readonly resource: string,
readonly width: number,
readonly height: number,
readonly bytes: number,
readonly maxWidth: number,
readonly maxHeight: number,
readonly maxBytes: number,
) {
super(
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
)
this.name = "ImageSizeError"
}
}
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
@ -21,15 +55,38 @@ const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSyste
const definition = Tool.make({
description:
"Read a text or binary file, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
parameters: Input,
success: Success,
toStructuredOutput: (output) =>
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
? { type: "media", mime: output.mime }
: output,
toModelOutput: ({ parameters, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: output.content },
mime: output.mime,
name: parameters.path,
},
]
},
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* registry.contribute((editor) =>
editor.set(name, {
@ -60,21 +117,118 @@ export const layer = Layer.effectDiscard(
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"))
if (
final.target.size > FileSystem.MAX_READ_BYTES ||
input.offset !== undefined ||
input.limit !== undefined
)
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
const content = yield* filesystem.readToolResolved(final.target, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
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),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.die(
new ImageSizeError(
final.target.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* Effect.die(
new ImageSizeError(
final.target.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
} finally {
decoded.free()
}
}
if (content.type === "binary")
return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource))
return content
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to read ${input.path}`,
error: Cause.squash(cause),
}),
),
Effect.gen(function* () {
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
? error.message
: `Unable to read ${input.path}`
return yield* new ToolFailure({ message, error })
}),
),
)
},
@ -85,5 +239,6 @@ export const layer = Layer.effectDiscard(
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
)

View file

@ -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, Layer, Schema } from "effect"
import { Effect, Exit, Fiber, 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"
@ -97,6 +97,24 @@ describe("FileSystem", () => {
),
)
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)
}).pipe(provide(directory)),
),
)
it.live("pages large UTF-8 text files by line with continuation", () =>
withTmp((directory) =>
Effect.gen(function* () {
@ -132,6 +150,152 @@ describe("FileSystem", () => {
),
)
it.live("rejects paged text when a late NUL appears after the requested page", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "late-binary.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]),
),
)
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)
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when invalid UTF-8 appears near EOF", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "invalid-utf8.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]),
),
)
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)
}).pipe(provide(directory)),
),
)
it.live("rejects PDFs for direct, large, and paged reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const small = path.join(directory, "small.pdf")
const large = path.join(directory, "large.pdf")
yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall"))
yield* Effect.promise(() =>
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)
}).pipe(provide(directory)),
),
)
it.live("rejects signature-bearing media beyond the ingestion cap before loading", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "huge.png")
yield* Effect.promise(async () => {
const handle = await fs.open(file, "w")
try {
await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0)
await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1)
} finally {
await handle.close()
}
})
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("huge.png") })
const exit = yield* service.readToolResolved(target).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", () =>
withTmp((directory) => {
let active = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
open: (target, options) =>
Effect.acquireRelease(
service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
const text = path.join(directory, "text.txt")
const binary = path.join(directory, "binary.pdf")
yield* Effect.promise(() => fs.writeFile(text, "hello"))
yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7"))
const service = yield* FileSystem.Service
const before =
process.platform === "win32"
? 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)
}
expect(active).toBe(0)
if (before !== undefined) {
const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
expect(after).toBeLessThanOrEqual(before + 2)
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {

View file

@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: base64 },
mime: "image/png",
name: "pixel.png",
},
])
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})

View file

@ -109,7 +109,7 @@ Recent work
])
})
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
@ -140,7 +140,7 @@ Recent work
status: "running",
input: { path: "README.md" },
content: [],
structured: {},
structured: { type: "media", mime: "image/png" },
}),
time: { created },
}),

View file

@ -0,0 +1,127 @@
import { expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLMEvent } from "@opencode-ai/llm"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = () => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events = EventV2.Service.of({
publish: (definition, data) =>
Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
published.push({
type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type,
data,
})
return event
}),
subscribe: () => Stream.empty,
all: () => Stream.empty,
aggregateEvents: () => Stream.empty,
sync: () => Effect.succeed(Effect.void),
listen: () => Effect.succeed(Effect.void),
beforeCommit: () => Effect.void,
project: () => Effect.void,
replay: () => Effect.void,
replayAll: () => Effect.succeed(undefined),
remove: () => Effect.void,
claim: () => Effect.void,
})
return {
published,
publisher: createLLMEventPublisher(events, {
sessionID,
agent: "build",
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
}),
}
}
const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
const result = LLMEvent.toolResult({
id: "call-image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: base64, filename: "pixel.png" },
],
},
output: {
structured: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png", name: "pixel.png" },
],
},
})
test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "session.next.tool.success.1")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
expect(success?.data).not.toHaveProperty("result")
expect(success?.data).toMatchObject({
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" },
],
})
})
test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "session.next.tool.success.1")
expect(success?.data).toHaveProperty("result")
})
test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: { type: "error", value: "Cannot read binary file" },
}),
),
)
expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
timestamp: Date.now(),
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" }],
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: base64 }] },
provider: { executed: false },
})
expect(decoded.result).toMatchObject({ type: "content" })
})

View file

@ -39,7 +39,9 @@ const filesystem = Layer.succeed(
resolveReadPath: () => Effect.die("unused"),
resolveRead: () => Effect.die("unused"),
readResolved: () => Effect.die("unused"),
readSampleResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
readToolResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.sync(() => {

View file

@ -34,7 +34,9 @@ const filesystem = Layer.succeed(
resolveReadPath: () => Effect.die("unused"),
resolveRead: () => Effect.die("unused"),
readResolved: () => Effect.die("unused"),
readSampleResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
readToolResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.succeed(

View file

@ -1,5 +1,7 @@
import { 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"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
@ -10,6 +12,7 @@ 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<FileSystem.ListPageInput, "offset" | "limit">[] = []
@ -20,6 +23,14 @@ let listReal = "/project/src"
let size = 5
let real = "/project/README.md"
let afterApproval = () => {}
let readContent: FileSystem.Content = 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(
FileSystem.Service,
FileSystem.Service.of({
@ -30,7 +41,7 @@ const filesystem = Layer.succeed(
type: "file" as const,
target: new FileSystem.ReadTarget({
real,
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
size,
dev: 1,
}),
@ -56,7 +67,7 @@ const filesystem = Layer.succeed(
? Effect.succeed(
new FileSystem.ReadTarget({
real,
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
size,
dev: 1,
}),
@ -65,22 +76,59 @@ const filesystem = Layer.succeed(
),
),
readResolved: () =>
readFailure === undefined
? Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return readContent
})
: Effect.die(readFailure),
readSampleResolved: (_target, maximumBytes) =>
Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
samples.push(maximumBytes)
return sample.slice(0, maximumBytes)
}),
readTextPageResolved: (_target, page = {}) =>
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,
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)
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
})
},
resolveRoot: () => Effect.die("unused"),
revalidateRoot: Effect.succeed,
list: () => Effect.die("unused"),
@ -126,8 +174,14 @@ const permission = Layer.succeed(
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const read = ReadTool.layer.pipe(Layer.provide(registry), Layer.provide(filesystem), Layer.provide(permission))
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, read))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
@ -141,6 +195,10 @@ describe("ReadTool", () => {
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
@ -156,6 +214,273 @@ describe("ReadTool", () => {
}),
)
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({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
],
})
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
expect(reads).toHaveLength(0)
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
])
}),
)
it.effect("rejects invalid or truncated image data after signature classification", () =>
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({
type: "binary",
content: Buffer.from(sample).toString("base64"),
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
}),
)
it.effect("rejects oversized images when resizing is disabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
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({
type: "binary",
content: base64,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
}),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
}),
)
it.effect("resizes images to configured dimensions before returning media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
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({
type: "binary",
content: base64,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(media?.type).toBe("media")
if (media?.type !== "media") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
expect(resized.get_width()).toBeLessThanOrEqual(4)
expect(resized.get_height()).toBeLessThanOrEqual(2_000)
resized.free()
}),
)
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({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
}),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("/1 bytes")
}),
)
it.effect("classifies supported image contents before 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({
type: "binary",
content: png,
encoding: "base64",
mime: "application/octet-stream",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
})
}),
)
it.effect("rejects unsupported binary before direct reads or paging", () =>
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
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
expect(reads).toEqual([])
expect(textPageInputs).toEqual([])
}),
)
it.effect("does not read when permission is denied", () =>
Effect.gen(function* () {
assertions.length = 0
@ -301,6 +626,8 @@ describe("ReadTool", () => {
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/large.txt"
afterApproval = () => {}
sample = new TextEncoder().encode("hello")
readFailure = undefined
const registry = yield* ToolRegistry.Service
expect(
@ -321,6 +648,78 @@ describe("ReadTool", () => {
}),
)
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" })
}),
)
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({
type: "binary",
content: "AAECAw==",
encoding: "base64",
mime: "application/octet-stream",
})
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}),
).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
@ -330,6 +729,8 @@ describe("ReadTool", () => {
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
sample = new TextEncoder().encode("hello")
readFailure = undefined
afterApproval = () => {
real = "/outside/README.md"
}