Merge remote-tracking branch 'origin/master' into worktree/agent-loop-testkit

# Conflicts:
#	packages/README.md
This commit is contained in:
Yichen Jiang
2026-07-17 19:13:37 +08:00
250 changed files with 14581 additions and 810 deletions

View File

@@ -1,6 +1,6 @@
# fs/ - filesystem capability family
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
@@ -8,9 +8,10 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs-local
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -12,8 +12,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).

View File

@@ -7,8 +7,8 @@
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { Dirent, Stats } from 'node:fs'
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -63,9 +63,9 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si
}
}
/** Opaque version token from a stat: mtime (ns precision) + size. */
function versionOf(info: Stats): FsVersion {
return FsVersion(`${info.mtimeMs}:${info.size}`)
/** Opaque version token from high-resolution identity and freshness metadata. */
function versionOf(info: BigIntStats): FsVersion {
return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
}
/**
@@ -98,6 +98,14 @@ export interface PathInfo {
size: number
}
/** Result of probing a path without following the final symlink component. */
export interface PathLinkInfo {
version: FsVersion
mode: number
type: 'file' | 'directory' | 'symlink' | 'other'
size: number
}
/** One local directory child with a resolved target and cheap metadata. */
export interface LocalDirEntry {
name: string
@@ -150,22 +158,62 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
}
}
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
if (info.isFile()) return 'file'
if (info.isDirectory()) return 'directory'
return 'other'
}
function pathLinkType(info: Stats | BigIntStats): PathLinkInfo['type'] {
if (info.isSymbolicLink()) return 'symlink'
return pathType(info)
}
async function probeStats<T extends Stats | BigIntStats>(
absolutePath: string,
readStats: (path: string) => Promise<T>,
): Promise<T | null> {
try {
return await readStats(absolutePath)
} catch (error: unknown) {
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
// the target is absent; any other metadata failure is a real permission/IO
// fault.
/* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
if (!isENOENT(error) && !isENOTDIR(error)) throw error
return null
}
}
/**
* Probe a path for its version, mode, type, and size. Null if absent.
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
* @returns the metadata, or null when the path — or a parent segment — does not exist.
*/
export async function probe(absolutePath: string): Promise<PathInfo | null> {
try {
const info = await stat(absolutePath)
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
} catch (error: unknown) {
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
// the target is absent; any other stat failure is a real permission/IO fault.
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
if (!isENOENT(error) && !isENOTDIR(error)) throw error
return null
const info = await probeStats(absolutePath, path => stat(path, { bigint: true }))
if (!info) return null
return {
version: versionOf(info),
mode: Number(info.mode & 0o777n),
type: pathType(info),
size: Number(info.size),
}
}
/**
* Probe a path without following the final symlink component.
* @param absolutePath - the path entry to inspect with `lstat` semantics.
* @returns path-entry metadata, or null when the entry is absent.
*/
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
const info = await probeStats(absolutePath, path => lstat(path, { bigint: true }))
if (!info) return null
return {
version: versionOf(info),
mode: Number(info.mode & 0o777n),
type: pathLinkType(info),
size: Number(info.size),
}
}

View File

@@ -5,6 +5,7 @@
*/
import { Context } from 'cordis'
import { resolve } from 'node:path'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -12,6 +13,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -21,6 +23,7 @@ import {
listDirectory,
normalizeLineEndings,
probe,
probeNoFollow,
readForEdit,
readTextForDiff,
readWholeText,
@@ -80,14 +83,26 @@ export class LocalFileSystem extends FileSystem {
}
}
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
return { targetKey: local.targetKey, displayPath: local.displayPath }
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
const info = await probe(target.targetKey)
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}

View File

@@ -6,8 +6,8 @@
* `dsh-fs-policy`, so it is not exercised here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
@@ -73,6 +73,18 @@ describe('resolve', () => {
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
expect(await fs.readText(target)).toBe('absolute')
})
it('honors a pre-aborted signal', async () => {
await expect(fs.resolve('a.txt', { signal: AbortSignal.abort() })).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('honors a signal aborted while resolution is in flight', async () => {
const controller = new AbortController()
const pending = fs.resolve('a.txt', { signal: controller.signal })
controller.abort()
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('stat', () => {
@@ -87,11 +99,104 @@ describe('stat', () => {
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
})
it('changes version after a same-size rewrite even when mtime is restored', async () => {
const path = join(dir, 'same-size.txt')
await writeFile(path, 'first')
const target = await fs.resolve(path)
const beforeInfo = await stat(path)
const beforeVersion = await versionOf(target)
await fs.writeText(target, 'other')
await utimes(path, beforeInfo.atime, beforeInfo.mtime)
expect((await stat(path)).size).toBe(beforeInfo.size)
expect(await versionOf(target)).not.toBe(beforeVersion)
})
it('honors a pre-aborted signal', async () => {
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('lstat', () => {
it('reports path metadata without following the final symlink component', async () => {
await writeFile(join(dir, 'real.txt'), 'hello')
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
expect((await fs.lstat('real.txt'))?.type).toBe('file')
expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
expect(await fs.lstat('missing.txt')).toBeUndefined()
})
it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
try {
await writeFile(join(other, 'x.txt'), 'in other')
expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
} finally {
await rm(other, { recursive: true, force: true })
}
})
})
describe('metadata cancellation', () => {
it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => {
await writeFile(join(dir, 'slow.txt'), 'hello')
const statStarted = Promise.withResolvers<undefined>()
const statRelease = Promise.withResolvers<undefined>()
const lstatStarted = Promise.withResolvers<undefined>()
const lstatRelease = Promise.withResolvers<undefined>()
let isolatedCtx: Context | undefined
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async stat(path: string) {
statStarted.resolve(undefined)
await statRelease.promise
return actual.stat(path, { bigint: true })
},
async lstat(path: string) {
lstatStarted.resolve(undefined)
await lstatRelease.promise
return actual.lstat(path, { bigint: true })
},
}
})
try {
const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts')
isolatedCtx = new Context()
await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir })
const isolatedFs = isolatedCtx.fs as InstanceType<typeof IsolatedLocalFileSystem>
const target = await isolatedFs.resolve('slow.txt')
const statController = new AbortController()
const lstatController = new AbortController()
const pendingStat = isolatedFs.stat(target, statController.signal)
const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal)
await Promise.all([statStarted.promise, lstatStarted.promise])
statController.abort()
lstatController.abort()
const statRejected = expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' })
const lstatRejected = expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' })
statRelease.resolve(undefined)
lstatRelease.resolve(undefined)
await Promise.all([statRejected, lstatRejected])
} finally {
statRelease.resolve(undefined)
lstatRelease.resolve(undefined)
await isolatedCtx?.fiber.dispose()
vi.doUnmock('node:fs/promises')
vi.resetModules()
}
})
})
describe('readText / streamText', () => {
it('reads whole-file text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
@@ -292,9 +397,6 @@ describe('writeText', () => {
await writeFile(join(dir, 'a.txt'), 'v1')
const target = await fs.resolve('a.txt')
const before = await versionOf(target)
// Change the byte length so the mtimeMs:size token provably differs (a
// same-size same-tick rewrite can collide — the documented version-token
// limitation; not what this test is about).
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
expect(outcome.version).not.toBe(before)
expect(outcome.version).toBe(await versionOf(target))

View File

@@ -14,6 +14,7 @@ import {
applyLiteralEdit,
listDirectory,
probe,
probeNoFollow,
readForEdit,
readWholeText,
resolveLocalTarget,
@@ -146,6 +147,27 @@ describe('probe', () => {
})
})
describe('probeNoFollow', () => {
it('reports symlinks without following them', async () => {
const real = join(dir, 'real.txt')
const link = join(dir, 'link.txt')
await writeFile(real, 'hi')
await symlink(real, link)
expect((await probeNoFollow(real))?.type).toBe('file')
const linkInfo = await probeNoFollow(link)
expect(linkInfo?.type).toBe('symlink')
expect(typeof linkInfo?.version).toBe('string')
expect(linkInfo?.size).toBeGreaterThan(0)
})
it('returns null for a missing path or a file-valued ancestor path segment', async () => {
expect(await probeNoFollow(join(dir, 'missing'))).toBeNull()
await writeFile(join(dir, 'afile'), 'i am a file')
expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull()
})
})
describe('listDirectory', () => {
it('lists direct children in stable order without reading content', async () => {
const root = join(dir, 'skills')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -15,12 +15,13 @@ A future sandboxed, virtual, or remote backend implements this interface and the
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements seven primitives.
A backend subclasses `FileSystem` and implements eight primitives.
| Member | Semantics |
|---|---|
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
@@ -41,7 +42,7 @@ This package declares three events (see the generated [events catalog](../../../
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## Model Experience
@@ -50,6 +51,6 @@ Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bou
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Seven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -12,6 +12,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsVersion,
FsWriteIntent,
@@ -29,6 +30,7 @@ export type {
FsDirEntry,
FsErrorCode,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -86,10 +88,10 @@ export abstract class FileSystem extends Service {
* async even though the local backend only normalizes + realpaths.
*
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
* @param opts - `cwd` overrides the backend's default base for relative paths.
* @param opts - optional cwd override and cancellation signal.
* @returns the stable target; the same file yields the same `targetKey`.
*/
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
/**
* Return target metadata, or `undefined` when the target does not exist.
@@ -99,6 +101,22 @@ export abstract class FileSystem extends Service {
*/
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
/**
* Return path metadata without following the final path component when it is a
* symbolic link. This is intentionally path-shaped, not target-shaped:
* {@link resolve} follows symlinks to produce the stable identity used by
* normal reads/writes, while `lstat` lets a consumer reject the path itself
* before that follow happens.
*
* `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is
* absent.
* @param path - the path to inspect; relative paths resolve against `opts.cwd`.
* @param opts - `cwd` overrides the backend's default base for relative paths.
* @param signal - aborts the metadata round-trip.
* @returns metadata only, never content; undefined for an absent path.
*/
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
/**
* Read the whole regular text file as a single decoded string.
* @param target - the resolved target to read.

View File

@@ -27,16 +27,17 @@ export function FsTargetKey(key: string): FsTargetKey {
/**
* Opaque file-version token — the freshness token a write/edit guards against.
* The local backend derives it from mtime+size; a remote backend might use a
* revision id. The policy layer records it for stale checks; consumers may
* display related metadata but MUST NOT interpret this token.
* The local backend derives it from high-resolution stat identity and freshness
* fields; a remote backend might use a revision id. The policy layer records it
* for stale checks; consumers may display related metadata but MUST NOT
* interpret this token.
*/
export type FsVersion = Branded<'FsVersion'>
/**
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
* never manufactures a version, it receives one from `stat`/write/edit outcomes.
* @param v - the backend's raw version string (the local backend derives it from mtime+size).
* @param v - the backend's raw version string.
* @returns the same string, branded; no validation is performed.
*/
export function FsVersion(v: string): FsVersion {
@@ -72,6 +73,21 @@ export interface FsInfo {
size?: number
}
/**
* Metadata about a path without following the final path component when it is a
* symbolic link. Unlike {@link FsInfo}, this path-level probe can report
* `symlink` so consumers with trust-boundary rules can reject repository-owned
* links before resolving a target.
*/
export interface FsPathInfo {
/** Opaque freshness token of the path entry right now. */
version: FsVersion
/** Whether the path entry is a regular file, directory, symlink, or other. */
type: 'file' | 'directory' | 'symlink' | 'other'
/** Byte size of the path entry, when the backend can report it. */
size?: number
}
/**
* One direct child returned by {@link FileSystem.listDir}. Listing returns
* metadata and resolved targets only; it must not read file contents.

View File

@@ -13,12 +13,13 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A minimal in-memory fake implementing the seven provider primitives. */
/** A minimal in-memory fake implementing the eight provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
@@ -30,6 +31,11 @@ class FakeFileSystem extends FileSystem {
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async lstat(path: string): Promise<FsPathInfo | undefined> {
const content = this.files.get(path)
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async readText(target: FsTarget): Promise<string> {
const content = this.files.get(target.targetKey)
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
@@ -120,6 +126,15 @@ describe('FileSystem provider seam', () => {
const fs = ctx.fs as FakeFileSystem
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
})
it('lstat returns path metadata before resolving a target', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.txt', 'hi')
expect(await fs.lstat('a.txt')).toEqual({ version: 'v1', type: 'file', size: 2 })
expect(await fs.lstat('missing.txt')).toBeUndefined()
})
})
describe('branded id factories', () => {

View File

@@ -0,0 +1,90 @@
# @deepseek-ai/dsh-tool-fs-search
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor, then the discovery tools.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
## Deployment requirement: co-located bash + filesystem
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
## Config
All keys are optional; the defaults are the shipped search caps.
| Key | Default | Meaning |
|---|---|---|
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
| `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 bash backend's own timeout stays a second safety cap. |
## Tools
| Tool | Arguments | Behavior |
|---|---|---|
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
**Token effect**: Fixed guidance cost per request while the plugin is active.
#### Glob guidance
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
```
#### Grep guidance
```markdown
Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.
```
### Tool schemas
**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
**Token effect**: Fixed schema cost on every request where the tools are visible.
### Results and spill notices
**What the model sees**: `glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction.
### Tool errors
**What the model sees**: Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
**Token effect**: Only a failing call adds these retained tokens.
## Known Limitations and Deferred Work
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.

View File

@@ -0,0 +1,49 @@
{
"name": "@deepseek-ai/dsh-tool-fs-search",
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-retention": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-spill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,179 @@
/**
* 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, retention, and formatting; process concerns (defaulting,
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
*
* @module @deepseek-ai/dsh-tool-fs-search/glob
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
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 { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
/**
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
* config), matching Claude Code's default `GlobTool` result limit.
*/
export const GLOB_MAX_RESULTS = 100
/**
* Directory names ripgrep must never descend into for a discovery listing: VCS
* metadata stores. `--no-ignore --hidden` would otherwise surface them in every
* broad search. Each name is excluded with TWO negated `--glob`s (see
* {@link buildGlobCommand}): an any-depth directory glob that matches — and
* prunes — the directory during traversal, and a contents glob that still
* excludes the internals when the search root itself is at or inside the
* directory (an explicit `path` of `.git` or `sub/.git`), where the prune glob
* alone never matches.
*/
export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
export interface GlobToolCaps {
/** Max paths retained inline; later paths go to the formatted spill file. */
maxResults: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
/** Validated `glob` arguments. */
export interface GlobInput {
pattern: string
path?: string
}
/**
* Validate value constraints the schema DSL can't express: a non-blank
* `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an
* ordinary tool argument error) otherwise.
*
* @param args - the schema-validated `glob` arguments.
* @returns the accepted input, unchanged.
*/
export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput {
if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} }
}
/**
* Build the fixed `rg --files` command 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.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
*/
export function buildGlobCommand(input: GlobInput): string {
const parts = [
'rg --files',
`--glob=${singleQuote(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}/**`)}`,
]),
]
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
return parts.join(' ')
}
/**
* Format the model-facing `glob` result: the retained paths, then — when the
* result was capped — a footer carrying either the formatted-spill recovery
* locator or the could-not-save explanation. The omitted count is a budget fact:
* the search itself completed.
*
* @param retained - the retention outcome over every discovered path.
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
const body = retained.items.join('\n')
if (!retained.truncated) return body
const recovery = spillRef !== undefined
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern or path to see more.'
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
/**
* Pending-call presentation: a search card titled by the pattern (and root).
*
* @param args - the raw tool arguments; `pattern` and `path` feed the title.
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
*/
export function presentGlobCall(args: { pattern: string; path?: string }): GenericCallView {
const where = args.path !== undefined ? ` in ${args.path}` : ''
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
}
/**
* 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.
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
*/
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
ctx.systemPrompt.section({
name: 'tool:glob',
order: 103,
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
})
ctx.tools.register(defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
+ `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
parameters: {
pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' },
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
const all: string[] = []
for (const line of run.stdout.split('\n')) {
if (line.length === 0) continue
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
retainer.push(displayPath)
}
const retained = retainer.finish()
// The complete sorted list is the recovery artifact; save it only when
// the inline page omitted paths (an uncapped result needs no spill file).
const spillRef = retained.truncated
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
: undefined
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
},
presentCall: presentGlobCall,
}))
}

View File

@@ -0,0 +1,315 @@
/**
* 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`.
*
* @module @deepseek-ai/dsh-tool-fs-search/grep
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
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 { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
/**
* Default cap on flat matches retained inline by one `grep` call (the
* `grepMaxMatches` config), matching Claude Code's default `GrepTool`
* `head_limit`.
*/
export const GREP_MAX_MATCHES = 250
/**
* Default cap in bytes on one matched-line preview (the `grepMaxLineBytes`
* config); the cut preserves UTF-8 boundaries.
*/
export const GREP_MAX_LINE_BYTES = 2000
/** Resolved grep-tool caps — plugin config after defaulting (see `Config` in index.ts). */
export interface GrepToolCaps {
/** Max flat matches retained inline; later matches go to the formatted spill file. */
maxMatches: number
/** Max bytes retained per matched-line preview. */
maxLineBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
/** Validated `grep` arguments. */
export interface GrepInput {
pattern: string
path?: string
include?: string
}
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
export interface GrepMatch {
path: string
lineNumber: number
line: string
}
/**
* Reject an `include` that is not ONE positive glob filter: blank strings,
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
* group is fine — `*.{ts,tsx}` is one glob with alternation, not a list.
*/
function validateInclude(include: string): void {
if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given')
if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported')
let braceDepth = 0
for (const char of include) {
if (char === '{') braceDepth++
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
else if (char === ',' && braceDepth === 0) {
throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)')
}
}
}
/**
* Validate value constraints the schema DSL can't express: a non-EMPTY
* `pattern` (whitespace is a legitimate regex), a non-blank `path` when given,
* and a single positive `include` glob ({@link GrepInput}). Throws a plain
* `Error` (an ordinary tool argument error) otherwise.
*
* @param args - the schema-validated `grep` arguments.
* @returns the accepted input, unchanged.
*/
export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput {
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
if (args.include !== undefined) validateInclude(args.include)
return {
pattern: args.pattern,
...args.path !== undefined ? { path: args.path } : {},
...args.include !== undefined ? { include: args.include } : {},
}
}
/**
* Build the fixed line-oriented `rg --json` command 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.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
*/
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(' ')
}
/**
* The uniform malformed-output failure: raw `rg --json` is an internal
* transport, so a shape surprise is a search failure, not a partial result.
*/
function malformedRecord(detail: string, cause?: unknown): SearchError {
return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined)
}
/**
* Parse one `rg --json` NDJSON line into a match, `undefined` for the
* non-match record types (`begin`/`end`/`context`/`summary`). A line that is
* not JSON, or a `match` record missing its path / line number / line content,
* throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid
* UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder
* preview rather than failing the whole search.
*/
function parseRecord(line: string): GrepMatch | undefined {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch (error: unknown) {
throw malformedRecord('a line is not JSON', error)
}
if (typeof parsed !== 'object' || parsed === null) throw malformedRecord('a record is not an object')
const record = parsed as { type?: unknown; data?: unknown }
// Non-match record types (begin/end/context/summary — and any future type)
// are transport framing, not results: skipped, not malformed.
if (record.type !== 'match') return undefined
if (typeof record.data !== 'object' || record.data === null) throw malformedRecord('a match record has no data')
const data = record.data as { path?: unknown; line_number?: unknown; lines?: unknown }
const pathText = typeof data.path === 'object' && data.path !== null ? (data.path as { text?: unknown }).text : undefined
if (typeof pathText !== 'string') throw malformedRecord('a match record has no path text')
if (typeof data.line_number !== 'number') throw malformedRecord('a match record has no line number')
if (typeof data.lines !== 'object' || data.lines === null) throw malformedRecord('a match record has no line content')
const lines = data.lines as { text?: unknown; bytes?: unknown }
if (typeof lines.text === 'string') {
return { path: pathText, lineNumber: data.line_number, line: lines.text.replace(/\r?\n$/, '') }
}
if (typeof lines.bytes === 'string') {
return { path: pathText, lineNumber: data.line_number, line: '(line is not valid UTF-8)' }
}
throw malformedRecord('a match record has neither line text nor bytes')
}
/**
* Parse complete `rg --json` stdout into flat matches, in output order (ripgrep
* emits one file's matches contiguously). Only `match` records are consumed.
*
* @param stdout - the complete raw `rg --json` stdout.
* @returns the flat matches; empty for output with no match records.
*/
export function parseGrepMatches(stdout: string): GrepMatch[] {
const matches: GrepMatch[] = []
for (const line of stdout.split('\n')) {
if (line.length === 0) continue
const match = parseRecord(line)
if (match !== undefined) matches.push(match)
}
return matches
}
/**
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
* mark the cut. The cap is a per-line budget fact; the complete line stays in
* the searched file for `read`.
*
* @param line - the matched line text (trailing newline already stripped).
* @param maxBytes - the preview budget in bytes.
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
*/
export function previewLine(line: string, maxBytes: number): string {
const retainer = new TextRetainer({ kind: 'head', maxBytes })
retainer.push(line)
const kept = retainer.finish()
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
}
/** `match` / `matches` for a count. */
function matchNoun(count: number): string {
return count === 1 ? 'match' : 'matches'
}
/**
* Group flat matches by file (first-seen order) into the model-facing body:
* each file's display path, then one `Line N: <text>` row per match.
*
* @param matches - the flat matches to render.
* @returns the grouped body text.
*/
export function formatGrepMatches(matches: GrepMatch[]): string {
const byFile = new Map<string, GrepMatch[]>()
for (const match of matches) {
const group = byFile.get(match.path)
if (group !== undefined) group.push(match)
else byFile.set(match.path, [match])
}
const sections: string[] = []
for (const [path, group] of byFile) {
sections.push(`${path}\n${group.map(m => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`)
}
return sections.join('\n\n')
}
/**
* Format the model-facing `grep` result: a found-count header, the retained
* matches grouped by file, then — when the result was capped — a footer
* carrying either the formatted-spill recovery locator or the could-not-save
* explanation. The omitted count is a budget fact: the search itself completed.
*
* @param retained - the retention outcome over every parsed match.
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: SpillRef | undefined): string {
const header = retained.truncated
? `Found ${retained.kept} of ${retained.seen} matches`
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
const body = formatGrepMatches(retained.items)
if (!retained.truncated) return `${header}\n\n${body}`
const recovery = spillRef !== undefined
? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
return `${header}\n\n${body}\n\n(${recovery})`
}
/**
* Pending-call presentation: a search card titled by the pattern (and target /
* include filter).
*
* @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title.
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
*/
export function presentGrepCall(args: { pattern: string; path?: string; include?: string }): GenericCallView {
const where = args.path !== undefined ? ` in ${args.path}` : ''
const filter = args.include !== undefined ? ` (${args.include})` : ''
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
}
/**
* 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.
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
*/
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
ctx.systemPrompt.section({
name: 'tool:grep',
order: 104,
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
})
ctx.tools.register(defineTool({
name: 'grep',
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
+ 'Use read on a matched file for surrounding context.',
parameters: {
pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
path: { type: 'string', description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.' },
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
const all: GrepMatch[] = []
for (const raw of parseGrepMatches(run.stdout)) {
const match: GrepMatch = {
path: toWorkdirRelative(raw.path, run.workdir),
lineNumber: raw.lineNumber,
line: previewLine(raw.line, caps.maxLineBytes),
}
all.push(match)
retainer.push(match)
}
const retained = retainer.finish()
// The spill file stores the FULL formatted match list (same grouped,
// per-line-previewed shape the model saw), so read offset/limit pages the
// same logical result; save only when the inline page omitted matches.
const spillRef = retained.truncated
? await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
)
: undefined
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
},
presentCall: presentGrepCall,
}))
}

View File

@@ -0,0 +1,110 @@
/**
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
*
* ## Bash-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. The package injects `tools`, `systemPrompt`, and
* `bash` — 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.
*
* @module @deepseek-ai/dsh-tool-fs-search
*/
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_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
export type { GlobInput, GlobToolCaps } from './glob.ts'
export {
GREP_MAX_LINE_BYTES,
GREP_MAX_MATCHES,
applyGrepTool,
buildGrepCommand,
formatGrepMatches,
formatGrepOutput,
parseGrepArgs,
parseGrepMatches,
presentGrepCall,
previewLine,
} from './grep.ts'
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
export type { 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'
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
/** Plugin config (all optional — `Config` supplies the defaults). */
export interface Config {
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
globMaxResults?: number
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
grepMaxMatches?: number
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
grepMaxLineBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
timeoutMs?: number
}
export const Config: z<Config> = z.object({
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
})
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** 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) {
throw new Error(`tool-fs-search: ${name} must be a positive integer`)
}
}
/** Register the `glob`/`grep` filesystem discovery tool suite. */
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
applyGlobTool(ctx, {
maxResults: resolved.globMaxResults,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})
applyGrepTool(ctx, {
maxMatches: resolved.grepMaxMatches,
maxLineBytes: resolved.grepMaxLineBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})
}

View File

@@ -0,0 +1,262 @@
/**
* 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.
*
* 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()`
* ({@link trySaveFormattedResult}).
*
* @module @deepseek-ai/dsh-tool-fs-search/search-core
*/
import { isAbsolute, relative, sep } from 'node:path'
import type { Context } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/**
* Default cap on the complete raw `rg` stdout the tools will parse (the
* `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer.
*/
export const RAW_OUTPUT_MAX_BYTES = 20_000_000
/**
* Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs`
* config), attached to both tool definitions for
* `@deepseek-ai/dsh-timeout-policy` to enforce through `exec.signal`.
*/
export const SEARCH_TIMEOUT_MS = 30_000
/**
* Stable, machine-routable codes for search failures. Package-owned (not
* `FsErrorCode`) because these tools are bash-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.
*/
export type SearchErrorCode =
| 'SEARCH_INVALID_PATTERN'
| 'SEARCH_FAILED'
| 'SEARCH_RAW_OUTPUT_OVERFLOW'
| 'SEARCH_ABORTED'
/**
* Typed search failure. Extends {@link HarnessError} so it carries a stable
* {@link SearchErrorCode} and chains `cause`; the tool registry surfaces
* `{ name, code }` on `isError` results so retry/permission/UI layers can
* branch without parsing messages.
*/
export class SearchError extends HarnessError {
override readonly code: SearchErrorCode
constructor(message: string, code: SearchErrorCode, options?: ErrorOptions) {
super(message, code, options)
this.code = code
}
}
/** 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. */
stdout: string
/** True when ripgrep exited 1: a successful search with zero results. */
noMatches: boolean
/** The resolved working directory the command ran in (the display-relativization base). */
workdir: string
}
/**
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
*/
function stderrExcerpt(stderr: CollectedOutput): string {
const text = stderr.text.trim()
if (text.length === 0) return ''
return stderr.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)
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')
}
return new SearchError(`${toolName} search failed (exit ${result.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.
*/
function completeStdout(toolName: string, result: BashRunResult, 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 (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
}
throw new SearchError(
`${toolName} produced more raw output than the bash executor 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.
*
* 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`.
*
* @param ctx - the plugin context; execution uses its `bash` 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 rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
*/
export async function runRipgrep(
ctx: Context,
exec: ToolExecution,
toolName: string,
command: string,
rawOutputMaxBytes: number,
): Promise<RipgrepRun> {
const cwd = exec.agent?.session.header.cwd
const spec = ctx.bash.resolve({
command,
stdoutMaxBytes: rawOutputMaxBytes,
...cwd !== undefined ? { workdir: cwd } : {},
...exec.signal ? { 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) {
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')
}
if (result.signal !== null || result.exitCode === null) {
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
}
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw classifyRunFailure(toolName, result)
}
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
return { stdout, noMatches: result.exitCode === 1, workdir: spec.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
* 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.
* @returns the workdir-relative display path when possible, else `path` unchanged.
*/
export function toWorkdirRelative(path: string, workdir: string): string {
if (!isAbsolute(path)) return path
const rel = relative(workdir, path)
if (rel.length === 0) return '.'
if (rel === '..' || rel.startsWith(`..${sep}`)) return path
return rel
}
/**
* Best-effort save of one COMPLETE formatted search result through
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
* result. `spillStore` is read with `ctx.get()` (not static inject) because
* formatted-result spill is optional; the spill owner is the calling agent's
* session header id and the source is the tool execution identity. A missing
* backend, a call with no session owner, or a `saveText()` rejection logs a
* warning and returns `undefined` — the caller keeps the inline result and
* reports that the complete result could not be saved; search success never
* turns into `isError` because spill storage is unavailable.
*
* @param ctx - the plugin context; `spillStore` is looked up opportunistically.
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
* @param content - the complete formatted result to persist.
* @returns the saved spill reference, or `undefined` when the result could not be saved.
*/
export async function trySaveFormattedResult(
ctx: Context,
exec: ToolExecution,
suggestedName: string,
content: string,
): Promise<SpillRef | undefined> {
const sessionId = exec.agent?.session.header.id
if (sessionId === undefined) {
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`)
return undefined
}
const spillStore = ctx.get('spillStore')
if (!spillStore) {
ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`)
return undefined
}
const save: SaveTextSpill = {
owner: { sessionId },
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
suggestedName,
content,
}
try {
return await spillStore.saveText(save)
} catch (error: unknown) {
// Best-effort: a storage failure must never fail the search or hide the
// inline result — the footer reports the unsaved remainder instead.
ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`)
return undefined
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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}.
*
* @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

