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:
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-spill-local
|
||||
|
||||
The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillFiles` and persists a tool's oversized text to a private, session-scoped file the model's `read` tool can open.
|
||||
|
||||
## Storage layout
|
||||
|
||||
Files land at `<root>/session-<hash>/<random>-<safeName>`:
|
||||
|
||||
- **`root`** — the config `root` (resolved to absolute), or a lazily-created private (0700) per-process directory under the OS temp dir when omitted. A predictable, world-readable root would let other local users read spilled tool output or plant symlinks.
|
||||
- **`session-<hash>`** — a short `sha256(sessionId)` prefix, so a session's spill files group together and a future cleanup can drop them per session.
|
||||
- **`<random>-<safeName>`** — an unpredictable hex prefix (defeats symlink planting in a shared root) plus the caller's `suggestedName` sanitized to one safe path segment (traversal-proof; mirrors the JSONL persistence backend's `encodeSegment`). The write is exclusive + owner-only (`open(path, 'wx', 0o600)`): it fails on any pre-existing path, symlink or not, so a planted target cannot redirect it.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. |
|
||||
|
||||
`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-spill-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)",
|
||||
"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",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-spill": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
|
||||
* returns its path + byte length, filename sanitization neutralizes traversal,
|
||||
* the configured `root` is honored (and the private default when omitted), and a
|
||||
* storage failure rejects. The Cordis-free `store.ts` helpers are exercised
|
||||
* directly for the naming/encoding edge cases.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
import LocalSpillFiles, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'dsh-spill-test-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function request(overrides: Partial<SaveTextSpill> = {}): SaveTextSpill {
|
||||
return {
|
||||
owner: { sessionId: SessionId('sess-1') },
|
||||
source: { toolName: 'web_fetch', callId: CallId('call-1'), label: 'result' },
|
||||
suggestedName: 'web_fetch.txt',
|
||||
content: 'the full body',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('encodeSegment', () => {
|
||||
it('keeps the safe set literal', () => {
|
||||
expect(encodeSegment('web_fetch.txt')).toBe('web_fetch.txt')
|
||||
expect(encodeSegment('a-B_9.z')).toBe('a-B_9.z')
|
||||
})
|
||||
|
||||
it('escapes separators and tilde (dots are literal except as whole-segment tokens)', () => {
|
||||
// `.` is in the safe set, so `..` inside a longer string stays literal; the
|
||||
// traversal defense is that separators escape, keeping the result ONE segment.
|
||||
expect(encodeSegment('../etc/passwd')).toBe('..~002Fetc~002Fpasswd')
|
||||
expect(encodeSegment('a/b')).toBe('a~002Fb')
|
||||
expect(encodeSegment('~')).toBe('~007E')
|
||||
})
|
||||
|
||||
it('escapes the whole-segment dot tokens', () => {
|
||||
expect(encodeSegment('.')).toBe('~002E')
|
||||
expect(encodeSegment('..')).toBe('~002E~002E')
|
||||
})
|
||||
|
||||
it('encodes the empty string to a non-empty segment', () => {
|
||||
expect(encodeSegment('')).toBe('~')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionDir', () => {
|
||||
it('is a stable per-session hash under the root', () => {
|
||||
const dir = sessionDir('/spill', 'sess-1')
|
||||
expect(dir).toBe(sessionDir('/spill', 'sess-1'))
|
||||
expect(dir).toMatch(/\/spill\/session-[0-9a-f]{12}$/)
|
||||
expect(sessionDir('/spill', 'sess-2')).not.toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveTextFile', () => {
|
||||
it('writes the content under the session dir and reports bytes', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'héllo' })
|
||||
expect(readFileSync(saved.path, 'utf8')).toBe('héllo')
|
||||
expect(saved.bytes).toBe(Buffer.byteLength('héllo', 'utf8'))
|
||||
expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(saved.path).toMatch(/\/[0-9a-f]{12}-r\.txt$/)
|
||||
})
|
||||
|
||||
it('sanitizes a traversal-shaped suggested name into one segment', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: '../../evil', content: 'x' })
|
||||
// The separators escaped, so the whole name is one leaf under the session dir.
|
||||
expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(saved.path.includes('/..')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates the session dir with owner-only permissions', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' })
|
||||
// 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold).
|
||||
expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700)
|
||||
expect(statSync(saved.path).mode & 0o600).toBe(0o600)
|
||||
})
|
||||
|
||||
it('gives distinct paths to two saves of the same name', async () => {
|
||||
const a = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'a' })
|
||||
const b = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'b' })
|
||||
expect(a.path).not.toBe(b.path)
|
||||
})
|
||||
})
|
||||
|
||||
describe('privateRoot', () => {
|
||||
it('is a stable absolute directory under the temp dir', () => {
|
||||
const first = privateRoot()
|
||||
expect(isAbsolute(first)).toBe(true)
|
||||
expect(privateRoot()).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalSpillFiles service', () => {
|
||||
it('registers as ctx.spillFiles and saves under the configured root', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, { root })
|
||||
const ref = await ctx.spillFiles.saveText(request())
|
||||
expect(dirname(ref.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(readFileSync(ref.path, 'utf8')).toBe('the full body')
|
||||
expect(ref.bytes).toBe(Buffer.byteLength('the full body', 'utf8'))
|
||||
})
|
||||
|
||||
it('resolves a relative configured root to absolute', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, { root: '.' })
|
||||
expect(isAbsolute((ctx.spillFiles as LocalSpillFiles).root)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the private root when none is configured', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, {})
|
||||
expect((ctx.spillFiles as LocalSpillFiles).root).toBe(privateRoot())
|
||||
})
|
||||
|
||||
it('rejects when the root is not writable (missing parent, exclusive open)', async () => {
|
||||
const ctx = new Context()
|
||||
// A file (not a dir) as the root makes mkdir under it fail — a real storage error.
|
||||
const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
|
||||
await ctx.plugin(LocalSpillFiles, { root: filePath })
|
||||
await expect(ctx.spillFiles.saveText(request())).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../spill" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user