From 5937e606df3559e221a7edb27b8400a617d98b5a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 2 Jun 2026 01:54:10 -0400 Subject: [PATCH] feat(opencode): add filesystem read and list routes --- packages/core/src/location-filesystem.ts | 83 +++++++++++++++-- .../core/test/location-filesystem.test.ts | 92 +++++++++++++++++++ .../routes/instance/httpapi/groups/v2.ts | 2 + .../routes/instance/httpapi/groups/v2/fs.ts | 54 +++++++++++ .../instance/httpapi/groups/v2/location.ts | 3 +- .../routes/instance/httpapi/handlers/v2.ts | 2 + .../routes/instance/httpapi/handlers/v2/fs.ts | 12 +++ .../test/server/httpapi-exercise/index.ts | 14 +-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 77 ++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 90 ++++++++++++++++++ 10 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 packages/core/test/location-filesystem.test.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts diff --git a/packages/core/src/location-filesystem.ts b/packages/core/src/location-filesystem.ts index 2acec8e02..4aadd598a 100644 --- a/packages/core/src/location-filesystem.ts +++ b/packages/core/src/location-filesystem.ts @@ -3,6 +3,7 @@ export * as LocationFileSystem from "./location-filesystem" import path from "path" import { pathToFileURL } from "url" import { Context, Effect, Layer, Schema } from "effect" +import { AppFileSystem } from "./filesystem" import { Location } from "./location" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -11,13 +12,22 @@ export const ReadInput = Schema.Struct({ }) export type ReadInput = typeof ReadInput.Type -export class Content extends Schema.Class("LocationFileSystem.Content")({ - type: Schema.Literals(["text", "binary"]), +export class TextContent extends Schema.Class("LocationFileSystem.TextContent")({ + type: Schema.Literal("text"), content: Schema.String, - encoding: Schema.Literal("base64").pipe(Schema.optional), - mime: Schema.String.pipe(Schema.optional), + mime: Schema.String, }) {} +export class BinaryContent extends Schema.Class("LocationFileSystem.BinaryContent")({ + type: Schema.Literal("binary"), + content: Schema.String, + encoding: Schema.Literal("base64"), + mime: Schema.String, +}) {} + +export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type")) +export type Content = typeof Content.Type + export const ListInput = Schema.Struct({ path: RelativePath.pipe(Schema.optional), }) @@ -70,7 +80,33 @@ export class Service extends Context.Service()("@opencode/v2 export const locationLayer = Layer.effect( Service, Effect.gen(function* () { + const fs = yield* AppFileSystem.Service const location = yield* Location.Service + const root = yield* fs.realPath(location.directory).pipe(Effect.orDie) + const resolve = Effect.fnUntraced(function* (input?: RelativePath) { + if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location")) + const absolute = path.resolve(location.directory, input ?? ".") + if (!AppFileSystem.contains(location.directory, absolute)) + return yield* Effect.die(new Error("Path escapes the location")) + const real = yield* fs.realPath(absolute).pipe(Effect.orDie) + if (!AppFileSystem.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + return { absolute, real } + }) + const entry = Effect.fnUntraced(function* (absolute: string) { + const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void)) + if (!real) return + if (!AppFileSystem.contains(root, real)) return + const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void)) + if (!info) return + const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined + if (!type) return + return new Entry({ + path: RelativePath.make(path.relative(location.directory, absolute)), + uri: pathToFileURL(real).href, + type, + mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real), + }) + }) const entries = [ new Entry({ path: RelativePath.make("README.md"), @@ -87,11 +123,42 @@ export const locationLayer = Layer.effect( ] return Service.of({ - read: Effect.fn("LocationFileSystem.read")(function* () { - return new Content({ type: "text", content: "# opencode\n", mime: "text/markdown" }) + read: Effect.fn("LocationFileSystem.read")(function* (input) { + const file = yield* resolve(input.path) + const info = yield* fs.stat(file.real).pipe(Effect.orDie) + if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie) + const mime = AppFileSystem.mimeType(file.real) + if (!bytes.includes(0)) { + const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe( + Effect.option, + ) + if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime }) + } + return new BinaryContent({ + type: "binary", + content: Buffer.from(bytes).toString("base64"), + encoding: "base64", + mime, + }) }), - list: Effect.fn("LocationFileSystem.list")(function* () { - return entries + list: Effect.fn("LocationFileSystem.list")(function* (input = {}) { + const directory = yield* resolve(input.path) + const info = yield* fs.stat(directory.real).pipe(Effect.orDie) + if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) + return yield* fs.readDirectoryEntries(directory.real).pipe( + Effect.orDie, + Effect.flatMap((items) => + Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name)), { + concurrency: "unbounded", + }), + ), + Effect.map((items) => + items + .filter((item): item is Entry => item !== undefined) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), + ), + ) }), find: Effect.fn("LocationFileSystem.find")(function* (input) { return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit) diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts new file mode 100644 index 000000000..de0cefa3c --- /dev/null +++ b/packages/core/test/location-filesystem.test.ts @@ -0,0 +1,92 @@ +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" +import { describe, expect } from "bun:test" +import { Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Location } from "@opencode-ai/core/location" +import { LocationFileSystem } from "@opencode-ai/core/location-filesystem" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { tmpdir } from "./fixture/tmpdir" +import { location } from "./fixture/location" +import { it } from "./lib/effect" + +function provide(directory: string) { + return Effect.provide( + LocationFileSystem.locationLayer.pipe( + Layer.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ), + ), + ), + ) +} + +function withTmp(f: (directory: string) => Effect.Effect) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(tmp.path).pipe(provide(tmp.path)))) +} + +describe("LocationFileSystem", () => { + it.live("reads text and binary files", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello")) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2]))) + const service = yield* LocationFileSystem.Service + + expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({ + type: "text", + content: "hello", + mime: "text/plain", + }) + expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({ + type: "binary", + content: "AAEC", + encoding: "base64", + mime: "application/octet-stream", + }) + }), + ), + ) + + it.live("lists direct children with relative paths and resolved URIs", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) + const service = yield* LocationFileSystem.Service + + expect(yield* service.list()).toEqual([ + { + path: RelativePath.make("src"), + uri: pathToFileURL(path.join(directory, "src")).href, + type: "directory", + mime: "application/x-directory", + }, + { + path: RelativePath.make("README.md"), + uri: pathToFileURL(path.join(directory, "README.md")).href, + type: "file", + mime: "text/markdown", + }, + ]) + }), + ), + ) + + it.live("rejects paths outside the location", () => + withTmp((directory) => + Effect.gen(function* () { + const service = yield* LocationFileSystem.Service + expect( + Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)), + ).toBe(true) + }), + ), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index d65e0a380..6e704956b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -4,6 +4,7 @@ import { ModelGroup } from "./v2/model" import { ProviderGroup } from "./v2/provider" import { SessionGroup } from "./v2/session" import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission" +import { FileSystemGroup } from "./v2/fs" export const V2Api = HttpApi.make("v2") .add(SessionGroup) @@ -13,6 +14,7 @@ export const V2Api = HttpApi.make("v2") .add(PermissionGroup) .add(SessionPermissionGroup) .add(PermissionSavedGroup) + .add(FileSystemGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts new file mode 100644 index 000000000..93472aa7a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts @@ -0,0 +1,54 @@ +import { LocationFileSystem } from "@opencode-ai/core/location-filesystem" +import { RelativePath } from "@opencode-ai/core/schema" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { V2Authorization } from "../../middleware/authorization" +import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" + +const ReadQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath, +}) + +const ListQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath.pipe(Schema.optional), +}) + +export const FileSystemGroup = HttpApiGroup.make("v2.fs") + .add( + HttpApiEndpoint.get("read", "/api/fs/read", { + query: ReadQuery, + success: LocationFileSystem.Content, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.read", + summary: "Read file", + description: "Read one file relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("list", "/api/fs/list", { + query: ListQuery, + success: Schema.Array(LocationFileSystem.Entry), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.list", + summary: "List directory", + description: "List direct children of one directory relative to the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 filesystem", + description: "Experimental v2 location-scoped filesystem routes.", + }), + ) + .middleware(V2LocationMiddleware) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index 6f1e47bca..e8e9c0fcb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,6 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationFileSystem } from "@opencode-ai/core/location-filesystem" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" @@ -35,7 +36,7 @@ export const locationQueryOpenApi = OpenApi.annotations({ export class V2LocationMiddleware extends HttpApiMiddleware.Service< V2LocationMiddleware, { - provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service + provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service | LocationFileSystem.Service } >()("@opencode/ExperimentalHttpApiV2Location") {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index d9f1bb7b8..245d79a47 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -8,6 +8,7 @@ import { modelHandlers } from "./v2/model" import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission" +import { fileSystemHandlers } from "./v2/fs" export const v2Handlers = Layer.mergeAll( sessionHandlers, @@ -17,6 +18,7 @@ export const v2Handlers = Layer.mergeAll( permissionHandlers, sessionPermissionHandlers, savedPermissionHandlers, + fileSystemHandlers, ).pipe( Layer.provide(v2LocationLayer), Layer.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts new file mode 100644 index 000000000..187ddcbcd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts @@ -0,0 +1,12 @@ +import { LocationFileSystem } from "@opencode-ai/core/location-filesystem" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) => + Effect.gen(function* () { + return handlers + .handle("read", (ctx) => LocationFileSystem.Service.use((fs) => fs.read(ctx.query))) + .handle("list", (ctx) => LocationFileSystem.Service.use((fs) => fs.list(ctx.query))) + }), +) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 19c2aa4c1..078db9c73 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -575,6 +575,12 @@ const scenarios: Scenario[] = [ ), http.protected.get("/api/model", "v2.model.list").json(200, array), http.protected.get("/api/provider", "v2.provider.list").json(200, array), + http.protected + .get("/api/fs/read", "v2.fs.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() })) + .json(200, object), + http.protected.get("/api/fs/list", "v2.fs.list").json(200, array), http.protected .get("/api/provider/{providerID}", "v2.provider.get") .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) @@ -645,13 +651,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: `/api/session?${new URLSearchParams({ limit: "2", - directory: ctx.directory ?? "", cursor: cursor({ - id: "ses_httpapi_missing", - time: 0, order: "desc", - direction: "next", directory: ctx.directory, + anchor: { id: "ses_httpapi_missing", time: 0, direction: "next" }, }), })}`, headers: ctx.headers(), @@ -669,8 +672,7 @@ const scenarios: Scenario[] = [ .get("/api/session", "v2.session.list.cursor.invalid") .at((ctx) => ({ path: `/api/session?${new URLSearchParams({ - cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), - search: "not-allowed-with-cursor", + cursor: "invalid", })}`, headers: ctx.headers(), })) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 5fbc54e65..77efd9885 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -247,6 +247,10 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2FsListErrors, + V2FsListResponses, + V2FsReadErrors, + V2FsReadResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -4727,6 +4731,74 @@ export class Permission3 extends HeyApiClient { } } +export class Fs extends HeyApiClient { + /** + * Read file + * + * Read one file relative to the requested location. + */ + public read( + parameters: { + location?: { + directory?: string + workspace?: string + } + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/read", + ...options, + ...params, + }) + } + + /** + * List directory + * + * List direct children of one directory relative to the requested location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + path?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/list", + ...options, + ...params, + }) + } +} + export class V2 extends HeyApiClient { private _session?: Session3 get session(): Session3 { @@ -4747,6 +4819,11 @@ export class V2 extends HeyApiClient { get permission(): Permission3 { return (this._permission ??= new Permission3({ client: this.client })) } + + private _fs?: Fs + get fs(): Fs { + return (this._fs ??= new Fs({ client: this.client })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cb10be295..d70c7a887 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3695,6 +3695,26 @@ export type PermissionSavedInfo = { resource: string } +export type LocationFileSystemTextContent = { + type: "text" + content: string + mime: string +} + +export type LocationFileSystemBinaryContent = { + type: "binary" + content: string + encoding: "base64" + mime: string +} + +export type LocationFileSystemEntry = { + path: string + uri: string + type: "file" | "directory" + mime: string +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -8516,6 +8536,76 @@ export type V2PermissionSavedRemoveResponses = { export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses] +export type V2FsReadData = { + body?: never + path?: never + query: { + location?: { + directory?: string + workspace?: string + } + path: string + } + url: "/api/fs/read" +} + +export type V2FsReadErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] + +export type V2FsReadResponses = { + /** + * Success + */ + 200: LocationFileSystemTextContent | LocationFileSystemBinaryContent +} + +export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] + +export type V2FsListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + path?: string + } + url: "/api/fs/list" +} + +export type V2FsListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] + +export type V2FsListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses] + export type TuiAppendPromptData = { body?: { text: string