Skip to content

Commit 09d9cf0

Browse files
authored
refactor(core): simplify search root protocol (#31060)
1 parent 4ac4df4 commit 09d9cf0

8 files changed

Lines changed: 18 additions & 89 deletions

File tree

packages/core/src/filesystem.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,13 @@ export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget"
153153
resource: Schema.String,
154154
}) {}
155155

156-
/** Canonical read authority for Location-scoped search and metadata leaves. */
156+
/** Canonical root and permission resource for Location-scoped search. */
157157
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
158-
absolute: Schema.String,
159158
real: Schema.String,
160-
directory: Schema.String,
161159
root: Schema.String,
162160
resource: Schema.String,
163161
reference: Schema.NonEmptyString.pipe(Schema.optional),
164162
type: Schema.Literals(["file", "directory"]),
165-
dev: Schema.Number,
166-
ino: Schema.Number.pipe(Schema.optional),
167163
}) {}
168164

169165
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
@@ -221,9 +217,8 @@ export interface Interface {
221217
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
222218
readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
223219
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
224-
/** Select a contained canonical read root without asserting leaf policy. */
220+
/** Resolve a contained canonical search root and its permission resource. */
225221
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
226-
readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
227222
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
228223
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
229224
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
@@ -535,22 +530,8 @@ export const layer = Layer.effect(
535530
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
536531
reference: input.reference,
537532
type,
538-
dev: info.dev,
539-
ino: Option.getOrUndefined(info.ino),
540533
})
541534
})
542-
const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) {
543-
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
544-
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
545-
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
546-
if (
547-
info.type !== (target.type === "file" ? "File" : "Directory") ||
548-
info.dev !== target.dev ||
549-
Option.getOrUndefined(info.ino) !== target.ino
550-
)
551-
return yield* Effect.die(new Error("Search root identity changed after approval"))
552-
return target
553-
})
554535
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
555536
return yield* fs.readDirectoryEntries(directory.real).pipe(
556537
Effect.orDie,
@@ -613,7 +594,6 @@ export const layer = Layer.effect(
613594
return yield* listResolved(yield* resolveList(input))
614595
}),
615596
resolveRoot,
616-
revalidateRoot,
617597
resolveList,
618598
listResolved,
619599
listPage: Effect.fn("FileSystem.listPage")(function* (input) {

packages/core/src/location-search.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,17 @@ export const MAX_LINE_PREVIEW_LENGTH = 2_000
2424

2525
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
2626

27-
const RootInput = {
28-
path: Schema.String.pipe(Schema.optional),
29-
reference: Schema.NonEmptyString.pipe(Schema.optional),
30-
}
31-
3227
export const FilesInput = Schema.Struct({
3328
pattern: Schema.String,
34-
...RootInput,
29+
...FileSystem.ListInput.fields,
3530
limit: ResultLimit.pipe(Schema.optional),
3631
})
3732
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
3833

3934
export const GrepInput = Schema.Struct({
4035
pattern: Schema.String,
4136
include: Schema.String.pipe(Schema.optional),
42-
...RootInput,
37+
...FileSystem.ListInput.fields,
4338
limit: ResultLimit.pipe(Schema.optional),
4439
})
4540
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
@@ -82,11 +77,8 @@ export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepRes
8277
}) {}
8378

8479
export interface Interface {
85-
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
86-
readonly grep: (
87-
input: GrepInput,
88-
root?: FileSystem.RootTarget,
89-
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
80+
readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
81+
readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
9082
}
9183

9284
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
@@ -123,8 +115,8 @@ export const layer = Layer.effect(
123115
})
124116

