Merge remote-tracking branch 'origin/feat/ripgrep-packaged-binary' into feat/pwsh-tool

This commit is contained in:
Huanqi Cao
2026-08-01 22:00:47 +08:00
18 changed files with 239 additions and 166 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 0152be017ae15fc83a3d5cb7df927f25d04d2d53
README.zh.md: 69ce49f3ad1621021dc1d0938cdc07a900d1cda8
README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a
README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// A deployment chooses how over-cap glob pages are selected.
@@ -30,6 +30,8 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. |
| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. |
| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). |
## Tools
@@ -42,7 +44,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests a collect-mode stdout budget of `rawOutputMaxBytes` from the subprocess seam and parses only complete retained stdout; if the seam still reports a lossy read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
Raw `rg` stdout and stderr are internal transport details. Each search requests collect-mode budgets from the subprocess seam — complete stdout within `rawOutputMaxBytes` and a `stderrMaxBytes` diagnostic tail — with no spill files on either stream (the tool never reads a raw spill path). If the seam still reports a lossy stdout read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query; a lossy stderr read only marks the diagnostic excerpt `[stderr truncated]`. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**面向模型的文件系统发现工具**`glob``grep`)由 **打包的 ripgrep 二进制**`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
**面向模型的文件系统发现工具**`glob``grep`)由 **打包的 ripgrep 二进制**`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
```ts ignore-check
// A deployment chooses how over-cap glob pages are selected.
@@ -30,6 +30,8 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行subprocess seam 的终止升级提供硬终止。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 |
| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获lossy 读取只保留尾部(标记 `[stderr truncated]`)。 |
## 工具
@@ -42,7 +44,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 subprocess seam 请求 `rawOutputMaxBytes` 的 collect 模式 stdout 预算,且只解析完整保留的 stdout如果 seam 仍报告 lossy 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误

View File

@@ -47,6 +47,10 @@ export interface GlobToolCaps {
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Terminate-escalation grace period (ms) for the search process. */
graceMs: number
/** Cap on the retained stderr diagnostic tail. */
stderrMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
@@ -337,7 +341,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
if (run.noMatches) return { root, paths: [] }

View File

@@ -45,6 +45,10 @@ export interface GrepToolCaps {
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Terminate-escalation grace period (ms) for the search process. */
graceMs: number
/** Cap on the retained stderr diagnostic tail. */
stderrMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
@@ -265,7 +269,7 @@ export function presentGrepResult(
* Register the `grep` tool and its system-prompt guidance.
*
* @param ctx - the plugin context; registrations are effects scoped to it, and
* execution uses its `bash` service.
* execution uses its `subprocess` service.
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
*/
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
@@ -315,7 +319,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
},
async execute(args, exec) {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
if (run.noMatches) return { matches: [] }
const all: GrepMatch[] = []

View File

@@ -30,7 +30,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
@@ -49,7 +49,9 @@ export {
export type { GrepInput, GrepToolCaps } from './grep.ts'
export {
RAW_OUTPUT_MAX_BYTES,
SEARCH_GRACE_MS,
SEARCH_META_MAX_BYTES,
SEARCH_STDERR_MAX_BYTES,
SEARCH_TIMEOUT_MS,
SearchError,
previewLine,
@@ -58,7 +60,6 @@ export {
trySaveFormattedResult,
} from './search-core.ts'
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
export { singleQuote } from './shell-quote.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs-search'
@@ -80,6 +81,10 @@ export interface Config {
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
graceMs?: number
/** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */
stderrMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
timeoutMs?: number
}
@@ -91,6 +96,8 @@ export const Config: z<Config> = z.object({
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
graceMs: z.number().default(SEARCH_GRACE_MS),
stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES),
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
})
@@ -121,12 +128,16 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('graceMs', resolved.graceMs)
assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
graceMs: resolved.graceMs,
stderrMaxBytes: resolved.stderrMaxBytes,
timeoutMs: resolved.timeoutMs,
})
applyGrepTool(ctx, {
@@ -134,6 +145,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
maxLineBytes: resolved.grepMaxLineBytes,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
graceMs: resolved.graceMs,
stderrMaxBytes: resolved.stderrMaxBytes,
timeoutMs: resolved.timeoutMs,
})
}

View File

@@ -44,15 +44,13 @@ export const SEARCH_TIMEOUT_MS = 30_000
/**
* Default cap in bytes on the retained stderr tail of one search run — a
* diagnostic excerpt only (the tool never reads `stderr.spillPath`).
* diagnostic excerpt only (the tool never reads a stderr spill path, and the
* collect disposition requests none).
*/
const SEARCH_STDERR_MAX_BYTES = 64 * 1024
/** Default whole-stream spill cap for search output (the subprocess seam requires an explicit budget). */
const SEARCH_SPILL_MAX_BYTES = 64 * 1024 * 1024
export const SEARCH_STDERR_MAX_BYTES = 64 * 1024
/** Default terminate grace period for a search process (ms). */
const SEARCH_GRACE_MS = 3_000
export const SEARCH_GRACE_MS = 3_000
/**
* Default cap in bytes on one search's serialized `presentationMeta` (the
@@ -110,7 +108,7 @@ export interface RipgrepRun {
/**
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
* the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`).
* the subprocess seam dropped bytes.
*/
function stderrExcerpt(stderrText: string, truncated: boolean): string {
const text = stderrText.trim()
@@ -118,15 +116,16 @@ function stderrExcerpt(stderrText: string, truncated: boolean): string {
return truncated ? `${text} [stderr truncated]` : text
}
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
/**
* Classify a nonzero-exit `rg` run into the search error vocabulary. There is
* no shell layer, so an exit 127 or shell "command not found" text cannot
* occur — a launch failure rejects at spawn (see {@link runRipgrep}).
*/
function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError {
const stderr = stderrExcerpt(stderrText, stderrTruncated)
if (/regex parse error|error parsing glob/i.test(stderr)) {
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
}
if (exitCode === 127 || /command not found/i.test(stderr)) {
return new SearchError(`${toolName} requires ripgrep (rg) to launch${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
}
return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
}
@@ -163,6 +162,13 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu
* (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the
* process tree.
*
* The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config`
* is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the
* binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary
* preprocessor for every matched file. The collect dispositions are the
* seam's diagnostic-tail shape (no spill files): the tools never read a raw
* spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`.
*
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
* success with zero results (`noMatches`), anything else throws a
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
@@ -176,6 +182,8 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu
* @param toolName - `glob` or `grep`, used in error messages.
* @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists).
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
* @param graceMs - the seam's terminate-escalation grace period.
* @param stderrMaxBytes - cap on the retained stderr diagnostic tail.
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
*/
export async function runRipgrep(
@@ -184,6 +192,8 @@ export async function runRipgrep(
toolName: string,
argv: readonly string[],
rawOutputMaxBytes: number,
graceMs: number,
stderrMaxBytes: number,
): Promise<RipgrepRun> {
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
@@ -191,16 +201,16 @@ export async function runRipgrep(
const cwd = exec.agent?.session.header.cwd
const workdir = cwd ?? process.cwd()
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } })
({ maxBytes })
const handle = ctx.subprocess.spawn({
argv: [rgPath, ...argv],
argv: [rgPath, '--no-config', ...argv],
cwd: workdir,
stdio: {
stdin: 'ignore',
stdout: collect(rawOutputMaxBytes),
stderr: collect(SEARCH_STDERR_MAX_BYTES),
stderr: collect(stderrMaxBytes),
},
graceMs: SEARCH_GRACE_MS,
graceMs,
signal: exec.signal,
} satisfies SubprocessSpawnSpec)
let outcome: SubprocessOutcome

View File

@@ -1,24 +0,0 @@
/**
* POSIX single-quoting helper retained for compatibility with older
* deployments and tests. The current `glob`/`grep` command builders spawn the
* packaged ripgrep binary with a plain argv vector — no shell layer exists —
* so no quoting is involved; this module is kept because its export is part
* of the package surface.
*
* @module @deepseek-ai/dsh-tool-fs-search/shell-quote
*/
/**
* POSIX single-quote a string for safe use as ONE shell word. Wraps the value
* in single quotes and rewrites every embedded single quote as `'\''` (close
* quote, an escaped literal quote, reopen quote). Inside single quotes the shell
* treats every other byte literally — spaces, newlines, `$`, backticks, `;`,
* `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result
* is a single, injection-safe argument regardless of the input.
*
* @param value - the raw, possibly model-controlled string to quote.
* @returns the value wrapped as one safe single-quoted shell word.
*/
export function singleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}

View File

@@ -1,59 +0,0 @@
/**
* Unit tests for the shell-quoting safety boundary, plus a REAL round-trip:
* every adversarial value, quoted, must survive `bash -c "printf '%s' <quoted>"`
* byte-for-byte — proving the quoting is inert in an actual shell, not just
* against a mental model of one.
*/
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search'
/** Adversarial values a model could pass as pattern / path / include. */
const HOSTILE: readonly string[] = [
'plain',
'with spaces',
"it's got 'quotes'",
'"double quoted"',
'$(rm -rf /tmp/nope)',
'`touch /tmp/nope`',
'$HOME and ${PATH}',
'semi;colon && chain || pipe | bg &',
'newline\nin the middle',
'-leading-dash',
'--leading-double-dash',
'*?[a-z]{x,y}',
'!bang',
'\\backslash\\',
'~tilde',
'# not a comment',
'>redirect <input 2>&1',
]
describe('singleQuote', () => {
it('wraps a plain value in single quotes', () => {
expect(singleQuote('abc')).toBe("'abc'")
})
it("rewrites embedded single quotes as '\\''", () => {
expect(singleQuote("a'b")).toBe("'a'\\''b'")
expect(singleQuote("''")).toBe("''\\'''\\'''")
})
it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))(
'round-trips %s through a real bash -c unchanged',
(_label, value) => {
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' })
expect(result.status).toBe(0)
expect(result.stdout).toBe(value)
},
)
it('a quoted command substitution does not execute (the world stays untouched)', () => {
const canary = `/tmp/dsh-quote-canary-${process.pid}`
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' })
expect(result.stdout).toBe(`$(touch ${canary})`)
// The canary file must NOT exist — the substitution stayed literal.
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
})
})

View File

@@ -291,6 +291,8 @@ describe('config validation', () => {
['grepMaxMatches', { grepMaxMatches: -1 }],
['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }],
['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }],
['graceMs', { graceMs: 0 }],
['stderrMaxBytes', { stderrMaxBytes: -1 }],
['timeoutMs', { timeoutMs: -100 }],
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
const ctx = new Context()
@@ -372,16 +374,30 @@ describe('workdir derivation and signal forwarding', () => {
expect(subprocess.spawns[1]?.cwd).toBe(process.cwd())
})
it('spawns the packaged ripgrep binary with the fixed argv and budgeted collect streams', async () => {
const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 1234 } })
it('spawns the packaged ripgrep binary with --no-config, the fixed argv, and budgeted collect streams', async () => {
const { ctx, subprocess } = await setup({
config: { rawOutputMaxBytes: 1234, graceMs: 5000, stderrMaxBytes: 4096 },
})
subprocess.handler = () => runResult('', { exitCode: 1 })
await call(ctx, 'grep', { pattern: 'needle' })
const spec = subprocess.spawns[0]
expect(spec?.argv[0]).toBe(rgPath)
expect(spec?.argv).toEqual([rgPath, '--json', '--regexp=needle'])
// --no-config keeps a host RIPGREP_CONFIG_PATH from injecting a
// preprocessor into this unconfined spawn.
expect(spec?.argv).toEqual([rgPath, '--no-config', '--json', '--regexp=needle'])
expect(spec?.stdio.stdin).toBe('ignore')
// stdout gets the tool's parse budget; stderr is a diagnostic excerpt.
// stdout gets the tool's parse budget; stderr is a diagnostic excerpt;
// both are the seam's diagnostic-tail shape (no spill files requested).
expect((spec?.stdio.stdout as { maxBytes: number }).maxBytes).toBe(1234)
expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(4096)
expect(spec?.graceMs).toBe(5_000)
})
it('defaults the stderr tail budget and grace period when the config omits them', async () => {
const { ctx, subprocess } = await setup()
subprocess.handler = () => runResult('', { exitCode: 1 })
await call(ctx, 'grep', { pattern: 'needle' })
const spec = subprocess.spawns[0]
expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(64 * 1024)
expect(spec?.graceMs).toBe(3_000)
})
@@ -430,7 +446,7 @@ describe('workdir derivation and signal forwarding', () => {
const controller = new AbortController()
controller.abort()
const exec = { signal: controller.signal, name: 'glob', callId: CallId('direct-pre-abort') } as unknown as ToolExecution
await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000)).rejects
await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)).rejects
.toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
})
@@ -489,20 +505,6 @@ describe('exit semantics and failure classification', () => {
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
})
it('a failed ripgrep launch classifies as SEARCH_FAILED naming ripgrep', async () => {
const { ctx, subprocess } = await setup()
subprocess.handler = () => runResult('', { exitCode: 127, stderr: { text: 'sh: rg: command not found' } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('requires ripgrep (rg)')
// The same classification holds from either evidence alone: the 127 exit
// with silent stderr, or a shell's command-not-found text on another exit.
subprocess.handler = () => runResult('', { exitCode: 127 })
expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)')
subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found' } })
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)')
})
it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => {
const { ctx, subprocess } = await setup()
subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory' } })
@@ -682,7 +684,7 @@ describe('glob results', () => {
subprocess.handler = () => runResult('sub/a.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' })
expect(result.isError).toBe(false)
expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden',
expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--no-config', '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden',
'--glob=!**/.git', '--glob=!**/.git/**',
'--glob=!**/.svn', '--glob=!**/.svn/**',
'--glob=!**/.hg', '--glob=!**/.hg/**',