refactor(fs-search): apply #1119 review fixes to the packaged-rg spawn

- delete the singleQuote shell-quoting helper and its bash-spawning tests
  (no in-repo consumers; no shell layer exists anymore)
- drop spill from both collect streams: the tool never reads a raw spill
  path, and a lossy stdout read is a pure SEARCH_RAW_OUTPUT_OVERFLOW error
- prepend --no-config so a host RIPGREP_CONFIG_PATH cannot inject a --pre
  preprocessor into the unconfined spawn
- promote graceMs and stderrMaxBytes to validated Config fields (defaults
  SEARCH_GRACE_MS / SEARCH_STDERR_MAX_BYTES) instead of inheriting
  bash-local's config
- correct the grep tool's JSDoc seam reference (bash -> subprocess)
- drop the dead exit-127/command-not-found classification branch
This commit is contained in:
Huanqi Cao
2026-08-01 21:56:16 +08:00
parent 36700f6916
commit a9871d4af1
14 changed files with 97 additions and 135 deletions

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("'", "'\\''")}'`
}