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