Merge origin/master into worktree/ci-under-minute

This commit is contained in:
Tianyi Cui
2026-07-22 17:15:46 +08:00
113 changed files with 4054 additions and 689 deletions

View File

@@ -1,5 +1,5 @@
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
@@ -61,6 +61,7 @@ class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
lstatTypes = new Map<string, FsPathInfo['type']>()
throwOnStat = new Set<string>()
throwOnRead = new Set<string>()
omitSizes = new Set<string>()
readTargets: string[] = []
readTextTargets: string[] = []
@@ -69,7 +70,7 @@ class RecordingFileSystem extends FileSystem {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal !== undefined) this.signals.push(opts.signal)
opts?.signal?.throwIfAborted()
const absolute = join(opts?.cwd ?? '/', path)
const absolute = resolve(opts?.cwd ?? '/', path)
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
}
@@ -113,6 +114,7 @@ class RecordingFileSystem extends FileSystem {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()
this.readTargets.push(target.targetKey)
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
const content = this.entries.get(target.targetKey)?.content ?? ''
return (async function* () {
const midpoint = Math.ceil(content.length / 2)
@@ -299,8 +301,8 @@ describe('workspace context instruction discovery', () => {
expect(files.map(file => file.displayPath)).toEqual([
'$DSH_HOME/AGENTS.md',
'AGENTS.md',
'packages/CLAUDE.md',
'packages/app/AGENTS.md',
join('packages', 'CLAUDE.md'),
join('packages', 'app', 'AGENTS.md'),
])
expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md'))
} finally {
@@ -358,22 +360,25 @@ describe('workspace context instruction discovery', () => {
}
})
it('skips a file that becomes unreadable after discovery without failing the request', async () => {
it('skips a provider file whose read fails after a successful metadata probe', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
const leaf = join(cwd, 'AGENTS.md')
await write(leaf, 'secret-ish rule')
await chmod(leaf, 0)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' })
fs.throwOnRead.add(leaf)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs)
expect(loaded).toBeUndefined()
await chmod(leaf, 0o600)
expect(fs.readTargets).toEqual([leaf])
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -958,7 +963,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('omitted AGENTS.md')
expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1138,8 +1143,8 @@ describe('workspace context request injection', () => {
})
it('keeps the direct provider API usable without an operation signal', async () => {
const root = '/virtual/no-signal-repo'
const home = '/virtual/no-signal-home'
const root = resolve('/virtual/no-signal-repo')
const home = resolve('/virtual/no-signal-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
@@ -1447,7 +1452,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
@@ -1751,7 +1756,7 @@ describe('dynamic nested workspace context injection', () => {
changes: [{
action: 'set',
scope: 'pkg',
path: 'pkg/AGENTS.md',
path: join('pkg', 'AGENTS.md'),
}],
})
const meta = workspaceContextOf(result)?.meta
@@ -1765,7 +1770,7 @@ describe('dynamic nested workspace context injection', () => {
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([
'<system-reminder>',
'Additional instructions from: pkg/AGENTS.md',
`Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
'',
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
'',
@@ -1804,7 +1809,7 @@ describe('dynamic nested workspace context injection', () => {
})
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
expect(text).toContain('local package rule')
expect(text).not.toContain('native package rule')
} finally {
@@ -1985,11 +1990,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>',
'Updated instructions from: pkg/AGENTS.md',
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
'',
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
@@ -2032,11 +2037,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.meta).toMatchObject({
changes: [{
action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md',
action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'),
}],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
expect(unchanged.additionalContexts).toBeUndefined()
} finally {
@@ -2070,11 +2075,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(removed)?.meta).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>',
'Instructions removed: pkg/AGENTS.md',
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
'',
'The previously loaded instructions from this file no longer apply.',
'</system-reminder>',
@@ -2115,9 +2120,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -2222,7 +2227,7 @@ describe('dynamic nested workspace context injection', () => {
const update = resumed.session.events.findLast(event => event.type === 'context/message')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
@@ -2350,8 +2355,8 @@ describe('dynamic nested workspace context injection', () => {
})
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('omitted pkg/AGENTS.md')
expect(firstText).not.toContain('## pkg/AGENTS.md')
expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
expect(firstText).toContain('subtree rule')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
} finally {
@@ -2494,14 +2499,19 @@ describe('dynamic nested workspace context injection', () => {
it('skips unreadable nested instruction files without attaching empty context', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
const nested = join(root, 'pkg/AGENTS.md')
await write(nested, 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await chmod(nested, 0)
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
fs.throwOnRead.add(nested)
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -2513,8 +2523,9 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(result.additionalContexts).toBeUndefined()
await chmod(nested, 0o600)
expect(fs.readTargets).toContain(nested)
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -2551,7 +2562,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')

View File

@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
@@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
## Known Limitations and Deferred Work

View File

@@ -8,6 +8,9 @@
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
export type { ScopeLayer } from './store.ts'
/** An opaque, identity-compared scope key. */
export type ScopeKey = object

View File

@@ -0,0 +1,247 @@
/**
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
*
* @module @deepseek-ai/dsh-scope
*/
import type { Context } from 'cordis'
import { scopeOf } from './index.ts'
import type { ScopeKey } from './index.ts'
/** One scope's aggregate contribution to a registry. */
export interface ScopeLayer {
/** Whether every table in this layer is empty. */
isEmpty(): boolean
}
/** Internal common read contract for the two entry-table implementations. */
interface EntryValues<V> {
values(): IterableIterator<V>
isEmpty(): boolean
}
/**
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
*
* Values are borrowed. Iterators are live within one nonempty table
* generation; draining the table detaches them from later insertions. Each
* successful insertion returns an idempotent undo for that exact entry.
*/
export class NamedEntries<V> implements EntryValues<V> {
private data = new Map<string, V>()
constructor(
private readonly duplicateError: (name: string) => Error,
) {}
/**
* Insert one unique name.
* @param name - name unique within this table.
* @param value - borrowed value to retain.
* @returns an idempotent undo that removes only this insertion.
*/
insert(name: string, value: V): () => void {
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(name)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Read one named value.
* @param name - name to resolve.
* @returns the retained value, or `undefined` when absent.
*/
get(name: string): V | undefined {
return this.data.get(name)
}
/**
* Test one name for membership.
* @param name - name to test.
* @returns whether the table contains that name.
*/
has(name: string): boolean {
return this.data.has(name)
}
/**
* Iterate live names in insertion order.
* @returns the native live key iterator.
*/
keys(): IterableIterator<string> {
return this.data.keys()
}
/**
* Iterate live entries in insertion order.
* @returns the native live entry iterator.
*/
entries(): IterableIterator<[string, V]> {
return this.data.entries()
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Insertion-ordered anonymous entries with independent registration identity.
*
* Equal values remain separate registrations. Values are borrowed, and
* iterators are live within one nonempty table generation; draining the table
* detaches them from later appends.
*/
export class AnonymousEntries<V> implements EntryValues<V> {
private data = new Map<symbol, V>()
/**
* Append one independently owned value.
* @param value - borrowed value to retain.
* @returns an idempotent undo for this exact append.
*/
append(value: V): () => void {
const data = this.data
const key = Symbol()
data.set(key, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(key)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Own the global and exact-scope layers for one registry.
*
* Reads never create scoped layers. Registrations derive both visibility and
* effect ownership from the supplied Cordis context, collect undo before
* notification, and reclaim only a completely empty aggregate layer.
*/
export class ScopedLayers<L extends ScopeLayer> {
/** The eagerly constructed context-global layer. */
readonly global: L
private readonly scoped = new Map<ScopeKey, L>()
constructor(
private readonly createLayer: (scope: ScopeKey | undefined) => L,
private readonly onChange: () => void,
) {
this.global = createLayer(undefined)
}
/**
* Read an existing exact-scope overlay.
* @param scope - exact scope key; `undefined` denotes no overlay.
* @returns the existing scoped layer, or `undefined` without creating one.
*/
peek(scope: ScopeKey | undefined): L | undefined {
if (scope === undefined) return undefined
return this.scoped.get(scope)
}
/**
* Materialize global named entries followed by exact-scope shadows.
* @param scope - exact viewing scope, or `undefined` for the global view.
* @param pick - select the named table from a layer.
* @returns an insertion-ordered effective map.
*/
merge<V>(
scope: ScopeKey | undefined,
pick: (layer: L) => NamedEntries<V>,
): Map<string, V> {
const merged = new Map(pick(this.global).entries())
const layer = this.peek(scope)
if (layer === undefined) return merged
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
return merged
}
/**
* Attach one synchronous layer mutation to its registration context.
* @param ctx - context that determines both scope visibility and effect ownership.
* @param action - atomic mutation returning its synchronous undo.
* @param options - Cordis effect label and optional change notification.
* @returns the exact disposer returned by `ctx.effect()`.
*/
effect(
ctx: Context,
action: (layer: L) => () => void,
options: { label: string; notify?: boolean },
): () => void {
const scope = scopeOf(ctx)
const notify = options.notify ?? true
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
let layer: L
let created = false
if (scope === undefined) {
layer = this.global
} else {
const existing = this.scoped.get(scope)
if (existing === undefined) {
layer = this.createLayer(scope)
this.scoped.set(scope, layer)
created = true
} else {
layer = existing
}
}
let undo: () => void
try {
undo = action(layer)
} catch (error) {
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
throw error
}
yield () => {
undo()
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
if (notify) this.onChange()
}
if (notify) this.onChange()
}.bind(this), options.label)
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
return dispose
}
}

View File

@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
AnonymousEntries,
createScope,
NamedEntries,
ScopedLayers,
type Scope,
type ScopeKey,
type ScopeLayer,
} from '@deepseek-ai/dsh-scope'
class TestLayer implements ScopeLayer {
readonly named: NamedEntries<number>
readonly anonymous = new AnonymousEntries<string>()
constructor(scope: ScopeKey | undefined) {
this.named = new NamedEntries(name =>
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
}
isEmpty(): boolean {
return this.named.isEmpty() && this.anonymous.isEmpty()
}
}
/** Mint one active scope for lifecycle tests. */
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
let scope!: Scope
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
return scope
}
describe('NamedEntries', () => {
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
const duplicate = new Error('caller duplicate')
const duplicateError = vi.fn(() => duplicate)
const entries = new NamedEntries<number>(duplicateError)
const undoA = entries.insert('a', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
const undoB = entries.insert('b', 2)
expect([...values]).toEqual([2])
expect([...entries.keys()]).toEqual(['a', 'b'])
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
expect(entries.get('a')).toBe(1)
expect(entries.get('missing')).toBeUndefined()
expect(entries.has('b')).toBe(true)
expect(entries.has('missing')).toBe(false)
expect(entries.isEmpty()).toBe(false)
expect(() => entries.insert('a', 3)).toThrow(duplicate)
expect(duplicateError).toHaveBeenCalledWith('a')
undoA()
entries.insert('a', 3)
undoA()
expect(entries.get('a')).toBe(3)
undoB()
expect([...entries.entries()]).toEqual([['a', 3]])
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
const undo = entries.insert('first', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.insert('replacement', 2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('AnonymousEntries', () => {
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
const entries = new AnonymousEntries<object>()
const value = {}
const undoFirst = entries.append(value)
const values = entries.values()
expect(values.next()).toEqual({ value, done: false })
const undoSecond = entries.append(value)
expect([...values]).toEqual([value])
expect([...entries.values()]).toEqual([value, value])
undoFirst()
undoFirst()
expect([...entries.values()]).toEqual([value])
undoSecond()
expect(entries.isEmpty()).toBe(true)
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new AnonymousEntries<number>()
const undo = entries.append(1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.append(2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('ScopedLayers', () => {
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(scope) => {
created.push(scope)
return new TestLayer(scope)
},
vi.fn(),
)
const key = {}
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 2)
expect(created).toEqual([undefined])
expect(layers.peek(undefined)).toBeUndefined()
expect(layers.peek(key)).toBeUndefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
expect(created).toEqual([undefined])
})
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const changed = vi.fn()
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(selected) => {
created.push(selected)
return new TestLayer(selected)
},
changed,
)
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 1)
const removeNamed = layers.effect(
scope.ctx,
layer => layer.named.insert('shared', 2),
{ label: 'test.named', notify: false },
)
const removeTail = layers.effect(
scope.ctx,
layer => layer.named.insert('c', 3),
{ label: 'test.tail', notify: false },
)
const removeAnonymous = layers.effect(
scope.ctx,
layer => layer.anonymous.append('kept'),
{ label: 'test.anonymous', notify: false },
)
expect(created).toEqual([undefined, key])
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
expect(changed).not.toHaveBeenCalled()
removeNamed()
expect(layers.peek(key)).toBeDefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
removeTail()
expect(layers.peek(key)).toBeDefined()
removeAnonymous()
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
const ctx = new Context()
const events: string[] = []
const layers = new ScopedLayers(
scope => new TestLayer(scope),
() => void events.push('notify'),
)
const dispose = layers.effect(
ctx,
(layer) => {
events.push('action')
const undo = layer.named.insert('x', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.order' },
)
expect(events).toEqual(['action', 'notify'])
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
dispose()
dispose()
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
expect(layers.global.isEmpty()).toBe(true)
})
it('returns the exact context effect disposer', () => {
const rawDispose = vi.fn()
const effect = vi.fn(() => rawDispose)
const ctx = { effect } as unknown as Context
const action = vi.fn(() => vi.fn())
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
expect(returned).toBe(rawDispose)
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
expect(action).not.toHaveBeenCalled()
})
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
let failFactory = true
const layers = new ScopedLayers(
(selected) => {
if (selected !== undefined && failFactory) throw new Error('factory failed')
return new TestLayer(selected)
},
vi.fn(),
)
expect(() => layers.effect(
scope.ctx,
layer => layer.named.insert('never', 1),
{ label: 'store.factory', notify: false },
)).toThrow('factory failed')
expect(layers.peek(key)).toBeUndefined()
failFactory = false
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('action failed') },
{ label: 'store.action', notify: false },
)).toThrow('action failed')
expect(layers.peek(key)).toBeUndefined()
const dispose = layers.effect(
scope.ctx,
layer => layer.named.insert('kept', 1),
{ label: 'store.kept', notify: false },
)
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('second action failed') },
{ label: 'store.existing-action', notify: false },
)).toThrow('second action failed')
expect(layers.peek(key)?.named.get('kept')).toBe(1)
dispose()
await scope.dispose()
})
it('rolls back a scoped insertion when notification throws', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const events: string[] = []
let notifications = 0
const layers = new ScopedLayers(
selected => new TestLayer(selected),
() => {
events.push('notify')
if (++notifications === 1) throw new Error('change failed')
},
)
expect(() => layers.effect(
scope.ctx,
(layer) => {
const undo = layer.named.insert('rollback', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.rollback' },
)).toThrow('change failed')
expect(events).toEqual(['notify', 'undo', 'notify'])
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
})

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
return result + text.slice(last)
}
/** One tool-schema provider stored in a prompt layer. */
type ToolProvider = (context: AssembleContext) => ToolProviderResult
/** One prompt-variable provider stored in a prompt layer. */
type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
/**
* Create one prompt layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
}
/** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly layers = new ScopedLayers(
scope => new PromptLayer(scope),
() => { this.ctx.emit('system-prompt/change') },
)
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, config: Config) {
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
: this.scopedSections.get(scope) ?? (() => {
const created: PromptSection[] = []
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(section)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.sections.insert(section.name, section),
{ label: 'systemPrompt.section()' },
)
}
/**
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
* @returns the exact Cordis effect disposer.
*/
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.toolProviders
: this.scopedToolProviders.get(scope) ?? (() => {
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
this.scopedToolProviders.set(scope, created)
return created
})()
layer.push(provider)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.toolProviders.append(provider),
{ label: 'systemPrompt.tools()' },
)
}
/**
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.variableProviders
: this.scopedVariableProviders.get(scope) ?? (() => {
const created = new Map<string, (context: AssembleContext) => string | undefined>()
this.scopedVariableProviders.set(scope, created)
return created
})()
if (layer.has(name)) {
throw new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`)
}
layer.set(name, provider)
// Install rollback before notifying listeners that may throw.
yield () => {
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.variables.insert(name, provider),
{ label: 'systemPrompt.variable()' },
)
}
/**
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
const scope = context.scope
// Scoped variables shadow globals.
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
for (const [name, provider] of this.layers.global.variables.entries()) {
variables[name] = provider(context)
}
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
for (const [name, provider] of scopedVariables ?? []) {
const scopedVariables = this.layers.peek(scope)?.variables
for (const [name, provider] of scopedVariables?.entries() ?? []) {
variables[name] = provider(context)
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
const sectionByName = this.layers.merge(scope, layer => layer.sections)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
...this.layers.global.toolProviders.values(),
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it('shadows a global section before evaluating either text provider', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const globalText = vi.fn(() => 'global text')
const scopedText = vi.fn(() => 'scoped text')
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
expect(globalText).not.toHaveBeenCalled()
expect(scopedText).toHaveBeenCalledOnce()
})
})
describe('scoped variables', () => {
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
const again = await mintScope(ctx, 'child2')
again.ctx.systemPrompt.variable('v', () => '3')
})
it('defers a scoped variable that replaces the last provider in its generation', async () => {
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
const calls: string[] = []
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
calls.push('first')
dispose()
scope.ctx.systemPrompt.variable('mode', () => {
calls.push('replacement')
return 'replacement'
})
return 'first'
})
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
expect(calls).toEqual(['first'])
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
expect(calls).toEqual(['first', 'replacement'])
})
})
describe('scoped tool providers and toolOrder × restriction', () => {

View File

@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
})
it('snapshots tool-provider membership before evaluating an assembly', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.tools(() => {
if (!added) {
added = true
ctx.systemPrompt.tools(() => ({
schemas: [{ name: 'late', description: '', parameters: {} }],
}))
}
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
})
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
})
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
})
it('live-iterates variables registered by an earlier provider', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.variable('first', () => {
if (!added) {
added = true
ctx.systemPrompt.variable('late', () => 'second value')
}
return 'first value'
})
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
first: 'first value',
late: 'second value',
})
})
it('rejects a duplicate variable name and an unreferenceable name', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -463,9 +463,40 @@ interface ToolView {
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** One guard registration; the wrapper preserves independent duplicate registrations. */
interface ToolGuardRegistration {
guard: ToolGuard
/** One scope's complete tool-registry contribution. */
class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`))
}
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
}
/** Whether every compiled restriction in this layer admits a global tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
|| (filter.deny !== undefined && filter.deny.has(name))) return false
}
return true
}
/** First monotonic denial from this layer's live guard registrations. */
guardReason(exec: ToolExecution): string | undefined {
for (const guard of this.guards.values()) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
return undefined
}
}
/** Approval decision plus whether the approval channel reported cancellation. */
@@ -509,13 +540,10 @@ export class ToolRegistry extends Service {
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
@@ -593,7 +621,6 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
@@ -603,26 +630,11 @@ export class ToolRegistry extends Service {
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(name)) {
throw new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`)
}
layer.set(name, definition)
// Install rollback before notifying listeners.
yield () => {
layer.delete(name)
// Drop empty scope layers.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
/**
@@ -655,22 +667,11 @@ export class ToolRegistry extends Service {
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const list = this.restrictions.get(scope) ?? []
this.restrictions.set(scope, list)
list.push(compiled)
yield () => {
const index = list.indexOf(compiled)
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.restrictions.append(compiled),
{ label: 'tools.restrict()' },
)
}
/**
@@ -684,63 +685,18 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the guard.
*/
guard(guard: ToolGuard): () => void {
const scope = scopeOf(this.ctx)
const registration = { guard }
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
layer.add(registration)
yield () => {
layer.delete(registration)
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
}
}.bind(this), 'tools.guard()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
if (!layer) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Get or create the guard layer for one agent scope. */
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
let layer = this.scopedGuards.get(scope)
if (layer === undefined) {
layer = new Set()
this.scopedGuards.set(scope, layer)
}
return layer
return this.layers.effect(
this.ctx,
layer => layer.guards.append(guard),
{ label: 'tools.guard()', notify: false },
)
}
/** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined {
for (const { guard } of this.globalGuards) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
}
return undefined
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.has(name))
&& (filter.deny === undefined || !filter.deny.has(name)))
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
}
/**
@@ -752,18 +708,18 @@ export class ToolRegistry extends Service {
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
const layer = scope === undefined ? undefined : this.scoped.get(scope)
const layer = this.layers.peek(scope)
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.global) {
for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name)
restrictableNames.add(name)
if (this.admits(scope, name)) visible.set(name, definition)
if (layer?.admits(name) ?? true) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer ?? []) {
for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name)
visible.set(name, definition)
}

View File

@@ -266,6 +266,49 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0)
})
it('live-iterates a guard registered by an earlier guard', async () => {
const ctx = await mount()
const calls: string[] = []
let added = false
ctx.tools.register(tool('t'))
ctx.tools.guard(() => {
calls.push('first')
if (!added) {
added = true
ctx.tools.guard(() => {
calls.push('late')
return 'late denial'
})
}
return undefined
})
expect(await run(ctx, 't')).toBe('Error: late denial')
expect(calls).toEqual(['first', 'late'])
})
it('defers a scoped guard that replaces the last guard in its generation', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const calls: string[] = []
ctx.tools.register(tool('t'))
scope.ctx.tools.register(tool('scope_sibling'))
const lift = scope.ctx.tools.guard(() => {
calls.push('first')
lift()
scope.ctx.tools.guard(() => {
calls.push('replacement')
return 'replacement denial'
})
return undefined
})
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(calls).toEqual(['first'])
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
expect(calls).toEqual(['first', 'replacement'])
})
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')

View File

@@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`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`).
- **`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`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). 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`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.

View File

@@ -32,6 +32,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -12,6 +12,7 @@ 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'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
@@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion {
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override the host platform for native-publication unit coverage. */
platform?: NodeJS.Platform
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Override the Win32 DACL copy boundary. */
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -133,6 +140,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
// A path component is a file, not a directory (e.g. "afile/child.txt" where
// "afile" is a regular file): the target can neither exist nor be created,
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
@@ -145,8 +153,22 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
while (true) {
try {
const realAncestor = await realpath(ancestor)
// On Windows, realpath of a regular file succeeds where POSIX returns
// ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
// Stat the ancestor to restore the semantic distinction: a non-directory
// ancestor means the target passes through a file and can never be created.
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
if (process.platform === 'win32') {
const parentInfo = await stat(realAncestor)
if (!parentInfo.isDirectory()) {
throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
}
}
/* v8 ignore stop */
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
if (error instanceof FsError) throw error
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
@@ -160,7 +182,9 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
if (info.isFile()) return 'file'
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
if (info.isDirectory()) return 'directory'
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
return 'other'
}
@@ -224,6 +248,7 @@ function listingIoError(displayPath: string, error: unknown): FsError {
if (error instanceof FsError) return error
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
@@ -394,9 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
/**
* Atomically replace a file through a private, synced staging file in the same directory.
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
* onto the empty temp before writing and preserves the target descriptor at publication.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - final mode, or `0o600` when omitted.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
@@ -416,6 +445,9 @@ export async function writeFileAtomic(
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -425,6 +457,9 @@ export async function writeFileAtomic(
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath)
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
@@ -433,7 +468,18 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during
// staging: the temp already carries that target's protected DACL, so rename recreates it.
if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath)
}
} else {
await rename(tempPath, absolutePath)
}
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */

View File

@@ -0,0 +1,134 @@
/**
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
* non-Windows processes never open Win32 libraries.
* @module @deepseek-ai/dsh-fs-local/win32
*/
import { toNamespacedPath } from 'node:path'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
type GetLastError = () => number
interface Win32Bindings {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
}
const DACL_SECURITY_INFORMATION = 0x00000004
const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
let bindings: Win32Bindings | undefined
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const advapi32 = koffi.load('advapi32.dll')
const kernel32 = koffi.load('kernel32.dll')
bindings = {
getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW,
setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW,
replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW,
getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.win32Code = win32Code
return error
}
/**
* Read a file's self-relative DACL security descriptor.
* @param path - existing file whose DACL is read.
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
*/
export async function readFileDaclWin32(path: string): Promise<Buffer> {
const api = await win32()
const nativePath = toNamespacedPath(path)
const needed: [number] = [0]
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed)
if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path)
const descriptor = Buffer.alloc(needed[0])
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) {
throw win32Error('GetFileSecurityW', api.getLastError(), path)
}
return descriptor.subarray(0, needed[0])
}
/**
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
* The destination must still be empty when confidentiality depends on this call.
* @param source - existing file whose DACL is copied.
* @param destination - existing file that receives the protected DACL.
*/
export async function copyFileDaclWin32(source: string, destination: string): Promise<void> {
const descriptor = await readFileDaclWin32(source)
const api = await win32()
const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0
if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) {
throw win32Error('SetFileSecurityW', api.getLastError(), destination)
}
}
/**
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
* @param replaced - existing destination file.
* @param replacement - closed staging file on the same volume.
*/
export async function replaceFileWin32(replaced: string, replacement: string): Promise<void> {
const api = await win32()
if (api.replaceFileW(
toNamespacedPath(replaced),
toNamespacedPath(replacement),
null,
0,
null,
null,
) === 0) {
throw win32Error('ReplaceFileW', api.getLastError(), replaced)
}
}

View File

@@ -6,7 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
@@ -23,6 +23,7 @@ import {
writeFileAtomic,
} from '../src/fsio.ts'
import type { LocalTarget } from '../src/fsio.ts'
import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string
@@ -367,24 +368,135 @@ describe('streamWholeText', () => {
})
})
// Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode
// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
const posixModes = process.platform !== 'win32'
function daclAcePolicy(descriptor: Buffer): string[] {
const daclOffset = descriptor.readUInt32LE(16)
if (daclOffset === 0) return []
const aceCount = descriptor.readUInt16LE(daclOffset + 4)
const policy: string[] = []
const seen = new Set<string>()
let offset = daclOffset + 8
for (let index = 0; index < aceCount; index++) {
const size = descriptor.readUInt16LE(offset + 2)
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
// INHERITED_ACE records provenance, not the entry's access policy.
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
const key = ace.toString('hex')
if (!seen.has(key)) {
seen.add(key)
policy.push(key)
}
offset += size
}
return policy
}
describe('writeFileAtomic — temp-file safety', () => {
it('writes through a private staging dir and owner-only temp file', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
if (posixModes) await chmod(file, 0o640)
let inspected = false
await writeFileAtomic(file, 'hello', 0o640, undefined, {
inspectTemp: async ({ stagingDir, tempPath }) => {
inspected = true
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)])
expect(staging.isDirectory()).toBe(true)
expect(temp.isFile()).toBe(true)
if (posixModes) {
expect(staging.mode & 0o777).toBe(0o700)
expect(temp.mode & 0o777).toBe(0o600)
}
},
})
expect(inspected).toBe(true)
expect(await readFile(file, 'utf8')).toBe('hello')
expect((await stat(file)).mode & 0o777).toBe(0o640)
if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640)
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
})
it('creates new files owner-only by default', async () => {
it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => {
const file = join(dir, 'protected.txt')
await writeFile(file, 'old')
await copyFileDaclWin32(file, file)
const expectedDacl = await readFileDaclWin32(file)
await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, {
inspectTemp: async ({ tempPath }) => {
expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl)
},
})
expect(await readFile(file, 'utf8')).toBe('new')
expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
})
it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const calls: string[] = []
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: async (source, temp) => {
calls.push(`copy:${source}`)
expect(await readFile(temp, 'utf8')).toBe('')
},
replaceFile: async (target, temp) => {
calls.push(`replace:${target}`)
await rename(temp, target)
},
})
expect(calls).toEqual([`copy:${file}`, `replace:${file}`])
expect(await readFile(file, 'utf8')).toBe('new')
})
it('creates a new Windows file through directory inheritance without replacement calls', async () => {
const file = join(dir, 'new.txt')
const unexpected = async (): Promise<void> => { throw new Error('unexpected native replacement call') }
await writeFileAtomic(file, 'new', undefined, undefined, {
platform: 'win32',
copyFileDacl: unexpected,
replaceFile: unexpected,
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('recreates a vanished Windows target with the already-protected temp', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' })
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw missing },
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' })
await expect(writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw denied },
})).rejects.toBe(denied)
expect(await readFile(file, 'utf8')).toBe('old')
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hello', undefined, undefined)
expect((await stat(file)).mode & 0o777).toBe(0o600)

View File

@@ -0,0 +1,146 @@
/** Host-independent binding tests for the Win32 DACL and replacement helpers. */
import { toNamespacedPath } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
interface NativeMock {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: () => number
}
async function importWithNative(native: NativeMock): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (definition: string) => {
if (definition.includes('GetFileSecurityW')) return native.getFileSecurityW
if (definition.includes('SetFileSecurityW')) return native.setFileSecurityW
if (definition.includes('ReplaceFileW')) return native.replaceFileW
if (definition.includes('GetLastError')) return native.getLastError
throw new Error(`unexpected native function: ${definition}`)
},
}),
},
}))
return import('../src/win32.ts')
}
function successfulNative(descriptor: Buffer): NativeMock & { installed: Buffer[]; replacements: string[][] } {
let lastError = 0
const installed: Buffer[] = []
const replacements: string[][] = []
return {
installed,
replacements,
getLastError: () => lastError,
getFileSecurityW: (_path, _requested, output, _length, needed) => {
needed[0] = descriptor.length
if (output === null) {
lastError = 122
return 0
}
descriptor.copy(output)
lastError = 0
return 1
},
setFileSecurityW: (_path, information, value) => {
expect(information).toBe(0x80000004)
installed.push(Buffer.from(value))
lastError = 0
return 1
},
replaceFileW: (replaced, replacement, backup, flags, exclude, reserved) => {
expect([backup, flags, exclude, reserved]).toEqual([null, 0, null, null])
replacements.push([replaced, replacement])
lastError = 0
return 1
},
}
}
afterEach(() => {
vi.doUnmock('koffi')
vi.resetModules()
})
describe('Windows file-security helpers', () => {
it('reads and installs a protected DACL before replacing the destination', async () => {
const descriptor = Buffer.from([1, 2, 3, 4])
const native = successfulNative(descriptor)
const { copyFileDaclWin32, readFileDaclWin32, replaceFileWin32 } = await importWithNative(native)
expect(await readFileDaclWin32('source')).toEqual(descriptor)
await copyFileDaclWin32('source', 'temp')
expect(native.installed).toEqual([descriptor])
await replaceFileWin32('target', 'temp')
expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]])
})
it('maps descriptor-size probe failures to Node-style codes', async () => {
const cases = [[2, 'ENOENT'], [3, 'ENOENT'], [5, 'EACCES'], [9999, 'EIO']] as const
for (const [win32Code, code] of cases) {
const native = successfulNative(Buffer.from([1]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 0
return 0
}
native.getLastError = () => win32Code
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code, win32Code, path: 'source' })
}
})
it('surfaces a descriptor read failure after the size probe', async () => {
const native = successfulNative(Buffer.from([1, 2]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 2
return 0
}
native.getLastError = () => 5
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code: 'EACCES', syscall: 'GetFileSecurityW' })
})
it('surfaces DACL installation and replacement failures', async () => {
const setFailure = successfulNative(Buffer.from([1]))
setFailure.setFileSecurityW = () => 0
setFailure.getLastError = () => 5
const setModule = await importWithNative(setFailure)
await expect(setModule.copyFileDaclWin32('source', 'temp')).rejects.toMatchObject({
code: 'EACCES',
syscall: 'SetFileSecurityW',
path: 'temp',
})
const replaceFailure = successfulNative(Buffer.from([1]))
replaceFailure.replaceFileW = () => 0
replaceFailure.getLastError = () => 2
const replaceModule = await importWithNative(replaceFailure)
await expect(replaceModule.replaceFileWin32('target', 'temp')).rejects.toMatchObject({
code: 'ENOENT',
syscall: 'ReplaceFileW',
path: 'target',
})
})
})

