Compare commits

...
10 changed files with 190 additions and 12 deletions
@@ -1515,7 +1515,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/read/${encodePath(input.path)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [404, 401, 400],
empty: false,
binary: true,
},
@@ -2391,6 +2391,10 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type FileNotFoundError = { readonly _tag: "FileNotFoundError"; readonly path: string; readonly message: string }
export const isFileNotFoundError = (value: unknown): value is FileNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FileNotFoundError"
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
+33 -6
View File
@@ -15,6 +15,10 @@ export const ReadInput = Schema.Struct({
})
export type ReadInput = typeof ReadInput.Type
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("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<GrepInput>("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<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
}
@@ -74,23 +80,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(
+33
View File
@@ -8370,6 +8370,16 @@
}
}
}
},
"404": {
"description": "FileNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileNotFoundErrorEncoded"
}
}
}
}
},
"description": "Serve one file relative to the requested location.",
@@ -13703,6 +13713,23 @@
"required": ["file", "patch", "additions", "deletions", "status"],
"additionalProperties": false
},
"FileNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["FileNotFoundError"]
},
"path": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "path", "message"],
"additionalProperties": false
},
"FileSystem.Entry": {
"type": "object",
"properties": {
@@ -14846,6 +14873,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15386,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
+9
View File
@@ -71,6 +71,15 @@ export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundErro
{ httpApiStatus: 404 },
) {}
export class FileNotFoundError extends Schema.TaggedError<FileNotFoundError>()(
"FileNotFoundError",
{
path: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
"AgentNotFoundError",
{
+2
View File
@@ -3,6 +3,7 @@ import { Location } from "@opencode-ai/schema/location"
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { FileNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const ListQuery = Schema.Struct({
@@ -22,6 +23,7 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
HttpApiEndpoint.get("fs.read", "/api/fs/read/*", {
query: LocationQuery,
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
error: FileNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
+12 -5
View File
@@ -1,5 +1,6 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { RelativePath } from "@opencode-ai/core/schema"
import { FileNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -12,11 +13,17 @@ 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.mapError(
(error) => new FileNotFoundError({ path: error.path, message: `File not found: ${error.path}` }),
),
)
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
}),
)
+30
View File
@@ -1,9 +1,12 @@
import { expect } from "bun:test"
import fs from "node:fs/promises"
import { createServer, type Server } from "node:http"
import path from "node:path"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -96,6 +99,33 @@ 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)
expect(yield* Effect.promise(() => missing.json())).toEqual({
_tag: "FileNotFoundError",
path: "deleted.txt",
message: "File not found: deleted.txt",
})
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.scoped),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
Effect.gen(function* () {
const requests: string[] = []
+33
View File
@@ -8370,6 +8370,16 @@
}
}
}
},
"404": {
"description": "FileNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileNotFoundErrorEncoded"
}
}
}
}
},
"description": "Serve one file relative to the requested location.",
@@ -13703,6 +13713,23 @@
"required": ["file", "patch", "additions", "deletions", "status"],
"additionalProperties": false
},
"FileNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["FileNotFoundError"]
},
"path": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "path", "message"],
"additionalProperties": false
},
"FileSystem.Entry": {
"type": "object",
"properties": {
@@ -14846,6 +14873,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15386,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
+33
View File
@@ -8370,6 +8370,16 @@
}
}
}
},
"404": {
"description": "FileNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileNotFoundErrorEncoded"
}
}
}
}
},
"description": "Serve one file relative to the requested location.",
@@ -13703,6 +13713,23 @@
"required": ["file", "patch", "additions", "deletions", "status"],
"additionalProperties": false
},
"FileNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["FileNotFoundError"]
},
"path": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "path", "message"],
"additionalProperties": false
},
"FileSystem.Entry": {
"type": "object",
"properties": {
@@ -14846,6 +14873,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15386,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},