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
@@ -1,15 +1,16 @@
/**
* Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
* REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
* the WORLD — actual files on disk are discovered and grepped, hostile
* patterns stay inert in a real shell, and real `rg` stderr classifies into
* the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
* PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
* suite (tools.spec.ts) carries the coverage gate.
* Integration tests: the REAL local subprocess service plus the PACKAGED
* ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`.
* These verify the WORLD — actual files on disk are discovered and grepped,
* hostile patterns stay inert (they are plain argv elements; there is no
* shell layer to escape), and real `rg` stderr classifies into the
* `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the
* suite runs on every platform without a system `rg` install; the
* fake-service suite (tools.spec.ts) carries the coverage gate.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,14 +18,11 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
let dir: string
let ctx: Context
@@ -43,7 +41,10 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
/** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
describe('search tools over the real subprocess service + the packaged rg', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
await mkdir(join(dir, 'src'), { recursive: true })
@@ -54,7 +55,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n')
// Deterministic --sort=modified order: alpha oldest, beta newest.
await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
@@ -63,7 +64,6 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
})
@@ -73,33 +73,33 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
describe('glob', () => {
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
const result = await call('glob', { pattern: '**/*.ts' })
const result = await call('glob', { pattern: '**/*.ts' }, agent())
expect(result.isError).toBe(false)
const paths = text(result).split('\n')
expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts')))
expect(paths).toContain('.hidden.ts')
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
expect(paths).not.toContain('.git/config.ts')
expect(paths).toContain(join('spaced dir', "wei'rd name.ts"))
expect(paths).not.toContain(join('.git', 'config.ts'))
expect(paths).not.toContain('notes.md')
})
it('scopes to a directory search root (path arg)', async () => {
const result = await call('glob', { pattern: '*.ts', path: 'src' })
expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent())
expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')])
})
it('reports zero discoveries as No files found', async () => {
expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found')
})
it('excludes VCS internals even when the search root IS the VCS directory', async () => {
// The prune glob alone never matches root-prefixed paths when rg is
// rooted at .git; the paired contents glob keeps the exclusion airtight.
expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found')
})
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
const result = await call('glob', { pattern: '[' })
const result = await call('glob', { pattern: '[' }, agent())
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
})
@@ -107,37 +107,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
describe('grep', () => {
it('greps a directory tree with grouped, line-numbered output', async () => {
const result = await call('grep', { pattern: 'alpha' })
const result = await call('grep', { pattern: 'alpha' }, agent())
expect(result.isError).toBe(false)
const output = text(result)
expect(output).toContain('Found 3 matches')
expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`)
expect(output).toContain('notes.md\nLine 1: alpha appears here too')
})
it('greps a single FILE target', async () => {
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent())
expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
})
it('greps a directory target with an include filter', async () => {
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent())
const output = text(result)
expect(output).toContain('alpha.ts')
expect(output).not.toContain('notes.md')
})
it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => {
// There is no shell layer between the argv vector and rg, so the pattern
// is a literal regex — but the world-untouched guarantee is the shipped
// contract, and a future shell-wrapping change must not reintroduce it.
// The canary name carries no path so the regex stays valid on every
// platform (a Windows path's backslashes would be regex escapes).
const canary = join(dir, 'pwned')
const result = await call('grep', { pattern: `$(touch ${canary})` })
const result = await call('grep', { pattern: '$(touch pwned)' }, agent())
expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
expect(text(result)).toBe('No matches found')
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
expect(existsSync(canary)).toBe(false)
})
it('a leading-dash pattern is a pattern, not a flag', async () => {
await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent())
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
})
@@ -155,7 +160,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
describe('per-session cwd', () => {
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
it('resolves the search in the SESSION workspace, not the process cwd', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
try {
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
@@ -170,7 +175,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
})
describe('pre-dispatch cancellation and bash-start failures', () => {
describe('pre-dispatch cancellation and spawn failures', () => {
it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
const controller = new AbortController()
controller.abort()