@@ -0,0 +1,190 @@
/**
* 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.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
let dir: string
let ctx: Context
let callCounter = 0
function call(name: string, args: unknown, agentObj?: object) {
return ctx.tools.execute({
callId: CallId(`it-${++callCounter}`),
name,
arguments: args,
...agentObj ? { agent: agentObj as never } : {},
})
}
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', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
await mkdir(join(dir, 'src'), { recursive: true })
await mkdir(join(dir, '.git'), { recursive: true })
await mkdir(join(dir, 'spaced dir'), { recursive: true })
await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n')
await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n')
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')
// 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))
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
describe('glob', () => {
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
const result = await call('glob', { pattern: '**/*.ts' })
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).toContain('.hidden.ts')
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
expect(paths).not.toContain('.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'])
})
it('reports zero discoveries as No files found', async () => {
expect(text(await call('glob', { pattern: '*.nomatch' }))).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')
})
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
const result = await call('glob', { pattern: '[' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' })
})
})
describe('grep', () => {
it('greps a directory tree with grouped, line-numbered output', async () => {
const result = await call('grep', { pattern: 'alpha' })
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('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' })
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 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 () => {
const canary = join(dir, 'pwned')
const result = await call('grep', { pattern: `$(touch ${canary})` })
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)
})
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' })
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
})
it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
const result = await call('grep', { pattern: '(unclosed' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
})
it('classifies a missing target as SEARCH_FAILED', async () => {
const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
})
})
describe('per-session cwd', () => {
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
try {
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } }
const globbed = await call('glob', { pattern: '*.ts' }, agentObj)
expect(text(globbed)).toBe('only-here.ts')
const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj)
expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true')
} finally {
await rm(sessionDir, { recursive: true, force: true })
}
})
})
describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => {
it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => {
const controller = new AbortController()
controller.abort()
const result = await ctx.tools.execute({
callId: CallId(`it-${++callCounter}`),
name: 'grep',
arguments: { pattern: 'x' },
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
})
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
const gone = join(dir, 'deleted-session-dir')
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(text(result)).toContain('could not start')
})
})
})