View File

@@ -9,14 +9,14 @@ Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../.
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
- `danger-full-access` — delegates unfenced.
## Threat model: a policy fence, not a kernel boundary
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](../../../.agents/notes/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 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
## Model Experience

View File

@@ -0,0 +1,76 @@
/**
* Path-containment mechanics for the filesystem sandbox. Canonical spellings
* take the fast lexical path; filesystem identity supplies the conservative
* fallback for alias-equivalent roots such as Windows 8.3 names and casing.
* @module @deepseek-ai/dsh-fs-sandbox/containment
*/
import type { BigIntStats } from 'node:fs'
import { stat } from 'node:fs/promises'
import { dirname, sep } from 'node:path'
const MISSING_CODES: ReadonlySet<NodeJS.ErrnoException['code']> = new Set(['ENOENT', 'ENOTDIR'])
function isMissing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code
return MISSING_CODES.has(code)
}
function comparablePath(path: string, caseSensitive: boolean): string {
return caseSensitive ? path : path.toLowerCase()
}
function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean {
const comparableTarget = comparablePath(path, caseSensitive)
const comparableRoot = comparablePath(root, caseSensitive)
if (comparableTarget === comparableRoot) return true
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep
return comparableTarget.startsWith(prefix)
}
async function statIfPresent(path: string): Promise<BigIntStats | undefined> {
try {
return await stat(path, { bigint: true })
} catch (error: unknown) {
/* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */
if (isMissing(error)) return undefined
/* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */
throw error
}
}
function sameIdentity(left: BigIntStats, right: BigIntStats): boolean {
return left.dev === right.dev && left.ino === right.ino
}
/**
* Determine whether a canonical target is a writable root or lies beneath it.
* The lexical fast path handles normal canonical spellings. When spellings
* differ, walk the target's existing ancestors and compare filesystem identity
* with the root; this recognizes Windows long-name/8.3 aliases and casing
* without weakening containment to a textual approximation.
* @param path - canonical target key, which may end in a missing suffix.
* @param root - canonical writable root.
* @param caseSensitive - whether lexical comparison preserves case; defaults
* to the host filesystem convention used by supported platforms.
* @returns whether the target is the root or a descendant of it.
*/
export async function isPathUnder(
path: string,
root: string,
caseSensitive = process.platform !== 'win32',
): Promise<boolean> {
if (isLexicallyUnder(path, root, caseSensitive)) return true
const rootInfo = await statIfPresent(root)
if (!rootInfo) return false
let ancestor = path
while (true) {
const ancestorInfo = await statIfPresent(ancestor)
if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true
const parent = dirname(ancestor)
if (parent === ancestor) return false
ancestor = parent
}
}

