diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index f9b100294ec..0fc58dc0558 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -396,6 +396,12 @@ Cursor 서버 주도 로컬 도구는 기본값으로 비활성화됩니다. Cod 필드는 최상위가 아니라 `providers.cursor`에 설정하십시오. 대시보드에서는 **Providers → Cursor → Edit JSON**을 사용해 저장한 뒤 다시 시작합니다. 레거시 `unsafeAllowNativeLocalExec: true`는 `nativeLocalExec`이 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. MCP, 화면 녹화, computer use는 `mcpServers`와 `desktopExecutor`가 따로 제어합니다. +네이티브 `grep`이 허용된 디렉터리 검색에서는 `glob`으로 파일을 고릅니다. `*.ts`는 모든 하위 폴더의 +파일명에 적용되고, `src/**/*.ts`는 선택한 검색 폴더를 기준으로 한 상대 경로에 적용됩니다(`./`로 시작해도 됩니다). 모든 운영체제에서 +패턴의 경로 구분자는 `/`를 사용합니다. 문자 범위와 중괄호 선택 패턴을 지원하며, Bun 1.4.4에서는 +`**/!(*.test).ts` 같은 제외 패턴과 `part-{1..3}.ts` 같은 숫자 범위도 지원합니다. +필터는 검색 폴더를 바꾸거나 네이티브 실행을 활성화하지 않습니다. + 각 `mcpServers.`는 `command`(stdio) 또는 `url`(Streamable HTTP) 중 하나를 받습니다. stdio는 `args`, `env`, `cwd`도 받습니다. HTTP는 `headers`를 받습니다. 둘 다 `enabled`(기본값 true)와 `toolPrefix`를 지원합니다. `desktopExecutor`는 `computerUseCommand`, `recordScreenCommand`, `cwd`, `env`, `timeoutMs`(기본값 `30000`)를 받습니다. 명령은 `sh -c`를 거치며 stdin에서 JSON 요청 하나를 읽고, stdout에 JSON 결과 하나를 써야 합니다. :::caution[보안] diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9be5f65a5bb..d4d626e0c7a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1455,6 +1455,12 @@ Set `nativeLocalExec` on `providers.cursor`, not at the top level. In the dashbo `nativeLocalExec: "on"` only when `nativeLocalExec` is unset. MCP, screen recording, and computer use are controlled separately by `mcpServers` and `desktopExecutor`. +For an enabled native `grep` directory search, `glob` filters file names: `*.ts` matches at any +depth, while `src/**/*.ts` matches paths relative to the selected search directory (`./` is optional). Use `/` for +pattern separators on every platform. Character classes and brace alternatives are supported; +Bun 1.4.4 also supports extglobs such as `**/!(*.test).ts` and ranges such as `part-{1..3}.ts`. +These filters do not change the selected directory or enable native execution. + Foreground `shellArgs` and `shellStreamArgs` requests are rejected before process creation on Windows, macOS, and Linux, including with `nativeLocalExec: "on"`. They require a kernel-backed descendant owner that this implementation does not provide; use the client's shell tool instead. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 78ef6f6d22b..e1e5a5b00ba 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", "google-vertex-key-project.test.ts": "adapters/google", diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index b9bc94a7434..4510ffb3aba 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; import { create } from "@bufbuild/protobuf"; import { DeleteErrorSchema, @@ -235,19 +235,21 @@ export function lsExec(execMsg: ExecServerMessage): Uint8Array { } } -function matchesGlob(file: string, glob?: string): boolean { - if (!glob) return true; - if (glob.startsWith("*.") || glob.startsWith("**/*.")) return file.endsWith(glob.slice(glob.indexOf("*") + 1)); - return file.includes(glob.replaceAll("*", "")); +function grepFileMatcher(root: string, glob?: string): (file: string) => boolean { + if (!glob) return () => true; + const matcher = new Bun.Glob(glob.replace(/^(?:\.\/)+/, "")); + // Bare patterns apply to each basename; slash patterns are relative to the selected root. + const pathPattern = glob.includes("/"); + return file => matcher.match(pathPattern ? relative(root, file).split(sep).join("/") : basename(file)); } -function collectFiles(root: string, glob?: string, out: string[] = []): string[] { +function collectFiles(root: string, matchesFile: (file: string) => boolean, out: string[] = []): string[] { if (out.length >= MAX_GREP_FILES) return out; for (const entry of readdirSync(root, { withFileTypes: true })) { if (entry.name === "node_modules" || entry.name === ".git") continue; const path = join(root, entry.name); - if (entry.isDirectory()) collectFiles(path, glob, out); - if (entry.isFile() && matchesGlob(path, glob)) out.push(path); + if (entry.isDirectory()) collectFiles(path, matchesFile, out); + if (entry.isFile() && matchesFile(path)) out.push(path); if (out.length >= MAX_GREP_FILES) break; } return out; @@ -269,7 +271,7 @@ export function grepExec(execMsg: ExecServerMessage): Uint8Array { const root = resolve(args.path || process.cwd()); try { const pattern = new RegExp(args.pattern, args.caseInsensitive ? "i" : ""); - const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, args.glob) : [root]; + const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, grepFileMatcher(root, args.glob)) : [root]; const matches = []; for (const file of files) { if (matches.length >= MAX_GREP_RESULTS) break; diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 76e5b5dce88..eeb5576f462 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -20,6 +20,18 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +For native `grep` directory searches, `src/adapters/cursor/native-exec-fs.ts` compiles the optional +filter once with `Bun.Glob`. A pattern without `/` matches each basename at every depth; a pattern +with `/` matches the path relative to the selected search root, using `/` on every platform; +an optional leading `./` keeps that root-relative scope. +This supports `**/*.ts`, brace alternatives and character classes; Bun 1.4.4 also supplies extglobs +and brace ranges. Empty filters keep the unfiltered search. The existing directory walk skips +`node_modules`, `.git` and symlink entries, collects at most 500 matching files, reads only files +at most 1,000,000 bytes, and returns at most 200 files with text matches. Glob matching never +expands or changes the search root. Explicit single-file searches retain their existing behavior. +`tests/providers/cursor/cursor-native-grep-globs.test.ts` exercises the dispatch gate, file selection, +all three output modes, traversal exclusions and runtime-specific syntax. + Foreground `shellArgs` and `shellStreamArgs` are unavailable on every platform, including with `nativeLocalExec: "on"`. `src/adapters/cursor/native-foreground-shell.ts` rejects them before spawn until a kernel-backed descendant owner exists; process-group disappearance is not cleanup proof. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d41fccb67a1..c05bb1d4388 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -656,6 +656,7 @@ "cursor-message-mapper.test.ts": "providers/cursor", "cursor-native-exec-common.test.ts": "providers/cursor", "cursor-native-exec-policy.test.ts": "providers/cursor", "cursor-native-exec-shell.test.ts": "providers/cursor", "cursor-foreground-shell.test.ts": "providers/cursor", "cursor-native-exec.test.ts": "providers/cursor", + "cursor-native-grep-globs.test.ts": "providers/cursor", "cursor-oauth-shell.test.ts": "providers/cursor", "cursor-oauth.test.ts": "providers/cursor", "cursor-pool.test.ts": "providers/cursor", "cursor-protobuf-events.test.ts": "providers/cursor", "cursor-repetition-breaker.test.ts": "providers/cursor", "cursor-request-builder.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-native-grep-globs.test.ts b/tests/providers/cursor/cursor-native-grep-globs.test.ts new file mode 100644 index 00000000000..46430eda2ba --- /dev/null +++ b/tests/providers/cursor/cursor-native-grep-globs.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, sep } from "node:path"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; +import { + AgentClientMessageSchema, + ExecServerMessageSchema, + GrepArgsSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(files: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cursor-grep-globs-")); + roots.push(root); + for (const [path, content] of Object.entries(files)) { + const file = join(root, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); + } + return root; +} + +async function grep(root: string, glob?: string, outputMode = "files_with_matches", allow = true) { + const frames = await handleCursorNativeExec(create(ExecServerMessageSchema, { + id: 17, + execId: "grep-glob-test", + message: { case: "grepArgs", value: create(GrepArgsSchema, { pattern: "needle", path: root, glob, outputMode }) }, + }), { unsafeAllowNativeLocalExec: allow }); + const response = fromBinary(AgentClientMessageSchema, frames[0]!); + if (response.message.case !== "execClientMessage" || response.message.value.message.case !== "grepResult") { + throw new Error("Expected native grep response"); + } + return response.message.value.message.value.result; +} + +async function matchedFiles(root: string, glob?: string): Promise { + const response = await grep(root, glob); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + return result.value.files.map(file => relative(root, file).split(sep).join("/")).sort(); +} + +describe("Cursor native grep globs", () => { + test("matches recursive and root-relative patterns without matching absolute ancestors", async () => { + const root = fixture({ + "main.ts": "needle", + "src/main.ts": "needle", + "src/deep/worker.ts": "needle", + "other/src/wrong.ts": "needle", + "src/notes.txt": "needle", + }); + expect(await matchedFiles(root, "**/*.ts")).toEqual([ + "main.ts", "other/src/wrong.ts", "src/deep/worker.ts", "src/main.ts", + ]); + expect(await matchedFiles(root, "src/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./*.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root, `${root.replaceAll(sep, "/")}/*.ts`)).toEqual([]); + }); + + test("matches basename patterns at every depth and preserves the unfiltered search", async () => { + const root = fixture({ "main.ts": "needle", "nested/main.ts": "needle", "nested/main.ts.bak": "needle", "note.txt": "needle" }); + expect(await matchedFiles(root, "*.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "main.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "./main.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root)).toEqual(["main.ts", "nested/main.ts", "nested/main.ts.bak", "note.txt"]); + expect(await matchedFiles(root, "")).toEqual(await matchedFiles(root)); + }); + + test("supports character classes and brace alternatives", async () => { + const root = fixture({ "a.ts": "needle", "b.tsx": "needle", "c.js": "needle", "nested/d.ts": "needle" }); + expect(await matchedFiles(root, "?.{ts,tsx}")).toEqual(["a.ts", "b.tsx", "nested/d.ts"]); + expect(await matchedFiles(root, "[ab].*")).toEqual(["a.ts", "b.tsx"]); + }); + + // Bun 1.4.3 is still the repository's test runner; these runtime features arrived in 1.4.4. + test.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.4.4"))("uses Bun 1.4.4 extglobs and brace ranges", async () => { + const root = fixture({ + "src/item-1.ts": "needle", "src/item-2.ts": "needle", "src/item-3.ts": "needle", + "src/item-4.ts": "needle", "src/item-2.test.ts": "needle", "src/deep/item-2.ts": "needle", + }); + expect(await matchedFiles(root, "src/item-{1..3}.ts")).toEqual(["src/item-1.ts", "src/item-2.ts", "src/item-3.ts"]); + expect(await matchedFiles(root, "src/**/!(*.test).ts")).toEqual([ + "src/deep/item-2.ts", "src/item-1.ts", "src/item-2.ts", "src/item-3.ts", "src/item-4.ts", + ]); + expect(await matchedFiles(root, "@(item-1|item-4).ts")).toEqual(["src/item-1.ts", "src/item-4.ts"]); + }); + + test("retains the opt-in gate for native grep", async () => { + const root = fixture({ "main.ts": "needle" }); + expect((await grep(root, "**/*.ts", "files_with_matches", false)).case).toBe("error"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test("preserves explicit single-file searches without applying a directory glob", async () => { + const root = fixture({ "main.ts": "needle" }); + const file = join(root, "main.ts"); + const response = await grep(file, "*.js"); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + expect(result.value.files).toEqual([file]); + }); + + test("keeps traversal skips, the file byte limit, and the selected search root", async () => { + const base = fixture({ + "search/src/main.ts": "needle", + "search/node_modules/dep.ts": "needle", + "search/.git/private.ts": "needle", + "search/large.ts": `needle${"x".repeat(1_000_000)}`, + "outside/other.ts": "needle", + }); + const root = join(base, "search"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "../outside/*.ts")).toEqual([]); + }); + + test.skipIf(process.platform === "win32")("does not follow directory or file symlinks while filtering", async () => { + const base = fixture({ "search/main.ts": "needle", "outside/other.ts": "needle" }); + const root = join(base, "search"); + symlinkSync(join(base, "outside"), join(root, "linked")); + symlinkSync(join(base, "outside", "other.ts"), join(root, "linked.ts")); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test.each(["content", "count"])("applies globs to %s output", async outputMode => { + const root = fixture({ "src/main.ts": "needle\nneedle", "src/no.txt": "needle" }); + const response = await grep(root, "src/**/*.ts", outputMode); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + expect(result.case).toBe(outputMode); + if (result.case === "content") { + expect(result.value.matches.map(match => match.file)).toEqual([join(root, "src", "main.ts")]); + expect(result.value.totalMatchedLines).toBe(2); + } else if (result.case === "count") { + expect(result.value.counts.map(match => ({ file: match.file, count: match.count }))).toEqual([{ file: join(root, "src", "main.ts"), count: 2 }]); + expect(result.value.totalFiles).toBe(1); + expect(result.value.totalMatches).toBe(2); + } + }); +});