Browse Source

feat(core): expose project reference filesystem access (#30423)

Shoubhit Dash 4 months ago
parent
commit
380931a827

+ 27 - 12
packages/core/src/location-filesystem.ts

@@ -5,10 +5,12 @@ import { pathToFileURL } from "url"
 import { Context, Effect, Layer, Schema } from "effect"
 import { AppFileSystem } from "./filesystem"
 import { Location } from "./location"
+import { ProjectReference } from "./project-reference"
 import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
 
 export const ReadInput = Schema.Struct({
   path: RelativePath,
+  reference: Schema.String.pipe(Schema.optional),
 })
 export type ReadInput = typeof ReadInput.Type
 
@@ -30,6 +32,7 @@ export type Content = typeof Content.Type
 
 export const ListInput = Schema.Struct({
   path: RelativePath.pipe(Schema.optional),
+  reference: Schema.String.pipe(Schema.optional),
 })
 export type ListInput = typeof ListInput.Type
 
@@ -77,31 +80,41 @@ export interface Interface {
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
 
-export const locationLayer = Layer.effect(
+export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
     const location = yield* Location.Service
+    const references = yield* ProjectReference.Service
     const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
-    const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
+    const select = Effect.fnUntraced(function* (reference?: string) {
+      if (!reference) return { directory: location.directory, root }
+      const resolved = yield* references.get(reference)
+      if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
+      if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
+      if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
+      return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
+    })
+    const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
       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))
+      const selected = yield* select(reference)
+      const absolute = path.resolve(selected.directory, input ?? ".")
+      if (!AppFileSystem.contains(selected.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 }
+      if (!AppFileSystem.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
+      return { absolute, real, ...selected }
     })
-    const entry = Effect.fnUntraced(function* (absolute: string) {
+    const entry = Effect.fnUntraced(function* (absolute: string, selected: { directory: string; root: string }) {
       const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
       if (!real) return
-      if (!AppFileSystem.contains(root, real)) return
+      if (!AppFileSystem.contains(selected.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)),
+        path: RelativePath.make(path.relative(selected.directory, absolute)),
         uri: pathToFileURL(real).href,
         type,
         mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
@@ -124,7 +137,7 @@ export const locationLayer = Layer.effect(
 
     return Service.of({
       read: Effect.fn("LocationFileSystem.read")(function* (input) {
-        const file = yield* resolve(input.path)
+        const file = yield* resolve(input.path, input.reference)
         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)
@@ -143,13 +156,13 @@ export const locationLayer = Layer.effect(
         })
       }),
       list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
-        const directory = yield* resolve(input.path)
+        const directory = yield* resolve(input.path, input.reference)
         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)), {
+            Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
               concurrency: "unbounded",
             }),
           ),
@@ -177,3 +190,5 @@ export const locationLayer = Layer.effect(
     })
   }),
 )
+
+export const locationLayer = layer.pipe(Layer.provideMerge(ProjectReference.locationLayer))

+ 149 - 10
packages/core/test/location-filesystem.test.ts

@@ -1,23 +1,34 @@
 import fs from "fs/promises"
 import path from "path"
-import { pathToFileURL } from "url"
+import { fileURLToPath } 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 { ProjectReference } from "@opencode-ai/core/project-reference"
+import { Repository } from "@opencode-ai/core/repository"
 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) {
+const inertReferences = ProjectReference.Service.of({
+  list: () => Effect.succeed([]),
+  get: () => Effect.succeed(undefined),
+  resolveMention: () => Effect.succeed(undefined),
+  ensurePath: () => Effect.void,
+  containsManagedPath: () => Effect.succeed(false),
+})
+
+function provide(directory: string, references = inertReferences) {
   return Effect.provide(
-    LocationFileSystem.locationLayer.pipe(
+    LocationFileSystem.layer.pipe(
       Layer.provide(
         Layer.mergeAll(
           AppFileSystem.defaultLayer,
           Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
+          Layer.succeed(ProjectReference.Service, references),
         ),
       ),
     ),
@@ -28,7 +39,7 @@ 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))))
+  ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
 }
 
 describe("LocationFileSystem", () => {
@@ -50,7 +61,7 @@ describe("LocationFileSystem", () => {
           encoding: "base64",
           mime: "application/octet-stream",
         })
-      }),
+      }).pipe(provide(directory)),
     ),
   )
 
@@ -61,21 +72,27 @@ describe("LocationFileSystem", () => {
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
         const service = yield* LocationFileSystem.Service
 
-        expect(yield* service.list()).toEqual([
+        const entries = yield* service.list()
+        expect(entries.map(({ uri: _uri, ...entry }) => entry)).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",
           },
         ])
-      }),
+        expect(
+          yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
+        ).toEqual(
+          yield* Effect.promise(() =>
+            Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
+          ),
+        )
+      }).pipe(provide(directory)),
     ),
   )
 
@@ -86,7 +103,129 @@ describe("LocationFileSystem", () => {
         expect(
           Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
         ).toBe(true)
-      }),
+      }).pipe(provide(directory)),
     ),
   )