View File

@@ -30,7 +30,6 @@
* @module @deepseek-ai/dsh-fs-sandbox
*/
import { sep } from 'node:path'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
@@ -39,6 +38,7 @@ import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent,
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { isPathUnder } from './containment.ts'
/**
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
@@ -48,13 +48,6 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
*/
export type Config = LocalConfig
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
function isUnder(path: string, root: string): boolean {
if (path === root) return true
const prefix = root.endsWith(sep) ? root : root + sep
return path.startsWith(prefix)
}
/**
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
@@ -147,7 +140,14 @@ export class SandboxedFileSystem extends LocalFileSystem {
// symlink ancestor swapped since the tool resolved this target), and the
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
let contained = false
for (const root of this.writableRoots) {
if (await isPathUnder(fresh.targetKey, root)) {
contained = true
break
}
}
if (!contained) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh

View File

@@ -0,0 +1,57 @@
/**
* Containment tests for lexical canonical paths and filesystem-identity aliases.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, parse } from 'node:path'
import { isPathUnder } from '../src/containment.ts'
let base: string
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), 'dsh-fssbx-containment-'))
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
describe('filesystem sandbox containment', () => {
it('accepts equal paths, descendants, and a filesystem-root boundary', async () => {
expect(await isPathUnder(base, base)).toBe(true)
expect(await isPathUnder(join(base, 'child'), base)).toBe(true)
expect(await isPathUnder(base, parse(base).root)).toBe(true)
})
it('uses case-insensitive lexical comparison for Windows-style containment', async () => {
expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true)
expect(await isPathUnder(join(base, 'case-sensitive-child'), base, true)).toBe(true)
})
it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => {
const realRoot = join(base, 'real')
const aliasRoot = join(base, 'alias')
await mkdir(realRoot)
await symlink(realRoot, aliasRoot)
expect(await isPathUnder(join(await realpath(realRoot), 'missing', 'file.txt'), aliasRoot)).toBe(true)
})
it('denies unrelated and missing roots', async () => {
const allowed = join(base, 'allowed')
const outside = join(base, 'outside')
await mkdir(allowed)
await mkdir(outside)
expect(await isPathUnder(join(outside, 'file.txt'), allowed)).toBe(false)
expect(await isPathUnder(join(outside, 'file.txt'), join(base, 'missing-root'))).toBe(false)
})
it('treats a regular-file path segment as a missing target, not containment', async () => {
const allowed = join(base, 'allowed')
const blocker = join(base, 'blocker')
await mkdir(allowed)
await writeFile(blocker, 'not a directory')
expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false)
})
})

View File

@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, parse } from 'node:path'
import { Context } from 'cordis'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
@@ -167,16 +167,15 @@ describe('workspace-write containment', () => {
})
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
// separator-suffixed-root branch: `/` already ends in the separator, so the
// prefix stays `/` and every absolute path is contained.
it('grants writes anywhere on that volume', async () => {
// A degenerate but valid config: the filesystem root containing the target.
// It exercises the separator-suffixed-root branch on POSIX and Windows.
const rootCtx = new Context()
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root })
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
const rootFs = rootCtx.fs as SandboxedFileSystem
try {
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
const path = join(base, 'anywhere.txt') // under HOME, outside temp — allowed only via the filesystem root
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
expect(await readFile(path, 'utf8')).toBe('anywhere')
} finally {

View File

@@ -12,6 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
@@ -496,7 +497,7 @@ describe('glob results', () => {
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')
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
it('validates arguments (blank pattern, blank path)', async () => {
@@ -578,7 +579,7 @@ describe('grep results', () => {
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')
expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`)
})
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
@@ -688,7 +689,7 @@ describe('presentation', () => {
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/a/b.ts', '/w')).toBe(join('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')

View File

@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
## What it does
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration

View File

@@ -8,7 +8,7 @@
*/
import type { ChildProcessByStdio } from 'node:child_process'
import { spawn } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { encodeMessage, MessageDecoder } from './framing.ts'
@@ -36,6 +36,132 @@ interface Pending {
reject: (error: Error) => void
}
/**
* Write one JSON-RPC message to the child stdin.
* @param stdin - the spawned server stdin.
* @param message - the unencoded JSON-RPC message.
* @param done - callback that reports asynchronous stream settlement.
*/
export type ConnectionWriter = (
stdin: Writable,
message: unknown,
done: (error?: Error | null) => void,
) => void
/** Host operations used to signal a detached process tree. */
export interface ProcessTreeOperations {
/** Signal a POSIX process group. */
readonly signal: (target: number, signal: NodeJS.Signals) => void
/** Signal the direct child when POSIX group signaling is unavailable. */
readonly killChild: (signal: NodeJS.Signals) => void
/** Terminate a Windows process tree by root pid. */
readonly taskkill: (pid: number) => void
}
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
export interface TaskkillResult {
/** Process exit status, or null when spawning failed. */
readonly status: number | null
/** Spawn failure, when the executable could not run. */
readonly error?: Error
}
/** Invoke a command synchronously for the Windows taskkill adapter. */
export type TaskkillRunner = (
command: string,
args: string[],
options: { stdio: 'ignore' },
) => TaskkillResult
/** Invoke the host process-signal primitive for a POSIX process group. */
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
/** taskkill status for "process not found": the requested process tree is already absent. */
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
/**
* Terminate one Windows process tree and wait for taskkill to finish.
* @param pid - root process id.
* @param run - command runner; tests inject results without requiring Windows.
*/
export function taskkillProcessTree(
pid: number,
run: TaskkillRunner = spawnSync,
): void {
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
if (result.error !== undefined) throw result.error
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
}
/**
* Signal one POSIX process group through an injectable host primitive.
* @param target - negative process-group id.
* @param signal - requested signal.
* @param run - host signal runner; tests inject it without touching real processes.
*/
export function signalProcessGroup(
target: number,
signal: NodeJS.Signals,
run: ProcessSignalRunner = processSignalRunner,
): void {
run(target, signal)
}
/**
* Wait until a process-tree liveness probe reports exit.
* @param isAlive - process-tree liveness probe.
* @param signal - optional bound for the wait.
* @param yieldNow - event-loop yield primitive.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
export async function waitForTreeExit(
isAlive: () => boolean,
signal?: AbortSignal,
yieldNow: () => Promise<unknown> = yieldToEventLoop,
): Promise<boolean> {
while (isAlive()) {
if (signal?.aborted) return false
await yieldNow()
}
return true
}
/**
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
* child; Windows requires taskkill to reach the full tree.
* @param platform - host platform.
* @param pid - detached root process id.
* @param signal - requested termination signal.
* @param operations - host operations.
*/
export function signalProcessTree(
platform: NodeJS.Platform,
pid: number,
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
operations.signal(-pid, signal)
} catch {
try {
operations.killChild(signal)
} catch {
// The direct child already exited; teardown remains idempotent.
}
}
}
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
@@ -50,14 +176,16 @@ export class LspConnection {
/**
* @param spec - how to launch the server and answer its config requests.
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
private readonly spec: ConnectionSpec,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
this.child = spawn(spec.command, [...spec.args], {
cwd: spec.cwd,
env: spec.env,
@@ -94,6 +222,20 @@ export class LspConnection {
return this.stderr.toString('utf8')
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
get failed(): boolean {
return this.closeReason !== undefined
}
/**
* Test whether a caught error is this connection's retained fatal transport cause.
* @param error - error caught by the instance or provider.
* @returns `true` only when this connection produced that exact failure.
*/
failedWith(error: unknown): boolean {
return this.closeReason === error
}
/**
* Send a request and await its result.
* @param method - the JSON-RPC method.
@@ -147,50 +289,38 @@ export class LspConnection {
return this.nextId
}
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
/** Request termination of the server's process tree. */
terminate(): void {
this.signalGroup('SIGTERM')
this.signalTree('SIGTERM')
}
/** Send SIGKILL to the server's process group. */
/** Force termination of the server's process tree. */
kill(): void {
this.signalGroup('SIGKILL')
this.signalTree('SIGKILL')
}
/**
* Wait until the owned process group has no members.
* Wait until the owned process tree has exited.
* @param signal - optional bound for the wait.
* @returns `true` when the group exited, or `false` when the signal aborted first.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
while (this.processGroupAlive()) {
if (signal?.aborted) return false
await yieldToEventLoop()
}
return true
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
}
/**
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
* direct child if the group send fails. Never throws — teardown races process exit.
*/
private signalGroup(sig: NodeJS.Signals): void {
/** Signal the whole process tree. */
private signalTree(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
try {
process.kill(-pid, sig)
} catch {
// The group is gone (already exited) or could not be signalled; try the direct child.
try {
this.child.kill(sig)
} catch {
// Already dead; nothing to signal.
}
}
signalProcessTree(process.platform, pid, sig, {
signal: signalProcessGroup,
killChild: this.child.kill.bind(this.child),
taskkill: taskkillProcessTree,
})
}
/** Whether the detached process group still has at least one member. */
private processGroupAlive(): boolean {
/** Whether the detached tree's root or POSIX process group is still alive. */
private processTreeAlive(): boolean {
const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false
@@ -218,7 +348,7 @@ export class LspConnection {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error))
this.signalGroup('SIGKILL')
this.signalTree('SIGKILL')
return
}
for (const message of messages) this.dispatch(message)
@@ -293,7 +423,7 @@ export class LspConnection {
reject(error)
}
try {
this.child.stdin.write(encodeMessage(message), done)
this.writer(this.child.stdin, message, done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {

View File

@@ -2,9 +2,9 @@
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace realpath, serves transient-open queries
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no
* sandbox confinement.
* through it, and replaces a selected transport that fails before or during the next read-only
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* and trust their configured servers — no sandbox confinement.
*
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
* unregisters from `ctx.lsp` and tears down every live server.
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = this.instanceFor(workspace)
}
try {
return await instance.query(request, source, signal)
} catch (error) {
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
if (instance.dead) this.evictIfCurrent(workspace, instance)
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
}
}
})
}

