feat(opencode): add filesystem read and list routes

This commit is contained in:
Dax Raad 2026-06-02 01:54:10 -04:00
parent 0136f03fa9
commit 5937e606df
10 changed files with 414 additions and 15 deletions

View File

@ -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<Content>("LocationFileSystem.Content")({
type: Schema.Literals(["text", "binary"]),
export class TextContent extends Schema.Class<TextContent>("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<BinaryContent>("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<Service, Interface>()("@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)

View File

@ -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<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
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)
}),
),
)
})

View File

@ -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",

View File

@ -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)

View File

@ -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") {}

View File

@ -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),

View File

@ -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)))
}),
)

View File

@ -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(),
}))

View File

@ -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<ThrowOnError extends boolean = false>(
parameters: {
location?: {
directory?: string
workspace?: string
}
path: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "query", key: "path" },
],
},
],
)
return (options?.client ?? this.client).get<V2FsReadResponses, V2FsReadErrors, ThrowOnError>({
url: "/api/fs/read",
...options,
...params,
})
}
/**
* List directory
*
* List direct children of one directory relative to the requested location.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
path?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "query", key: "path" },
],
},
],
)
return (options?.client ?? this.client).get<V2FsListResponses, V2FsListErrors, ThrowOnError>({
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 {

View File

@ -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<LocationFileSystemEntry>
}
export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses]
export type TuiAppendPromptData = {
body?: {
text: string