+
+  it.live("reads and lists paths relative to a local project reference", () =>
+    withTmp((directory) => {
+      const docs = path.join(directory, "docs")
+      return Effect.gen(function* () {
+        yield* Effect.promise(async () => {
+          await fs.mkdir(docs)
+          await fs.writeFile(path.join(docs, "README.md"), "docs")
+        })
+        const service = yield* LocationFileSystem.Service
+
+        expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
+          type: "text",
+          content: "docs",
+        })
+        expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
+      }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
+    }),
+  )
+
+  it.live("materializes Git references before filesystem access", () =>
+    withTmp((directory) => {
+      const docs = path.join(directory, "docs")
+      const ensured: string[] = []
+      return Effect.gen(function* () {
+        yield* Effect.promise(async () => {
+          await fs.mkdir(docs)
+          await fs.writeFile(path.join(docs, "README.md"), "docs")
+        })
+        expect(
+          yield* (yield* LocationFileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
+        ).toMatchObject({ content: "docs" })
+        expect(ensured).toEqual([docs])
+      }).pipe(
+        provide(
+          directory,
+          references(
+            {
+              sdk: {
+                name: "sdk",
+                kind: "git",
+                repository: "owner/repo",
+                reference: Repository.parseRemote("owner/repo"),
+                path: docs,
+              },
+            },
+            (target) => Effect.sync(() => ensured.push(target ?? "")),
+          ),
+        ),
+      )
+    }),
+  )
+
+  it.live("rejects unknown, invalid, and escaping project reference paths", () =>
+    withTmp((directory) => {
+      const docs = path.join(directory, "docs")
+      return Effect.gen(function* () {
+        yield* Effect.promise(() => fs.mkdir(docs))
+        const service = yield* LocationFileSystem.Service
+        expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
+        expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
+        expect(
+          Exit.isFailure(
+            yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
+          ),
+        ).toBe(true)
+      }).pipe(
+        provide(
+          directory,
+          references({
+            docs: { name: "docs", kind: "local", path: docs },
+            invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
+          }),
+        ),
+      )
+    }),
+  )
+
+  it.live("rejects aliases when project references are disabled", () =>
+    withTmp((directory) =>
+      Effect.gen(function* () {
+        expect(
+          Exit.isFailure(yield* (yield* LocationFileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
+        ).toBe(true)
+      }).pipe(provide(directory)),
+    ),
+  )
+
+  it.live("rejects symlink escapes from project references", () =>
+    withTmp((directory) => {
+      const docs = path.join(directory, "docs")
+      const outside = path.join(directory, "outside.txt")
+      return Effect.gen(function* () {
+        if (process.platform === "win32") return
+        yield* Effect.promise(async () => {
+          await fs.mkdir(docs)
+          await fs.writeFile(outside, "outside")
+          await fs.symlink(outside, path.join(docs, "link.txt"))
+        })
+        expect(
+          Exit.isFailure(
+            yield* (yield* LocationFileSystem.Service)
+              .read({ reference: "docs", path: RelativePath.make("link.txt") })
+              .pipe(Effect.exit),
+          ),
+        ).toBe(true)
+      }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
+    }),
+  )
 })
+
+function references(
+  entries: Record<string, ProjectReference.Resolved>,
+  ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
+) {
+  return ProjectReference.Service.of({
+    list: () => Effect.succeed(Object.values(entries)),
+    get: (name) => Effect.succeed(entries[name]),
+    resolveMention: () => Effect.succeed(undefined),
+    ensurePath,
+    containsManagedPath: () => Effect.succeed(false),
+  })
+}

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts

@@ -8,11 +8,13 @@ import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./loc
 const ReadQuery = Schema.Struct({
   ...LocationQuery.fields,
   path: RelativePath,
+  reference: Schema.String.pipe(Schema.optional),
 })
 
 const ListQuery = Schema.Struct({
   ...LocationQuery.fields,
   path: RelativePath.pipe(Schema.optional),
+  reference: Schema.String.pipe(Schema.optional),
 })
 
 export const FileSystemGroup = HttpApiGroup.make("v2.fs")

+ 7 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts

@@ -3,6 +3,7 @@ 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 { ProjectReference } from "@opencode-ai/core/project-reference"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { Effect, Layer, Schema } from "effect"
@@ -36,7 +37,12 @@ export const locationQueryOpenApi = OpenApi.annotations({
 export class V2LocationMiddleware extends HttpApiMiddleware.Service<
   V2LocationMiddleware,
   {
-    provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service | LocationFileSystem.Service
+    provides:
+      | Catalog.Service
+      | PluginBoot.Service
+      | PermissionV2.Service
+      | ProjectReference.Service
+      | LocationFileSystem.Service
   }
 >()("@opencode/ExperimentalHttpApiV2Location") {}
 

+ 19 - 1
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -9,7 +9,12 @@ type OpenApiResponse = {
   readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
 }
 type OpenApiOperation = {
-  readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
+  readonly parameters?: ReadonlyArray<{
+    readonly name: string
+    readonly in: string
+    readonly required?: boolean
+    readonly schema?: { readonly type?: string }
+  }>
   readonly responses?: Record<string, OpenApiResponse>
   readonly security?: unknown
 }
@@ -53,6 +58,19 @@ describe("PublicApi OpenAPI v2 errors", () => {
     }
   })
 
+  test("documents optional project reference aliases for filesystem reads and lists", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+
+    for (const path of ["/api/fs/read", "/api/fs/list"]) {
+      expect(spec.paths[path]?.get?.parameters, path).toContainEqual({
+        in: "query",
+        name: "reference",
+        required: false,
+        schema: { type: "string" },
+      })
+    }
+  })
+
   test("does not rewrite /api endpoint errors to legacy error components", () => {
     const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
     const refs = v2Operations(spec)