View File

@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec } from './connection.ts'
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
readonly killGraceMs: number
}
/**
* Force-kill a process tree only when graceful termination did not make it exit.
* @param treeExited - whether the tree exited within its grace period.
* @param forceKill - forceful process-tree termination primitive.
*/
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
if (!treeExited) forceKill()
}
/**
* A single initialized server process. Not exported as a provider — the provider single-flights and
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
@@ -58,9 +67,10 @@ export class LspInstance {
/**
* @param spec - the launch, initialize, and teardown parameters.
* @param writer - optional connection writer used by transport conformance tests.
*/
constructor(private readonly spec: InstanceSpec) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
@@ -70,7 +80,16 @@ export class LspInstance {
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
get dead(): boolean {
return this.processClosed || this.disposed
return this.processClosed || this.disposed || this.connection.failed
}
/**
* Test whether a caught query error came from this instance's transport.
* @param error - error caught by the provider.
* @returns `true` only for the connection's retained fatal transport cause.
*/
isTransportFailure(error: unknown): boolean {
return this.connection.failedWith(error)
}
/**
@@ -84,7 +103,12 @@ export class LspInstance {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
const run = abortable(this.queue, signal)
.then(() => this.runQuery(request, source, signal))
.catch(async (error: unknown) => {
if (this.isTransportFailure(error)) await this.startTeardown()
throw error
})
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
// on the wait does not deserialize the queue.
@@ -272,7 +296,7 @@ export class LspInstance {
try {
await this.gracefulShutdown(shutdownDeadline.signal)
} catch {
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
} finally {
shutdownDeadline[Symbol.dispose]()
}
@@ -286,20 +310,20 @@ export class LspInstance {
await abortable(this.connection.closed, signal)
}
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let groupExited: boolean
let treeExited: boolean
try {
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
if (!groupExited) this.connection.kill()
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([
this.connection.closed,
this.connection.waitForProcessGroupExit(),
this.connection.waitForProcessTreeExit(),
])
}
}

View File

@@ -1,6 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import {
signalProcessGroup,
signalProcessTree,
taskkillProcessTree,
waitForTreeExit,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type {
ConnectionWriter,
ProcessSignalRunner,
ProcessTreeOperations,
TaskkillRunner,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
})
it('treats signaling an already-closed child as a teardown race', async () => {
const conn = connectScript('')
await conn.closed
expect(() => { conn.kill() }).not.toThrow()
})
it('answers a server workspace/configuration request from static config', async () => {
const seen: SeenRequest[] = []
const conn = connect(
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
})
/** Spawn a raw connection running an inline node script as the "server". */
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: ['-e', script],
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
maxMessageBytes: 16_000_000,
maxStderrBytes,
configuration: null,
}, () => Promise.resolve(null))
}, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
})
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
const conn = connectScript('require("node:fs").closeSync(0); process.stderr.write("stdin closed"); setInterval(()=>{}, 1000)')
await waitFor(() => conn.stderrTail === 'stdin closed')
const timeout = new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
})
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
const failure = new Error('fixture stdin failure')
const writer: ConnectionWriter = (_stdin, _message, done) => {
queueMicrotask(() => { done(failure) })
}
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
})
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
@@ -230,6 +248,72 @@ describe('LspConnection edge behavior', () => {
})
})
describe('process-tree signaling', () => {
it('forwards POSIX process-group signals through the host runner', () => {
const run: ProcessSignalRunner = vi.fn(() => true)
signalProcessGroup(-42, 'SIGKILL', run)
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('waits for tree exit and stops when its bound aborts', async () => {
const isAlive = vi.fn()
.mockReturnValueOnce(true)
.mockReturnValue(false)
const yieldNow = vi.fn(() => Promise.resolve())
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
expect(yieldNow).toHaveBeenCalledOnce()
const controller = new AbortController()
controller.abort()
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
})
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
const operations = fakeProcessTreeOperations()
signalProcessTree('win32', 42, 'SIGTERM', operations)
expect(operations.taskkill).toHaveBeenCalledWith(42)
expect(operations.signal).not.toHaveBeenCalled()
signalProcessTree('linux', 42, 'SIGKILL', operations)
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
const fallback = fakeProcessTreeOperations()
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
expect(fallback.killChild).not.toHaveBeenCalled()
})
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
const posixGone = fakeProcessTreeOperations()
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
})
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
taskkillProcessTree(42, success)
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
const spawnFailure = new Error('cannot spawn taskkill')
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
})
})
/** Create observable process-tree operations without touching host processes. */
function fakeProcessTreeOperations(): ProcessTreeOperations {
return {
signal: vi.fn(),
killChild: vi.fn(),
taskkill: vi.fn(),
}
}
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()

View File