View File

@@ -0,0 +1,50 @@
/**
* Real-load-path guard for @deepseek-ai/dsh-tool-fs-search. `tool-fs-search` is
* a NAMESPACE plugin with `inject` — so a stray `export default apply` would
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
* collapse the module to the bare `apply` function, DROPPING `inject`. The
* plugin would then read `ctx.bash` without having injected it and throw
* `cannot get property … without inject` the moment it loads (postmortem 0001).
*
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
* `Loader.prototype.unwrapExports` and mounts the result over a bash executor,
* exercising the exact path the Loader uses. Prove the guard bites: add
* `export default apply` to `src/index.ts`, watch this go red, revert.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
describe('dsh-tool-fs-search real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolFsSearch).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Record<string, unknown>
expect(unwrapped).toBe(toolFsSearch)
expect(unwrapped.name).toBe('tool-fs-search')
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash'])
expect(typeof unwrapped.Config).toBe('function')
expect(typeof unwrapped.apply).toBe('function')
})
it('boots over ctx.bash through the unwrapped module without an inject error', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, {})
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped)
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
await fiber.dispose()
})
})

View File

@@ -0,0 +1,59 @@
/**
* 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

@@ -0,0 +1,632 @@
/**
* Consumer-surface tests for the search tools over a FAKE bash executor and a
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
* bypasses the tool registry. The fake executor makes every seam outcome
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
* validation, shell-safe command construction, workdir derivation, signal
* forwarding, `SEARCH_*` error classification, retention, formatted-result
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
* pinned separately in integration.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import {
buildGlobCommand,
buildGrepCommand,
formatGrepMatches,
parseGrepMatches,
presentGlobCall,
presentGrepCall,
previewLine,
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
/** A successful run result over the given stdout; overrides script the failure shapes. */
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: stdout, truncated: false },
stderr: { text: '', truncated: false },
...overrides,
}
}
/**
* A scriptable fake executor: `resolve()` mirrors the real request→spec
* defaulting (workdir falls back to `/work`), `run()` returns whatever the
* test armed via `handler`, and `start()` throws — the search tools must NEVER
* create a background task.
*/
class FakeBash extends BashExecutor {
requests: BashExecRequest[] = []
specs: BashExecSpec[] = []
startCalls = 0
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
override resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
this.specs.push(spec)
return Promise.resolve(this.handler(spec))
}
override start(): BashProcess {
this.startCalls++
throw new Error('search tools must never start a background task')
}
}
/** A recording spill backend; arm `failWith` to script a storage failure. */
class FakeSpill extends SpillStore {
saves: SaveTextSpill[] = []
failWith?: Error
override saveText(input: SaveTextSpill): Promise<SpillRef> {
if (this.failWith) return Promise.reject(this.failWith)
this.saves.push(input)
return Promise.resolve({
locator: SpillLocator(`/spill/${input.suggestedName}`),
bytes: Buffer.byteLength(input.content, 'utf8'),
retrievalHint: 'Use the fake retrieval hint.',
})
}
}
interface SetupOptions {
config?: ToolFsSearch.Config
spill?: boolean
}
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const bash = ctx.bash as FakeBash
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber }
}
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } })
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
...options.agent ? { agent: options.agent as never } : {},
...options.signal ? { signal: options.signal } : {},
})
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
/** One rg --json match record line. */
function matchLine(path: string, lineNumber: number, lineText: string): string {
return JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: lineText }, line_number: lineNumber, absolute_offset: 0, submatches: [] } })
}
describe('registration', () => {
it('registers glob and grep with their prompt sections', async () => {
const { ctx } = await setup()
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
})
it('stays pending until ctx.bash exists (inject)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolFsSearch) // no bash executor
expect(ctx.tools.schemas()).toHaveLength(0)
})
it('unregisters everything on fiber disposal (HMR safety)', async () => {
const { ctx, fiber } = await setup()
expect(ctx.tools.schemas()).toHaveLength(2)
await fiber.dispose()
expect(ctx.tools.schemas()).toHaveLength(0)
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
expect(sections).not.toContain('tool:glob')
expect(sections).not.toContain('tool:grep')
})
it('attaches the configured timeoutMs to both tool definitions', async () => {
const { ctx } = await setup({ config: { timeoutMs: 5000 } })
expect(ctx.tools.get('glob')?.timeoutMs).toBe(5000)
expect(ctx.tools.get('grep')?.timeoutMs).toBe(5000)
})
it('defaults the timeout budget to 30 seconds', async () => {
const { ctx } = await setup()
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
})
})
describe('config validation', () => {
it.each([
['globMaxResults', { globMaxResults: 0 }],
['grepMaxMatches', { grepMaxMatches: -1 }],
['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }],
['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }],
['timeoutMs', { timeoutMs: -100 }],
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
})
})
describe('command construction (shell-safe)', () => {
it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => {
const command = buildGlobCommand({ pattern: '**/*.ts' })
expect(command).toBe(
"rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden "
+ "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' "
+ "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' "
+ "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'",
)
})
it('glob: the search root rides behind -- and is quoted', () => {
const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' })
expect(command).toContain("-- 'docs dir'")
})
it('grep: fixed rg --json template with the pattern in --regexp= form', () => {
expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'")
})
it('grep: include and path are quoted, include in --glob= form, path behind --', () => {
const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' })
expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'")
})
it.each([
['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"],
['a backtick pattern', '`touch pwned`', "'`touch pwned`'"],
['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''],
['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''],
['a pattern with newlines', 'a\nb', "'a\nb'"],
['a leading-dash pattern', '--flag', "'--flag'"],
['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"],
])('quotes %s into one inert shell word', (_label, raw, quoted) => {
expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`)
})
})
describe('workdir derivation and signal forwarding', () => {
it('forwards the session cwd as the request workdir', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('a.ts\n')
await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
expect(bash.requests[0]?.workdir).toBe('/sessions/s1')
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
})
it('omits the request workdir without a session cwd so resolve() defaults apply', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('a.ts\n')
await call(ctx, 'glob', { pattern: '*' }, { agent: agent() })
expect(bash.requests[0]).not.toHaveProperty('workdir')
expect(bash.specs[0]?.workdir).toBe('/work')
// A non-agent caller takes the same default path.
await call(ctx, 'grep', { pattern: 'x' })
expect(bash.requests[1]).not.toHaveProperty('workdir')
})
it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => {
const { ctx, bash } = await setup()
const controller = new AbortController()
controller.abort()
bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true })
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(bash.specs[0]?.signal).toBe(controller.signal)
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(text(result)).toContain('aborted')
})
it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' })
expect(text(result)).toContain('timed out after 1234ms')
})
it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => {
// The seam contract: run() REJECTS for a pre-aborted signal (it never
// spawns). The plain rejection must not escape the SEARCH_* taxonomy.
const { ctx, bash } = await setup()
const controller = new AbortController()
controller.abort()
bash.handler = () => { throw new Error('aborted before spawn') }
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
})
it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => {
const { ctx, bash } = await setup()
bash.handler = () => { throw new Error('spawn bash ENOENT') }
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(text(result)).toContain('could not start')
})
})
describe('exit semantics and failure classification', () => {
it('exit 1 is a successful empty search', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 1 })
const glob = await call(ctx, 'glob', { pattern: '*.nope' })
expect(glob.isError).toBe(false)
expect(text(glob)).toBe('No files found')
const grep = await call(ctx, 'grep', { pattern: 'nope' })
expect(grep.isError).toBe(false)
expect(text(grep)).toBe('No matches found')
})
it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } })
const result = await call(ctx, 'grep', { pattern: '(' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(text(result)).toContain('regex parse error')
})
it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '[' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
})
it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ 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.
bash.handler = () => runResult('', { exitCode: 127 })
expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)')
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } })
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, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } })
const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(text(result)).toContain('IO error')
})
it('a nonzero exit with EMPTY stderr still reports the exit code', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 3 })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(text(result)).toContain('exit 3')
})
it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', {
exitCode: 2,
stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' },
})
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(text(result)).toContain('tail of diagnostics [stderr truncated]')
})
it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(text(result)).toContain('SIGKILL')
})
it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: null })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
})
})
describe('raw output acquisition', () => {
it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } })
bash.handler = () => runResult('', { exitCode: 1 })
await call(ctx, 'glob', { pattern: '*.ts' })
await call(ctx, 'grep', { pattern: 'needle' })
expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234])
expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234])
})
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(text(result)).toContain('narrow pattern, path, or include')
})
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => {
// An executor retaining more inline than this package's cap (or a
// deployment lowering rawOutputMaxBytes below the bash retention) must not
// smuggle an over-cap parse through the untruncated path.
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(text(result)).toContain('narrow pattern, path, or include')
})
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
})
})
describe('glob results', () => {
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
})
it('validates arguments (blank pattern, blank path)', async () => {
const { ctx } = await setup()
expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string')
expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string')
})
it('threads a valid path through to the command as the quoted search root', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('sub/a.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' })
expect(result.isError).toBe(false)
expect(bash.specs[0]?.command).toContain("-- 'sub'")
})
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
owner: { sessionId: 'session-1' },
source: { toolName: 'glob', label: 'result' },
suggestedName: 'glob-results.txt',
content: 'a.ts\nb.ts\nc.ts\nd.ts',
})
expect(spill?.saves[0]?.source.callId).toBeDefined()
})
it('does not create a spill file when the result fits inline', async () => {
const { ctx, bash, spill } = await setup({ spill: true })
bash.handler = () => runResult('a.ts\nb.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
expect(text(result)).toBe('a.ts\nb.ts')
expect(spill?.saves).toHaveLength(0)
})
it.each([
['no spill backend loaded', { fail: false, spill: false, ownerless: false }],
['saveText fails', { fail: true, spill: true, ownerless: false }],
['no session owner', { fail: false, spill: true, ownerless: true }],
])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill })
if (mode.fail && spill) spill.failWith = new Error('disk full')
bash.handler = () => runResult('a.ts\nb.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') })
expect(result.isError).toBe(false) // spill unavailability never fails the search
expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)')
})
})
describe('grep results', () => {
it('groups matches by file with line numbers', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult([
JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }),
matchLine('a.ts', 3, 'const x = 1\n'),
matchLine('a.ts', 9, 'const y = 2\n'),
JSON.stringify({ type: 'end', data: { path: { text: 'a.ts' } } }),
matchLine('b.ts', 1, 'const z = 3'),
JSON.stringify({ type: 'summary', data: {} }),
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'const' })
expect(result.isError).toBe(false)
expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3')
})
it('reports a single match in the singular', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`)
expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit')
})
it('relativizes absolute match paths against the resolved workdir', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
})
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } })
// 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7.
// Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed.
bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`)
const result = await call(ctx, 'grep', { pattern: 'a' })
expect(text(result)).toContain('Line 1: aéaéa (line truncated)')
})
it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => {
const { ctx, bash } = await setup()
const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } })
bash.handler = () => runResult(`${record}\n`)
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)')
})
it('strips a CRLF terminator from the matched line text', () => {
const matches = parseGrepMatches(`${matchLine('a.txt', 1, 'windows line\r\n')}\n`)
expect(matches[0]?.line).toBe('windows line')
})
it('caps at grepMaxMatches and spills the full formatted match list', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),
matchLine('a.ts', 2, 'two'),
matchLine('b.ts', 3, 'three'),
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves[0]).toMatchObject({
source: { toolName: 'grep', label: 'result' },
suggestedName: 'grep-results.txt',
content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three',
})
})
it('reports the unsaved remainder when capped with no spill backend', async () => {
const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } })
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`)
const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)')
})
it('validates arguments (empty pattern, blank path, bad include)', async () => {
const { ctx } = await setup()
expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string')
expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string')
expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob')
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns')
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list')
})
it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 1 })
const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' })
expect(result.isError).toBe(false)
})
})
describe('rg --json transport failures (SEARCH_FAILED)', () => {
it.each([
['a non-JSON line', 'not json at all'],
['a non-object record', '42'],
['a match record with no data', JSON.stringify({ type: 'match' })],
['a match record with no path text', JSON.stringify({ type: 'match', data: { path: {}, lines: { text: 'x' }, line_number: 1 } })],
['a match record with a non-object path', JSON.stringify({ type: 'match', data: { path: 'a.ts', lines: { text: 'x' }, line_number: 1 } })],
['a match record with no line number', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: { text: 'x' } } })],
['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })],
['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })],
])('%s fails the search', async (_label, line) => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${line}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
})
})
describe('the no-background-task invariant', () => {
it('never calls ctx.bash.start() across successful and failed searches', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('a.ts\n')
await call(ctx, 'glob', { pattern: '*' })
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } })
await call(ctx, 'grep', { pattern: 'x' })
expect(bash.startCalls).toBe(0)
})
})
describe('presentation', () => {
it('glob titles carry the pattern and optional root', () => {
expect(presentGlobCall({ pattern: '**/*.ts' })).toMatchObject({ card: 'generic', title: 'Glob **/*.ts', kind: 'search' })
expect(presentGlobCall({ pattern: '*.md', path: 'docs' }).title).toBe('Glob *.md in docs')
})
it('grep titles carry the pattern, target, and include filter', () => {
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
})
})
describe('helpers', () => {
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
expect(toWorkdirRelative('/w', '/w')).toBe('.')
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')
expect(toWorkdirRelative('rel/b.ts', '/w')).toBe('rel/b.ts')
// Normalization makes this land OUTSIDE the workdir → original path kept.
expect(toWorkdirRelative('/w/../up.ts', '/w')).toBe('/w/../up.ts')
})
it('previewLine keeps a within-budget line untouched', () => {
expect(previewLine('short', 100)).toBe('short')
})
it('formatGrepMatches groups by first-seen file order', () => {
const grouped = formatGrepMatches([
{ path: 'b.ts', lineNumber: 2, line: 'x' },
{ path: 'a.ts', lineNumber: 1, line: 'y' },
{ path: 'b.ts', lineNumber: 5, line: 'z' },
])
expect(grouped).toBe('b.ts\nLine 2: x\nLine 5: z\n\na.ts\nLine 1: y')
})
})

