فهرست منبع

refactor(core): simplify search root protocol (#31060)

Kit Langton 3 ماه پیش
والد
کامیت
09d9cf01f9

+ 2 - 22
packages/core/src/filesystem.ts

@@ -153,17 +153,13 @@ export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget"
   resource: Schema.String,
 }) {}
 
-/** Canonical read authority for Location-scoped search and metadata leaves. */
+/** Canonical root and permission resource for Location-scoped search. */
 export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
-  absolute: Schema.String,
   real: Schema.String,
-  directory: Schema.String,
   root: Schema.String,
   resource: Schema.String,
   reference: Schema.NonEmptyString.pipe(Schema.optional),
   type: Schema.Literals(["file", "directory"]),
-  dev: Schema.Number,
-  ino: Schema.Number.pipe(Schema.optional),
 }) {}
 
 export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
@@ -221,9 +217,8 @@ export interface Interface {
   readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
   readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
   readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
-  /** Select a contained canonical read root without asserting leaf policy. */
+  /** Resolve a contained canonical search root and its permission resource. */
   readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
-  readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
   readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
   readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
   readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
@@ -535,22 +530,8 @@ export const layer = Layer.effect(
         resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
         reference: input.reference,
         type,
-        dev: info.dev,
-        ino: Option.getOrUndefined(info.ino),
       })
     })
-    const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) {
-      const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
-      if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
-      const info = yield* fs.stat(canonical).pipe(Effect.orDie)
-      if (
-        info.type !== (target.type === "file" ? "File" : "Directory") ||
-        info.dev !== target.dev ||
-        Option.getOrUndefined(info.ino) !== target.ino
-      )
-        return yield* Effect.die(new Error("Search root identity changed after approval"))
-      return target
-    })
     const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
       return yield* fs.readDirectoryEntries(directory.real).pipe(
         Effect.orDie,
@@ -613,7 +594,6 @@ export const layer = Layer.effect(
         return yield* listResolved(yield* resolveList(input))
       }),
       resolveRoot,
