feat(fs-search): spawn the packaged ripgrep binary through the subprocess seam

glob/grep now run the @vscode/ripgrep binary via ctx.subprocess with a plain
argv vector: no system rg install, no shell layer, unconditional registration.
The load-time command -v rg probe and the bash-seam coupling are removed;
timeouts ride the cooperative exec.signal plus the seam's terminate
escalation. The fs-glob-sampling ACP snapshot executes the real packaged
binary against an mtime-pinned fixture. Adds the packaged-ripgrep-search
Agent Note, updates the roster-note facts and both shipped-composition e2es,
and regenerates the doc catalogs and third-party notices (surfacing
pre-existing manifest drift plus the new @vscode/ripgrep row; the notices
generator also learns pnpm 11's truncated virtual-store names).
This commit is contained in:
Huanqi Cao
2026-08-01 18:04:38 +08:00
parent de520bee24
commit 18700f428d
32 changed files with 923 additions and 699 deletions

View File

@@ -1,10 +1,11 @@
/**
* The model-facing `glob` tool: discover files whose paths match a glob
* pattern, sorted by modification time. Execution goes through the bash seam
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
* model-facing schema, argument validation, shell-safe command construction,
* result parsing, inline sampling, and formatting; process concerns (defaulting,
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
* pattern, sorted by modification time. Execution spawns the packaged
* ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam
* with a plain argv vector — this module owns the model-facing schema,
* argument validation, argv construction, result parsing, inline sampling,
* and formatting; process concerns (spawn execution, tree termination,
* environment scrubbing, output capture) stay behind `ctx.subprocess`.
* @module @deepseek-ai/dsh-tool-fs-search/glob
*/
@@ -13,11 +14,9 @@ import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
@@ -73,32 +72,35 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp
}
/**
* Build the fixed `rg --files` command for one `glob` call. Every
* Build the fixed `rg --files` argv for one `glob` call. Every
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
* passes through {@link singleQuote}; the search root rides behind `--` so a
* leading-dash path can never be parsed as a flag. `--sort=modified` orders by
* modification time, `--no-ignore --hidden` searches ignored and hidden files,
* and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
* is a plain argv element — no shell layer exists, so no quoting applies; the
* search root rides behind `--` so a leading-dash path can never be parsed as
* a flag. `--sort=modified` orders by modification time, `--no-ignore
* --hidden` searches ignored and hidden files, and
* {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
* @returns the complete ripgrep argument vector (excluding the binary itself).
*/
export function buildGlobCommand(input: GlobInput): string {
export function buildGlobCommand(input: GlobInput): string[] {
const parts = [
'rg --files',
`--glob=${singleQuote(input.pattern)}`,
'--sort=modified --no-ignore --hidden',
'--files',
`--glob=${input.pattern}`,
'--sort=modified',
'--no-ignore',
'--hidden',
// Two negated globs per VCS name: the bare form prunes the directory
// during traversal; the /** form still excludes the contents when the
// search root is AT or INSIDE the directory (where the bare form,
// matched against root-prefixed paths, never fires).
...GLOB_VCS_EXCLUDES.flatMap(name => [
`--glob=${singleQuote(`!**/${name}`)}`,
`--glob=${singleQuote(`!**/${name}/**`)}`,
`--glob=!**/${name}`,
`--glob=!**/${name}/**`,
]),
]
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
return parts.join(' ')
if (input.path !== undefined) parts.push('--', input.path)
return parts
}
/**
@@ -285,7 +287,7 @@ export function presentGlobResult(_args: { pattern: string; path?: string }, res
* Register the `glob` 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 glob caps (plugin config after defaulting).
*/
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {

View File

@@ -1,11 +1,12 @@
/**
* The model-facing `grep` tool: search file contents with a ripgrep regular
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
* line-oriented `rg --json` command so file path, line number, and line text
* parse without colon-splitting ambiguity — this module owns the model-facing
* schema, argument validation, shell-safe command construction, `--json`
* record parsing, per-line preview retention, match retention, grouping, and
* formatting; process concerns stay behind `ctx.bash`.
* expression. Execution spawns the packaged ripgrep binary
* (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv
* vector using a fixed line-oriented `rg --json` command so file path, line
* number, and line text parse without colon-splitting ambiguity — this module
* owns the model-facing schema, argument validation, argv construction,
* `--json` record parsing, per-line preview retention, match retention,
* grouping, and formatting; process concerns stay behind `ctx.subprocess`.
*
* @module @deepseek-ai/dsh-tool-fs-search/grep
*/
@@ -15,12 +16,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { GrepMatch } from './search-core.ts'
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
@@ -96,20 +95,21 @@ export function parseGrepArgs(args: { pattern: string; path?: string; include?:
}
/**
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
* Build the fixed line-oriented `rg --json` argv for one `grep` call. Every
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
* and include ride in `--flag=value` form and the target behind `--`, so a
* leading-dash value can never be parsed as a flag.
* {@link GrepInput.include}) is a plain argv element — no shell layer exists,
* so no quoting applies; the pattern and include ride in `--flag=value` form
* and the target behind `--`, so a leading-dash value can never be parsed as
* a flag.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
* @returns the complete ripgrep argument vector (excluding the binary itself).
*/
export function buildGrepCommand(input: GrepInput): string {
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
return parts.join(' ')
export function buildGrepCommand(input: GrepInput): string[] {
const parts = ['--json', `--regexp=${input.pattern}`]
if (input.include !== undefined) parts.push(`--glob=${input.include}`)
if (input.path !== undefined) parts.push('--', input.path)
return parts
}
/**

View File

@@ -1,28 +1,27 @@
/**
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
* bash executor seam (`ctx.bash`). This single plugin registers both tools
* only when the mounted bash executor can find `rg` on its `PATH`.
* packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers
* both tools; the binary ships inside the npm dependency, so no system `rg`
* install and no shell layer is involved.
*
* ## Bash-backed, not a `ctx.fs` provider method
* ## Spawn-backed, not a `ctx.fs` provider method
*
* Local workspace discovery is a process-backed `rg` workflow, so these tools
* execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed
* ripgrep command templates — never `ctx.bash.start()`, never a model-visible
* background task. The tool layer owns schemas, argument validation, shell
* quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result
* parsing, retention, formatted-result spill, and timeout declaration; the
* bash executor owns request defaulting/capping, subprocess execution,
* process-group termination, environment scrubbing, raw output capture, and
* backend substitution. At load, the package probes `command -v rg` through the
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
* sections are not registered. The package injects `tools`, `systemPrompt`,
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
* execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates —
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
* task. The tool layer owns schemas, argument validation, argv construction
* ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} /
* {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing,
* retention, formatted-result spill, and timeout declaration; the subprocess
* seam owns spawn execution, process-tree termination, environment scrubbing,
* and raw output capture. The package injects `tools`, `systemPrompt`, and
* `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read
* opportunistically with `ctx.get()` because formatted-result spill is optional.
*
* Returned paths are displayed relative to the resolved bash workdir and are
* follow-up-readable only in co-located deployments where the bash workdir and
* the filesystem `read` root are the same workspace — a documented v1
* deployment requirement, not runtime-validated.
* Returned paths are displayed relative to the resolved workdir and are
* follow-up-readable only in co-located deployments where the workdir and the
* filesystem `read` root are the same workspace — a documented v1 deployment
* requirement, not runtime-validated.
*
* @module @deepseek-ai/dsh-tool-fs-search
*/
@@ -65,7 +64,7 @@ export { singleQuote } from './shell-quote.ts'
export const name = 'tool-fs-search'
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
export const inject = ['tools', 'systemPrompt', 'subprocess']
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
export interface Config {
@@ -98,9 +97,6 @@ export const Config: z<Config> = z.object({
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
@@ -109,36 +105,14 @@ function assertPositiveInteger(name: string, value: number): void {
}
/**
* Check whether the mounted bash executor can find `rg`.
*
* Nonzero exit means "not available" and disables this optional tool suite.
* Infrastructure failures stay loud: a deployment with a broken bash executor
* should not silently lose tools in a way that looks like a deliberate skip.
*
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
*/
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
let result
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
}
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
}
return result.exitCode === 0
}
/**
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
* Register the `glob`/`grep` filesystem discovery tool suite. The packaged
* ripgrep binary is always available (an npm dependency), so registration is
* unconditional.
*
* @param ctx - plugin context; registrations are effects scoped to this plugin.
* @param config - resolved plugin configuration from schemastery.
* @returns when ripgrep is unavailable, resolves without registering any tools.
*/
// oxlint-disable-next-line typescript/require-await -- async keeps a load-time config rejection a rejection, not a synchronous throw
export async function apply(ctx: Context, config: Config): Promise<void> {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
@@ -148,10 +122,6 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
return
}
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,

View File

@@ -0,0 +1,12 @@
/**
* Minimal type surface for the `@vscode/ripgrep` package: an ESM module that
* resolves the platform ripgrep binary (`@vscode/ripgrep-<platform>-<arch>`
* optional dependency) and exports its absolute path as the named export
* `rgPath` (no bundled type declarations).
* @module @deepseek-ai/dsh-tool-fs-search/ripgrep-types
*/
declare module '@vscode/ripgrep' {
/** Absolute path to the packaged ripgrep executable for the current platform. */
export const rgPath: string
}

View File

@@ -1,16 +1,19 @@
/**
* Shared execution plumbing for the `glob` / `grep` search tools: the
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
* turns a fixed `rg` command into complete raw stdout, the best-effort
* formatted-result spill handoff, and workdir-relative path display.
* package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the
* PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and
* returns complete raw stdout, the best-effort formatted-result spill handoff,
* and workdir-relative path display.
*
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
* model-visible background task. Raw `rg` stdout is an internal transport
* detail: the tools request a per-run stdout capture budget from the bash seam,
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
* read executor spill files. The model-facing recovery artifact is the
* formatted result saved through `ctx.spillStore.saveText()`
* Both tools execute as ordinary foreground spawns through `ctx.subprocess` —
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
* task. The ripgrep binary ships inside the npm package, so no system `rg`
* install is required, and no shell layer exists between the argv vector and
* ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal
* transport detail: the tools request a per-run stdout capture budget from the
* subprocess seam, parse only complete in-memory stdout within
* `rawOutputMaxBytes`, and never read spill files. The model-facing recovery
* artifact is the formatted result saved through `ctx.spillStore.saveText()`
* ({@link trySaveFormattedResult}).
*
* @module @deepseek-ai/dsh-tool-fs-search/search-core
@@ -18,10 +21,11 @@
import { isAbsolute, relative, sep } from 'node:path'
import type { Context } from 'cordis'
import { rgPath } from '@vscode/ripgrep'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -38,6 +42,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
*/
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`).
*/
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
/** Default terminate grace period for a search process (ms). */
const SEARCH_GRACE_MS = 3_000
/**
* Default cap in bytes on one search's serialized `presentationMeta` (the
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
@@ -52,14 +68,14 @@ export const SEARCH_META_MAX_BYTES = 65_536
/**
* Stable, machine-routable codes for search failures. Package-owned (not
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
* `FsErrorCode`) because these tools are spawn-backed discovery, not `ctx.fs`
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
* timeout, caller cancellation, or the bash executor's own timeout cut the
* search short.
* parsed (a failed `rg` launch, inaccessible target, signal kill, malformed
* `--json`); `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded
* `rawOutputMaxBytes` or stayed truncated after that requested stdout budget;
* `SEARCH_ABORTED` — the cooperative tool timeout or caller cancellation cut
* the search short.
*/
export type SearchErrorCode =
| 'SEARCH_INVALID_PATTERN'
@@ -84,7 +100,7 @@ export class SearchError extends HarnessError {
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
export interface RipgrepRun {
/** Complete raw stdout retained by the bash executor within the requested cap. */
/** Complete raw stdout retained by the subprocess seam within the requested cap. */
stdout: string
/** True when ripgrep exited 1: a successful search with zero results. */
noMatches: boolean
@@ -94,73 +110,71 @@ export interface RipgrepRun {
/**
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
* the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`).
*/
function stderrExcerpt(stderr: CollectedOutput): string {
const text = stderr.text.trim()
function stderrExcerpt(stderrText: string, truncated: boolean): string {
const text = stderrText.trim()
if (text.length === 0) return ''
return stderr.truncated ? `${text} [stderr truncated]` : text
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). */
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
const stderr = stderrExcerpt(result.stderr)
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 (result.exitCode === 127 || /command not found/i.test(stderr)) {
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
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 ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
}
/**
* Acquire the COMPLETE raw stdout of a finished run, enforcing
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
* bash backend could not retain complete stdout within the requested budget, so
* the tool fails clearly instead of parsing a silently-partial stream.
* subprocess seam could not retain complete stdout within the requested
* budget, so the tool fails clearly instead of parsing a silently-partial
* stream.
*/
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutputMaxBytes: number): string {
const narrow = 'narrow pattern, path, or include and retry'
if (!result.stdout.truncated) {
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
if (!stdout.lossy) {
const inlineBytes = Buffer.byteLength(stdout.text, 'utf8')
if (inlineBytes > rawOutputMaxBytes) {
throw new SearchError(
`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
return result.stdout.text
return stdout.text
}
throw new SearchError(
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
`${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
/**
* Run one fixed `rg` command through the bash seam and return its complete raw
* stdout. The bash request workdir is the calling agent's session cwd
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
* configured default. `exec.signal` is forwarded so the cooperative tool
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
* command; the bash backend's own timeout stays a second safety cap.
* Run the packaged ripgrep binary with a plain argv vector and return its
* complete raw stdout. The working directory is the calling agent's session
* cwd (`exec.agent.session.header.cwd`) when available, else
* `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout
* (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the
* process tree.
*
* 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 →
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
* infrastructure failures (pre-aborted signal, unusable workdir, missing
* shell) — is translated into the same taxonomy: a pre-aborted signal becomes
* `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as
* `cause`.
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's
* infrastructure failures — is translated into `SEARCH_FAILED` with the
* original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`.
*
* @param ctx - the plugin context; execution uses its `bash` service.
* @param ctx - the plugin context; execution uses its `subprocess` service.
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
* @param toolName - `glob` or `grep`, used in error messages.
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
* @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.
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
*/
@@ -168,54 +182,64 @@ export async function runRipgrep(
ctx: Context,
exec: ToolExecution,
toolName: string,
command: string,
argv: readonly string[],
rawOutputMaxBytes: number,
): Promise<RipgrepRun> {
const cwd = exec.agent?.session.header.cwd
const spec = ctx.bash.resolve({
command,
stdoutMaxBytes: rawOutputMaxBytes,
...cwd !== undefined ? { workdir: cwd } : {},
signal: exec.signal,
})
let result: BashRunResult
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
// The seam contract: run() REJECTS only for infrastructure failures — a
// pre-aborted signal, an unusable workdir, a missing shell. Translate them
// so these failures stay machine-routable under the SEARCH_* taxonomy.
if (spec.signal?.aborted === true) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
}
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
}
if (result.aborted) {
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
}
if (result.timedOut) {
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
const cwd = exec.agent?.session.header.cwd
const workdir = cwd ?? process.cwd()
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } })
const handle = ctx.subprocess.spawn({
argv: [rgPath, ...argv],
cwd: workdir,
stdio: {
stdin: 'ignore',
stdout: collect(rawOutputMaxBytes),
stderr: collect(SEARCH_STDERR_MAX_BYTES),
},
graceMs: SEARCH_GRACE_MS,
signal: exec.signal,
} satisfies SubprocessSpawnSpec)
let outcome: SubprocessOutcome
try {
outcome = await handle.done
} catch (error: unknown) {
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
}
if (result.signal !== null || result.exitCode === null) {
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
const stdout = handle.collected.stdout?.readFrom(0)
const stderr = handle.collected.stderr?.readFrom(0)
if (stdout === undefined || stderr === undefined) {
throw new SearchError(`${toolName} search command produced no collected output streams`, 'SEARCH_FAILED')
}
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw classifyRunFailure(toolName, result)
// The signal can abort while the spawn is awaited; the static narrowing that
// proves this re-check "always false" cannot see AbortSignal state changes.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
}
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
if (outcome.signal !== null || outcome.exitCode === null) {
throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
}
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy)
}
const text = completeStdout(toolName, stdout, rawOutputMaxBytes)
return { stdout: text, noMatches: outcome.exitCode === 1, workdir }
}
/**
* Map an `rg` output path to its display form: absolute paths inside the
* resolved bash workdir become workdir-relative; everything else (relative
* output, paths outside the workdir) passes through unchanged. Display-only —
* returned paths are follow-up-readable in co-located bash/filesystem
* resolved workdir become workdir-relative; everything else (relative output,
* paths outside the workdir) passes through unchanged. Display-only —
* returned paths are follow-up-readable in co-located workdir/filesystem
* deployments where both resolve the same workspace (the documented v1
* deployment requirement).
*
* @param path - one path as ripgrep printed it.
* @param workdir - the resolved bash workdir the command ran in.
* @param workdir - the resolved workdir the command ran in.
* @returns the workdir-relative display path when possible, else `path` unchanged.
*/
export function toWorkdirRelative(path: string, workdir: string): string {

View File

@@ -1,12 +1,9 @@
/**
* The one shell-quoting helper both search tools MUST route every
* model-controlled value through before it enters an `rg` command string. The
* bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this
* is the safety boundary that stops a `pattern`, `path`, or `include` from
* breaking out of its argument and injecting shell syntax.
*
* Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or
* concatenate an unquoted model value — they call {@link singleQuote}.
* 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
*/