From ed3d7a55f40d6ed8e2d382aca81f8b925b8103fb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 8 Dec 2025 14:04:37 +0900 Subject: [PATCH] feat(tools): add glob tool with timeout protection - Override OpenCode's built-in glob with 60s timeout - Kill process on expiration to prevent indefinite hanging - Reuse grep's CLI resolver for ripgrep detection Generated by [OpenCode](https://opencode.ai/) --- README.ko.md | 6 ++ README.md | 6 ++ src/tools/glob/cli.ts | 129 ++++++++++++++++++++++++++++++++++++ src/tools/glob/constants.ts | 12 ++++ src/tools/glob/index.ts | 3 + src/tools/glob/tools.ts | 36 ++++++++++ src/tools/glob/types.ts | 21 ++++++ src/tools/glob/utils.ts | 26 ++++++++ src/tools/index.ts | 2 + 9 files changed, 241 insertions(+) create mode 100644 src/tools/glob/cli.ts create mode 100644 src/tools/glob/constants.ts create mode 100644 src/tools/glob/index.ts create mode 100644 src/tools/glob/tools.ts create mode 100644 src/tools/glob/types.ts create mode 100644 src/tools/glob/utils.ts diff --git a/README.ko.md b/README.ko.md index 5b88acf..fd6b7f1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -190,6 +190,12 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다. - 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다. - 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다. +#### Glob + +- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다. + - 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다. + - 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다. + #### 내장 MCPs - **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다. diff --git a/README.md b/README.md index eaaea71..1f3744a 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,12 @@ The features you use in your editor—other agents cannot access them. Oh My Ope - The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging. - This tool enforces strict limits and completely replaces the built-in `grep`. +#### Glob + +- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool. + - The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely. + - This tool enforces timeouts and kills the process on expiration. + #### Built-in MCPs - **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites. diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts new file mode 100644 index 0000000..20e900b --- /dev/null +++ b/src/tools/glob/cli.ts @@ -0,0 +1,129 @@ +import { spawn } from "bun" +import { + resolveGrepCli, + DEFAULT_TIMEOUT_MS, + DEFAULT_LIMIT, + DEFAULT_MAX_DEPTH, + DEFAULT_MAX_OUTPUT_BYTES, + RG_FILES_FLAGS, +} from "./constants" +import type { GlobOptions, GlobResult, FileMatch } from "./types" +import { stat } from "node:fs/promises" + +function buildRgArgs(options: GlobOptions): string[] { + const args: string[] = [ + ...RG_FILES_FLAGS, + `--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`, + ] + + if (options.hidden) args.push("--hidden") + if (options.noIgnore) args.push("--no-ignore") + + args.push(`--glob=${options.pattern}`) + + return args +} + +function buildFindArgs(options: GlobOptions): string[] { + const args: string[] = ["."] + + const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH) + args.push("-maxdepth", String(maxDepth)) + + args.push("-type", "f") + args.push("-name", options.pattern) + + if (!options.hidden) { + args.push("-not", "-path", "*/.*") + } + + return args +} + +async function getFileMtime(filePath: string): Promise { + try { + const stats = await stat(filePath) + return stats.mtime.getTime() + } catch { + return 0 + } +} + +export async function runRgFiles(options: GlobOptions): Promise { + const cli = resolveGrepCli() + const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) + const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT) + + const isRg = cli.backend === "rg" + const args = isRg ? buildRgArgs(options) : buildFindArgs(options) + + const paths = options.paths?.length ? options.paths : ["."] + if (isRg) { + args.push(...paths) + } + + const cwd = paths[0] || "." + + const proc = spawn([cli.path, ...args], { + stdout: "pipe", + stderr: "pipe", + cwd: isRg ? undefined : cwd, + }) + + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + proc.kill() + reject(new Error(`Glob search timeout after ${timeout}ms`)) + }, timeout) + proc.exited.then(() => clearTimeout(id)) + }) + + try { + const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + if (exitCode > 1 && stderr.trim()) { + return { + files: [], + totalFiles: 0, + truncated: false, + error: stderr.trim(), + } + } + + const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES + const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout + + const lines = outputToProcess.trim().split("\n").filter(Boolean) + + const files: FileMatch[] = [] + let truncated = false + + for (const line of lines) { + if (files.length >= limit) { + truncated = true + break + } + + const filePath = isRg ? line : `${cwd}/${line}` + const mtime = await getFileMtime(filePath) + files.push({ path: filePath, mtime }) + } + + files.sort((a, b) => b.mtime - a.mtime) + + return { + files, + totalFiles: files.length, + truncated: truncated || truncatedOutput, + } + } catch (e) { + return { + files: [], + totalFiles: 0, + truncated: false, + error: e instanceof Error ? e.message : String(e), + } + } +} diff --git a/src/tools/glob/constants.ts b/src/tools/glob/constants.ts new file mode 100644 index 0000000..38623e7 --- /dev/null +++ b/src/tools/glob/constants.ts @@ -0,0 +1,12 @@ +export { resolveGrepCli, type GrepBackend } from "../grep/constants" + +export const DEFAULT_TIMEOUT_MS = 60_000 +export const DEFAULT_LIMIT = 100 +export const DEFAULT_MAX_DEPTH = 20 +export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + +export const RG_FILES_FLAGS = [ + "--files", + "--color=never", + "--glob=!.git/*", +] as const diff --git a/src/tools/glob/index.ts b/src/tools/glob/index.ts new file mode 100644 index 0000000..96c7964 --- /dev/null +++ b/src/tools/glob/index.ts @@ -0,0 +1,3 @@ +import { glob } from "./tools" + +export { glob } diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts new file mode 100644 index 0000000..73a4498 --- /dev/null +++ b/src/tools/glob/tools.ts @@ -0,0 +1,36 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { runRgFiles } from "./cli" +import { formatGlobResult } from "./utils" + +export const glob = tool({ + description: + "Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " + + "Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " + + "Returns matching file paths sorted by modification time. " + + "Use this tool when you need to find files by name patterns.", + args: { + pattern: tool.schema.string().describe("The glob pattern to match files against"), + path: tool.schema + .string() + .optional() + .describe( + "The directory to search in. If not specified, the current working directory will be used. " + + "IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " + + "simply omit it for the default behavior. Must be a valid directory path if provided." + ), + }, + execute: async (args) => { + try { + const paths = args.path ? [args.path] : undefined + + const result = await runRgFiles({ + pattern: args.pattern, + paths, + }) + + return formatGlobResult(result) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) diff --git a/src/tools/glob/types.ts b/src/tools/glob/types.ts new file mode 100644 index 0000000..6691a9b --- /dev/null +++ b/src/tools/glob/types.ts @@ -0,0 +1,21 @@ +export interface FileMatch { + path: string + mtime: number +} + +export interface GlobResult { + files: FileMatch[] + totalFiles: number + truncated: boolean + error?: string +} + +export interface GlobOptions { + pattern: string + paths?: string[] + hidden?: boolean + noIgnore?: boolean + maxDepth?: number + timeout?: number + limit?: number +} diff --git a/src/tools/glob/utils.ts b/src/tools/glob/utils.ts new file mode 100644 index 0000000..3e4ac62 --- /dev/null +++ b/src/tools/glob/utils.ts @@ -0,0 +1,26 @@ +import type { GlobResult } from "./types" + +export function formatGlobResult(result: GlobResult): string { + if (result.error) { + return `Error: ${result.error}` + } + + if (result.files.length === 0) { + return "No files found" + } + + const lines: string[] = [] + lines.push(`Found ${result.totalFiles} file(s)`) + lines.push("") + + for (const file of result.files) { + lines.push(file.path) + } + + if (result.truncated) { + lines.push("") + lines.push("(Results are truncated. Consider using a more specific path or pattern.)") + } + + return lines.join("\n") +} diff --git a/src/tools/index.ts b/src/tools/index.ts index dfe1437..2c757ce 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,6 +18,7 @@ import { } from "./ast-grep" import { grep } from "./grep" +import { glob } from "./glob" export const builtinTools = { lsp_hover, @@ -34,4 +35,5 @@ export const builtinTools = { ast_grep_search, ast_grep_replace, grep, + glob, }