From 210650915707323f350479e16188a9d826924b44 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 22 Aug 2026 15:34:00 +0800 Subject: [PATCH] fix(server): return 404 for missing files --- packages/core/src/filesystem.ts | 39 +++++++++++++++++++++++++----- packages/server/src/handlers/fs.ts | 13 ++++++---- packages/server/test/fetch.test.ts | 25 +++++++++++++++++++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 1fc9b545deee..e761693c01f0 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -15,6 +15,10 @@ export const ReadInput = Schema.Struct({ }) export type ReadInput = typeof ReadInput.Type +export class NotFoundError extends Schema.TaggedError()("FileSystem.NotFoundError", { + path: RelativePath, +}) {} + export const Content = Schema.Struct({ uri: Schema.String, name: Schema.String.pipe(Schema.optional), @@ -50,7 +54,9 @@ export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ export const Event = FileSystem.Event export interface Interface { - readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> + readonly read: ( + input: ReadInput, + ) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError> readonly list: (input?: ListInput) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect } @@ -68,23 +74,44 @@ const baseLayer = Layer.effect( const absolute = path.resolve(location.directory, input ?? ".") if (!FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the location")) - const real = yield* fs.realPath(absolute).pipe(Effect.orDie) + const real = yield* fs.realPath(absolute) if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) return { absolute, real, directory: location.directory } }) return Service.of({ find: search.find, read: Effect.fn("FileSystem.read")(function* (input) { - const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) + const target = yield* resolve(input.path).pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.fail(new NotFoundError({ path: input.path })), + (_, error) => Effect.die(error), + ), + ) + const info = yield* fs.stat(target.real).pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.fail(new NotFoundError({ path: input.path })), + (_, error) => Effect.die(error), + ), + ) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) return { - content: yield* fs.readFile(target.real).pipe(Effect.orDie), + content: yield* fs.readFile(target.real).pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.fail(new NotFoundError({ path: input.path })), + (_, error) => Effect.die(error), + ), + ), mime: FSUtil.mimeType(target.real), } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { - const target = yield* resolve(input.path) + const target = yield* resolve(input.path).pipe(Effect.orDie) const info = yield* fs.stat(target.real).pipe(Effect.orDie) if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) return yield* fs.readDirectoryEntries(target.real).pipe( diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index d623f2d7e64a..d13b3be0cb77 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -12,11 +12,14 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler .handleRaw("fs.read", (ctx) => Effect.gen(function* () { const fs = yield* FileSystem.Service - const file = yield* fs.read({ - path: RelativePath.make( - decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), - ), - }) + const file = yield* fs + .read({ + path: RelativePath.make( + decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), + ), + }) + .pipe(Effect.catchTag("FileSystem.NotFoundError", () => Effect.succeed(undefined))) + if (!file) return HttpServerResponse.empty({ status: 404 }) return HttpServerResponse.uint8Array(file.content, { contentType: file.mime }) }), ) diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 1e73946a0fd3..1127ad45c571 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -1,5 +1,8 @@ import { expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" import { Effect } from "effect" +import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerFetch } from "../src/fetch" @@ -52,6 +55,28 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c }).pipe(Effect.scoped), ) +it.live("returns 404 when a previously readable file is deleted", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir("opencode-fs-read-endpoint-")), + (tmp) => + Effect.gen(function* () { + const handler = yield* ServerFetch.make(options) + const file = path.join(tmp.path, "deleted.txt") + yield* Effect.promise(() => fs.writeFile(file, "content")) + const url = new URL("http://opencode.local/api/fs/read/deleted.txt") + url.searchParams.set("location[directory]", tmp.path) + + const readable = yield* Effect.promise(() => handler(new Request(url))) + expect(readable.status).toBe(200) + + yield* Effect.promise(() => fs.unlink(file)) + const missing = yield* Effect.promise(() => handler(new Request(url))) + expect(missing.status).toBe(404) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.scoped), +) + it.live("serves the session view operation and missing-session error", () => Effect.gen(function* () { const handler = yield* ServerFetch.make(options)