Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`는 `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[보안]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-adapters.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 11 additions & 9 deletions src/adapters/cursor/native-exec-fs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 150 additions & 0 deletions tests/providers/cursor/cursor-native-grep-globs.test.ts
Original file line number Diff line number Diff line change
@@ -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, string> = {}): 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<string[]> {
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);
}
});
});
Loading