Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(selected).pipe(Effect.orDie)
const real = yield* fs.realPath(absolute)
const root = yield* fs.realPath(selected)
if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
Expand Down
39 changes: 37 additions & 2 deletions packages/core/test/tool-read.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { Effect, Exit, Layer, PlatformError } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
Expand All @@ -18,6 +18,8 @@ import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"

const assertions: PermissionV2.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = `${process.cwd()}/${missingPath}`
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
Expand Down Expand Up @@ -70,7 +72,24 @@ const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () =>
const image = Image.layer.pipe(Layer.provide(config))
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))),
FSUtil.Service.use((fs) =>
Effect.succeed(
FSUtil.Service.of({
...fs,
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: path,
}),
)
: Effect.succeed(path),
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const infrastructure = Layer.mergeAll(
testFileSystem,
Expand Down Expand Up @@ -453,6 +472,22 @@ describe("ReadTool", () => {
}),
)

it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service

expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
}),
).toEqual({ type: "error", value: `Unable to read ${missingPath}` })
expect(assertions).toEqual([])
expect(readCalls).toEqual([])
}),
)

it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
resolvedType = "directory"
Expand Down
Loading