diff --git a/.changeset/safe-indexing-roots.md b/.changeset/safe-indexing-roots.md new file mode 100644 index 0000000000..773a61730a --- /dev/null +++ b/.changeset/safe-indexing-roots.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent filesystem-root workspaces from starting FFF and codebase indexing. diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 66ad56a4b6..8c991c92fa 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -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() - 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 diff --git a/packages/core/src/kilocode/fff.ts b/packages/core/src/kilocode/fff.ts index f217a41760..35ccff3bc2 100644 --- a/packages/core/src/kilocode/fff.ts +++ b/packages/core/src/kilocode/fff.ts @@ -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() } } diff --git a/packages/core/test/kilocode/fff.test.ts b/packages/core/test/kilocode/fff.test.ts index 686e0171bb..5d60cb5ac0 100644 --- a/packages/core/test/kilocode/fff.test.ts +++ b/packages/core/test/kilocode/fff.test.ts @@ -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) diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts index b175c6d1bc..2d4e69441a 100644 --- a/packages/opencode/src/kilocode/indexing.ts +++ b/packages/opencode/src/kilocode/indexing.ts @@ -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() 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 => { 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())) } diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts index f9f3366734..3487490958 100644 --- a/packages/opencode/test/kilocode/indexing-startup.test.ts +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -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(