Skip to content
96 changes: 79 additions & 17 deletions packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.)`,
Expand All @@ -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
},
})
255 changes: 255 additions & 0 deletions packages/opencode/test/tool/glob.test.ts
Original file line number Diff line number Diff line change
@@ -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")
},
})
})
})
Loading