@@ -16,8 +16,6 @@
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
@@ -28,7 +26,7 @@
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
*/
import { appendFileSync, closeSync } from 'node:fs'
import { appendFileSync } from 'node:fs'
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
@@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
@@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (method === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause()
if (closeStdinAfterInitialized) closeSync(0)
return
}
if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) {
if (hang) return
const reply = (): void => {
if (closeStdinAfterReply) closeSync(0)
if (errorReply) {
send({ id, error: { code: -32000, message: 'server refused the request' } })
} else {
@@ -202,6 +196,6 @@ function send(message: Record<string, unknown>): void {
// Keep the event loop alive.
process.stdin.resume()
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
if (pauseStdinAfterInitialized) {
setInterval(() => {}, 1000)
}

View File

@@ -92,7 +92,8 @@ describe('readHostSource', () => {
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
})
it('rejects a FIFO with no writer without blocking in open', async () => {
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')

View File

@@ -1,9 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
@@ -26,7 +29,11 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
function makeInstance(
env: Record<string, string> = {},
overrides: Partial<InstanceSpec> = {},
writer?: ConnectionWriter,
): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: [fixtureServer],
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
})
}, writer)
live.push(instance)
return instance
}
@@ -201,17 +208,25 @@ describe('LspInstance query and abort', () => {
})
it('terminates when stdin fails during the didOpen write', async () => {
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
// the instance must still become dead so its provider can replace it.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
}, failingWriter('textDocument/didOpen'))
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true)
})
it('awaits process exit before rejecting a request write failure', async () => {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
}, failingWriter('textDocument/definition'))
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
expect(processAlive(pid)).toBe(false)
})
it('rejects when the server lacks the operation capability', async () => {
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
@@ -228,8 +243,7 @@ describe('LspInstance query and abort', () => {
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
})
describe('LspInstance disposal', () => {
it('escalates only when the process tree survives its grace period', () => {
const forceKill = vi.fn()
escalateProcessTree(false, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
escalateProcessTree(true, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
})
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
const marker = join(root, 'helper.pid')
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
await first
} finally {
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
await waitForProcessExit(helperPid)
}
})
@@ -322,6 +345,26 @@ function processAlive(pid: number): boolean {
}
}
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
const started = Date.now()
while (processAlive(pid)) {
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Write normally except for one method whose callback receives a deterministic transport error. */
function failingWriter(method: string): ConnectionWriter {
return (stdin, message, done) => {
if ((message as { method?: unknown }).method === method) {
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
return
}
stdin.write(encodeMessage(message), done)
}
}
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now()

View File

@@ -137,9 +137,15 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose()
})
it('rejects a non-utf-16 position encoding at initialize', async () => {
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
const marker = join(root, 'initialize-rejection-exit.log')
const ctx = await mount({
LSP_FAKE_ENCODING: 'utf-8',
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_MARKER: marker,
})
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
await ctx.fiber.dispose()
})

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => {
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
env: { PATH: `::${join(root, 'empty')}` },
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose()
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
await ctx.fiber.dispose()
})
it('rejects an absolute command that is not executable at load', async () => {
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
const notExe = join(root, 'not-exe.txt')
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
@@ -13,7 +13,7 @@ import {
} from '@deepseek-ai/dsh-tool-lsp'
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = '/home/u/proj'
const WS = resolve('/home/u/proj')
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
@@ -52,8 +52,9 @@ describe('renderUri', () => {
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const uri = pathToFileURL('/other/lib/b.ts').href
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
const uri = pathToFileURL(outside).href
expect(renderUri(uri, WS)).toBe(outside)
})
it('renders the workspace root itself as "."', () => {
@@ -72,8 +73,8 @@ describe('renderUri', () => {
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
// An encoded path separator is invalid on every platform and must remain verbatim.
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
})
})

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -40,8 +42,11 @@ async function mount(
let seq = 0
const testToolSignal = new AbortController().signal
const workspaceRoot = resolve('/virtual/workspace')
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
const workspaceAlias = resolve('/virtual/workspace-alias')
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
return ctx.tools.execute({
signal: testToolSignal,
callId: `c-${++seq}` as never,
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/ws',
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: workspaceRoot,
}
describe('tool-lsp registration', () => {
@@ -107,40 +112,39 @@ describe('tool-lsp execution', () => {
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
const provider = stubProvider(() => okLocations)
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'goToDefinition',
filePath: 'a.ts',
position: { line: 2, character: 4 },
workspaceRoot: '/ws',
workspaceRoot,
})
})
it('renders locations relative to the workspace', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
// Relativizing against the alias would misclassify the location as external.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot,
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('renders hover content', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
})
@@ -153,14 +157,14 @@ describe('tool-lsp execution', () => {
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
})
it('returns a structured INVALID_ARGS on a bad operation', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('INVALID_ARGS')
})
@@ -176,7 +180,7 @@ describe('tool-lsp execution', () => {
},
}
const { ctx } = await mount(provider)
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
// undefined); the point is the tool threads it through without throwing.
expect(seen).toHaveLength(1)

View File

@@ -325,13 +325,19 @@ describe('probeTimeoutMs config', () => {
})
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
// The same sleeping launcher passes under the default 5000ms budget and
// fails under a 250ms one — the config demonstrably reaches spawnSync.
// The same 1s launcher reads usable under a generous budget and unusable
// under a 250ms one — the config demonstrably reaches spawnSync. Both bounds
// keep a wide margin from the launcher's 1s runtime so a loaded host (where
// spawnSync blocks the worker and fork/exec latency inflates wall-clock)
// cannot flip either verdict; the vitest timeout clears the patient budget.
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
const patient = await setup(
{ probeTimeoutMs: 15_000 },
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
)
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
const impatient = await setup(
@@ -339,7 +345,7 @@ describe('probeTimeoutMs config', () => {
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
)
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
}, 30_000)
})
describe('the default seatbelt probe (sandbox-exec contract)', () => {

View File

@@ -120,6 +120,7 @@ declare module 'cordis' {
* skipped for a sole candidate, whose own refusal remains the fail-closed end.
*/
export abstract class SandboxProvider extends Service {
/* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */
constructor(ctx: Context) {
super(ctx, 'sandbox')
}

View File

@@ -71,7 +71,7 @@ class RecordingPort implements PromptPort {
}
}
describe('create-sdk terminal contract', () => {
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
it('renders package-manager-specific setup commands', () => {
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')

View File

@@ -25,7 +25,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('globalConfigDir', () => {
it('prefers an explicit DSH_HOME override', () => {
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh'))
})
it('falls back to ~/.dsh when DSH_HOME is unset', () => {

View File

@@ -31,7 +31,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
@@ -62,5 +62,4 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.
- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive.
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.

View File

@@ -33,6 +33,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -8,7 +8,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
@@ -21,6 +21,7 @@ import {
type JsonlCompression,
} from './format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
export type { JsonlCompression } from './format.ts'
@@ -81,9 +82,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
private coordinator: PersistenceCoordinator<JsonlTornMarker>
private rootEncodingCheck: Promise<void> | undefined
/** Runtime host platform used to decide whether directory sync is supported. */
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
constructor(ctx: Context, public config: Config) {
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
@@ -254,32 +252,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// --- materialization / append / repair (file mechanics) ---
/** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */
/** Atomically write the header line + first batch (temp-write, fsync, publish). */
private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
const dir = sessionDir(this.root, meta.cwd)
await mkdir(this.root, { recursive: true, mode: 0o700 })
await this.syncDir(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDir(this.root)
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
// Materialization is the first write; an existing log is an id collision.
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`)
}
await this.rejectOppositeArtifact(meta.cwd, meta.id)
const content = await this.encodeMaterialization(meta, events)
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
const handle = await open(tmp, 'wx', 0o600)
try {
await handle.writeFile(content)
await handle.sync()
} finally {
await handle.close()
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
if (process.platform === 'win32') {
await this.materializeWin32(dir, finalPath, meta.id, content)
} else {
await this.materializePosix(dir, finalPath, meta.id, content)
}
// Publish with link()+unlink(): unlike rename(), link fails if another
// process materialized the same id first.
}
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
private async materializePosix(
dir: string,
finalPath: string,
id: SessionId,
content: Buffer | string,
): Promise<void> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
await this.syncDirPosix(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDirPosix(this.root)
await this.rejectExistingLog(finalPath, id)
const tmp = await this.writeSyncedTempFile(finalPath, content)
// Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
// final path already exists, so two processes materializing the same id
// concurrently cannot clobber each other. rename() would silently overwrite.
let linked = false
try {
await link(tmp, finalPath)
@@ -290,16 +292,64 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
if (!linked) await rm(tmp, { force: true })
}
// The published link becomes crash-durable only after its directory fsync.
await this.syncDir(dir)
// Best-effort temp cleanup: the log is already published and durable, so a failure to
// remove the (now-redundant) temp hard link must not reject the append.
// link() succeeded — the log is published. fsync the directory so the new
// entry survives a power loss: the new link is not crash-durable until the
// parent directory's metadata is synced.
await this.syncDirPosix(dir)
// Best-effort temp cleanup: the log is already published and durable, so a
// failure to remove the (now-redundant) temp hard link must NOT reject the
// append. Swallow only the rm failure; nothing else of consequence runs here.
try {
await rm(tmp, { force: true })
} catch {
/* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */
}
}
/* v8 ignore stop */
/* v8 ignore start -- native Windows coverage exercises this integration path */
private async materializeWin32(
dir: string,
finalPath: string,
id: SessionId,
content: Buffer | string,
): Promise<void> {
await ensureDurableDirectoryWin32(this.root)
await ensureDurableDirectoryWin32(dir)
await this.rejectExistingLog(finalPath, id)
const tmp = await this.writeSyncedTempFile(finalPath, content)
try {
await publishNewFileWin32(tmp, finalPath)
} catch (error) {
await rm(tmp, { force: true })
throw error
}
}
/* v8 ignore stop */
private async rejectExistingLog(finalPath: string, id: SessionId): Promise<void> {
// Never publish over an existing committed log: materialize is the first
// write of a session the backend believes is new. A file here means a
// different session shares this id on disk — reject loudly. (createCore
// already guards the create path, so this is unreachable-in-practice TOCTOU
// defense.)
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`)
}
}
private async writeSyncedTempFile(finalPath: string, content: Buffer | string): Promise<string> {
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
const handle = await open(tmp, 'wx', 0o600)
try {
await handle.writeFile(content)
await handle.sync()
} finally {
await handle.close()
}
return tmp
}
/** Encode the header and first batch without combining their frame boundaries. */
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
@@ -317,22 +367,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.compression === 'zstd' ? compressZstdFrame(body) : body
}
/** fsync a directory when the host exposes that durability primitive. */
private async syncDir(dir: string): Promise<void> {
/** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
/* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
private async syncDirPosix(dir: string): Promise<void> {
const handle = await open(dir, 'r')
try {
try {
await handle.sync()
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code
// Node opens directories on Windows but its fsync binding rejects them.
// File-content fsync remains mandatory; only this unsupported primitive is skipped.
if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error
}
await handle.sync()
} finally {
await handle.close()
}
}
/* v8 ignore stop */
/**
* Append and fsync event lines. On a partial write or sync failure, restore the
@@ -343,17 +388,37 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
const content = await this.encodeEventBatch(events)
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
const handle = await open(path, 'a')
let closed = false
const closeAppendHandle = async (): Promise<void> => {
if (closed) return
closed = true
await handle.close()
}
try {
const { size: before } = await handle.stat()
try {
await handle.writeFile(content)
await handle.sync()
} catch (error) {
// Roll back whatever bytes landed so a retry starts from a clean EOF.
await handle.truncate(before)
await handle.sync()
try {
await closeAppendHandle()
await this.rollbackAppend(path, before)
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`)
}
throw error
}
} finally {
await closeAppendHandle()
}
}
private async rollbackAppend(path: string, size: number): Promise<void> {
const handle = await open(path, 'r+')
try {
await handle.truncate(size)
await handle.sync()
} finally {
await handle.close()
}
@@ -505,13 +570,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await handle.close()
return true
} catch (error) {
// Only ENOENT means absent. A permission/I/O error must surface, not be
// collapsed to `false` — otherwise load() reports "not found" and collision
// checks proceed under a false absence assumption.
if (isENOENT(error)) return false
// Only ENOENT means absent. A permission/I/O error must surface rather
// than letting load or collision checks proceed under false absence.
// Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
// the immediate parent so a blocked cwd bucket remains a storage fault.
/* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
if (isENOENT(error)) {
await this.assertLogParentAllowsAbsence(path)
return false
}
/* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
throw error
}
}
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
private async assertLogParentAllowsAbsence(path: string): Promise<void> {
try {
const parent = dirname(path)
const info = await fsStat(parent)
if (info.isDirectory()) return
const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
error.path = parent
throw error
} catch (error) {
if (isENOENT(error)) return
throw error
}
}
/* v8 ignore stop */
}
export default SessionPersistenceJsonl

View File

@@ -0,0 +1,150 @@
/**
* Windows durable namespace helpers for the JSONL backend.
*
* POSIX publishes a newly-created log by creating a directory entry and then
* fsyncing the parent directory. Windows does not expose that parent-directory
* fsync contract through Node, so the Windows path uses the native durable
* namespace primitive instead: create a staging object in the target directory
* and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
* replacement or cross-volume copy fallback.
*
* @module dsh-session-persistence-jsonl/win32
*/
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
type GetLastError = () => number
interface Win32Bindings {
moveFileExW: MoveFileExW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
dest: string
}
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
let bindings: Win32Bindings | undefined
/** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const kernel32 = koffi.load('kernel32.dll')
bindings = {
moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
case ERROR_NOT_SAME_DEVICE:
return 'EXDEV'
case ERROR_FILE_EXISTS:
case ERROR_ALREADY_EXISTS:
return 'EEXIST'
case ERROR_INVALID_NAME:
return 'EINVAL'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.dest = dest
error.win32Code = win32Code
return error
}
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
async function assertDirectory(path: string): Promise<boolean> {
try {
const info = await stat(path)
if (info.isDirectory()) return true
const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
error.path = path
throw error
} catch (error) {
if (isENOENT(error)) return false
throw error
}
}
/**
* Publish `existing` at `replacement` with Windows write-through rename
* semantics. The destination must not already exist; the move must stay within
* the volume (no copy fallback flag is set).
* @param existing - the synced staging path to move.
* @param replacement - the final path, which must not already exist.
*/
export async function publishNewFileWin32(existing: string, replacement: string): Promise<void> {
const api = await win32()
const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH)
if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
}
/**
* Create `target` and its missing ancestors with durable Windows namespace
* publication. Each missing directory is first created as a random staging
* sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
* with another creator are accepted only after verifying the winner is a
* directory.
* @param target - the absolute directory path to create durably when absent.
*/
export async function ensureDurableDirectoryWin32(target: string): Promise<void> {
const absolute = resolve(target)
const root = parse(absolute).root
await assertDirectory(root)
const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0)
let current = root
for (const segment of segments) {
const next = join(current, segment)
if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next)
current = next
}
}
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
try {
await publishNewFileWin32(staging, target)
} catch (error) {
await rm(staging, { recursive: true, force: true })
if (isEEXIST(error) && await assertDirectory(target)) return
throw error
}
}

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -47,21 +46,6 @@ afterEach(async () => {
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function rejectDirectorySync(code: string): Promise<void> {
const handle = await open(root, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
if ((await this.stat()).isDirectory()) {
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
error.code = code
throw error
}
return realSync.call(this)
})
}
function appendClosedTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
@@ -358,26 +342,43 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
await rejectDirectorySync('EPERM')
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = 'win32'
const m = meta('windows-directory-sync')
it('reports both the append failure and a failed rollback', async () => {
const m = meta('rollback-failure')
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
})
await ctx.sessionPersistence.append(m.id, oneTurnLog())
it.each([
['linux', 'EPERM'],
['win32', 'EIO'],
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
await rejectDirectorySync(code)
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = platform
const m = meta(`directory-sync-${platform}-${code}`)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
const path = rawLogPath(root, undefined, m.id)
const handle = await (await import('node:fs/promises')).open(path, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
let failed = false
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
return realSync.call(this)
})
const backend = ctx.sessionPersistence as unknown as {
rollbackAppend: (path: string, size: number) => Promise<void>
}
const realRollback = backend.rollbackAppend.bind(backend)
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
try {
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
] as SessionEvent[])
throw new Error('expected append to reject')
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const aggregate = error as AggregateError
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
expect(aggregate.errors).toHaveLength(2)
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
} finally {
backend.rollbackAppend = realRollback
syncSpy.mockRestore()
}
})
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {

View File

@@ -0,0 +1,169 @@
/**
* Unit tests for the Windows durable namespace helper with a mocked kernel32
* binding. The real JSONL suite exercises the helper on native Windows; these
* tests keep the Win32 error mapping and race handling covered on every host.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number
const roots: string[] = []
function stripNamespace(path: string): string {
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length)
return path
}
async function tempRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-'))
roots.push(dir)
return dir
}
async function importWithMove(moveFileExW: MoveFileExW): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => {
let lastError = 0
const setLastError = (code: number): void => { lastError = code }
const move: MoveFileExW = (existing, replacement, flags, setError) => {
const ok = moveFileExW(existing, replacement, flags, setError)
lastError = ok === 0 ? lastError : 0
return ok
}
return {
default: {
load: () => ({
func: (_convention: string, name: string, result: string) => {
if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => {
expect(result).toBe('int')
const ok = move(existing, replacement, flags, setLastError)
return ok
}
return () => lastError
},
}),
},
}
})
return import('../src/win32.ts')
}
async function importWithError(code: number): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (_convention: string, name: string) => {
if (name === 'MoveFileExW') return () => 0
return () => code
},
}),
},
}))
return import('../src/win32.ts')
}
async function importWithFilesystemMove(): Promise<typeof import('../src/win32.ts')> {
return importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
}
afterEach(async () => {
vi.doUnmock('koffi')
vi.resetModules()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
describe('Windows durable namespace helpers', () => {
it('publishes a new file with write-through MoveFileExW semantics', async () => {
const { publishNewFileWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const tmp = join(root, 'log.tmp')
const final = join(root, 'log.jsonl')
await writeFile(tmp, 'content')
await publishNewFileWin32(tmp, final)
expect(existsSync(tmp)).toBe(false)
expect(readFileSync(final, 'utf8')).toBe('content')
})
it('maps Win32 publish failures to Node-style errno codes', async () => {
const cases = [
[ERROR_FILE_NOT_FOUND, 'ENOENT'],
[ERROR_PATH_NOT_FOUND, 'ENOENT'],
[ERROR_ACCESS_DENIED, 'EACCES'],
[ERROR_NOT_SAME_DEVICE, 'EXDEV'],
[ERROR_FILE_EXISTS, 'EEXIST'],
[ERROR_ALREADY_EXISTS, 'EEXIST'],
[ERROR_INVALID_NAME, 'EINVAL'],
[9999, 'EIO'],
] as const
for (const [win32Code, code] of cases) {
const { publishNewFileWin32 } = await importWithError(win32Code)
await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' })
}
})
it('creates missing directories through staging siblings and tolerates an already-created race', async () => {
const root = await tempRoot()
const raced = join(root, 'raced')
const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (to === raced) {
mkdirSync(to)
setLastError(ERROR_ALREADY_EXISTS)
return 0
}
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
expect(existsSync(join(root, 'a', 'b'))).toBe(true)
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
await ensureDurableDirectoryWin32(raced)
expect(existsSync(raced)).toBe(true)
})
it('surfaces directory publication failures other than an existing-target race', async () => {
const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
const root = await tempRoot()
await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' })
})
it('rejects a non-directory component instead of treating it as missing', async () => {
const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const blocked = join(root, 'blocked')
writeFileSync(blocked, 'x')
await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
})
})

View File

@@ -476,7 +476,9 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const walPath = await freshDbPath()
const bWal = await backend(walPath)
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
const probe = openDatabase(walPath, 'wal')
expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
probe.close()
await bWal.dispose()
const deletePath = await freshDbPath()

View File

@@ -316,7 +316,9 @@ async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean;
try {
const info = await stat(fullPath)
if (info.isDirectory()) return 'directory'
/* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */
if (info.isFile()) return 'file'
/* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */
return undefined
} catch (error) {
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)

View File

@@ -10,7 +10,7 @@ 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 { basename, dirname, isAbsolute, join, normalize } 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'
@@ -63,7 +63,8 @@ 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(dirname(dir)).toBe(normalize('/spill'))
expect(basename(dir)).toMatch(/^session-[0-9a-f]{12}$/)
expect(sessionDir('/spill', 'sess-2')).not.toBe(dir)
})
})
@@ -74,7 +75,7 @@ describe('saveTextFile', () => {
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$/)
expect(basename(saved.path)).toMatch(/^[0-9a-f]{12}-r\.txt$/)
})
it('sanitizes a traversal-shaped suggested name into one segment', async () => {
@@ -84,11 +85,16 @@ describe('saveTextFile', () => {
expect(saved.path.includes('/..')).toBe(false)
})
it('creates the session dir with owner-only permissions', async () => {
it('creates the session directory and file with owner-only POSIX 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)
const directory = statSync(dirname(saved.path))
const file = statSync(saved.path)
expect(directory.isDirectory()).toBe(true)
expect(file.isFile()).toBe(true)
if (process.platform !== 'win32') {
expect(directory.mode & 0o777).toBe(0o700)
expect(file.mode & 0o777).toBe(0o600)
}
})
it('gives distinct paths to two saves of the same name', async () => {

View File

@@ -12,7 +12,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented.
## Capabilities and context
@@ -28,8 +28,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th
| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
```yaml
- id: subagent-acp

View File

@@ -52,7 +52,7 @@ export interface Config {
* before the parent escalates to a signal.
*/
disposeEofGraceMs?: number
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
/** Termination confirmation window (ms), including forced exit on every platform. */
disposeGraceMs?: number
}

View File

@@ -60,9 +60,9 @@ export interface AcpRunSpec {
*/
disposeEofGraceMs: number
/**
* Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in
* {@link SubagentRun.dispose}. The plugin fills this from its
* `disposeGraceMs` config.
* Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
* `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
* fills this from its `disposeGraceMs` config.
*/
disposeGraceMs: number
/**
@@ -79,7 +79,7 @@ export interface AcpRunSpec {
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/**
@@ -304,9 +304,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
// The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces
// from stdin EOF, including the final flush, so this backend uses a wider
// EOF grace before signals escalate.
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
disposal = disposeProcess()
return disposal
},

View File

@@ -215,7 +215,8 @@ describe('cwd resolution', () => {
await ctx.fiber.dispose()
})
it('rejects a config cwd directory without search permission at load', async () => {
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
it.skipIf(process.platform === 'win32')('rejects a config cwd directory without search permission at load', async () => {
// statSync().isDirectory() is true for a mode-600 directory, but a
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
@@ -472,13 +473,9 @@ describe('dsh-subagent-acp', () => {
}
})
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
// A child that keeps its loop alive past stdin EOF (so the graceful window
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
// — dispose returns there, never reaching the SIGKILL tier. The child touches
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
// run and the marker would be absent — making this a GENUINE middle-tier guard.
it('terminates a child that ignores EOF using the host platform semantics', async () => {
// POSIX uses the catchable SIGTERM tier and records the marker. Windows has
// no distinct graceful signal, so disposal skips directly to forced exit.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
const ready = join(tmp, 'ready')
const sigterm = join(tmp, 'sigterm')
@@ -492,7 +489,7 @@ describe('dsh-subagent-acp', () => {
MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
},
// Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
// Tiny EOF grace so the ignored-EOF window elapses quickly.
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
}
@@ -503,9 +500,7 @@ describe('dsh-subagent-acp', () => {
run.dispose(),
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
])).resolves.toBeUndefined()
// The child caught SIGTERM and exited — proof the middle rung fired (not a
// jump straight to the uncatchable SIGKILL).
expect(existsSync(sigterm)).toBe(true)
expect(existsSync(sigterm)).toBe(process.platform !== 'win32')
} finally {
rmSync(tmp, { recursive: true, force: true })
}

View File

@@ -16,13 +16,13 @@ Spawn-failure capture: a promise that resolves (never rejects) with the child's
### `disposeChildProcess(child, graces)`
The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact;
2. `SIGTERM`, then wait `graces.disposeGraceMs`;
3. `SIGKILL`, then await the now-certain exit — a child that ignores EOF and traps `SIGTERM` cannot wedge dispose forever.
2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`;
3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal.
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush.
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush.
The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
@@ -35,7 +35,7 @@ A per-run isolated config directory for an external CLI child (the target of `CL
## Testing
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end.
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end.
## Model Experience

View File

@@ -51,16 +51,6 @@ export function spawnFailure(child: ChildProcess): Promise<Error> {
})
}
/**
* Resolve once the child process exits (any code/signal); immediate if it is
* already gone.
* @param child - the child process to await.
*/
function waitForExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
}
/**
* Race the child's exit against a timer. Neither outcome leaves anything
* behind on the child: the exit listener is removed on timeout and the timer
@@ -97,36 +87,85 @@ export interface DisposeLadderGraces {
/**
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
* ON ITS OWN — flush durable state, tear down its own nested subprocesses —
* before the parent escalates to `SIGTERM`. A separate (usually WIDER)
* before the parent escalates to platform termination. A separate (usually WIDER)
* grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
* child's EOF-driven teardown may itself be waiting on a signal-trapping
* grandchild plus a final flush, needing more than one signal-grace of
* headroom.
*/
disposeEofGraceMs: number
/** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */
/**
* Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
* `SIGKILL`; Windows applies it after the direct forced termination.
*/
disposeGraceMs: number
}
/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
let accepted = false
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.off('exit', onExit)
child.off('error', onError)
}
const settle = (complete: () => void): void => {
if (settled) return
settled = true
cleanup()
complete()
}
const onExit = (): void => { settle(resolve) }
const onError = (error: Error): void => { settle(() => { reject(error) }) }
child.once('exit', onExit)
child.once('error', onError)
const timer = setTimeout(() => {
const disposition = accepted ? 'accepted' : 'refused'
settle(() => {
reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
})
}, ms).unref()
try {
accepted = child.kill('SIGKILL')
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
} catch (error: unknown) {
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
}
})
}
/**
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
* cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit.
* cooperative flush, then use the host's graceful and forced termination semantics. POSIX
* sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node
* maps both signals to `TerminateProcess`.
*
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
* @param platform - the host platform, injectable for unit coverage.
* @throws When forced termination errors or the child does not report exit within
* `disposeGraceMs`.
*/
export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
export async function disposeChildProcess(
child: ChildProcess,
graces: DisposeLadderGraces,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
// Already gone: nothing to reap.
if (child.exitCode !== null || child.signalCode !== null) return
// 1. Close stdin and allow cooperative teardown and durable-state flush.
child.stdin?.end()
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
// 2. SIGTERM, escalating if the child still does not exit within the grace.
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
// 3. Force-kill and await the (now-certain) exit.
child.kill('SIGKILL')
await waitForExit(child)
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
if (platform !== 'win32') {
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
}
// 3. Force-kill and await a bounded exit edge.
await forceTerminateWithin(child, graces.disposeGraceMs)
}
/**

View File

@@ -191,7 +191,7 @@ describe('disposeChildProcess', () => {
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
@@ -200,7 +200,7 @@ describe('disposeChildProcess', () => {
it('recognizes a child that exits synchronously on SIGTERM', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
@@ -208,7 +208,7 @@ describe('disposeChildProcess', () => {
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
// Quiescence, not a request: at resolution the child has ACTUALLY exited
// (the exit event landed, despite the scripted post-SIGKILL delay).
@@ -217,16 +217,103 @@ describe('disposeChildProcess', () => {
it('recognizes a child already gone when the final exit wait begins', async () => {
const fake = new FakeChild({ synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
queueMicrotask(() => {
if (marker === 'exitCode') fake.exitCode = 0
else fake.signalCode = 'SIGTERM'
})
return true
})
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('walks the ladder for a child spawned without a stdin pipe', async () => {
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it('propagates a forced-termination error without waiting for the grace', async () => {
const fake = new FakeChild()
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
fake.emit('error', failure)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toBe(failure)
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
const fake = new FakeChild()
const failure = new Error('invalid signal state')
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds a refused forced termination that produces no error or exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds an accepted forced termination that never reports exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return true
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
})
describe('createIsolatedConfigDir', () => {
@@ -236,8 +323,9 @@ describe('createIsolatedConfigDir', () => {
expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
const st = await stat(dir.path)
expect(st.isDirectory()).toBe(true)
// Private (0700) per the defensive-patterns temp-dir rule.
expect(st.mode & 0o777).toBe(0o700)
// Windows reports synthetic POSIX mode bits; privacy comes from the
// inherited directory ACL rather than chmod-compatible mode bits.
if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
} finally {
await dir.remove()
}

View File

@@ -4,9 +4,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie
Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Replay may partition subprocess-backed scenarios with `scenarioShard`; every lane still runs fixture guards against the complete table, while record and refresh reject sharding because they write fixtures. Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
@@ -38,6 +38,8 @@ defineAcpSnapshotSuite({
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript).
@@ -53,4 +55,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set `persistenceCompression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path.
- **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier.
- **Built mode requires current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path.

View File

@@ -153,11 +153,21 @@ export interface RunOptions {
configPath?: string
}
/** Derive one stable, fixed-length spill root owned by this scenario. */
function scenarioSpillRoot(fixtureFile: string): string {
/**
* Derive one stable, fixed-length spill root owned by this scenario.
* Windows uses a two-character-shorter root because drive resolution adds its drive prefix.
* @param fixtureFile - The scenario fixture whose parent directory provides the stable identity.
* @param platform - the host platform, injectable for unit coverage.
* @returns the root-relative snapshot spill directory.
*/
export function snapshotSpillRoot(
fixtureFile: string,
platform: NodeJS.Platform = process.platform,
): string {
const scenario = basename(dirname(fixtureFile))
const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
return `/tmp/dsh-acp-snap-${key}`
const root = platform === 'win32' ? '/t' : '/tmp'
return `${root}/dsh-acp-snap-${key}`
}
/**
@@ -176,7 +186,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
// before stdout normalization, so tmpdir() length differences churn expected outputs.
// Scenario ownership also matters: replay runs concurrently, and one teardown
// must never delete another scenario's in-flight full-output recovery file.
const spillRoot = scenarioSpillRoot(opts.fixtureFile)
const spillRoot = snapshotSpillRoot(opts.fixtureFile)
// Everything past the temp-dir creation is followed by failure-safe cleanup,
// so a failure in workspace seeding, spawn, or any step never leaks resources.
let launched: LaunchedAcpTestAgent | undefined

View File

@@ -37,7 +37,9 @@ export {
scrubRequestHeaders,
scrubSystemPrompts,
scrubToolSchemas,
type CwdPathMode,
type NormalizeContext,
type NormalizeOptions,
} from './normalize.ts'
export {
defineAcpSnapshotSuite,

View File

@@ -21,6 +21,8 @@ import {
} from '@agentclientprotocol/sdk'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const EXIT_MARKER_GRACE_MS = 250
/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */
export interface AgentUnderTest {
/** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
@@ -231,6 +233,15 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
return
}
const propagateFailureAfterDrain = async (): Promise<never> => {
await drained
closeUpdateStream()
throw failure
}
// Windows implements the supported signal names as forced termination. The exit markers
// may therefore arrive after the error wins the race above but before fallback begins.
if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
// An `error` after spawn is not an exit edge: in particular, a failed
// signal can leave the subprocess live. Force termination, await the
// already-observed exit edge, and only then propagate the child error so
@@ -240,6 +251,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
child.once('error', observeFallbackError)
if (!child.kill('SIGKILL')) {
child.off('error', observeFallbackError)
// A successful earlier signal may win between the live check and this fallback call.
// In that case `kill()` correctly reports no process to signal; the original child error
// remains the shutdown result once inherited stdio and callbacks have drained.
if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
closeUpdateStream()
throw new AggregateError(
[failure, new Error('Fallback SIGKILL was not accepted by the child process')],
@@ -258,9 +273,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
'ACP test agent failed and fallback termination was refused',
)
}
await drained
closeUpdateStream()
throw failure
return propagateFailureAfterDrain()
},
}
}
@@ -270,6 +283,17 @@ function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
}
/** Give an accepted Windows termination request a bounded window to publish its exit marker. */
function exitMarkerWithinGrace(exited: Promise<void>): Promise<boolean> {
return Promise.race([
exited.then(() => true),
new Promise<false>((resolve) => {
const timer = setTimeout(() => { resolve(false) }, EXIT_MARKER_GRACE_MS)
timer.unref()
}),
])
}
/** Whether the child still lacks either OS termination marker. */
function isRunning(child: ChildProcessWithoutNullStreams): boolean {
return child.exitCode === null && child.signalCode === null

View File

@@ -13,19 +13,33 @@ const TOOLS = '{{tools}}'
const MESSAGE_PREFIX = '{{messagePrefix}}'
const UPDATED_AT = '{{updatedAt}}'
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
const PATH_TAG_RE = /(<path>)([^<]*)(<\/path>)/g
const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
const LOCAL_SPILL_PATH_RE = new RegExp(
String.raw`\{\{cwd\}\}/\.spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
String.raw`\{\{cwd\}\}[\\/]\.spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
'g',
)
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/](?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
'g',
)
/** Convert separators only inside generated path-bearing text markers. */
function canonicalizeEmbeddedPaths(value: string): string {
return value
.replace(PATH_TAG_RE, (_match, open: string, path: string, close: string) =>
`${open}${path.replaceAll('\\', '/')}${close}`)
.replace(ADDITIONAL_INSTRUCTIONS_PATH_RE, (_match, prefix: string, path: string) =>
`${prefix}${path.replaceAll('\\', '/')}`)
}
/** Inputs the normalizers need to recognize a run's volatile values. */
export interface NormalizeContext {
/** The session id(s) the run issued — replaced with `{{sessionId}}`. */
@@ -34,13 +48,28 @@ export interface NormalizeContext {
cwd: string
}
/** How cwd-rooted path separators are represented after the cwd is tokenized. */
export type CwdPathMode = 'canonical' | 'native'
/** Optional controls shared by stdout and session-log normalization. */
export interface NormalizeOptions {
/** Use `/` for shared goldens, or preserve captured separators for a platform-specific golden. */
cwdPathMode?: CwdPathMode
}
/** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
function scrubString(value: string, ctx: NormalizeContext): string {
function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string {
let out = value
// cwd first (longest, most specific), then explicit session ids, then any
// residual UUID (covers ids that appear in places we didn't enumerate).
out = out.split(ctx.cwd).join(CWD)
out = out.split(`/private${CWD}`).join(CWD)
if (cwdPathMode === 'canonical') {
// Restrict separator conversion to paths rooted at the cwd token. A global
// backslash rewrite would corrupt regexes, commands, and model-authored text.
out = out.replace(CWD_ROOTED_PATH_RE, path => path.replaceAll('\\', '/'))
out = canonicalizeEmbeddedPaths(out)
}
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
@@ -49,12 +78,15 @@ function scrubString(value: string, ctx: NormalizeContext): string {
}
/** Recursively scrub a parsed JSON value (strings replaced; structure kept). */
function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
if (typeof value === 'string') return scrubString(value, ctx)
if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx))
function scrubValue(value: unknown, ctx: NormalizeContext, cwdPathMode: CwdPathMode, key?: string): unknown {
if (typeof value === 'string') {
const scrubbed = scrubString(value, ctx, cwdPathMode)
return cwdPathMode === 'canonical' && key === 'path' ? scrubbed.replaceAll('\\', '/') : scrubbed
}
if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx, cwdPathMode))
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx)
for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx, cwdPathMode, k)
return out
}
return value
@@ -68,9 +100,15 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
*
* @param rawStdout The captured stdout bytes, decoded utf8.
* @param ctx The run's volatile values to scrub.
* @param options Separator output controls; shared canonical paths are the default.
* @returns The normalized NDJSON transcript, one frame per line.
*/
export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string {
export function normalizeStdout(
rawStdout: string,
ctx: NormalizeContext,
options: NormalizeOptions = {},
): string {
const cwdPathMode = options.cwdPathMode ?? 'canonical'
const lines = rawStdout.split('\n').filter(line => line.trim().length > 0)
// Map each distinct JSON-RPC id (request/response correlate by id) to a stable
// sequence number, in first-seen order, so id churn doesn't perturb the expected output.
@@ -88,7 +126,7 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
}
const update = (frame.params as { update?: Record<string, unknown> } | undefined)?.update
if (update?.sessionUpdate === 'session_info_update') update.updatedAt = UPDATED_AT
return scrubValue(frame, ctx) as Record<string, unknown>
return scrubValue(frame, ctx, cwdPathMode) as Record<string, unknown>
})
return frames.map(f => JSON.stringify(f)).join('\n') + '\n'
}
@@ -102,9 +140,15 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
*
* @param rawLog The raw session `.jsonl` content.
* @param ctx The run's volatile values to scrub.
* @param options Separator output controls; shared canonical paths are the default.
* @returns The normalized JSONL log, one record per line.
*/
export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): string {
export function normalizeSessionLog(
rawLog: string,
ctx: NormalizeContext,
options: NormalizeOptions = {},
): string {
const cwdPathMode = options.cwdPathMode ?? 'canonical'
const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
const records = lines.map((line) => {
const record = JSON.parse(line) as Record<string, unknown>
@@ -122,7 +166,7 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
if ('durationMs' in data) data.durationMs = 0
}
}
return scrubValue(record, ctx) as Record<string, unknown>
return scrubValue(record, ctx, cwdPathMode) as Record<string, unknown>
})
return records.map(r => JSON.stringify(r)).join('\n') + '\n'
}

