Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md
#	docs/capability-seams.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/README.md
#	examples/acp-agent/fs.cordis.snapshot.yml
#	examples/acp-agent/fs.cordis.yml
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
#	examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
#	packages/bash/bash/src/index.ts
#	packages/bash/tool-bash/package.json
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/fs/README.md
#	packages/fs/tool-fs/src/edit.ts
#	packages/fs/tool-fs/src/write.ts
#	packages/sandbox/README.md
#	pnpm-lock.yaml
This commit is contained in:
kingwl
2026-07-20 11:40:29 +08:00
1293 changed files with 74019 additions and 16297 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 |
|---|---|---|
@@ -9,9 +9,10 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (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/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (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 — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox RFC](../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). 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 mode fence and the read-before-edit gate are orthogonal and compose.
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 — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). 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 mode fence and the read-before-edit gate are orthogonal and compose. 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 Agent Note](../../../.agents/notes/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`).
@@ -25,9 +25,13 @@ The package-root SDK surface is the default/named `LocalFileSystem` class plus `
Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`).
- **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard.
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.

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,
@@ -42,7 +45,7 @@ type ResolvedConfig = Required<Config>
/**
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
* (a resolution default, NOT a containment boundary — see the filesystem
* capability-seam RFC); enforce
* capability-seam Agent Note); enforce
* containment with a stricter backend or a `tools/execute` permission plugin.
*/
export class LocalFileSystem extends FileSystem {
@@ -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

@@ -51,13 +51,21 @@ Because the plugin influences the world only through events, removing it does no
### Filesystem tool outcome
**What the model sees**: This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
#### What the model sees
**Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload.
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
#### Token effect
Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Observed state does not survive a session resume** — persistence of the `WeakMap` record is deferred, so a resumed session must re-read files before guarded writes/edits.
- **Actors without an agent session can never satisfy the policy** — their edits throw `FS_NOT_OBSERVED` and their writes always resolve `createIfAbsent`, so a non-agent caller cannot overwrite an existing file through the gate.
- **Direct `ctx.fs` reads emit no `fs/observed`** — a file read outside the `read` tool stays unobserved, and a later guarded edit rejects with `FS_NOT_OBSERVED` until the tool reads it.
- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)).
- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)).

View File

@@ -16,12 +16,16 @@ The per-call mode is the tool-stamped effective mode (session override or escala
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s.

View File

@@ -1,8 +1,8 @@
# @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)):
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 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| Layer | Package | Role |
|---|---|---|
@@ -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,15 +42,19 @@ 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 Agent Note](../../../.agents/notes/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 Agent Note](../../../.agents/notes/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
Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## 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).
- **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 Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.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 Agent Note](../../../.agents/notes/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

@@ -13,6 +13,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsVersion,
FsWriteIntent,
@@ -30,6 +31,7 @@ export type {
FsDirEntry,
FsErrorCode,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -104,10 +106,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.
@@ -117,6 +119,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,122 @@
# @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.
##### 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.
```
#### Token effect
Fixed guidance cost per request while the plugin is active.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
### 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.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
### 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.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### 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.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## 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

@@ -22,7 +22,7 @@ All keys are optional; the defaults are the shipped read caps.
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. |
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
## Tools (schemas per [the filesystem tool schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
| Tool | Arguments | Behavior |
|---|---|---|
@@ -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 Agent Note](../../../.agents/notes/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.)
@@ -46,60 +46,102 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
#### What the model sees
**Token effect**: Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools.
Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
#### Read guidance
##### Read guidance
```markdown
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
```
#### Write guidance
##### Write guidance
```markdown
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
```
#### Edit guidance
##### Edit guidance
```markdown
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
```
#### Token effect
Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Tool restrictions do not remove this section, but plugin activation or disposal may invalidate reuse from it.
### Tool schemas
**What the model sees**: The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
#### What the model sees
**Token effect**: Fixed schema cost on every request in that tool view.
The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
#### Token effect
Fixed schema cost on every request in that tool view.
#### KV Cache effect
Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
### Read result
**What the model sees**: A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
#### What the model sees
**Token effect**: Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction.
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
#### Token effect
Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Write and edit results
**What the model sees**: Write returns the exact five-line envelope `<path><displayPath></path>`, `<type>file</type>`, `<content>`, `Created file` or `Updated file`, then `</content>`. Edit returns exactly `The file <displayPath> has been updated successfully.` or, for `replace_all`, `The file <displayPath> has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments.
#### What the model sees
**Token effect**: Success text is small, but large mutation arguments and any result are resent until compaction.
Write returns the exact five-line envelope `<path><displayPath></path>`, `<type>file</type>`, `<content>`, `Created file` or `Updated file`, then `</content>`. Edit returns exactly `The file <displayPath> has been updated successfully.` or, for `replace_all`, `The file <displayPath> has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments.
#### Token effect
Success text is small, but large mutation arguments and any result are resent until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Tool errors
**What the model sees**: Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
#### What the model sees
**Token effect**: Only a failing call adds these retained tokens.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
#### Token effect
Only a failing call adds these retained tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## 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

@@ -39,6 +39,7 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",

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'
import type { FsSandboxSurface } from './sandbox.ts'
/** Validated `edit` arguments after defaulting. */
@@ -95,8 +95,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes.
const sandboxMode = await sandbox.stampMode('edit', args, exec)
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
@@ -84,10 +84,11 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
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'
import type { FsSandboxSurface } from './sandbox.ts'
/**
@@ -80,8 +80,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
// > backend default) BEFORE anything executes; an escalating call
// resolves approval here and throws its distinct text on any non-grant.
const sandboxMode = await sandbox.stampMode('write', args, exec)
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

@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts'
@@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
ctx = await fsHarness(workdir, SYSTEM)
// agentLoop.create prepares a session with no cwd, so the provider default
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' })
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
@@ -65,10 +64,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
try {
ctx = await fsHarness(configDir, SYSTEM)
const handle = await ctx.agents.create({
agentId: AgentId('fs-e2e-cwd'),
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
meta: { cwd: sessionDir },
agentOptions: { model: 'deepseek-v4-flash' },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
handle.agent.send([{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])

View File

@@ -1,10 +1,7 @@
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -17,13 +14,9 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
*/
export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona } })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)

View File

@@ -384,6 +384,33 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
})
it('a stale observed version from an older read fails closed at edit CAS', async () => {
await writeFile(join(dir, 'a.txt'), 'older content\n')
const target = await ctx.fs.resolve('a.txt')
const firstInfo = await ctx.fs.stat(target)
if (!firstInfo) throw new Error('expected first stat')
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
await writeFile(join(dir, 'a.txt'), 'newer current content\n')
const secondInfo = await ctx.fs.stat(target)
if (!secondInfo) throw new Error('expected second stat')
expect(secondInfo.version).not.toBe(firstInfo.version)
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
// Reproduce an older concurrent read winning the observation race.
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
const edit = await callOwned('edit', {
file_path: 'a.txt',
old_string: 'newer',
new_string: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n')
})
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
// fs/observed is a plain ctx.emit after the write succeeded; a throwing listener cannot
// roll the write back — it only turns the tool result into isError.

View File

@@ -14,6 +14,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -46,6 +47,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) ?? ''
}
@@ -104,6 +110,16 @@ describe('registration', () => {
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
})
it('declares read parallel-safe while write/edit remain exclusive', async () => {
const { ctx } = await setup()
expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
.toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
.toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } }))
.toEqual({ kind: 'exclusive' })
})
it('registers prompt sections for each tool', async () => {
const { ctx } = await setup()
const prompt = renderPrompt(await ctx.systemPrompt.assemble())