Răsfoiți Sursa

fix: make search raw output recovery backend-neutral

Dudu-0223 2 luni în urmă
părinte
comite
3bb90bd4b6

+ 16 - 2
docs/core-data-structures/bash.md

@@ -6,7 +6,7 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t
 
 ## Request vs. spec: the `resolve()` split
 
-The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`, filled from config) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory came from.
+The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from.
 
 ```ts type-equiv
 interface BashExecRequest {
@@ -15,6 +15,13 @@ interface BashExecRequest {
   workdir?: string | undefined
   /** Timeout override in milliseconds (implementations cap it). */
   timeoutMs?: number | undefined
+  /**
+   * Foreground stdout capture budget in bytes. Absent uses the executor's
+   * default output cap. Trusted in-process consumers use this when they must
+   * parse complete stdout up to their own bounded limit; the model-facing bash
+   * tool does not expose it as a parameter.
+   */
+  stdoutMaxBytes?: number | undefined
   /** Abort signal — implementations kill the command when it fires. */
   signal?: AbortSignal | undefined
   /**
@@ -52,6 +59,11 @@ interface BashExecSpec {
   command: string
   workdir: string
   timeoutMs: number
+  /**
+   * Resolved foreground stdout capture budget in bytes. `run()` uses it for
+   * stdout; background tasks and stderr keep the executor's own output cap.
+   */
+  stdoutMaxBytes: number
   /** Abort signal — implementations kill the command when it fires. */
   signal?: AbortSignal | undefined
   /**
@@ -84,7 +96,9 @@ interface BashExecSpec {
 
 The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task.
 
-`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin`/`stdoutMaxBytes` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+
+`stdoutMaxBytes` is also trusted-plugin-only. It lets a foreground consumer ask the executor to retain complete stdout up to a bounded parser budget without changing stderr, background tasks, or the model-facing bash tool's ordinary `maxOutputBytes` behavior.
 
 Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path.
 

+ 7 - 9
docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md

@@ -77,7 +77,7 @@ The `path` field follows the same split as Claude Code: `grep.path` is a file-or
 
 `grep` builds a fixed line-oriented `rg --json` command against the supplied file/directory target (`path` when supplied, else the bash workdir) so file path, line number, and line text are parsed without colon-splitting ambiguity. It consumes `match` records, treats malformed JSON or malformed match records as `SEARCH_FAILED`, maps result paths relative to the bash workdir when possible, applies per-line preview retention with `grepMaxLineBytes`, pushes each match into `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`, then groups only the retained preview matches by file for inline output. The spill file stores the full formatted match list, not only the omitted tail, so `read offset/limit` works against the same logical result the model saw.
 
-Raw `rg` stdout is an internal transport detail. If `ctx.bash.run()` returns untruncated stdout, the tool parses `stdout.text`. If stdout is truncated and `stdout.spillPath` is present, the tool reads that local raw spill file up to `rawOutputMaxBytes + 1` bytes and parses it only when the complete file fits within `rawOutputMaxBytes`. If the raw spill file is larger than `rawOutputMaxBytes`, or stdout is truncated without a spill path, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model.
+Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model.
 
 Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, missing `rg`, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`.
 
@@ -93,7 +93,7 @@ When a search produces more logical results than the inline cap and `ctx.spillFi
 
 When spill storage is absent, the call has no session owner, or saving fails, the tool still returns the inline page and a footer explaining that the complete result could not be saved. Search success must not turn into an `isError` result solely because formatted-result spill storage is unavailable.
 
-The bash raw spill file and the formatted search spill file are different artifacts. The raw bash spill file is a local executor implementation detail used only so the search tool can parse complete `rg` stdout. The formatted spill file is the stable model-facing recovery path produced by `ctx.spillFiles.saveText()`.
+The bash raw output stream and the formatted search spill file are different artifacts. Raw `rg` stdout is parsed only in memory within the requested bash stdout cap; the formatted spill file is the stable model-facing recovery path produced by `ctx.spillFiles.saveText()`.
 
 ### Result shape
 
@@ -122,13 +122,13 @@ If the complete logical result fits under the inline cap, no formatted spill fil
 
 **Put `glob` / `grep` on `ctx.fs`.** Rejected for v1: it forces every filesystem backend to grow a search API and makes local ripgrep behavior part of the provider seam. Search is useful product behavior, but it is not a universal text-storage primitive like `readText` or `writeText`.
 
-**Directly spawn ripgrep from `dsh-fs-local`.** Rejected for this RFC's v1: direct spawn gives the cleanest argv boundary, stdout/stderr control, and early-stop control, but it duplicates process execution concerns that the bash seam already owns: environment scrubbing, process-group kill, timeout propagation, sandbox/remote executor substitution, and raw output spill. It remains a reasonable optimization if bash-backed search proves too shell-string-sensitive or if raw bash spill recovery is not portable enough.
+**Directly spawn ripgrep from `dsh-fs-local`.** Rejected for this RFC's v1: direct spawn gives the cleanest argv boundary, stdout/stderr control, and early-stop control, but it duplicates process execution concerns that the bash seam already owns: environment scrubbing, process-group kill, timeout propagation, sandbox/remote executor substitution, and bounded output capture. It remains a reasonable optimization if bash-backed search proves too shell-string-sensitive or if foreground streaming becomes necessary.
 
 **Use `ctx.bash.start()` for streaming early stop.** Rejected: `start()` creates model-visible background task semantics: task ids, owner tokens, `bash_output`, `bash_kill`, completion notifications, and no built-in timeout. `grep` needs a foreground tool result, not a background bash workflow. If streaming search becomes necessary, the right abstraction is a foreground streaming process handle on the bash/process seam, not borrowing the public background-task API.
 
-**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. The search tool may read raw spill internally, but model recovery uses a formatted result saved through `ctx.spillFiles.saveText()`.
+**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. Search parses raw stdout only as an internal transport; model recovery uses a formatted result saved through `ctx.spillFiles.saveText()`.
 
-**Add `spillFiles.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search still has to parse raw `rg` output before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result, and raw bash spill remains an executor-local recovery detail.
+**Add `spillFiles.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search only needs bounded in-memory raw `rg` stdout before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result.
 
 **Rely on the generic `dsh-spill-policy`.** Rejected: generic post-execute spill sees only the final tool result. If `grep` / `glob` return the first page inline, the generic policy cannot recover omitted results. The search tools must save the complete formatted result themselves before returning the bounded model-facing text.
 
@@ -136,7 +136,7 @@ If the complete logical result fits under the inline cap, no formatted spill fil
 
 **Keep early-stop search and skip formatted spill files.** Rejected for this proposal: early stop is more efficient but gives the model no path to inspect later results. The chosen v1 optimizes result recoverability and implementation simplicity, with `timeoutMs`, `rawOutputMaxBytes`, bash backend caps, and formatted spill files as safety backstops.
 
-**Expand the bash seam with a raw-output reader first.** Deferred: a remote bash backend may eventually need a portable `readRawOutput(ref, maxBytes)` style API instead of local `spillPath` reads. v1 uses the existing local-readable `stdout.spillPath` to avoid widening the bash seam for one consumer.
+**Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly.
 
 ## Testing
 
@@ -151,7 +151,7 @@ If the complete logical result fits under the inline cap, no formatted spill fil
 - `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillFiles` stays optional via `ctx.get('spillFiles')`.
 - The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`).
 - The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display.
-- When bash stdout is truncated, the tools parse the full raw stdout only through a local `stdout.spillPath` that fits within `rawOutputMaxBytes`; missing spill paths or over-cap raw output are clear search failures, and raw `rg` output is never exposed to the model.
+- The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model.
 - Oversized complete formatted results are saved through `ctx.spillFiles.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`.
 - The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement.
 
@@ -163,6 +163,4 @@ Shell command construction is the sharpest safety edge. Because `ctx.bash` accep
 
 The v1 assumes a co-located bash/filesystem deployment. If bash searches one workspace and the `read` tool resolves paths against another, returned paths may not be follow-up-readable. The package documents this requirement but does not verify it at runtime.
 
-Raw bash spill recovery is local-path-shaped in v1. A remote or sandboxed bash backend may return no readable `spillPath` or may require a future raw-output read API. In that case broad searches fail clearly instead of pretending a truncated raw result is complete.
-
 Spill paths are local filesystem paths in v1. The formatted-result design works for local deployments where `read` can open spill files; remote or workspace-confined deployments need either an allowlist for spill paths or a future virtual spill URI bridge.

+ 1 - 1
packages/bash/bash-local/README.md

@@ -21,7 +21,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
 
 - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
 - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
-- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
+- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
 - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
 - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
 

+ 7 - 2
packages/bash/bash-local/src/index.ts

@@ -121,10 +121,13 @@ export class LocalBashExecutor extends BashExecutor {
       this.config.maxTimeoutMs,
       'bash-local: request.timeoutMs',
     )
+    const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
+    assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
     return {
       command: request.command,
       workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
       timeoutMs,
+      stdoutMaxBytes,
       ...request.signal ? { signal: request.signal } : {},
       // Carry stdin/env through verbatim — optional, no config default (absent
       // means none). env merges AFTER the scrub in run.ts.
@@ -144,7 +147,8 @@ export class LocalBashExecutor extends BashExecutor {
     const outcome = await runBash({
       command: spec.command,
       cwd: spec.workdir,
-      maxOutputBytes: this.config.maxOutputBytes,
+      stdoutMaxBytes: spec.stdoutMaxBytes,
+      stderrMaxBytes: this.config.maxOutputBytes,
       graceMs: this.config.graceMs,
       signal: d.signal,
       stdin: spec.stdin,
@@ -170,7 +174,8 @@ export class LocalBashExecutor extends BashExecutor {
     const running = runBash({
       command: spec.command,
       cwd: spec.workdir,
-      maxOutputBytes: this.config.maxOutputBytes,
+      stdoutMaxBytes: this.config.maxOutputBytes,
+      stderrMaxBytes: this.config.maxOutputBytes,
       graceMs: this.config.graceMs,
       signal: spec.signal,
       stdin: spec.stdin,

+ 6 - 4
packages/bash/bash-local/src/run.ts

@@ -77,8 +77,10 @@ export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
 export interface SpawnSpec {
   command: string
   cwd: string
-  /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
-  maxOutputBytes: number
+  /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
+  stdoutMaxBytes: number
+  /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
+  stderrMaxBytes: number
   /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
   graceMs: number
   /**
@@ -351,8 +353,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
     ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
     : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
 
-  const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
-  const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
+  const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
+  const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
   child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
   child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
 

+ 17 - 0
packages/bash/bash-local/tests/executor.spec.ts

@@ -86,6 +86,23 @@ describe('LocalBashExecutor.run', () => {
     const { bash } = await setup()
     expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
     expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
+    expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
+    expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
+  })
+
+  it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
+    const { bash } = await setup({ maxOutputBytes: 100 })
+    expect(bash.resolve({ command: 'true' }).stdoutMaxBytes).toBe(100)
+
+    const result = await bash.run(bash.resolve({
+      command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2',
+      stdoutMaxBytes: 500,
+    }))
+
+    expect(result.stdout.truncated).toBe(false)
+    expect(result.stdout.text).toBe('x'.repeat(500))
+    expect(result.stderr.truncated).toBe(true)
+    expect(result.stderr.text.length).toBeLessThanOrEqual(100)
   })
 
   it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {

+ 21 - 6
packages/bash/bash-local/tests/run.spec.ts

@@ -26,7 +26,8 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
   return {
     command,
     cwd: process.cwd(),
-    maxOutputBytes: 64_000,
+    stdoutMaxBytes: 64_000,
+    stderrMaxBytes: 64_000,
     graceMs: 3_000,
     ...overrides,
   }
@@ -229,10 +230,24 @@ describe('stdin and extra env (set by in-process plugins)', () => {
 })
 
 describe('output truncation and spill', () => {
+  it('applies stdout and stderr caps independently', async () => {
+    const result = await runBash(
+      spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
+        stdoutMaxBytes: 500,
+        stderrMaxBytes: 100,
+      }),
+      { spillDir },
+    ).done
+    expect(result.stdout.truncated).toBe(false)
+    expect(result.stdout.text).toBe('x'.repeat(500))
+    expect(result.stderr.truncated).toBe(true)
+    expect(result.stderr.text.length).toBeLessThanOrEqual(100)
+  })
+
   it('keeps the tail and spills the full stream to disk', async () => {
     // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
     const result = await runBash(
-      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
+      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
       { spillDir },
     ).done
     expect(result.stdout.truncated).toBe(true)
@@ -247,7 +262,7 @@ describe('output truncation and spill', () => {
 
   it('does not truncate output exactly at the cap', async () => {
     const result = await runBash(
-      spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
+      spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
       { spillDir },
     ).done
     expect(result.stdout.truncated).toBe(false)
@@ -258,7 +273,7 @@ describe('output truncation and spill', () => {
   it('settles with the tail and no spill path when final spill close fails', async () => {
     failNextClose.value = true
     const result = await runBash(
-      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
+      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
       { spillDir },
     ).done
     expect(failNextClose.value).toBe(false)
@@ -377,7 +392,7 @@ describe('review fixes: env scrubbing and spill hardening', () => {
 
   it('creates spill files with owner-only permissions and random names', async () => {
     const result = await runBash(
-      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
+      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
       { spillDir },
     ).done
     const path = result.stdout.spillPath!
@@ -388,7 +403,7 @@ describe('review fixes: env scrubbing and spill hardening', () => {
 
   it('defaults spills into a private per-process directory', async () => {
     const result = await runBash(
-      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
+      spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
     ).done
     const dir = dirname(result.stdout.spillPath!)
     expect(dir).toMatch(/dsh-bash-/)

+ 1 - 1
packages/bash/bash/README.md

@@ -28,6 +28,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
 
 ## Vocabulary
 
-`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
+`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete stdout up to their own limit; the model-facing bash tool does not expose it. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
 
 `stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).

+ 12 - 0
packages/bash/bash/src/types.ts

@@ -52,6 +52,13 @@ export interface BashExecRequest {
   workdir?: string | undefined
   /** Timeout override in milliseconds (implementations cap it). */
   timeoutMs?: number | undefined
+  /**
+   * Foreground stdout capture budget in bytes. Absent uses the executor's
+   * default output cap. Trusted in-process consumers use this when they must
+   * parse complete stdout up to their own bounded limit; the model-facing bash
+   * tool does not expose it as a parameter.
+   */
+  stdoutMaxBytes?: number | undefined
   /** Abort signal — implementations kill the command when it fires. */
   signal?: AbortSignal | undefined
   /**
@@ -95,6 +102,11 @@ export interface BashExecSpec {
   command: string
   workdir: string
   timeoutMs: number
+  /**
+   * Resolved foreground stdout capture budget in bytes. `run()` uses it for
+   * stdout; background tasks and stderr keep the executor's own output cap.
+   */
+  stdoutMaxBytes: number
   /** Abort signal — implementations kill the command when it fires. */
   signal?: AbortSignal | undefined
   /**

+ 1 - 0
packages/bash/bash/tests/service.spec.ts

@@ -13,6 +13,7 @@ class StubExecutor extends BashExecutor {
       command: request.command,
       workdir: request.workdir ?? '/stub',
       timeoutMs: request.timeoutMs ?? 1000,
+      stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
       ...request.signal ? { signal: request.signal } : {},
       owner: request.owner,
     }

+ 1 - 1
packages/bash/tool-bash/README.md

@@ -44,7 +44,7 @@ When a background task finishes, a short notice is injected into the owning agen
 
 ## The tool builds its request from named args only
 
-The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+The `BashExecRequest` seam carries optional trusted-plugin fields (`stdoutMaxBytes`, `stdin`, and `env`); hooks use `stdin`/`env` to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env`, `stdin`, or `stdoutMaxBytes` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries none of those fields — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
 
 ## Permissions
 

+ 15 - 8
packages/bash/tool-bash/tests/tools.spec.ts

@@ -98,6 +98,7 @@ class LossyReadBashExecutor extends BashExecutor {
       command: request.command,
       workdir: request.workdir ?? process.cwd(),
       timeoutMs: request.timeoutMs ?? 0,
+      stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
       ...request.signal ? { signal: request.signal } : {},
       owner: request.owner,
     }
@@ -873,11 +874,12 @@ describe('the model-facing bash tool builds its request from named args only (no
   /**
    * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
    * test can assert what the model-facing tool DID and DID NOT forward. The `bash`
-   * tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
-   * model that power), so it must build its request from named args only and
+   * tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or
+   * `env`) as parameters, so it must build its request from named args only and
    * never spread unknown tool-call keys into it. This guard's job is to catch a
    * future refactor that blindly forwards `...args` — which would silently thread
-   * model input into the post-scrub `env` merge — NOT to defend a trust boundary
+   * model input into the post-scrub `env` merge or per-run capture budget — NOT
+   * to defend a trust boundary
    * (the credential scrub in dsh-bash-local is the security control; see the
    * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
    * unused here.
@@ -890,6 +892,7 @@ describe('the model-facing bash tool builds its request from named args only (no
         command: request.command,
         workdir: request.workdir ?? process.cwd(),
         timeoutMs: request.timeoutMs ?? 0,
+        stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
         ...request.signal ? { signal: request.signal } : {},
         ...request.stdin !== undefined ? { stdin: request.stdin } : {},
         ...request.env !== undefined ? { env: request.env } : {},
@@ -920,12 +923,12 @@ describe('the model-facing bash tool builds its request from named args only (no
     return { ctx, bash: ctx.bash as RecordingBashExecutor }
   }
 
-  it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
+  it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => {
     const { ctx, bash } = await setupRecording()
-    // Extra args: the model includes `env` and `stdin` keys hoping they reach the
+    // Extra args: the model includes trusted-plugin keys hoping they reach the
     // executor. The bash tool's schema ignores unknown keys, and execute() builds
     // the request from only command/workdir/timeoutMs/signal — so the recorded
-    // request carries NEITHER. (Not a security wall — the model could set an env
+    // request carries NONE. (Not a security wall — the model could set an env
     // var or feed stdin via shell syntax anyway; this just keeps the request
     // shape honest so a future `...args` spread can't silently forward input.)
     await ctx.tools.execute({
@@ -936,6 +939,7 @@ describe('the model-facing bash tool builds its request from named args only (no
         description: 'echo',
         env: { SNEAKY_API_KEY: 'leak' },
         stdin: 'malicious payload',
+        stdoutMaxBytes: 999_999,
       },
     })
     expect(bash.requests).toHaveLength(1)
@@ -943,9 +947,10 @@ describe('the model-facing bash tool builds its request from named args only (no
     expect(request.command).toBe('echo hi')
     expect('env' in request).toBe(false)
     expect('stdin' in request).toBe(false)
+    expect('stdoutMaxBytes' in request).toBe(false)
   })
 
-  it('a background bash call likewise carries no env/stdin', async () => {
+  it('a background bash call likewise carries no trusted-only fields', async () => {
     const { ctx, bash } = await setupRecording()
     // start() throws in this recorder, but resolve() runs first and records the
     // request — which is all this no-forward assertion needs.
@@ -958,15 +963,17 @@ describe('the model-facing bash tool builds its request from named args only (no
         run_in_background: true,
         env: { TOKEN: 'leak' },
         stdin: 'x',
+        stdoutMaxBytes: 999_999,
       },
     })
     expect(bash.requests).toHaveLength(1)
     const request = bash.requests[0]!
     expect('env' in request).toBe(false)
     expect('stdin' in request).toBe(false)
+    expect('stdoutMaxBytes' in request).toBe(false)
     // The owner token IS set on a background call (the isolation fence) — proving
     // the recorder sees the real request the consumer built, so the absent
-    // env/stdin above is a real negative, not a recorder that drops everything.
+    // trusted-only fields above are a real negative, not a recorder that drops everything.
     expect('owner' in request).toBe(true)
   })
 })

+ 2 - 2
packages/fs/tool-fs-search/README.md

@@ -39,8 +39,8 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c
 
 ## Two budgets, two artifacts
 
-Raw `rg` stdout is an internal transport detail. When the executor truncates it, the tool recovers the complete stream from the executor's **raw bash spill file** — read locally, capped at `rawOutputMaxBytes`, never shown to the model. 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.spillFiles.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the saved path. 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`.
+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.spillFiles.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the saved path. 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 truncated with no recovery file), 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.
+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.

+ 16 - 36
packages/fs/tool-fs-search/src/search-core.ts

@@ -7,17 +7,15 @@
  * 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: when the executor truncates it, the ONLY recovery source is the
- * executor's local raw spill file, read here up to `rawOutputMaxBytes` and
- * never exposed to the model. The model-facing recovery artifact is the
+ * 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.spillFiles.saveText()`
- * ({@link trySaveFormattedResult}) — a different artifact from the bash raw
- * spill file.
+ * ({@link trySaveFormattedResult}).
  *
  * @module @deepseek-ai/dsh-tool-fs-search/search-core
  */
 
-import { readFile, stat } from 'node:fs/promises'
 import { isAbsolute, relative, sep } from 'node:path'
 import type { Context } from 'cordis'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -45,7 +43,7 @@ export const SEARCH_TIMEOUT_MS = 30_000
  * 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 was truncated with no recovery file); `SEARCH_ABORTED` — the tool
+ * 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.
  */
@@ -72,7 +70,7 @@ export class SearchError extends HarnessError {
 
 /** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
 export interface RipgrepRun {
-  /** Complete raw stdout — inline executor text, or the raw spill file's content. */
+  /** 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
@@ -104,14 +102,11 @@ function classifyRunFailure(toolName: string, result: BashRunResult): SearchErro
 
 /**
  * Acquire the COMPLETE raw stdout of a finished run, enforcing
- * `rawOutputMaxBytes` on BOTH transports: inline executor text (an executor
- * retaining more than this package's cap must not smuggle an over-cap parse
- * through the untruncated path) and the executor's local raw spill file, read
- * only when the complete file fits the cap. A missing spill path or over-cap
- * output is a clear failure telling the model to narrow the search — never a
- * silently-partial parse.
+ * `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.
  */
-async function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): Promise<string> {
+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')
@@ -123,26 +118,10 @@ async function completeStdout(toolName: string, result: BashRunResult, rawOutput
     }
     return result.stdout.text
   }
-  const spillPath = result.stdout.spillPath
-  if (spillPath === undefined) {
-    throw new SearchError(
-      `${toolName} produced more raw output than the bash executor retained and no raw spill file is available; ${narrow}`,
-      'SEARCH_RAW_OUTPUT_OVERFLOW',
-    )
-  }
-  try {
-    const { size } = await stat(spillPath)
-    if (size > rawOutputMaxBytes) {
-      throw new SearchError(
-        `${toolName} produced ${size} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
-        'SEARCH_RAW_OUTPUT_OVERFLOW',
-      )
-    }
-    return await readFile(spillPath, 'utf8')
-  } catch (error: unknown) {
-    if (error instanceof SearchError) throw error
-    throw new SearchError(`${toolName} could not read the executor's raw output spill file`, 'SEARCH_FAILED', { cause: error })
-  }
+  throw new SearchError(
+    `${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
+    'SEARCH_RAW_OUTPUT_OVERFLOW',
+  )
 }
 
 /**
@@ -181,6 +160,7 @@ export async function runRipgrep(
   const cwd = exec.agent?.session.header.cwd
   const spec = ctx.bash.resolve({
     command,
+    stdoutMaxBytes: rawOutputMaxBytes,
     ...cwd !== undefined ? { workdir: cwd } : {},
     ...exec.signal ? { signal: exec.signal } : {},
   })
@@ -208,7 +188,7 @@ export async function runRipgrep(
   if (result.exitCode !== 0 && result.exitCode !== 1) {
     throw classifyRunFailure(toolName, result)
   }
-  const stdout = await completeStdout(toolName, result, rawOutputMaxBytes)
+  const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
   return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
 }
 

+ 12 - 35
packages/fs/tool-fs-search/tests/tools.spec.ts

@@ -2,7 +2,7 @@
  * 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 file, abort/timeout,
+ * 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
@@ -10,10 +10,7 @@
  * pinned separately in integration.spec.ts.
  */
 
-import { afterEach, describe, expect, it } from 'vitest'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+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'
@@ -66,6 +63,7 @@ class FakeBash extends BashExecutor {
       command: request.command,
       workdir: request.workdir ?? '/work',
       timeoutMs: request.timeoutMs ?? 60_000,
+      stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
       signal: request.signal,
       owner: request.owner,
     }
@@ -388,28 +386,18 @@ describe('exit semantics and failure classification', () => {
 })
 
 describe('raw output acquisition', () => {
-  let dir: string
-  afterEach(async () => {
-    await rm(dir, { recursive: true, force: true })
-  })
-
-  it('parses the complete raw spill file when stdout is truncated', async () => {
-    dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
-    const spillPath = join(dir, 'raw.txt')
-    await writeFile(spillPath, 'one.ts\ntwo.ts\nthree.ts\n')
-    const { ctx, bash } = await setup()
-    bash.handler = () => runResult('', { stdout: { text: 'one.ts\n', truncated: true, spillPath } })
-    const result = await call(ctx, 'glob', { pattern: '*.ts' })
-    expect(result.isError).toBe(false)
-    expect(text(result)).toBe('one.ts\ntwo.ts\nthree.ts')
+  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 the raw spill file exceeds the cap', async () => {
-    dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
-    const spillPath = join(dir, 'raw.txt')
-    await writeFile(spillPath, 'x'.repeat(64))
+  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 } })
+    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')
@@ -419,7 +407,6 @@ describe('raw output acquisition', () => {
     // 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.
-    dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
     const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
     bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
     const result = await call(ctx, 'grep', { pattern: 'x' })
@@ -428,21 +415,11 @@ describe('raw output acquisition', () => {
   })
 
   it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
-    dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
     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' })
   })
-
-  it('fails with SEARCH_FAILED when the raw spill file cannot be read', async () => {
-    dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
-    const { ctx, bash } = await setup()
-    bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true, spillPath: join(dir, 'gone.txt') } })
-    const result = await call(ctx, 'glob', { pattern: '*' })
-    expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
-    expect(text(result)).toContain('raw output spill file')
-  })
 })
 
 describe('glob results', () => {

+ 1 - 0
packages/hooks/hook-protocol/tests/runner.spec.ts

@@ -22,6 +22,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
         command: request.command,
         workdir: request.workdir ?? '/stub',
         timeoutMs: request.timeoutMs ?? 0,
+        stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
         ...request.signal ? { signal: request.signal } : {},
         ...request.stdin !== undefined ? { stdin: request.stdin } : {},
         ...request.env !== undefined ? { env: request.env } : {},