View File

@@ -21,6 +21,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
import {
type CwdPathMode,
type NormalizeContext,
normalizeSessionLog,
normalizeStdout,
@@ -36,6 +37,9 @@ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
/** The structured tool-schema snapshot beside each header-pinning fixture. */
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
/** The optional full Windows-native stdout transcript. */
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
/** Stable session-log token standing in for the sidecar's initial schemas. */
const TOOLS_TOKEN = '{{tools}}'
@@ -101,6 +105,61 @@ export interface Scenario {
* {@link headerClass}.
*/
configPath?: string
/**
* Whether Windows additionally compares stdout with native separators against
* `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still
* compared on every platform, and the fixture guard requires this sidecar
* exactly when the option is set.
*/
pinsNativeWindowsStdout?: boolean
/**
* Whether the driven behavior needs POSIX process semantics the harness
* cannot exercise on Windows (e.g. cancelling a live bash tool call kills a
* detached process group). The scenario's run test is skipped on Windows;
* its fixtures stay guarded on every platform.
*/
posixOnly?: boolean
}
/**
* Whether a scenario's run test is skipped for this mode and host: record mode
* skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly}
* scenarios skip on Windows.
*
* @param scenario The scenario whose run test is being registered.
* @param recording Whether the suite runs in record mode.
* @param platform The running Node platform, injectable for unit coverage.
* @returns True when the scenario's run test must not execute.
*/
export function scenarioSkipped(
scenario: Scenario,
recording: boolean,
platform: NodeJS.Platform = process.platform,
): boolean {
if (recording && !scenario.recorded) return true
return scenario.posixOnly === true && platform === 'win32'
}
/** One stdout expected output selected for a platform run. */
interface StdoutExpectedVariant {
file: string
cwdPathMode: CwdPathMode
}
/**
* Select the shared stdout expected output plus any platform-native assertion declared by a scenario.
*
* @param scenario The scenario whose stdout contract is being selected.
* @param platform The running Node platform, injectable for unit coverage.
* @returns The ordered expected-output variants: shared canonical first, then optional Windows native.
*/
export function stdoutExpectedVariants(
scenario: Scenario,
platform: NodeJS.Platform = process.platform,
): StdoutExpectedVariant[] {
const canonical: StdoutExpectedVariant = { file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' }
if (platform !== 'win32' || scenario.pinsNativeWindowsStdout !== true) return [canonical]
return [canonical, { file: WINDOWS_STDOUT_SNAPSHOT, cwdPathMode: 'native' }]
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
@@ -489,8 +548,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
scenarioSuite('snapshot scenarios', () => {
for (const scenario of selectedScenarios) {
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones
// (sidecar-driven errors/cancel) are never re-recorded.
it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
// (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on
// Windows, where their process semantics cannot be driven.
it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
const dir = join(snapshotsDir, scenario.name)
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
const overrideFile = join(dir, 'replay.override.json')
@@ -594,11 +654,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
}
const stdout = normalizeStdout(result.rawStdout, ctx)
if (REFRESHING) {
await writeFile(join(dir, 'stdout.expected.jsonl'), stdout)
for (const expected of stdoutExpectedVariants(scenario)) {
const stdout = normalizeStdout(result.rawStdout, ctx, { cwdPathMode: expected.cwdPathMode })
if (REFRESHING) {
await writeFile(join(dir, expected.file), stdout)
}
await expect(stdout, `${expected.file} mismatch`).toMatchFileSnapshot(join(dir, expected.file))
}
await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.expected.jsonl'))
// A model turn always produces a log worth comparing; a hook scenario can
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
@@ -685,10 +747,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
it('every registered scenario has its required fixture files', async () => {
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
for (const { name, overridden, pinsHeader } of scenarios) {
for (const { name, overridden, pinsHeader, pinsNativeWindowsStdout } of scenarios) {
const dir = join(snapshotsDir, name)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
expect(
existsSync(join(dir, WINDOWS_STDOUT_SNAPSHOT)),
`${name}/${WINDOWS_STDOUT_SNAPSHOT} presence must match \`pinsNativeWindowsStdout\``,
).toBe(pinsNativeWindowsStdout === true)
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
.toBe(overridden === true)

View File

@@ -300,7 +300,10 @@ function flushLogsAndExit(): void {
`setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`,
`setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`,
].join(';')
spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref()
spawn(process.execPath, ['-e', code], {
detached: true,
stdio: ['ignore', 'inherit', 'inherit'],
}).unref()
}
process.exit(0)
}

View File

@@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts'
import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts'
import { launchAcpTestAgent } from '../src/launcher.ts'
const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined }))
@@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
const fixtureFile = '/fixtures/scenario/session.jsonl'
const posix = snapshotSpillRoot(fixtureFile, 'linux')
const windows = snapshotSpillRoot(fixtureFile, 'win32')
expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows.length + 2).toBe(posix.length)
})
function environmentEcho(rawStdout: string): Record<string, unknown> {
const frames = rawStdout.trim().split('\n')
.map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
@@ -143,6 +152,9 @@ describe('runScenario', () => {
update.sessionUpdate === 'agent_message_chunk'
&& update.content.type === 'text'
&& update.content.text === 'late inherited stdout')
// Arm rejection handling before close may exhaust the stream; the later assertion still
// observes the original promise and turns a missing inherited frame into the test failure.
void lateUpdate.catch(() => undefined)
await launched.close()
@@ -180,6 +192,97 @@ describe('runScenario', () => {
}
})
it('preserves the child error when the requested signal sets an exit marker', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
expect(signal).toBe('SIGTERM')
originalKill('SIGKILL')
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' })
return true
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenCalledOnce()
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error when the requested signal publishes its exit marker later', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
expect(signal).toBe('SIGTERM')
setTimeout(() => { originalKill('SIGKILL') }, 10)
return true
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenCalledOnce()
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error when fallback refusal races with an exit marker', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
if (signal === 'SIGTERM') return true
originalKill('SIGKILL')
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' })
return false
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error after accepted fallback termination drains', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
if (signal === 'SIGTERM') return true
return originalKill('SIGKILL')
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('rejects promptly when fallback termination emits an error', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
@@ -294,7 +397,11 @@ describe('runScenario', () => {
expect(result.sessionLogs[0]?.createdAt).toBe(42)
expect(result.sessionLogs[0]?.content).toContain('turn/start')
// The harvested log embeds the run's REAL temp cwd (template-substituted).
expect(result.sessionLogs[0]?.content).toContain(result.cwd)
// The cwd is JSON-encoded in the log line, so compare the parsed field
// rather than substring-matching a raw path (which breaks when the path
// separator is escaped inside JSON text on Windows).
const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}'
expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
})
it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
@@ -315,7 +422,17 @@ describe('runScenario', () => {
expect(result.stderr).toContain('fake bin booted')
expect(result.rawStdout).toContain('replay.override.json')
// Child paths ride one env var, joined with the platform delimiter.
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
// Parse the fake bin's env-probe chunk rather than substring-matching a
// JSON-encoded path (the escaping breaks raw-substring compares on Windows).
const envChunk = result.rawStdout.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } })
.find(f => f.params?.update?.content?.text?.startsWith('env:'))
const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as {
childFiles: string | null
}
expect(env.childFiles).toBe(childFiles.join(delimiter))
})
it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
@@ -328,7 +445,10 @@ describe('runScenario', () => {
expect(roots.every(root => typeof root === 'string')).toBe(true)
expect(new Set(roots).size).toBe(2)
expect((roots[0] as string).length).toBe((roots[1] as string).length)
expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length)
expect(roots).toEqual([
snapshotSpillRoot(first.fixtureFile),
snapshotSpillRoot(second.fixtureFile),
])
})
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {

View File

@@ -44,6 +44,56 @@ describe('normalizeStdout', () => {
expect(out).not.toContain(ctx.sessionIds[0] as string)
})
it('canonicalizes only cwd-rooted path separators', () => {
const windowsCtx: NormalizeContext = {
sessionIds: [],
cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
}
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
path: `${windowsCtx.cwd}\\nested\\proof.txt`,
regex: String.raw`\d+\w+`,
command: String.raw`printf "\\n"`,
},
})
const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
params: { path: string; regex: string; command: string }
}
expect(frame.params).toEqual({
path: '{{cwd}}/nested/proof.txt',
regex: String.raw`\d+\w+`,
command: String.raw`printf "\\n"`,
})
})
it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
const raw = JSON.stringify({
path: String.raw`nested\AGENTS.md`,
content: String.raw`<path>.\nested\task.txt</path>
Additional instructions from: nested\AGENTS.md`,
regex: String.raw`\d+\w+`,
})
const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
path: string
content: string
regex: string
}
expect(frame).toEqual({
path: 'nested/AGENTS.md',
content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
regex: String.raw`\d+\w+`,
})
})
it('can preserve native cwd-rooted separators for a platform golden', () => {
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
})
it('scrubs a stray UUID not in the known list', () => {
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
@@ -172,6 +222,33 @@ describe('normalizeSessionLog', () => {
expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
})
it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snap-012345678\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
})
it('shares cwd-rooted path handling with stdout normalization', () => {
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
})
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
.toContain('{{cwd}}/nested/proof.txt')
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
.toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
})
it('scrubs the session id in the header', () => {
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
expect(out).toContain('{{sessionId}}')

View File

@@ -15,9 +15,11 @@ import {
normalizedToolSchemas,
parseToolSchemasSnapshot,
refreshFixtureReplacements,
scenarioSkipped,
sessionFixtureNames,
restorePinnedToolSchemas,
stabilizeRefreshLog,
stdoutExpectedVariants,
unknownToolCallIds,
} from '../src/suite.ts'
@@ -252,6 +254,48 @@ describe('sessionFixtureNames', () => {
})
})
describe('stdoutExpectedVariants', () => {
const scenario: Scenario = {
name: 'windows-native',
hasModelTurn: true,
recorded: true,
pinsNativeWindowsStdout: true,
}
it('adds the native sidecar after the shared golden on Windows', () => {
expect(stdoutExpectedVariants(scenario, 'win32')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
{ file: 'stdout.expected.windows.jsonl', cwdPathMode: 'native' },
])
})
it('keeps only the shared golden on other platforms or without the declaration', () => {
expect(stdoutExpectedVariants(scenario, 'linux')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
])
expect(stdoutExpectedVariants({ ...scenario, pinsNativeWindowsStdout: false }, 'win32')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
])
})
})
describe('scenarioSkipped', () => {
const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false }
const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true }
it('skips authored scenarios only while recording', () => {
expect(scenarioSkipped(authored, true, 'linux')).toBe(true)
expect(scenarioSkipped(authored, false, 'linux')).toBe(false)
})
it('skips posixOnly scenarios on Windows and nowhere else', () => {
expect(scenarioSkipped(posix, false, 'win32')).toBe(true)
expect(scenarioSkipped(posix, false, 'linux')).toBe(false)
expect(scenarioSkipped(posix, false, 'darwin')).toBe(false)
expect(scenarioSkipped(authored, false, 'win32')).toBe(false)
})
})
describe('fixtureContext', () => {
it('reads the fixture header id and cwd', () => {
const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n')

View File

@@ -37,8 +37,8 @@ describe('runLoaderSmoke', () => {
marker: 'present',
input: 'one\ntwo\n',
})
expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`)
expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`)
expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh')))
expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents')))
expect(result.stderr).toContain('fixture stderr')
expect(existsSync(output.cwd)).toBe(false)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)

View File

@@ -63,11 +63,11 @@ A log-only `session/title` event maps to ACP `session_info_update` with `title`
## Tool-call presentation
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
## Terminal card (capability-gated)
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { join as pathJoin, resolve as pathResolve } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -50,6 +51,16 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
return { type, seq: 0, time: 0, data } as SessionEvent
}
/** ACP path fields are filesystem paths; expectations use the host separator. */
function nativePath(...segments: string[]): string {
return pathJoin(...segments)
}
/** Resolve root-relative fixtures the same way the bridge does on this host. */
function nativeAbsolute(...segments: string[]): string {
return pathResolve(...segments)
}
describe('streamSessionEventUpdate', () => {
it('maps a title event to session_info_update with the event timestamp', () => {
expect(updatesFor({
@@ -576,10 +587,10 @@ describe('terminal-card mapping (capability-gated)', () => {
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent)
// Relative workdir resolved against the session cwd — the card header matches
// where execution actually ran (tool-bash resolves the same way).
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir'))
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
@@ -792,10 +803,12 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// paths remain absolute so the editor can open the real file.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' })
const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const out: SessionNotification['update'][] = []
const rendering = { enabled: false, cwd: '/work/proj' }
const rendering = { enabled: false, cwd: workspace }
for (const event of [
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
@@ -804,8 +817,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',
status: 'completed',
title: 'Edit src/b.ts',
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
title: `Edit ${nativePath('src', 'b.ts')}`,
content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
})
await ctx.fiber.dispose()
})
@@ -856,21 +869,25 @@ describe('relative-path display titles (bridge relativizes the title against the
it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 })
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'a.ts')
const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 })
expect(update).toMatchObject({
title: 'Read src/a.ts (from line 5)',
locations: [{ path: '/work/proj/src/a.ts', line: 5 }],
title: `Read ${nativePath('src', 'a.ts')} (from line 5)`,
locations: [{ path: file, line: 5 }],
})
await ctx.fiber.dispose()
})
it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' })
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' })
expect(update).toMatchObject({
title: 'Edit src/b.ts',
locations: [{ path: '/work/proj/src/b.ts' }],
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }],
title: `Edit ${nativePath('src', 'b.ts')}`,
locations: [{ path: file }],
content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }],
})
await ctx.fiber.dispose()
})
@@ -887,8 +904,8 @@ describe('relative-path display titles (bridge relativizes the title against the
// with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
// matching targets under `cwd + sep` in the reference adapter.
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') })
expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`)
await ctx.fiber.dispose()
})
@@ -901,8 +918,8 @@ describe('relative-path display titles (bridge relativizes the title against the
it('a relative path is passed through unchanged (already display-friendly)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' })
expect((update as { title: string }).title).toBe('Read src/a.ts')
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') })
expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`)
await ctx.fiber.dispose()
})
})