125117
return Service.of({
126-
files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) {
127-
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
118+
files: Effect.fn("LocationSearch.files")(function* (input) {
119+
const root = yield* filesystem.resolveRoot(input)
128120
if (root.type !== "directory")
129121
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
130122
const result = yield* ripgrep.files({
@@ -145,8 +137,8 @@ export const layer = Layer.effect(
145137
partial: result.partial || items.length !== result.items.length,
146138
})
147139
}),
148-
grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) {
149-
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
140+
grep: Effect.fn("LocationSearch.grep")(function* (input) {
141+
const root = yield* filesystem.resolveRoot(input)
150142
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
151143
const result = yield* ripgrep.grep({
152144
cwd,

packages/core/src/tool/glob.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ const definition = Tool.make({
4545
})
4646

4747
/**
48-
* Location-scoped glob leaf. FileSystem selects a canonical root for
49-
* permission metadata; LocationSearch owns containment and traversal.
48+
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
49+
* LocationSearch resolves the current root and owns containment and traversal.
5050
*
5151
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
5252
*/
@@ -73,7 +73,7 @@ export const layer = Layer.effectDiscard(
7373
limit: parameters.limit,
7474
},
7575
})
76-
return yield* search.files(parameters, root)
76+
return yield* search.files(parameters)
7777
}).pipe(
7878
Effect.catchCause((cause) =>
7979
Effect.fail(

packages/core/src/tool/grep.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ const definition = Tool.make({
6060
})
6161

6262
/**
63-
* Location-scoped grep leaf. FileSystem selects a canonical root for
64-
* permission metadata; LocationSearch owns containment and ripgrep execution.
63+
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
64+
* LocationSearch resolves the current root and owns containment and ripgrep execution.
6565
*
6666
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
6767
*/
@@ -89,7 +89,7 @@ export const layer = Layer.effectDiscard(
8989
limit: parameters.limit,
9090
},
9191
})
92-
return yield* search.grep(parameters, root)
92+
return yield* search.grep(parameters)
9393
}).pipe(
9494
Effect.catchCause((cause) => {
9595
const error = Cause.squash(cause)

packages/core/test/location-search.test.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -243,32 +243,6 @@ describe("LocationSearch", () => {
243243
),
244244
)
245245

246-
it.live("rejects an approved root swapped to a symlink before ripgrep traversal", () =>
247-
withTmp((directory) =>
248-
Effect.gen(function* () {
249-
if (process.platform === "win32") return
250-
const source = path.join(directory, "src")
251-
const outside = `${directory}-outside`
252-
yield* Effect.promise(async () => {
253-
await fs.mkdir(source)
254-
await fs.mkdir(outside)
255-
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
256-
})
257-
const filesystem = yield* FileSystem.Service
258-
const approved = yield* filesystem.resolveRoot({ path: RelativePath.make("src") })
259-
yield* Effect.promise(async () => {
260-
await fs.rmdir(source)
261-
await fs.symlink(outside, source)
262-
})
263-
264-
expect(
265-
Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit)),
266-
).toBe(true)
267-
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
268-
}).pipe(provide(directory)),
269-
),
270-
)
271-
272246
it.live("honors a pre-aborted cancellation signal", () =>
273247
withTmp((directory) =>
274248
Effect.gen(function* () {

packages/core/test/tool-glob.test.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const sessionID = SessionV2.ID.make("ses_glob_tool_test")
1313
const assertions: PermissionV2.AssertInput[] = []
1414
const resolutions: FileSystem.ListInput[] = []
1515
const searches: LocationSearch.FilesInput[] = []
16-
const roots: FileSystem.RootTarget[] = []
1716
let allow = true
1817
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
1918

@@ -45,17 +44,13 @@ const filesystem = Layer.succeed(
4544
const relative = input.path ?? RelativePath.make(".")
4645
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
4746
return new FileSystem.RootTarget({
48-
absolute: `/project/${relative}`,
4947
real: `/project/${relative}`,
50-
directory: "/project",
5148
root: "/project",
5249
resource,
5350
reference: input.reference,
5451
type: "directory",
55-
dev: 1,
5652
})
5753
}),
58-
revalidateRoot: Effect.succeed,
5954
resolveList: () => Effect.die("unused"),
6055
listResolved: () => Effect.die("unused"),
6156
listPage: () => Effect.die("unused"),
@@ -69,10 +64,9 @@ const filesystem = Layer.succeed(
6964
const search = Layer.succeed(
7065
LocationSearch.Service,
7166
LocationSearch.Service.of({
72-
files: (input, root) =>
67+
files: (input) =>
7368
Effect.sync(() => {
7469
searches.push(input)
75-
if (root) roots.push(root)
7670
return result
7771
}),
7872
grep: () => Effect.die("unused"),
@@ -92,7 +86,6 @@ const reset = () => {
9286
assertions.length = 0
9387
resolutions.length = 0
9488
searches.length = 0
95-
roots.length = 0
9689
allow = true
9790
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
9891
}
@@ -130,7 +123,6 @@ describe("GlobTool", () => {
130123
])
131124
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
132125
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
133-
expect(roots).toMatchObject([{ resource: "src" }])
134126
}),
135127
)
136128

packages/core/test/tool-grep.test.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { testEffect } from "./lib/effect"
2222

2323
const assertions: PermissionV2.AssertInput[] = []
2424
const searches: LocationSearch.GrepInput[] = []
25-
const roots: FileSystem.RootTarget[] = []
2625
let allow = true
2726
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
2827
let searchFailure: Ripgrep.InvalidPatternError | undefined
@@ -37,17 +36,13 @@ const filesystem = Layer.succeed(
3736
resolveRoot: (input = {}) =>
3837
Effect.succeed(
3938
new FileSystem.RootTarget({
40-
absolute: `/project/${input.path ?? "."}`,
4139
real: `/project/${input.path ?? "."}`,
42-
directory: "/project",
4340
root: "/project",
4441
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
4542
reference: input.reference,
4643
type: "directory",
47-
dev: 1,
4844
}),
4945
),
50-
revalidateRoot: Effect.succeed,
5146
resolveList: () => Effect.die("unused"),
5247
listResolved: () => Effect.die("unused"),
5348
listPage: () => Effect.die("unused"),
@@ -61,10 +56,9 @@ const search = Layer.succeed(
6156
LocationSearch.Service,
6257
LocationSearch.Service.of({
6358
files: () => Effect.die("unused"),
64-
grep: (input, root) =>
59+
grep: (input) =>
6560
Effect.sync(() => {
6661
searches.push(input)
67-
if (root) roots.push(root)
6862
if (searchFailure) throw searchFailure
6963
return result
7064
}),
@@ -107,7 +101,6 @@ const settle = (input: Record<string, unknown>) =>
107101
const reset = () => {
108102
assertions.length = 0
109103
searches.length = 0
110-
roots.length = 0
111104
allow = true
112105
searchFailure = undefined
113106
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
@@ -172,7 +165,6 @@ describe("GrepTool", () => {
172165
},
173166
])
174167
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
175-
expect(roots).toMatchObject([{ resource: "src" }])
176168
}),
177169
)
178170

packages/core/test/tool-read.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ const filesystem = Layer.succeed(
4343
return Effect.succeed(readResult)
4444
},
4545
resolveRoot: () => Effect.die("unused"),
46-
revalidateRoot: Effect.succeed,
4746
list: () => Effect.die("unused"),
4847
resolveList: () => Effect.die("unused"),
4948
listResolved: () => Effect.die("unused"),

0 commit comments

Comments
 (0)