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

@ -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"
}