diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c..6a78b6dc0 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -6,6 +6,13 @@ import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" +// altimate_change start — glob hardening: timeout, home/root blocking, default exclusions +import os from "os" +import { abortAfter } from "../util/abort" +import { IGNORE_PATTERNS } from "./ls" + +const GLOB_TIMEOUT_MS = 30_000 +// altimate_change end export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -33,32 +40,84 @@ export const GlobTool = Tool.define("glob", { search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) await assertExternalDirectory(ctx, search, { kind: "directory" }) + // altimate_change start — block home/root directory to prevent scanning entire filesystem + const homeDir = os.homedir() + if (search === "/" || search === homeDir) { + return { + title: path.relative(Instance.worktree, search), + metadata: { count: 0, truncated: false }, + output: `The directory "${search}" is too broad to search. Please specify a more specific \`path\` parameter within your project or a subdirectory.`, + } + } + // altimate_change end + const limit = 100 const files = [] let truncated = false - for await (const file of Ripgrep.files({ - cwd: search, - glob: [params.pattern], - signal: ctx.abort, - })) { - if (files.length >= limit) { - truncated = true - break + // altimate_change start — 30s timeout with default directory exclusions + let timedOut = false + + const defaultExclusions = IGNORE_PATTERNS.map((p) => `!${p}*`) + const globs = [params.pattern, ...defaultExclusions] + + const timeout = abortAfter(GLOB_TIMEOUT_MS) + const localAbort = new AbortController() + const parentSignals = ctx.abort ? [ctx.abort] : [] + const signal = AbortSignal.any([timeout.signal, localAbort.signal, ...parentSignals]) + + try { + for await (const file of Ripgrep.files({ + cwd: search, + glob: globs, + signal, + })) { + if (files.length >= limit) { + truncated = true + break + } + const full = path.resolve(search, file) + const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 + files.push({ + path: full, + mtime: stats, + }) + } + } catch (err: any) { + if ( + err?.name === "AbortError" && + timeout.signal.aborted && + !ctx.abort?.aborted + ) { + // Our timeout fired — return partial results + timedOut = true + } else { + // User cancellation, ENOENT, permission error, etc. — propagate + throw err } - const full = path.resolve(search, file) - const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 - files.push({ - path: full, - mtime: stats, - }) + } finally { + localAbort.abort() + timeout.clearTimeout() } + // altimate_change end files.sort((a, b) => b.mtime - a.mtime) const output = [] - if (files.length === 0) output.push("No files found") + // altimate_change start — timeout-aware output messages + if (files.length === 0 && timedOut) { + output.push( + `Glob search timed out after ${GLOB_TIMEOUT_MS / 1000}s with no results. The search directory "${search}" is too broad for the pattern "${params.pattern}". Use a more specific \`path\` parameter to narrow the search scope.`, + ) + } else if (files.length === 0) { + output.push("No files found") + } if (files.length > 0) { output.push(...files.map((f) => f.path)) - if (truncated) { + if (timedOut) { + output.push("") + output.push( + `(Search timed out after ${GLOB_TIMEOUT_MS / 1000}s: only partial results shown. Use a more specific \`path\` parameter to narrow the search scope.)`, + ) + } else if (truncated) { output.push("") output.push( `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, @@ -70,9 +129,12 @@ export const GlobTool = Tool.define("glob", { title: path.relative(Instance.worktree, search), metadata: { count: files.length, - truncated, + // altimate_change start — include timeout in truncated flag + truncated: truncated || timedOut, + // altimate_change end }, output: output.join("\n"), } + // altimate_change end }, }) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 000000000..4dcb0194c --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test" +import os from "os" +import path from "path" +import fs from "fs/promises" +import { GlobTool } from "../../src/tool/glob" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { SessionID, MessageID } from "../../src/session/schema" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.glob", () => { + test("finds files matching pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + await Bun.write(path.join(dir, "b.txt"), "world") + await Bun.write(path.join(dir, "c.md"), "readme") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "*.txt" }, ctx) + expect(result.metadata.count).toBe(2) + expect(result.output).toContain("a.txt") + expect(result.output).toContain("b.txt") + expect(result.output).not.toContain("c.md") + }, + }) + }) + + test("returns 'No files found' when no matches", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "*.nonexistent" }, ctx) + expect(result.metadata.count).toBe(0) + expect(result.output).toBe("No files found") + }, + }) + }) + + test("truncates results at 100 files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + for (let i = 0; i < 110; i++) { + await Bun.write(path.join(dir, `file${String(i).padStart(3, "0")}.txt`), "") + } + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "*.txt" }, ctx) + expect(result.metadata.count).toBe(100) + expect(result.metadata.truncated).toBe(true) + expect(result.output).toContain("Results are truncated") + }, + }) + }) + + test("respects user abort signal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + }, + }) + const controller = new AbortController() + controller.abort() + const abortCtx = { ...ctx, abort: controller.signal } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + await expect(glob.execute({ pattern: "*.txt" }, abortCtx)).rejects.toThrow() + }, + }) + }) + + test("finds nested files with ** pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "sub", "deep"), { recursive: true }) + await Bun.write(path.join(dir, "sub", "deep", "target.yml"), "") + await Bun.write(path.join(dir, "other.txt"), "") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "**/target.yml" }, ctx) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain("target.yml") + }, + }) + }) + + test("uses custom path parameter", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "subdir")) + await Bun.write(path.join(dir, "subdir", "inner.txt"), "") + await Bun.write(path.join(dir, "outer.txt"), "") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute( + { pattern: "*.txt", path: path.join(tmp.path, "subdir") }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain("inner.txt") + expect(result.output).not.toContain("outer.txt") + }, + }) + }) + + test("blocks home directory with helpful message", async () => { + const homeDir = os.homedir() + await Instance.provide({ + directory: homeDir, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "**/*.yml", path: homeDir }, ctx) + expect(result.metadata.count).toBe(0) + expect(result.output).toContain("too broad to search") + }, + }) + }) + + test("blocks root directory with helpful message", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "**/*.yml", path: "/" }, ctx) + expect(result.metadata.count).toBe(0) + expect(result.output).toContain("too broad to search") + }, + }) + }) + + test("allows subdirectory of home", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "project.yml"), "") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "*.yml" }, ctx) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain("project.yml") + }, + }) + }) + + test("excludes node_modules by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "node_modules", "pkg"), { recursive: true }) + await Bun.write(path.join(dir, "node_modules", "pkg", "index.ts"), "") + await Bun.write(path.join(dir, "src.ts"), "") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "**/*.ts" }, ctx) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain("src.ts") + expect(result.output).not.toContain("node_modules") + }, + }) + }) + + test("propagates non-abort errors instead of treating as timeout", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + // Searching a non-existent path should throw ENOENT, not return "timed out" + const badPath = path.join(tmp.path, "nonexistent-dir-12345") + await expect( + glob.execute({ pattern: "*.txt", path: badPath }, ctx), + ).rejects.toThrow() + }, + }) + }) + + test("completes without timeout on small directories", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "*.txt" }, ctx) + expect(result.metadata.count).toBe(1) + expect(result.metadata.truncated).toBe(false) + expect(result.output).not.toContain("timed out") + }, + }) + }) + + test("excludes dist and build by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "dist"), { recursive: true }) + await fs.mkdir(path.join(dir, "build"), { recursive: true }) + await Bun.write(path.join(dir, "dist", "bundle.js"), "") + await Bun.write(path.join(dir, "build", "output.js"), "") + await Bun.write(path.join(dir, "app.js"), "") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + const result = await glob.execute({ pattern: "**/*.js" }, ctx) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain("app.js") + expect(result.output).not.toContain("dist") + expect(result.output).not.toContain("build") + }, + }) + }) +})