View File

@@ -5,8 +5,8 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
@@ -68,6 +68,26 @@ interface RegisteredCommand {
readonly descriptor: CommandDescriptor
}
/** All command registrations owned by one global or scoped layer. */
class CommandLayer implements ScopeLayer {
readonly commands: NamedEntries<RegisteredCommand>
/**
* Create one command layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.commands = new NamedEntries(name => new Error(scope === undefined
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no command registrations. */
isEmpty(): boolean {
return this.commands.isEmpty()
}
}
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent.
*/
export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>()
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { this.notifyChange() },
)
constructor(ctx: Context) {
super(ctx, 'commands')
@@ -218,25 +240,12 @@ export class CommandService extends Service {
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(registered.definition.name)) {
throw new Error(scope === undefined
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${registered.definition.name}" is already registered in this scope`)
}
layer.set(registered.definition.name, registered)
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.notifyChange()
}
this.notifyChange()
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
return this.layers.effect(
this.ctx,
layer => layer.commands.insert(registered.definition.name, registered),
{ label: 'commands.register()' },
)
}
/**
@@ -285,19 +294,7 @@ export class CommandService extends Service {
/** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global)
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
return visible
}
/** Create the registration layer for one agent scope on demand. */
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
return this.layers.merge(agent, layer => layer.commands)
}
/** Notify every registry observer without making UI refresh load-bearing. */

