From f09ecb248fdcae0a83b96285cd00d9e900e043ef Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 4 Apr 2026 08:38:38 -0700 Subject: [PATCH 1/5] fix: add 30s timeout to glob tool to prevent indefinite hangs (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `abortAfter` timeout that kills the `rg` process after 30 seconds - Return partial results on timeout with actionable guidance message - Fix error handling: only treat `AbortError` from timeout signal as timeout — properly re-throw ENOENT, permission, and user abort errors - Add `localAbort` controller to kill `rg` process on early loop exit (e.g., 100-file limit hit), preventing background resource waste - Add comprehensive glob tool tests (6 tests covering basic matching, truncation, user abort, nested patterns, and custom path) Closes #636 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/tool/glob.ts | 68 +++++++++--- packages/opencode/test/tool/glob.test.ts | 135 +++++++++++++++++++++++ 2 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/tool/glob.test.ts diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c6..e97568ff5e 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -6,6 +6,9 @@ import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" +import { abortAfter } from "../util/abort" + +const GLOB_TIMEOUT_MS = 30_000 export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -36,29 +39,60 @@ export const GlobTool = Tool.define("glob", { 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 + let timedOut = false + + 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: [params.pattern], + 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 (timeout.signal.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() } files.sort((a, b) => b.mtime - a.mtime) const output = [] - if (files.length === 0) output.push("No files found") + 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,7 +104,7 @@ export const GlobTool = Tool.define("glob", { title: path.relative(Instance.worktree, search), metadata: { count: files.length, - truncated, + truncated: truncated || timedOut, }, output: output.join("\n"), } diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 0000000000..fecca06ce5 --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +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") + }, + }) + }) +}) From aceefae9855479fb8bbea86ecf462883620ecb57 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 4 Apr 2026 08:58:57 -0700 Subject: [PATCH 2/5] feat: add home/root blocking and default directory exclusions to glob tool - Block glob searches from `/` and `~` with immediate helpful message - Add default directory exclusions (`node_modules/`, `dist/`, `build/`, `.cache/`, `.venv/`, etc.) reusing `IGNORE_PATTERNS` from `ls` tool - Document `.gitignore` and `.ignore` file support in glob tool description - Add 5 new tests: home/root blocking, subdirectory of home allowed, `node_modules` excluded, `dist`/`build` excluded - Add `altimate_change` markers to all custom code in upstream file Closes #636 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/tool/glob.ts | 24 ++++++- packages/opencode/src/tool/glob.txt | 2 + packages/opencode/test/tool/glob.test.ts | 87 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index e97568ff5e..0e1c87d4f8 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -6,9 +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, @@ -36,11 +40,26 @@ 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 + // 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] : [] @@ -49,7 +68,7 @@ export const GlobTool = Tool.define("glob", { try { for await (const file of Ripgrep.files({ cwd: search, - glob: [params.pattern], + glob: globs, signal, })) { if (files.length >= limit) { @@ -75,9 +94,11 @@ export const GlobTool = Tool.define("glob", { localAbort.abort() timeout.clearTimeout() } + // altimate_change end files.sort((a, b) => b.mtime - a.mtime) const output = [] + // 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.`, @@ -108,5 +129,6 @@ export const GlobTool = Tool.define("glob", { }, output: output.join("\n"), } + // altimate_change end }, }) diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt index 627da6cae9..ea1ebe919d 100644 --- a/packages/opencode/src/tool/glob.txt +++ b/packages/opencode/src/tool/glob.txt @@ -1,6 +1,8 @@ - Fast file pattern matching tool that works with any codebase size - Supports glob patterns like "**/*.js" or "src/**/*.ts" - Returns matching file paths sorted by modification time +- Respects .gitignore and .ignore files in the search directory +- Common directories (node_modules, dist, build, .cache, .venv, etc.) are excluded by default - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index fecca06ce5..84604c8dcf 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,4 +1,5 @@ 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" @@ -132,4 +133,90 @@ describe("tool.glob", () => { }, }) }) + + 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("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") + }, + }) + }) }) From 4042795b4f5a7f625d843b5ffda13f6083c08b1f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 4 Apr 2026 08:59:39 -0700 Subject: [PATCH 3/5] fix: add missing `altimate_change` markers and revert glob.txt changes - Wrap `truncated || timedOut` in marker block - Revert glob.txt description changes (triggers marker check on .txt files) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/tool/glob.ts | 2 ++ packages/opencode/src/tool/glob.txt | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0e1c87d4f8..81e3d41157 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -125,7 +125,9 @@ export const GlobTool = Tool.define("glob", { title: path.relative(Instance.worktree, search), metadata: { count: files.length, + // altimate_change start — include timeout in truncated flag truncated: truncated || timedOut, + // altimate_change end }, output: output.join("\n"), } diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt index ea1ebe919d..627da6cae9 100644 --- a/packages/opencode/src/tool/glob.txt +++ b/packages/opencode/src/tool/glob.txt @@ -1,8 +1,6 @@ - Fast file pattern matching tool that works with any codebase size - Supports glob patterns like "**/*.js" or "src/**/*.ts" - Returns matching file paths sorted by modification time -- Respects .gitignore and .ignore files in the search directory -- Common directories (node_modules, dist, build, .cache, .venv, etc.) are excluded by default - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. From 7557178d8e0b729ab18d56b40e0cfc854d343bf8 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 4 Apr 2026 09:03:47 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20tighten=20error=20handling=20and=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `AbortError` type check to catch block: only suppress errors that are AbortErrors AND from our timeout AND not user-initiated aborts - Add test for ENOENT propagation (non-abort errors not masked as timeout) - Add test for normal completion without timeout on small directories Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/tool/glob.ts | 6 ++++- packages/opencode/test/tool/glob.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 81e3d41157..6a78b6dc07 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -83,7 +83,11 @@ export const GlobTool = Tool.define("glob", { }) } } catch (err: any) { - if (timeout.signal.aborted) { + if ( + err?.name === "AbortError" && + timeout.signal.aborted && + !ctx.abort?.aborted + ) { // Our timeout fired — return partial results timedOut = true } else { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 84604c8dcf..519ff7172e 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -197,6 +197,39 @@ describe("tool.glob", () => { }) }) + 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, abort: undefined }) + 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) => { From 5a66762d02449a26ceac3aaf0d44279f77b02cca Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 4 Apr 2026 09:04:04 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20resolve=20type=20error=20in=20glob?= =?UTF-8?q?=20test=20=E2=80=94=20`abort`=20field=20requires=20`AbortSignal?= =?UTF-8?q?`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/test/tool/glob.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 519ff7172e..4dcb0194c4 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -222,7 +222,7 @@ describe("tool.glob", () => { directory: tmp.path, fn: async () => { const glob = await GlobTool.init() - const result = await glob.execute({ pattern: "*.txt" }, { ...ctx, abort: undefined }) + 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")