Merge branch 'master' into feat/plan-mode
This commit is contained in:
@@ -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 })
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
134
packages/fs/fs-local/src/win32.ts
Normal file
134
packages/fs/fs-local/src/win32.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
146
packages/fs/fs-local/tests/win32.spec.ts
Normal file
146
packages/fs/fs-local/tests/win32.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
76
packages/fs/fs-sandbox/src/containment.ts
Normal file
76
packages/fs/fs-sandbox/src/containment.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
57
packages/fs/fs-sandbox/tests/containment.spec.ts
Normal file
57
packages/fs/fs-sandbox/tests/containment.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -472,13 +472,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 +488,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 +499,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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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). 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.
|
||||
|
||||
@@ -177,11 +177,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}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +210,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
|
||||
|
||||
@@ -38,7 +38,9 @@ export {
|
||||
scrubRequestHeaders,
|
||||
scrubSystemPrompts,
|
||||
scrubToolSchemas,
|
||||
type CwdPathMode,
|
||||
type NormalizeContext,
|
||||
type NormalizeOptions,
|
||||
} from './normalize.ts'
|
||||
export {
|
||||
defineAcpSnapshotSuite,
|
||||
|
||||
@@ -23,6 +23,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`). */
|
||||
@@ -238,6 +240,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
|
||||
@@ -247,6 +258,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')],
|
||||
@@ -265,9 +280,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
'ACP test agent failed and fallback termination was refused',
|
||||
)
|
||||
}
|
||||
await drained
|
||||
closeUpdateStream()
|
||||
throw failure
|
||||
return propagateFailureAfterDrain()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -277,6 +290,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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -35,6 +36,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}}'
|
||||
|
||||
@@ -100,6 +104,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. */
|
||||
@@ -479,8 +538,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
scenarioSuite('snapshot scenarios', () => {
|
||||
for (const scenario of scenarios) {
|
||||
// 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')
|
||||
@@ -584,11 +644,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/*`).
|
||||
@@ -675,10 +737,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)
|
||||
|
||||
@@ -341,7 +341,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)
|
||||
}
|
||||
|
||||
@@ -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 } } } })
|
||||
@@ -144,6 +153,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()
|
||||
|
||||
@@ -181,6 +193,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 })
|
||||
@@ -295,7 +398,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 () => {
|
||||
@@ -316,7 +423,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 () => {
|
||||
@@ -329,7 +446,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 () => {
|
||||
|
||||
@@ -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}}')
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
normalizedToolSchemas,
|
||||
parseToolSchemasSnapshot,
|
||||
refreshFixtureReplacements,
|
||||
scenarioSkipped,
|
||||
sessionFixtureNames,
|
||||
restorePinnedToolSchemas,
|
||||
stabilizeRefreshLog,
|
||||
stdoutExpectedVariants,
|
||||
unknownToolCallIds,
|
||||
} from '../src/suite.ts'
|
||||
|
||||
@@ -230,6 +232,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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}`]
|
||||
@@ -1083,6 +1100,7 @@ export function createTuiChat(
|
||||
() => toolsExpanded,
|
||||
() => showReasoning,
|
||||
() => tokens,
|
||||
runtime.formatCwd,
|
||||
() => target.current?.model,
|
||||
() => contextWindow === undefined
|
||||
? undefined
|
||||
@@ -1510,6 +1528,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)
|
||||
@@ -1720,6 +1762,7 @@ export function createTuiChat(
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
disposeAgent()
|
||||
disposeSchemeListener()
|
||||
disposeTargetListeners()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -413,6 +417,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 +501,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 () => {
|
||||
@@ -1175,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')
|
||||
@@ -1400,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user