-      revalidateRoot,
       resolveList,
       listResolved,
       listPage: Effect.fn("FileSystem.listPage")(function* (input) {

+ 8 - 16
packages/core/src/location-search.ts

@@ -24,14 +24,9 @@ export const MAX_LINE_PREVIEW_LENGTH = 2_000
 
 export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
 
-const RootInput = {
-  path: Schema.String.pipe(Schema.optional),
-  reference: Schema.NonEmptyString.pipe(Schema.optional),
-}
-
 export const FilesInput = Schema.Struct({
   pattern: Schema.String,
-  ...RootInput,
+  ...FileSystem.ListInput.fields,
   limit: ResultLimit.pipe(Schema.optional),
 })
 export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
@@ -39,7 +34,7 @@ export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSigna
 export const GrepInput = Schema.Struct({
   pattern: Schema.String,
   include: Schema.String.pipe(Schema.optional),
-  ...RootInput,
+  ...FileSystem.ListInput.fields,
   limit: ResultLimit.pipe(Schema.optional),
 })
 export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
@@ -82,11 +77,8 @@ export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepRes
 }) {}
 
 export interface Interface {
-  readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
-  readonly grep: (
-    input: GrepInput,
-    root?: FileSystem.RootTarget,
-  ) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
+  readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
+  readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
@@ -123,8 +115,8 @@ export const layer = Layer.effect(
     })
 
     return Service.of({
-      files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) {
-        const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
+      files: Effect.fn("LocationSearch.files")(function* (input) {
+        const root = yield* filesystem.resolveRoot(input)
         if (root.type !== "directory")
           return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
         const result = yield* ripgrep.files({
@@ -145,8 +137,8 @@ export const layer = Layer.effect(
           partial: result.partial || items.length !== result.items.length,
         })
       }),
-      grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) {
-        const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
+      grep: Effect.fn("LocationSearch.grep")(function* (input) {
+        const root = yield* filesystem.resolveRoot(input)
         const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
         const result = yield* ripgrep.grep({
           cwd,

+ 3 - 3
packages/core/src/tool/glob.ts

@@ -45,8 +45,8 @@ const definition = Tool.make({
 })
 
 /**
- * Location-scoped glob leaf. FileSystem selects a canonical root for
- * permission metadata; LocationSearch owns containment and traversal.
+ * Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
+ * LocationSearch resolves the current root and owns containment and traversal.
  *
  * TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
  */
@@ -73,7 +73,7 @@ export const layer = Layer.effectDiscard(
                 limit: parameters.limit,
               },
             })
-            return yield* search.files(parameters, root)
+            return yield* search.files(parameters)
           }).pipe(
             Effect.catchCause((cause) =>
               Effect.fail(

+ 3 - 3
packages/core/src/tool/grep.ts

@@ -60,8 +60,8 @@ const definition = Tool.make({
 })
 
 /**
- * Location-scoped grep leaf. FileSystem selects a canonical root for
- * permission metadata; LocationSearch owns containment and ripgrep execution.
+ * Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
+ * LocationSearch resolves the current root and owns containment and ripgrep execution.
  *
  * TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
  */
@@ -89,7 +89,7 @@ export const layer = Layer.effectDiscard(
                 limit: parameters.limit,
               },
             })
-            return yield* search.grep(parameters, root)
+            return yield* search.grep(parameters)
           }).pipe(
             Effect.catchCause((cause) => {
               const error = Cause.squash(cause)

+ 0 - 26
packages/core/test/location-search.test.ts

@@ -243,32 +243,6 @@ describe("LocationSearch", () => {
     ),
   )
 
-  it.live("rejects an approved root swapped to a symlink before ripgrep traversal", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        if (process.platform === "win32") return
-        const source = path.join(directory, "src")
-        const outside = `${directory}-outside`
-        yield* Effect.promise(async () => {
-          await fs.mkdir(source)
-          await fs.mkdir(outside)
-          await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
-        })
-        const filesystem = yield* FileSystem.Service
-        const approved = yield* filesystem.resolveRoot({ path: RelativePath.make("src") })
-        yield* Effect.promise(async () => {
-          await fs.rmdir(source)
-          await fs.symlink(outside, source)
-        })
-
-        expect(
-          Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit)),
-        ).toBe(true)
-        yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
-      }).pipe(provide(directory)),
-    ),
-  )
-
   it.live("honors a pre-aborted cancellation signal", () =>
     withTmp((directory) =>
       Effect.gen(function* () {

+ 1 - 9
packages/core/test/tool-glob.test.ts

@@ -13,7 +13,6 @@ const sessionID = SessionV2.ID.make("ses_glob_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const resolutions: FileSystem.ListInput[] = []
 const searches: LocationSearch.FilesInput[] = []
-const roots: FileSystem.RootTarget[] = []
 let allow = true
 let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
 
@@ -45,17 +44,13 @@ const filesystem = Layer.succeed(
         const relative = input.path ?? RelativePath.make(".")
         const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
         return new FileSystem.RootTarget({
-          absolute: `/project/${relative}`,
           real: `/project/${relative}`,
-          directory: "/project",
           root: "/project",
           resource,
           reference: input.reference,
           type: "directory",
-          dev: 1,
         })
       }),
-    revalidateRoot: Effect.succeed,
     resolveList: () => Effect.die("unused"),
     listResolved: () => Effect.die("unused"),
     listPage: () => Effect.die("unused"),
@@ -69,10 +64,9 @@ const filesystem = Layer.succeed(
 const search = Layer.succeed(
   LocationSearch.Service,
   LocationSearch.Service.of({
-    files: (input, root) =>
+    files: (input) =>
       Effect.sync(() => {
         searches.push(input)
-        if (root) roots.push(root)
         return result
       }),
     grep: () => Effect.die("unused"),
@@ -92,7 +86,6 @@ const reset = () => {
   assertions.length = 0
   resolutions.length = 0
   searches.length = 0
-  roots.length = 0
   allow = true
   result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
 }
@@ -130,7 +123,6 @@ describe("GlobTool", () => {
       ])
       expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
       expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
-      expect(roots).toMatchObject([{ resource: "src" }])
     }),
   )
 

+ 1 - 9
packages/core/test/tool-grep.test.ts

@@ -22,7 +22,6 @@ import { testEffect } from "./lib/effect"
 
 const assertions: PermissionV2.AssertInput[] = []
 const searches: LocationSearch.GrepInput[] = []
-const roots: FileSystem.RootTarget[] = []
 let allow = true
 let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
 let searchFailure: Ripgrep.InvalidPatternError | undefined
@@ -37,17 +36,13 @@ const filesystem = Layer.succeed(
     resolveRoot: (input = {}) =>
       Effect.succeed(
         new FileSystem.RootTarget({
-          absolute: `/project/${input.path ?? "."}`,
           real: `/project/${input.path ?? "."}`,
-          directory: "/project",
           root: "/project",
           resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
           reference: input.reference,
           type: "directory",
-          dev: 1,
         }),
       ),
-    revalidateRoot: Effect.succeed,
     resolveList: () => Effect.die("unused"),
     listResolved: () => Effect.die("unused"),
     listPage: () => Effect.die("unused"),
@@ -61,10 +56,9 @@ const search = Layer.succeed(
   LocationSearch.Service,
   LocationSearch.Service.of({
     files: () => Effect.die("unused"),
-    grep: (input, root) =>
+    grep: (input) =>
       Effect.sync(() => {
         searches.push(input)
-        if (root) roots.push(root)
         if (searchFailure) throw searchFailure
         return result
       }),
@@ -107,7 +101,6 @@ const settle = (input: Record<string, unknown>) =>
 const reset = () => {
   assertions.length = 0
   searches.length = 0
-  roots.length = 0
   allow = true
   searchFailure = undefined
   result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
@@ -172,7 +165,6 @@ describe("GrepTool", () => {
         },
       ])
       expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
-      expect(roots).toMatchObject([{ resource: "src" }])
     }),
   )
 

+ 0 - 1
packages/core/test/tool-read.test.ts

@@ -43,7 +43,6 @@ const filesystem = Layer.succeed(
       return Effect.succeed(readResult)
     },
     resolveRoot: () => Effect.die("unused"),
-    revalidateRoot: Effect.succeed,
     list: () => Effect.die("unused"),
     resolveList: () => Effect.die("unused"),
     listResolved: () => Effect.die("unused"),