mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(cli): prevent filesystem root indexing
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent filesystem-root workspaces from starting FFF and codebase indexing.
|
||||
@@ -13,7 +13,7 @@ import { RelativePath } from "../schema"
|
||||
import { Flag } from "../flag/flag"
|
||||
// kilocode_change start
|
||||
import * as SearchTarget from "../kilocode/search-target"
|
||||
import { scanning } from "../kilocode/fff"
|
||||
import { allowed, scanning } from "../kilocode/fff"
|
||||
// kilocode_change end
|
||||
|
||||
export interface Interface {
|
||||
@@ -48,20 +48,25 @@ export const ripgrepLayer = Layer.effect(
|
||||
directories: [] as string[],
|
||||
}
|
||||
const directories = new Set<string>()
|
||||
yield* ripgrep
|
||||
.find({
|
||||
cwd: location.directory,
|
||||
pattern: "*",
|
||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
state.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
state.directories = Array.from(directories)
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
// kilocode_change start - never eagerly enumerate a filesystem root.
|
||||
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(location.directory)))
|
||||
if (allowed(real)) {
|
||||
yield* ripgrep
|
||||
.find({
|
||||
cwd: real,
|
||||
pattern: "*",
|
||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
state.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
state.directories = Array.from(directories)
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
}
|
||||
// kilocode_change end
|
||||
return Service.of({
|
||||
glob: (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -176,14 +181,16 @@ export const fffLayer = Layer.effect(
|
||||
}).pipe(Effect.ignore)
|
||||
const make = Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const real = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
if (!allowed(real)) return yield* Effect.die(new Error("FFF indexing is disabled for filesystem roots."))
|
||||
const result = yield* Effect.try({
|
||||
try: () =>
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
basePath: real,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
...scanning(location.directory),
|
||||
...scanning(real),
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.orDie)
|
||||
@@ -329,7 +336,17 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.KILO_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
// kilocode_change start - FFF owns an initial scan and watcher, so roots must use the non-indexing fallback.
|
||||
const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (Flag.KILO_DISABLE_FFF || !Fff.available()) return ripgrepLayer
|
||||
const location = yield* Location.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(location.directory)))
|
||||
return allowed(real) ? fffLayer : ripgrepLayer
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export function scanning(directory: string) {
|
||||
return {
|
||||
enableFsRootScanning: directory === path.parse(directory).root,
|
||||
enableHomeDirScanning: directory === os.homedir(),
|
||||
}
|
||||
function root(directory: string, api: typeof path.posix) {
|
||||
if (!api.isAbsolute(directory)) return false
|
||||
return api.normalize(directory) === api.normalize(api.parse(directory).root)
|
||||
}
|
||||
|
||||
export function allowed(directory: string) {
|
||||
const value = path.win32.normalize(directory)
|
||||
const prefix = "\\\\?\\UNC\\"
|
||||
const windows = value.toUpperCase().startsWith(prefix.toUpperCase()) ? `\\\\${value.slice(prefix.length)}` : value
|
||||
return !root(directory, path.posix) && !root(windows, path.win32)
|
||||
}
|
||||
|
||||
export function scanning(directory: string) {
|
||||
return { enableHomeDirScanning: directory === os.homedir() }
|
||||
}
|
||||
|
||||
@@ -3,43 +3,162 @@ import { FileFinder, type InitOptions } from "@ff-labs/fff-bun"
|
||||
import "@opencode-ai/core/filesystem"
|
||||
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { scanning } from "@opencode-ai/core/kilocode/fff"
|
||||
import { Cause, Context, Effect, Layer, Scope } from "effect"
|
||||
import { allowed, scanning } from "@opencode-ai/core/kilocode/fff"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
describe("FFF scanning boundaries", () => {
|
||||
test("enables filesystem-root scanning only at the exact root", () => {
|
||||
const root = path.parse(process.cwd()).root
|
||||
expect(scanning(root)).toEqual({ enableFsRootScanning: true, enableHomeDirScanning: root === os.homedir() })
|
||||
expect(scanning(path.join(root, "workspace"))).toEqual({
|
||||
enableFsRootScanning: false,
|
||||
enableHomeDirScanning: false,
|
||||
})
|
||||
test("rejects POSIX, Windows drive, and UNC roots", () => {
|
||||
expect(allowed("/")).toBe(false)
|
||||
expect(allowed("C:\\")).toBe(false)
|
||||
expect(allowed("D:/")).toBe(false)
|
||||
expect(allowed("\\\\server\\share\\")).toBe(false)
|
||||
expect(allowed("\\\\?\\C:\\workspace\\..")).toBe(false)
|
||||
expect(allowed("\\\\?\\UNC\\server\\share\\")).toBe(false)
|
||||
})
|
||||
|
||||
test("enables home scanning only at the exact home directory", () => {
|
||||
const home = os.homedir()
|
||||
expect(scanning(home)).toEqual({
|
||||
enableFsRootScanning: home === path.parse(home).root,
|
||||
enableHomeDirScanning: true,
|
||||
})
|
||||
expect(scanning(path.join(home, "workspace"))).toEqual({
|
||||
enableFsRootScanning: false,
|
||||
enableHomeDirScanning: false,
|
||||
})
|
||||
test("allows ordinary project directories", () => {
|
||||
expect(allowed("/workspace")).toBe(true)
|
||||
expect(allowed("C:\\workspace")).toBe(true)
|
||||
expect(allowed("D:/workspace")).toBe(true)
|
||||
expect(allowed("\\\\server\\share\\workspace")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps explicit home scanning without opting into filesystem-root scanning", () => {
|
||||
expect(scanning(os.homedir())).toEqual({ enableHomeDirScanning: true })
|
||||
expect(scanning(path.join(os.homedir(), "workspace"))).toEqual({ enableHomeDirScanning: false })
|
||||
expect(scanning(os.homedir())).not.toHaveProperty("enableFsRootScanning")
|
||||
})
|
||||
|
||||
test("does not start FFF or fallback indexing at a filesystem root", async () => {
|
||||
const root = path.parse(process.cwd()).root
|
||||
const tmp = process.platform === "win32" ? undefined : await tmpdir()
|
||||
const link = tmp ? path.join(tmp.path, "root") : undefined
|
||||
if (link) await fs.symlink(root, link)
|
||||
const create = FileFinder.create
|
||||
const calls = { fff: 0, ripgrep: 0 }
|
||||
FileFinder.create = () => {
|
||||
calls.fff++
|
||||
return { ok: false, error: "FFF must not start at a filesystem root" }
|
||||
}
|
||||
const ripgrep = Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: () =>
|
||||
Effect.sync(() => {
|
||||
calls.ripgrep++
|
||||
return []
|
||||
}),
|
||||
glob: () => Effect.succeed({ items: [], truncated: false, partial: false }),
|
||||
grep: () => Effect.succeed({ items: [], truncated: false, partial: false }),
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
for (const directory of [root, link].filter((item): item is string => item !== undefined)) {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
|
||||
const layer = FileSystemSearch.locationLayer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(ripgrep),
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(layer)
|
||||
const service = Context.get(context, FileSystemSearch.Service)
|
||||
expect(yield* service.find({ query: "", type: "file", limit: 1 })).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
expect(calls).toEqual({ fff: 0, ripgrep: 0 })
|
||||
} finally {
|
||||
FileFinder.create = create
|
||||
await tmp?.[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("FFF lifecycle", () => {
|
||||
test("rechecks a changed symlink before creating a picker and retries with the canonical path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "project")
|
||||
const link = path.join(tmp.path, "link")
|
||||
const type = process.platform === "win32" ? "junction" : "dir"
|
||||
await fs.mkdir(project)
|
||||
await fs.symlink(project, link, type)
|
||||
const create = FileFinder.create
|
||||
const calls: InitOptions[] = []
|
||||
FileFinder.create = (opts) => {
|
||||
calls.push(opts)
|
||||
return { ok: false, error: "native creation intercepted" }
|
||||
}
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(link) }))),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(layer)
|
||||
const service = Context.get(context, FileSystemSearch.Service)
|
||||
expect(calls).toEqual([])
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(link)
|
||||
await fs.symlink(path.parse(project).root, link, type)
|
||||
})
|
||||
|
||||
for (const effect of [
|
||||
service.find({ query: "", type: "file", limit: 1 }).pipe(Effect.asVoid),
|
||||
service.glob({ pattern: "*", limit: 1 }).pipe(Effect.asVoid),
|
||||
service.grep({ pattern: "needle", limit: 1 }).pipe(Effect.asVoid),
|
||||
]) {
|
||||
const result = yield* Effect.exit(effect)
|
||||
expect(result._tag).toBe("Failure")
|
||||
if (result._tag === "Failure") {
|
||||
expect(Cause.pretty(result.cause)).toContain("FFF indexing is disabled for filesystem roots.")
|
||||
}
|
||||
}
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(link)
|
||||
await fs.symlink(project, link, type)
|
||||
})
|
||||
yield* Effect.exit(service.find({ query: "", type: "file", limit: 1 }))
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].basePath).toBe(project)
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
FileFinder.create = create
|
||||
}
|
||||
})
|
||||
|
||||
test("retries a failed first search and reuses one picker", async () => {
|
||||
if (!Fff.available()) return
|
||||
|
||||
const dir = await tmpdir()
|
||||
expect(allowed(dir.path)).toBe(true)
|
||||
const create = FileFinder.create
|
||||
const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined }
|
||||
try {
|
||||
@@ -97,6 +216,8 @@ describe("FFF lifecycle", () => {
|
||||
expect(calls.create).toBe(2)
|
||||
expect(calls.opts?.disableMmapCache).toBe(true)
|
||||
expect(calls.opts?.disableContentIndexing).toBe(true)
|
||||
expect(calls.opts).not.toHaveProperty("enableFsRootScanning")
|
||||
expect(calls.opts?.enableHomeDirScanning).toBe(false)
|
||||
|
||||
yield* service.find({ query: "", type: "file", limit: 1 })
|
||||
expect(calls.create).toBe(2)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { type IndexingTelemetryEvent, type VectorStoreSearchResult } from "@kilocode/kilo-indexing/engine"
|
||||
import { toIndexingConfigInput, type IndexingConfig } from "@kilocode/kilo-indexing/config"
|
||||
@@ -7,6 +8,7 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
|
||||
import { IndexingStatus, disabledIndexingStatus } from "@kilocode/kilo-indexing/status"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
|
||||
import { allowed } from "@opencode-ai/core/kilocode/fff"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -32,6 +34,7 @@ const consent = new Map<string, boolean>()
|
||||
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
|
||||
const noWorkspace = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
|
||||
const unsafeRoot = () => disabledIndexingStatus("Codebase indexing is disabled for filesystem roots.")
|
||||
const noConsent = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled until you enable it for this project in Kilo Settings.")
|
||||
|
||||
@@ -264,6 +267,15 @@ export namespace KiloIndexing {
|
||||
|
||||
const boot = async (hit: Cache): Promise<Entry> => {
|
||||
const dir = Instance.directory
|
||||
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
|
||||
return track(hit, await inert(() => noWorkspace()))
|
||||
}
|
||||
try {
|
||||
if (!allowed(realpathSync.native(dir))) return track(hit, await inert(() => unsafeRoot()))
|
||||
} catch (err) {
|
||||
log.warn("indexing directory resolution failed", { err, workspacePath: dir })
|
||||
return track(hit, await inert(() => failed(err)))
|
||||
}
|
||||
const startup = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const baseline = yield* baselineDirectory(dir)
|
||||
@@ -275,9 +287,6 @@ export namespace KiloIndexing {
|
||||
const cfg = startup.cfg
|
||||
const project = (await AppRuntime.runPromise(primaryWorktree(dir))) ?? dir
|
||||
projects.set(dir, project)
|
||||
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
|
||||
return track(hit, await inert(() => noWorkspace()))
|
||||
}
|
||||
if (process.env["KILO_PLATFORM"] === "vscode" && !consent.get(project)) {
|
||||
return track(hit, await inert(() => noConsent()))
|
||||
}
|
||||
|
||||
@@ -533,6 +533,74 @@ describe("indexing startup degradation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not allocate an indexing worker for a filesystem root", async () => {
|
||||
const created: string[] = []
|
||||
IndexingWorker.override((directory, root, hooks) => {
|
||||
created.push(directory)
|
||||
return inline(directory, root, hooks)
|
||||
})
|
||||
|
||||
const root = path.parse(process.cwd()).root
|
||||
await using tmp = await tmpdir()
|
||||
const link = process.platform === "win32" ? undefined : path.join(tmp.path, "root")
|
||||
if (link) await fs.symlink(root, link)
|
||||
|
||||
for (const directory of [root, link].filter((item): item is string => item !== undefined)) {
|
||||
await provideTestInstance({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: "Disabled",
|
||||
message: "Codebase indexing is disabled for filesystem roots.",
|
||||
})
|
||||
expect(await KiloIndexing.available()).toBe(false)
|
||||
expect(KiloIndexing.ready()).toBe(false)
|
||||
expect(await KiloIndexing.search("filesystem root")).toEqual([])
|
||||
expect(created).toEqual([])
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("handles removed directories with no-workspace flag %s", async (disabled) => {
|
||||
await using tmp = await tmpdir({ config: cfg })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
|
||||
if (disabled) process.env["KILO_DISABLE_CODEBASE_INDEXING"] = "vscode-no-workspace"
|
||||
const directory = path.join(tmp.path, "project")
|
||||
await fs.mkdir(directory)
|
||||
const created: string[] = []
|
||||
IndexingWorker.override((directory) => {
|
||||
created.push(directory)
|
||||
throw new Error("removed workspaces must not start an indexing worker")
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await fs.rmdir(directory)
|
||||
await KiloIndexing.init()
|
||||
const status = await KiloIndexing.current()
|
||||
expect(await KiloIndexing.available()).toBe(false)
|
||||
expect(KiloIndexing.ready()).toBe(false)
|
||||
expect(await KiloIndexing.search("removed workspace")).toEqual([])
|
||||
expect(created).toEqual([])
|
||||
if (disabled) {
|
||||
expect(status).toMatchObject({
|
||||
state: "Disabled",
|
||||
message: "Codebase indexing is disabled because no workspace folder is open in VS Code.",
|
||||
})
|
||||
return
|
||||
}
|
||||
expect(status.state).toBe("Error")
|
||||
expect(status.message).toContain("Failed to initialize:")
|
||||
expect(status.message).toContain("ENOENT")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not validate the indexing model when indexing is disabled", async () => {
|
||||
global.fetch = (() =>
|
||||
Promise.resolve(
|
||||
|
||||
Reference in New Issue
Block a user