From 823d327401ba93d24174c9feb50b5dbe4f60f646 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 19:50:14 +0200 Subject: [PATCH] fix(core): handle missing read paths (#33255) --- packages/core/src/tool/read.ts | 4 +-- packages/core/test/tool-read.test.ts | 39 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 64f02d813..22ec57b94 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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("\\", "/") || "." diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 605a1e17d..1d9553c77 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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" @@ -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 @@ -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, @@ -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"