ソースを参照

feat(opencode): add filesystem read and list routes

Dax Raad 2 ヶ月 前
コミット
5937e606df

+ 75 - 8
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<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)

+ 92 - 0
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<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)
+      }),
+    ),
+  )
+})

+ 2 - 0
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",

+ 54 - 0
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)

+ 2 - 1
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") {}
 

+ 2 - 0
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),

+ 12 - 0
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)))
+  }),
+)

+ 8 - 6
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(),
     }))

+ 77 - 0
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<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 {

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