View File

@@ -94,6 +94,19 @@ describe('CommandService', () => {
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
})
it('removes a registration when its contributing plugin fiber is disposed', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.commands.register(command('temporary'))
}, { inject: ['commands'] }))
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
await fiber.dispose()
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
})
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')

View File

@@ -10,6 +10,8 @@ This package owns interactive terminal presentation and input only. It injects `
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.

View File

@@ -6,7 +6,7 @@
*/
import { homedir } from 'node:os'
import { relative, resolve, sep } from 'node:path'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import {
CombinedAutocompleteProvider,
Container,
@@ -30,6 +30,7 @@ import {
type OverlayHandle,
type SelectListTheme,
type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
@@ -169,6 +170,12 @@ export interface TuiRuntime {
terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
/**
* Override the footer's logical working-directory label without changing the session directory used by tools.
* @param cwd - Operational working directory from the session header.
* @returns Unescaped label; the TUI makes terminal controls visible.
*/
formatCwd?: (cwd: string | undefined) => string
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
now?(): number
}
@@ -237,17 +244,21 @@ function displayText(text: string): string {
* backgrounds alike; grouping uses foreground-only gutter bars and reverse
* video rather than fixed background fills.
*/
function createPalette(enabled: boolean): Palette {
function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
return {
accent: ansi('94', '39', enabled),
accent2: ansi('95', '39', enabled),
text: text => text,
muted: ansi('90', '39', enabled),
dim: ansi('2', '22', enabled),
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
// (bright black / gray) which renders as a readable muted tone on any scheme.
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
success: ansi('32', '39', enabled),
warning: ansi('33', '39', enabled),
error: ansi('31', '39', enabled),
code: ansi('36', '39', enabled),
// ANSI 36 (cyan) is difficult to read on a light background — use
// ANSI 34 (blue) which is legible on both light and dark schemes.
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
added: ansi('32', '39', enabled),
removed: ansi('31', '39', enabled),
bold: ansi('1', '22', enabled),
@@ -692,8 +703,10 @@ function formatCwd(cwd: string | undefined): string {
const home = homedir()
const rel = relative(resolve(home), resolve(cwd))
if (rel === '') return '~'
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`)
return displayText(cwd)
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
if (isAbsolute(rel)) return cwd
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
return cwd
}
interface SessionTokenTotals {
@@ -737,6 +750,7 @@ class FooterComponent implements Component {
private readonly toolsExpanded: () => boolean,
private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number },
private readonly cwdFormatter: TuiRuntime['formatCwd'],
private readonly currentModel: () => string | undefined,
private readonly contextPercent: () => number | undefined,
private readonly runningSeconds: () => number,
@@ -760,6 +774,9 @@ class FooterComponent implements Component {
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
const compactRight = `${context} ${modelState}`
const formattedCwd = displayText(
this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
)
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
const compact = truncateToWidth(compactRight, width, '')
return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
@@ -768,7 +785,7 @@ class FooterComponent implements Component {
const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight
const rightClipped = truncateToWidth(right, rightAvailable, '')
const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3)
const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '')
const cwd = truncateToWidth(formattedCwd, cwdAvailable, '')
const left = [cwd, counters].filter(Boolean).join(' ')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped)))
return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`]
@@ -1077,6 +1094,7 @@ export function createTuiChat(
() => toolsExpanded,
() => showReasoning,
() => tokens,
runtime.formatCwd,
() => target.current?.model,
() => contextWindow === undefined
? undefined
@@ -1504,6 +1522,30 @@ export function createTuiChat(
void shutdown(true)
}
/** Swap the palette and all derived themes for the given terminal color scheme. */
const applyColorScheme = (scheme: TerminalColorScheme): void => {
if (scheme === currentScheme) return
currentScheme = scheme
Object.assign(palette, createPalette(resolved.color, scheme))
Object.assign(mdTheme, markdownTheme(palette))
rebuildTranscript(false)
setStatus(agent.status)
requestRender()
}
let currentScheme: TerminalColorScheme = 'dark'
// Apply any color scheme the terminal reports. Registering before the query
// below means even a synchronous reply reaches `applyColorScheme`; in practice
// the startup query's reply is the only report, since dsh-tui leaves
// unsolicited color-scheme notifications disabled.
const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)
// Ask the terminal for its color scheme via device-status report; the reply,
// if any, arrives through the listener above. Most terminals do not respond,
// so we keep the dark-optimised palette. Swallow a query-write failure for the
// same reason.
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1714,6 +1756,7 @@ export function createTuiChat(
disposeStatus()
disposeError()
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
}

View File

@@ -12,7 +12,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config } from '../src/index.ts'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -28,6 +28,7 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
@@ -144,7 +145,12 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
}, options.config), {
terminal,
exit,
now: options.now ?? (() => 0),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
})
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -1,5 +1,5 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
@@ -168,6 +168,10 @@ describe('TUI config', () => {
describe('pi-tui chat lifecycle and transcript', () => {
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
// A fixed short cwd keeps the footer's token counters inside the 88-column
// fake terminal regardless of where the checkout lives; cwd rendering has
// its own dedicated variants test below.
cwd: '/workspace',
beforeMount(session) {
session.append('session/title', {
title: 'Restored session title',
@@ -316,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ inputTokens: 500, outputTokens: 8 },
{ turn: 3, step: 1 },
)
await tick()
await vi.waitFor(() => {
expect(result.terminal.output).toContain('final live answer')
})
expect(result.terminal.output).toContain('◒ Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
@@ -324,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.output).toContain('final live answer')
expect(result.terminal.progress).toContain(true)
result.session.append('assistant/chunk', {
@@ -413,6 +418,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('renders the ANSI palette and every markdown/content style', async () => {
const result = await setup({
cwd: '/workspace',
config: { color: true },
beforeMount(session) {
session.append('user/message', {
@@ -496,9 +502,21 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(unsetResult.terminal.output).toContain('cwd unset')
await dispose(unsetResult)
const homeParent = resolve(home, '..')
const parentResult = await setup({ cwd: homeParent })
expect(parentResult.terminal.output).toContain(homeParent)
await dispose(parentResult)
const outsideResult = await setup({ cwd: '/opt' })
expect(outsideResult.terminal.output).toContain('/opt')
await dispose(outsideResult)
const logicalResult = await setup({
cwd: '/w',
formatCwd: cwd => `logical:${cwd}\x1b`,
})
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
await dispose(logicalResult)
})
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
@@ -1174,8 +1192,9 @@ describe('TUI user-interaction dialogs', () => {
result.terminal.send('x')
result.terminal.send(' ')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select at least one option')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Select at least one option')
})
result.terminal.send('c')
await tick()
result.terminal.send('\x1b')
@@ -1399,4 +1418,57 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
await ctx.fiber.dispose()
})
it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
const result = await setup({ config: { color: true } })
// Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
// A report matching the current scheme is a no-op: no palette rebuild or
// re-render (ESC [?997;1n = dark, the startup default).
const beforeSameScheme = result.terminal.output.length
result.terminal.send('\x1b[?997;1n')
await tick()
expect(result.terminal.output.length).toBe(beforeSameScheme)
// Simulate the terminal responding with a light color scheme report
// (ESC [?997;2n = light, ESC [?997;1n = dark).
result.terminal.send('\x1b[?997;2n')
await tick()
await tick()
// After switching to light-optimised palette: palette.dim uses ANSI 90
// (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
// line. The cumulative output still contains the initial SGR 2 render,
// so we assert that a LATER write (appended after the scheme switch)
// uses ANSI 90 for the same header text.
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
// Switch back to dark scheme.
result.terminal.send('\x1b[?997;1n')
await tick()
await tick()
// After switching back, a new write uses SGR 2 for the header detail.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await dispose(result)
})
it('keeps the dark palette when the terminal rejects the color-scheme query', async () => {
class QueryFailTerminal extends FakeTerminal {
override write(data: string): void {
// The device-status query is the only write that fails; the promise
// rejects and the swallowed `.catch` leaves the dark palette in place.
if (data === '\x1b[?996n') throw new Error('query write failed')
super.write(data)
}
}
const terminal = new QueryFailTerminal()
const result = await createTuiTestHarness(terminal, vi.fn(), {
config: { color: true },
cwd: process.cwd(),
})
await tick()
expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await disposeTuiTestHarness(result)
})
})

View File

@@ -28,7 +28,7 @@ describe('dsh path helpers', () => {
it('resolves explicit path before DSH_HOME and the default', () => {
const envHome = join(homedir(), 'env-dsh')
expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe('/tmp/explicit-dsh')
expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe(resolve('/tmp/explicit-dsh'))
expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome)
expect(resolveDshHome(undefined, {})).toBe(defaultDshHome())
})

View File

@@ -9,17 +9,15 @@ import {
} from '@deepseek-ai/dsh-web-search-deepseek'
/**
* Real-API smoke for the DeepSeek search provider. Self-skips without
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
* triggers native `web_search` and returns the structured result blocks the
* provider parses — a mock cannot confirm the wire shape is real.
* Disabled real-API probe for the DeepSeek search provider. The live endpoint
* can complete without structured source blocks, so this is not a reliable
* merge signal. Its body remains because mocks cannot confirm the wire shape.
*/
const apiKey = process.env.DEEPSEEK_API_KEY
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
maybe('DeepSeekSearchProvider real API', () => {
it('returns citeable sources for a live query via native web_search', async () => {
it.skip('returns citeable sources for a live query via native web_search', async () => {
const provider = new DeepSeekSearchProvider({
apiKey: apiKey!,
baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL,