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
37 changes: 31 additions & 6 deletions packages/sdk/src/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,41 @@ import {
import { resolveShellInvocation } from "../utils/shell-invocation";
import type { SDKMessage } from "../types";

// #463:LUME_BASH_PATH 在 resolveWindowsBashPath 里优先级最高且只做形态匹配
// (bash/zsh 结尾),指向不存在的路径也能让 resolveShellInvocation 跳过
// where.exe 发现(逐候选 1s 超时),恒定解析为 bash 形态——分类断言因此与
// runner 上 bash 探测快慢无关。仅影响静态分类;真正 spawn 的测试不得使用。
function useDeterministicTestShell(): () => void {
const original = process.env.LUME_BASH_PATH;
process.env.LUME_BASH_PATH = join("C:", "lume-test-deterministic", "bash.exe");
return () => {
if (original === undefined) delete process.env.LUME_BASH_PATH;
else process.env.LUME_BASH_PATH = original;
};
}

describe("BashTool shell invocation", () => {
test("classifies read-only shell commands dynamically for permissions and concurrency", () => {
// simple 路径(natives 可用)才能证明单命令只读;不可用时非 simple 的
// Bash 命令一律 fail-closed(#300),白名单加速只在 natives 可用时生效。
expect(BashTool.isReadOnly?.({ command: "git status" })).toBe(nativeBashAvailable);
expect(BashTool.isConcurrencySafe?.({ command: "git status" })).toBe(nativeBashAvailable);
expect(BashTool.isReadOnly?.({ command: "git commit -m change" })).toBeFalse();
expect(BashTool.isReadOnly?.({ command: "rg TODO src > results.txt" })).toBeFalse();
expect(BashTool.isReadOnly?.({ command: "powershell -Command Get-ChildItem" })).toBeTrue();
expect(BashTool.isReadOnly?.({ command: "powershell -Command Set-Content out.txt x" })).toBeFalse();
//
// #463:无 natives 时 "git status" 走 parse-unavailable 回退,回退按
// resolveShellInvocation 的解析结果选方言——Windows CI 的 where.exe
// bash 发现逐候选 1s 超时,runner 慢时会全部超时回退 powershell.exe,
// "git status" 命中 PowerShell 白名单翻转成 true。注入确定性
// LUME_BASH_PATH(形态匹配命中即返回,不做存在性检查)把回退 shell
// 钉在 bash 形态上,结论不再依赖 shell 发现快慢。
const restoreShellEnv = useDeterministicTestShell();
try {
expect(BashTool.isReadOnly?.({ command: "git status" })).toBe(nativeBashAvailable);
expect(BashTool.isConcurrencySafe?.({ command: "git status" })).toBe(nativeBashAvailable);
expect(BashTool.isReadOnly?.({ command: "git commit -m change" })).toBeFalse();
expect(BashTool.isReadOnly?.({ command: "rg TODO src > results.txt" })).toBeFalse();
expect(BashTool.isReadOnly?.({ command: "powershell -Command Get-ChildItem" })).toBeTrue();
expect(BashTool.isReadOnly?.({ command: "powershell -Command Set-Content out.txt x" })).toBeFalse();
} finally {
restoreShellEnv();
}
});

// CI 无 natives 二进制(dist 不入库),analyzeBashCommand 走 parse-unavailable 回退:
Expand Down
31 changes: 30 additions & 1 deletion packages/sdk/src/tools/grep.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { buildGrepArgs } from "./grep.js";
import { buildGrepArgs, buildNativeSearchOptions, EXCLUDED_DIRS } from "./grep.js";

describe("buildGrepArgs", () => {
test("maps -A/-B context flags for the GNU grep fallback", () => {
Expand Down Expand Up @@ -32,3 +32,32 @@ describe("buildGrepArgs", () => {
expect(args).not.toContain("-C");
});
});

describe("buildNativeSearchOptions", () => {
test("passes hidden:false and gitignore:true to the native engine (#337)", () => {
// native 引擎 Rust 侧 hidden 默认 true,会把 .git/HEAD、packed-refs 当
// 普通文件命中;必须与 rg 回退同口径显式关掉。
const options = buildNativeSearchOptions({ pattern: "todo" }, "/tmp/project", "files_with_matches", 0, 250);
expect(options.hidden).toBe(false);
expect(options.gitignore).toBe(true);
expect(options.pattern).toBe("todo");
expect(options.path).toBe("/tmp/project");
});

test("keeps the fallback exclusion list covered by the hidden:false semantics", () => {
// native 通道没有独立 exclude 参数:rg/grep 回退显式排除的目录全部是
// 点前缀,hidden:false(跳过隐藏目录)即同等口径。若未来往清单加入非
// 隐藏目录,此不变量失败即为提醒——native 层需要真正的排除机制。
expect(EXCLUDED_DIRS).toContain(".git");
expect(EXCLUDED_DIRS.every((directory) => directory.startsWith("."))).toBeTrue();
});

test("preserves pagination and mode fields", () => {
const options = buildNativeSearchOptions({ pattern: "x", "-A": 2, "-B": 1 }, "/tmp", "count", 10, 50);
expect(options.mode).toBe("count");
expect(options.offset).toBe(10);
expect(options.max_count).toBe(50);
expect(options.context_after).toBe(2);
expect(options.context_before).toBe(1);
});
});
22 changes: 16 additions & 6 deletions packages/sdk/src/tools/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { defineTool } from './types.js'
import { ensurePathAllowed, resolveInputPath } from '../utils/pathing.js'
import { resolveRipgrepInvocation } from '../utils/ripgrep.js'
import { isNativeAvailable, nativeGrep } from '@lume/natives'
import type { NativeGrepOptions } from '@lume/natives'

const SEARCH_LIMIT = 250
const SEARCH_TIMEOUT_MS = 30_000
const MAX_COLUMNS = 500
const EXCLUDED_DIRS = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
export const EXCLUDED_DIRS = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']

type SearchMode = 'content' | 'files_with_matches' | 'count'

Expand Down Expand Up @@ -229,19 +230,31 @@ function isCommandNotFound(error?: Error): boolean {
}

async function runNativeSearch(input: any, searchPath: string, outputMode: SearchMode, offset: number, headLimit: number): Promise<{ data: string; _meta?: Record<string, unknown> } | undefined> {
const result = await nativeGrep(buildNativeSearchOptions(input, searchPath, outputMode, offset, headLimit))
return result && !result.error
? formatNativeResult(input.pattern, searchPath, outputMode, offset, headLimit, result, 'native')
: undefined
}

export function buildNativeSearchOptions(input: any, searchPath: string, outputMode: SearchMode, offset: number, headLimit: number): NativeGrepOptions {
const context = input['-C'] ?? input.context
const mode = outputMode === 'files_with_matches'
? 'filesWithMatches' as const
: outputMode === 'count'
? 'count' as const
: 'content' as const
const result = await nativeGrep({
return {
pattern: input.pattern,
path: searchPath,
glob: input.glob,
type: input.type,
ignore_case: input['-i'] ?? false,
multiline: input.multiline ?? false,
// native 引擎默认搜隐藏文件(Rust 侧 unwrap_or(true)),会把 .git/HEAD、
// packed-refs 当普通文件命中,count/total 虚高且分页错位(#337)。rg 回退
// 默认跳过隐藏并显式排除 EXCLUDED_DIRS——这些目录全部点前缀,native 通道
// 无独立 exclude 参数,hidden:false 即同等口径。
hidden: false,
context,
context_before: input['-B'],
context_after: input['-A'],
Expand All @@ -252,10 +265,7 @@ async function runNativeSearch(input: any, searchPath: string, outputMode: Searc
cache: true,
gitignore: true,
timeout_ms: SEARCH_TIMEOUT_MS,
})
return result && !result.error
? formatNativeResult(input.pattern, searchPath, outputMode, offset, headLimit, result, 'native')
: undefined
}
}

function buildRgArgs(input: any, outputMode: SearchMode, searchPath: string): string[] {
Expand Down
Loading