View File

@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../util/retention" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" },
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },
{ "path": "../../bash/bash" },
{ "path": "../../spill/spill" }
]
}

View File

@@ -34,7 +34,7 @@ Field names are snake_case to match Claude Code and existing harness tool schema
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
@@ -100,6 +100,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
## Known Limitations and Deferred Work
- **No directory-listing, glob, grep, or search tools ship** — a deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md); `ctx.fs.listDir` serves provider code such as skill discovery but still has no model-facing consumer, so models fall back to `bash`.
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)).

View File

@@ -12,7 +12,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionCwd } from './session-cwd.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
@@ -75,8 +75,7 @@ export function applyEditTool(ctx: Context): void {
},
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseEditArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
// No stat — the bare default never manufactures a version basis.

View File

@@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionCwd } from './session-cwd.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
export const READ_LIMIT = 2000
@@ -86,8 +86,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args, caps.limit)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// One stat: type check + size routing + the version recorded as observed.
// A concurrent write can only make a later guarded mutation fail stale and require reread.

View File

@@ -18,3 +18,16 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
}
/**
* Resolution options shared by all model-facing filesystem tools.
* @param exec - the tool-execution context supplying session cwd and cancellation.
* @returns provider resolution options for the current tool call.
*/
export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } {
const cwd = sessionCwd(exec)
return {
...cwd !== undefined ? { cwd } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
}
}

View File

@@ -13,7 +13,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionCwd } from './session-cwd.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/**
* Validate value constraints the schema DSL can't express: only a non-blank
@@ -61,8 +61,7 @@ export function applyWriteTool(ctx: Context): void {
},
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseWriteArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)

View File

@@ -14,6 +14,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -44,6 +45,11 @@ class FakeFs extends FileSystem {
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async lstat(path: string): Promise<FsPathInfo | undefined> {
const content = this.files.get(`key:${path}`)
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async readText(target: FsTarget): Promise<string> {
return this.files.get(target.targetKey) ?? ''
}