feat(spill): add tool-output spill seam, local backend, and policy

Oversized plain-text tool results now spill to a session-scoped file and
return a bounded preview plus the spill path, so a verbose result stays
readable via `read` without consuming the next model request in full.

- dsh-spill: minimal SpillFiles seam (saveText → session-scoped SpillPath)
- dsh-spill-local: private 0700 session dirs, traversal-safe names, exclusive
  owner-only writes
- dsh-spill-policy: tools/post-execute transformer; no-op unless maxInlineBytes
  is set; skips read; best-effort on save failure (never turns a success into
  an isError)

web_fetch is the showcase — no tool-specific spill code. The coding-agent
example loads the stack so its keyless Loader smoke guards the namespace-plugin
export shape. Snapshot gap for a transcript-visible web_fetch spill is recorded
in the RFC's Consequences (ACP replay is keyless and cannot hit the web).
This commit is contained in:
Dudu-0223
2026-07-08 19:20:50 +08:00
parent 4f2f34c6fd
commit 463b72ce96
36 changed files with 1549 additions and 1 deletions

View File

@@ -0,0 +1,61 @@
/**
* `LocalSpillFiles`: the host-filesystem implementation of the
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
* and exclusive owner-only write) and returns a path the local `read` tool can
* open.
*
* @module @deepseek-ai/dsh-spill-local
*/
import { Context } from 'cordis'
import { resolve } from 'node:path'
import z from 'schemastery'
import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import { privateRoot, saveTextFile } from './store.ts'
export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts'
export type { SavedText, SaveTextOptions } from './store.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/**
* Root directory for spill files. Omitted uses a lazily-created private
* (0700) per-process directory under the OS temp dir — the safe default for
* a local deployment. Set it to keep spill files under a known location.
*/
root?: string
}
/**
* Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
* with unpredictable names, an exclusive owner-only (0600) write, and a private
* (0700) root — a spilled tool result must not be readable by other local users
* or redirectable via a planted symlink.
*/
export class LocalSpillFiles extends SpillFiles {
static Config: z<Config> = z.object({
root: z.string(),
})
/** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
readonly root: string
constructor(ctx: Context, config: Config) {
super(ctx)
this.root = config.root !== undefined ? resolve(config.root) : privateRoot()
}
async saveText(input: SaveTextSpill): Promise<SpillRef> {
const saved = await saveTextFile({
root: this.root,
sessionId: input.owner.sessionId,
suggestedName: input.suggestedName,
content: input.content,
})
return { path: SpillPath(saved.path), bytes: saved.bytes }
}
}
export default LocalSpillFiles

View File

@@ -0,0 +1,102 @@
/**
* Cordis-free storage mechanics for the local spill backend: private
* session-scoped directory selection, safe-name derivation, path-traversal
* protection, and the exclusive owner-only write. Kept out of the service class
* (like `dsh-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
* without a `ctx` and without the OS temp dir.
*
* @module @deepseek-ai/dsh-spill-local/store
*/
import { createHash, randomBytes } from 'node:crypto'
import { mkdtempSync } from 'node:fs'
import { mkdir, open } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
let defaultRoot: string | undefined
/**
* The default spill root: a private (0700) per-process directory under the OS
* tmpdir, created lazily. Predictable world-readable paths would let other
* local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
* an unpredictable suffix and 0700 semantics.
*/
export function privateRoot(): string {
defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-'))
return defaultRoot
}
/**
* Encode an arbitrary string as one safe path segment, injectively over ALL JS
* (UTF-16) strings. A session id / suggested name is untrusted input, so this
* neutralizes `../`, absolute paths, NUL, and separators before any filesystem
* use. Each code unit is kept literal (`[A-Za-z0-9._-]`, minus `~`) or escaped
* as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
* inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
* can never traverse. An empty string encodes to `~` (never an empty segment).
* (Mirrors the JSONL persistence backend's `encodeSegment`.)
*/
export function encodeSegment(raw: string): string {
if (raw.length === 0) return '~'
if (raw === '.') return '~002E'
if (raw === '..') return '~002E~002E'
let out = ''
for (let i = 0; i < raw.length; i++) {
const code = raw.charCodeAt(i)
const ch = String.fromCharCode(code)
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
out += ch
} else {
out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
}
}
return out
}
/** The session-scoped directory: `<root>/session-<hash(sessionId)>`, a short stable hash. */
export function sessionDir(root: string, sessionId: string): string {
const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12)
return join(root, `session-${hash}`)
}
/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */
export interface SaveTextOptions {
/** The spill root directory (configured or the lazy private default). */
root: string
/** The owning session id (scopes the directory). */
sessionId: string
/** Caller-suggested base name; sanitized to one safe segment before use. */
suggestedName: string
/** The full text to persist. */
content: string
}
/** A written spill file. */
export interface SavedText {
path: string
bytes: number
}
/**
* Write `content` to a fresh file under the session-scoped directory and return
* its path + byte length. The filename is a random hex prefix plus the
* sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in
* a shared root) AND stays readable. The open is exclusive + owner-only
* (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
* pre-planted target cannot redirect the write.
*/
export async function saveTextFile(options: SaveTextOptions): Promise<SavedText> {
const dir = sessionDir(options.root, options.sessionId)
await mkdir(dir, { recursive: true, mode: 0o700 })
const safeName = encodeSegment(options.suggestedName)
const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`)
const bytes = Buffer.byteLength(options.content, 'utf8')
const handle = await open(path, 'wx', 0o600)
try {
await handle.writeFile(options.content)
} finally {
await handle.close()
}
return { path, bytes }
}