Merge commit '404501a41ccf2a3b638b1b737087948fe08d5c4c' into codex/product-subagent-presets

# Conflicts:
#	packages/subagent/subagent-claude-code/tests/real-product.spec.ts
This commit is contained in:
pku-xht
2026-08-10 12:59:50 +08:00
141 changed files with 1674 additions and 546 deletions

View File

@@ -9,7 +9,7 @@
* writes CRLF on Windows, so exact text assertions normalize line endings.
*/
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n')
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
function samePath(actual: string, expected: string): boolean {
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
const norm = (value: string) => (
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value
)
return norm(actual) === norm(expected)
}
@@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
it('falls through an empty configured path to platform resolution', () => {
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
// fallback candidate cannot exist either.
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
expect(resolvePwshPath('', {
PATH: 'P:\\Store',
ProgramFiles: 'P:\\no-program-files',
SystemRoot: 'S:\\no-windows',
}, 'win32')).toBe('pwsh')
})
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
@@ -80,6 +86,13 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
})
it('uses stable Windows roots when the environment omits both overrides', () => {
expect(candidatePwshPaths({})).toEqual([
join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
])
})
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
const candidates = candidatePwshPaths({
ProgramFiles: 'P:\\Program Files',
@@ -154,12 +167,12 @@ describe('spawn construction (pure, every platform)', () => {
})
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
it('resolves with output and the effective timeout', async () => {
const { bash } = await setup({ timeoutMs: 5_000 })
it('resolves with output and the effective timeout', { timeout: 15_000 }, async () => {
const { bash } = await setup({ timeoutMs: 10_000 })
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
expect(result.exitCode).toBe(0)
expect(lf(result.stdout.text)).toBe('hi\n')
expect(result.timeoutMs).toBe(5_000)
expect(result.timeoutMs).toBe(10_000)
})
it('uses config cwd, overridable per call', async () => {
@@ -301,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
env: { BG_VAR: 'bg-env' },
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
}))
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]')
await proc.done
const output = partialOutput + lf(proc.readOutput().delta)
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
expect(proc.exitCode).toBe(0)
})

View File

@@ -1,4 +1,5 @@
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -6,14 +7,19 @@ import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
async function bootHmr(dir: string): Promise<Context> {
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dir).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.plugin(Hmr, {
root,
ignored: [],
debounce: 0,
...usePolling === undefined ? {} : { usePolling },
})
return ctx
}
@@ -26,6 +32,57 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
}
describe('HMR exact config paths', () => {
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
const alias = `${target}-alias`
const aliasFilename = join(alias, 'module.ts')
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
writeFileSync(aliasFilename, 'export const generation = 0\n')
// This acceptance owns alias-to-cache identity. Other cases below exercise
// native events; polling keeps Windows fs.watch queue pressure out of it.
const ctx = await bootHmr(alias, ['.'], true)
const filename = join(await realpath(target), 'module.ts')
const expected = pathToFileURL(filename).href
const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
const observed: string[] = []
ctx.on('hmr/change', (url) => { observed.push(url) })
try {
const deadline = Date.now() + 20_000
for (let generation = 1; !observed.includes(expected); generation += 1) {
if (Date.now() >= deadline) {
throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
}
// The watch base, not the writer spelling, is the alias under test.
// Grow the file on every write: polling must not depend on timestamp
// precision when several generations land inside one filesystem tick.
writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
// Leave Chokidar's atomic-write window idle so one coalesced change can publish.
await new Promise(resolve => setTimeout(resolve, 250))
}
expect(cacheHas).toHaveBeenCalledWith(expected)
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
rmSync(target, { recursive: true, force: true })
}
})
it('collapses filesystem aliases before registering an exact watch', async () => {
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
const alias = `${target}-alias`
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const ctx = await bootHmr(alias)
try {
await ctx.hmr.registerConfig('plugins.yml', () => {})
await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
.rejects.toThrow('config path already registered')
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
rmSync(target, { recursive: true, force: true })
}
})
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
@@ -133,6 +190,9 @@ describe('HMR exact config paths', () => {
expect(observed.error).toBeInstanceOf(Error)
expect(observed.error.message).toBe('42')
// Let Chokidar's atomic-write window close before requiring a distinct
// second notification from the same path.
await new Promise(resolve => setTimeout(resolve, 250))
writeFileSync(filename, 'invalid again')
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
} finally {

View File

@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -11,6 +11,9 @@ import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@
const execFileAsync = promisify(execFile)
const roots: string[] = []
/** Normalize Git's platform checkout line endings for source-content assertions. */
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
async function temporaryRoot(name: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
roots.push(root)
@@ -116,9 +119,13 @@ describe('RepositoryCache', () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'build-helper'), { recursive: true })
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true })
await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
const shadowPnpm = join(root, 'shadow-pnpm')
await mkdir(shadowPnpm)
await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 })
await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n')
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
private: true,
@@ -135,38 +142,46 @@ describe('RepositoryCache', () => {
' .: {}',
'',
].join('\n'))
await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({
await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({
name: 'repository-build-helper',
version: '1.0.0',
bin: 'index.js',
})}\n`)
await writeFile(join(repository, 'build-helper', 'index.js'), [
await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [
'#!/usr/bin/env node',
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)",
"writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
scripts: {
// The fixture owns dependency installation, not platform-specific
// node_modules/.bin shim generation during pnpm's Git preparation.
prepack: [
'node ./node_modules/repository-build-helper/index.js',
'node ./node_modules/repository-prepare-helper/index.js',
].join(' && '),
},
devDependencies: {
'repository-build-helper': 'file:../build-helper',
'repository-prepare-helper': 'file:../prepare-helper',
'repository-build-helper': 'file:./build-helper',
'repository-prepare-helper': 'file:./prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
@@ -181,13 +196,22 @@ describe('RepositoryCache', () => {
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
vi.stubEnv('PNPM_HOME', shadowPnpm)
vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter))
vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n')
const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as {
path: string
pathExt: string
}
expect(environment.path.split(delimiter)).not.toContain(shadowPnpm)
expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
.resolves.toBe('repository skill source\n')
expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')))
.toBe('repository skill source\n')
await expect(readFile(join(installed, 'package.json'), 'utf8'))
.resolves.toContain('repository-plugin-fixture')
})

View File

@@ -166,7 +166,11 @@ describe('QueueDock', () => {
expect(view.getByText('remove me')).toBeTruthy()
expect(view.getByText('second')).toBeTruthy()
act(() => { finishUpdate?.() })
expect(updateQueue).toHaveBeenCalledOnce()
await act(async () => {
finishUpdate?.()
await Promise.resolve()
})
await waitFor(() => {
expect(header).toHaveProperty('disabled', false)
expect(header.getAttribute('aria-expanded')).toBe('false')

View File

@@ -19,7 +19,7 @@
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from 'shiki/engine/javascript'
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
@@ -139,15 +139,49 @@ const cssVariablesTheme = createCssVariablesTheme({
fontStyle: true,
})
/**
* The client regex engine compiles each TextMate pattern when its scanner is
* created. Shiki otherwise defers patterns longer than 3,000 characters until
* their first match; that compilation counts against Shiki's 500 ms per-line
* budget and can return a partial token stream under host contention. Eager
* compilation leaves the same budget in place for scanning user content.
*/
const regexEngine = createJavaScriptRegexEngine({
forgiving: true,
regexConstructor: pattern => defaultJavaScriptRegexConstructor(pattern, {
lazyCompileLength: Number.POSITIVE_INFINITY,
}),
})
let singleton: HighlighterCore | undefined
/** Representative paths through every boot grammar, compiled before user content is timed. */
const BOOT_GRAMMAR_WARMUPS = [
{ lang: 'typescript', code: 'const answer: number = 42' },
{ lang: 'shellscript', code: 'printf \'%s\\n\' "$HOME"' },
{ lang: 'json', code: '{"ready":true}' },
] as const
/** Construct and pre-tokenize the boot grammars outside the user-content scan budget. */
function createHighlighter(): HighlighterCore {
const instance = createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: LANGS,
engine: regexEngine,
})
for (const sample of BOOT_GRAMMAR_WARMUPS) {
instance.codeToTokens(sample.code, {
lang: sample.lang,
theme: 'css-variables',
tokenizeTimeLimit: 0,
})
}
return instance
}
/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
singleton ??= createHighlighter()
return singleton
}

View File

@@ -47,7 +47,7 @@ describe('highlightToHtml', () => {
// Once every grammar has registered, the same call highlights.
await vi.waitFor(() => {
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
})
}, { timeout: 5_000 })
})
})

View File

@@ -58,7 +58,7 @@ const STREAM_DOC = [
describe('incremental streaming rendering', () => {
for (const chunkSize of [1, 3, 7, 16]) {
it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => {
it(`matches a fresh render at every prefix (chunk=${chunkSize})`, { timeout: 20_000 }, () => {
const live = render(<MarkdownText text="" streaming />)
for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))

View File

@@ -445,7 +445,7 @@ describe('MarkdownText', () => {
const startedAt = performance.now()
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
expect(performance.now() - startedAt).toBeLessThan(1_000)
expect(performance.now() - startedAt).toBeLessThan(3_000)
expect(container.querySelector('.katex')).toBeNull()
})

View File

@@ -4111,17 +4111,18 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('warns when an asynchronous file-result projection fails', async () => {
it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => {
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const fs = ctx.fs as RecordingFileSystem
const agent = stubAgent('/')
const root = resolve('/')
const agent = stubAgent(root)
const failure = new Error('projection failed')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
fs.entries.set('/.git', { type: 'directory' })
fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' })
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' })
vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure })
ctx.emit('tools/result', stubToolExecution({
@@ -4134,7 +4135,7 @@ describe('dynamic nested workspace context injection', () => {
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure)
})
}, { timeout: 10_000 })
} finally {
await ctx.fiber.dispose()
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/scope/README.md
README.md: b73f99fdffb7e3dba5e4eb31b35ff623e8f3d57c
README.zh.md: dd54ef053a8b5d8507c5049dd924b3fbe85bf5ee
README.md: a8fbe97ae3b59f223bb52e44860439803fda420c
README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8

View File

@@ -2,12 +2,12 @@
English | [中文](README.zh.md)
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`setScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
## Public API
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` records the enclosing scope via `setScopeParent` before the scope is usable.
- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Ordinarily written once at mint; re-linking an existing key is the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the caller's contract — this relation cannot see what a session logged). A link closing a cycle throws. `scopeChainOf` returns `[key, parent, …]` nearest-first.
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal.
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first.
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.

View File

@@ -2,12 +2,12 @@
[English](README.md) | 中文
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件反向永不成立。agent loop智能体循环为每个实时 agent 创建一个作用域agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件反向永不成立。agent loop智能体循环为每个实时 agent 创建一个作用域agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
## 公开 API
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `setScopeParent` 记录其外围作用域。
- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。通常在创建时写入一次;对已有键重新认父空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是调用方的约定——该关系看不见会话记录了什么)。会闭环的链接直接抛错`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
- `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber继承标签嵌套作用域会遮蔽外层标签最近的标签生效
- `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式generatoreffect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置Cordis 按函数标识去重嵌套 effectyield 一个包装函数会使作用域 teardown 成为并行的同级操作)。
- `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。

View File

@@ -38,25 +38,49 @@ const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
*/
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
/**
* Record `parent` as `key`'s enclosing scope.
*
* Ordinarily set once when the child scope is minted ({@link createScope}'s
* `parent` option). Re-linking an existing key to a different parent is the
* blank-session recompose operation: valid only while nothing produced under
* the old parent is retained, which is the caller's contract to uphold — this
* relation cannot see what a session logged. A link that would close a cycle
* is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
*/
export function setScopeParent(key: ScopeKey, parent: ScopeKey): void {
/** The privileged handle to move one scope key's parent link. */
export interface ScopeParentBinding {
/**
* Re-link the bound key to a different parent, with the same cycle check as
* the bind. Valid only while nothing produced under the old parent is
* retained — the blank-session recompose contract, which the holder upholds
* because this relation cannot see what a session logged.
* @param parent - the new enclosing scope key.
*/
rebind(parent: ScopeKey): void
}
/** Cycle-checked write shared by the bind and every rebind. */
function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
}
scopeParents.set(key, parent)
}
/**
* Bind `parent` as `key`'s enclosing scope, once.
*
* A key that already has a parent throws: there is no open re-link path, so a
* scope's ancestry cannot be moved by anyone but the original binder, who
* alone receives the {@link ScopeParentBinding}. A link that would close a
* cycle is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
* @returns the binding that alone may re-link this key.
*/
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
if (scopeParents.has(key)) {
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
}
linkScopeParent(key, parent)
return {
rebind(next: ScopeKey): void {
linkScopeParent(key, next)
},
}
}
/**
* Read one key's enclosing scope.
* @param key - the scope key to inspect.
@@ -98,7 +122,7 @@ function scope(): void {}
/** Options accepted by {@link createScope}. */
export interface CreateScopeOptions {
/** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
parent?: ScopeKey
}
@@ -111,7 +135,7 @@ export interface CreateScopeOptions {
* @returns the scoped context and exact/shared disposal boundaries.
*/
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
if (options?.parent !== undefined) setScopeParent(key, options.parent)
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
@@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
/**
* Build an opaque receiver that preserves the base filter, admits untagged
* listeners globally, and admits tagged listeners for a matching key or any
* of its ancestors ({@link setScopeParent}): a listener owned by an enclosing
* of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
* scope receives every descendant scope's events, which is what lets one
* standing composition observe each of the agents composed under it. A tag
* BELOW the dispatch key stays excluded — events flow up the chain, never

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
@@ -166,22 +166,30 @@ describe('scope parent chain', () => {
expect(scopeParentOf(preset)).toBeUndefined()
expect(scopeChainOf(agent)).toEqual([agent, preset])
expect(scopeChainOf(undefined)).toEqual([])
expect(() => { setScopeParent(preset, agent) }).toThrow(/cycle/)
expect(() => { setScopeParent(preset, preset) }).toThrow(/cycle/)
expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/)
expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/)
})
it('re-links to a different parent (the blank-session recompose path)', () => {
it('re-links only through the binding held by the original binder', () => {
const ctx = new Context()
const presetA = { id: 'a' }
const presetB = { id: 'b' }
const agent = { id: 'agent' }
createScope(ctx, presetA)
createScope(ctx, presetB)
createScope(ctx, agent, { parent: presetA })
const binding = bindScopeParent(agent, presetA)
createScope(ctx, agent)
setScopeParent(agent, presetB)
// A bound key cannot be re-bound from the outside; only the binding moves it.
expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/)
binding.rebind(presetB)
expect(scopeChainOf(agent)).toEqual([agent, presetB])
// The rebind keeps the cycle check: a parent may not adopt its ancestor.
const child = { id: 'child' }
const childBinding = bindScopeParent(child, agent)
void childBinding
expect(() => { binding.rebind(child) }).toThrow(/cycle/)
})
it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {

View File

@@ -1589,7 +1589,7 @@ describe('per-agent presentation', () => {
})
it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => {
const { setScopeParent } = await import('@deepseek-ai/dsh-scope')
const { bindScopeParent } = await import('@deepseek-ai/dsh-scope')
const { ctx, systemPrompt } = await setup({ mode: 'native' })
registerEcho(ctx)
// The preset's standing scope declares once; the agent only PARENTS to it
@@ -1597,7 +1597,7 @@ describe('per-agent presentation', () => {
const standing = await mintAgentScope(ctx, 'preset:code-like')
standing.scope.ctx.tools.presentAs('code')
const joined = await mintAgentScope(ctx, 'joined-agent')
setScopeParent(joined.agent, standing.agent)
bindScopeParent(joined.agent, standing.agent)
const loner = await mintAgentScope(ctx, 'loner-agent')
expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
README.md: 6051c07628c0214f9396d12554719c5614610826
README.zh.md: 23f48080136b5d212bef34d931a74773c5da4fd3
README.md: 462618b990f8d07e9b855248db1e149b3c673964
README.zh.md: 050216bb4b4d22ef53ab0823eef09c1aaf7d1170

View File

@@ -47,7 +47,7 @@ The provider creates the directory `0700` and creates or atomically replaces the
## Hot reload
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
## Security boundary

View File

@@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-…
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
<a id="security-boundary"></a>

View File

@@ -42,7 +42,7 @@ import { mkdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { Document, parseDocument, type YAMLError } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
@@ -98,24 +98,27 @@ const GROUP_OTHER_BITS = 0o077
* here — so the check is skipped rather than faked, and the file's protection
* there is whatever the create and replace APIs express.
* @param filename - absolute path of the document.
* @throws when the file exists with group or other permission bits set.
* @throws when the path hierarchy is invalid or the file exists with group or other permission bits set.
*/
async function assertOwnerOnly(filename: string): Promise<void> {
/* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */
if (process.platform === 'win32') return
let mode: number
try {
mode = (await stat(filename)).mode
} catch (error) {
if (!isENOENT(error)) throw error
await canonicalizeWatchPath(filename)
return
}
/* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */
if (process.platform === 'win32') return
/* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */
const offending = mode & GROUP_OTHER_BITS
if (offending === 0) return
throw new Error(
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
+ ` run "chmod 600 ${filename}" before starting again`,
)
/* v8 ignore stop */
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
@@ -271,7 +274,7 @@ export class CredentialsLocal extends Credentials {
/* jscpd:ignore-start -- same watcher discipline as settings-local by design:
the serialized-refresh and quiesce-on-dispose shape is the reviewed
lifecycle contract, not accidental repetition. */
const watcher = chokidarWatch(this.spec.filename, {
const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,

View File

@@ -168,7 +168,7 @@ describe('layer ladder', () => {
expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
})
it('refuses a document other OS users can read', async () => {
it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
@@ -191,6 +191,13 @@ describe('layer ladder', () => {
.rejects.toThrow(/ENOTDIR/)
})
it('propagates a permission check rejected before the OS lookup', async () => {
const dir = await tempDir()
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials\0.yaml'), watch: false }))
.rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
})
it('propagates a read that fails for a reason other than absence', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
@@ -274,7 +281,7 @@ describe('document writes', () => {
const seen = updates(ctx)
await ctx.credentials.set(KEY, 'sk-fresh')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n')
expect((await stat(path)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
expect(seen).toEqual([KEY])
})

View File

@@ -78,7 +78,7 @@ describe('read-modify-write', () => {
const home = join(dir, 'home')
const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
if (process.platform !== 'win32') expect((await stat(home)).mode & 0o777).toBe(0o700)
})
})

View File

@@ -6,6 +6,25 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
const fsHarness = vi.hoisted(() => ({
nextReadError: undefined as NodeJS.ErrnoException | undefined,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
readFile: (async (path: unknown, ...rest: never[]) => {
const error = fsHarness.nextReadError
if (error !== undefined) {
fsHarness.nextReadError = undefined
throw error
}
return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
}) as typeof actual.readFile,
}
})
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
@@ -48,6 +67,7 @@ const KEY = credentialRef('DSH_CRED_PIPE')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
fsHarness.nextReadError = undefined
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
@@ -107,6 +127,21 @@ describe('watcher pipeline', () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' })
})
it('keeps the last good snapshot when the read fails after its permission check', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
const ctx = await boot({ path, debounceMs: 5 })
fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(fsHarness.nextReadError).toBeUndefined()
})
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' })
})
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')

View File

@@ -923,4 +923,20 @@ describe('E2B subprocess terminal service', () => {
await fiber.dispose()
await expect(terminal.terminate()).resolves.toBeUndefined()
})
it('contains an immediate automatic terminal release rejection before disposal retries it', async () => {
const { fiber, fake } = await service()
fake.groups = []
const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec())
const terminate = vi.spyOn(terminal, 'terminate')
.mockRejectedValueOnce(new Error('automatic release failed'))
fake.handle.succeed(0)
await terminal.done
await vi.waitFor(() => { expect(terminate).toHaveBeenCalledTimes(1) })
await new Promise(resolve => setTimeout(resolve, 0))
await fiber.dispose()
expect(terminate).toHaveBeenCalledTimes(2)
expect(fake.handle.disconnects).toBe(1)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
@@ -416,7 +416,7 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('snapshots a created project skill through catalog refresh and progressive loading', async () => {
it('snapshots a created project skill through catalog refresh and progressive loading', { timeout: 15_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-'))
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-'))
try {
@@ -449,6 +449,15 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(ToolFs)
ctx.on('tools/post-execute', async (exec, _result, next) => {
const decision = await next()
if (exec.callId === 'write-skill') {
await vi.waitFor(async () => {
expect((await ctx.skills.list({ cwd: root })).map(skill => skill.name)).toContain('hot-skill')
}, { timeout: 5_000 })
}
return decision
})
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('skill-refresh-session'),
@@ -491,7 +500,8 @@ describe('dsh-agent-spine-demo bundle', () => {
callId: event.data.message.source.callId,
isError: result.isError,
text: result.content.map(block => block.type === 'text' ? block.text : '').join('\n')
.replaceAll(root, '{{cwd}}'),
.replaceAll(root, '{{cwd}}')
.replaceAll(sep, '/'),
}]
}
return []

View File

@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {

View File

@@ -22,6 +22,29 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
import * as DirectoryPickerAuto from '../src/index.ts'
const renameControl = vi.hoisted(() => ({
attempts: 0,
failureCode: 'EPERM',
injectedFailures: 0,
remainingFailures: 0,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async rename(oldPath: string, newPath: string): Promise<void> {
renameControl.attempts++
if (renameControl.remainingFailures > 0) {
renameControl.remainingFailures--
renameControl.injectedFailures++
throw Object.assign(new Error(`injected rename failure for ${newPath}`), { code: renameControl.failureCode })
}
await actual.rename(oldPath, newPath)
},
}
})
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
@@ -41,6 +64,10 @@ afterEach(async () => {
}
root = undefined
fakeBin = undefined
renameControl.attempts = 0
renameControl.failureCode = 'EPERM'
renameControl.injectedFailures = 0
renameControl.remainingFailures = 0
})
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
@@ -163,9 +190,30 @@ describe('real Loader composition', () => {
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
await ctx.loader.remove(backendEntry.id)
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
renameControl.remainingFailures = 1
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
expect(entryNames(ctx)).not.toContain(NATIVE)
// Same self-dispose persistence as above: let the write land before teardown.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
expect(renameControl.injectedFailures).toBe(1)
expect(renameControl.remainingFailures).toBe(0)
expect(renameControl.attempts).toBeGreaterThanOrEqual(2)
})
it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx } = await loadComposition('127.0.0.1')
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
const include = [...ctx.loader.entries()]
.find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined
if (include === undefined) throw new Error('expected the root Include tree')
renameControl.failureCode = 'EIO'
renameControl.remainingFailures = 1
await autoEntry.fiber!.dispose()
await expect.poll(() => renameControl.injectedFailures).toBe(1)
await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' })
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
context = undefined
})
})

View File

@@ -272,7 +272,7 @@ describe('PiAiAdapter provider routing', () => {
await Promise.race([
server.responseClosed,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100)
setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 1_000)
}),
])

View File

@@ -275,10 +275,28 @@ describe('draft-provider model discovery', () => {
it('reports cancellation during the body read as an abort, not a raw reason', async () => {
const ctx = await harness()
const controller = new AbortController()
// Chunked, so the headers arrive and the cancellation lands mid-body.
const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 })
const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal })
setTimeout(() => { controller.abort('test cancellation') }, 40)
const bodyRead = Promise.withResolvers<undefined>()
vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
const signal = init?.signal
if (signal === undefined || signal === null) throw new Error('expected a discovery signal')
return new Response(new ReadableStream<Uint8Array>({
pull(stream) {
bodyRead.resolve(undefined)
return new Promise<void>((resolve) => {
signal.addEventListener('abort', () => {
stream.error(signal.reason)
resolve()
}, { once: true })
})
},
}))
})
const probe = ctx.llm.discoverModels('llm-pi-ai', {
baseURL: 'https://slow.example/v1',
signal: controller.signal,
})
await bodyRead.promise
controller.abort('test cancellation')
await expect(probe).rejects.toMatchObject({ code: 'ABORTED' })
})

View File

@@ -38,9 +38,9 @@ describe('lsp-local provider resolution', () => {
// A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
const bin = join(root, 'bin')
await mkdir(bin)
const exe = join(bin, 'fake-lsp')
await writeFile(exe, '#!/bin/sh\nexit 0\n')
await chmod(exe, 0o755)
const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp')
await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n')
if (process.platform !== 'win32') await chmod(exe, 0o755)
const ctx = new Context()
await ctx.plugin(Lsp)
@@ -49,7 +49,7 @@ describe('lsp-local provider resolution', () => {
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
env: { PATH: bin },
env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} },
extensionToLanguage: { '.ts': 'typescript' },
}))).resolves.toBeDefined()
await ctx.fiber.dispose()

View File

@@ -55,7 +55,7 @@ describe('renderUri', () => {
it('returns an absolute path for a file: URI outside the workspace', () => {
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
const uri = pathToFileURL(outside).href
expect(renderUri(uri, WS_URI)).toBe(outside)
expect(renderUri(uri, WS_URI)).toBe(outside.replaceAll('\\', '/'))
})
it('renders the workspace root itself as "."', () => {
@@ -83,7 +83,7 @@ describe('renderUri', () => {
})
it('preserves backslashes as ordinary POSIX filename characters', () => {
expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', WS_URI)).toBe('dir\\name/a.ts')
expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', 'file:///home/u/proj')).toBe('dir\\name/a.ts')
})
it('keeps malformed or mismatched URI coordinates verbatim', () => {

View File

@@ -8,7 +8,7 @@
* projection units exist exactly once, keyed per session inside the plugins
* themselves (they predate presets and were written for a shared world). An
* agent joins by having its scope key parented to the mount's
* ({@link setScopeParent}), which makes the mount's registrations visible to
* ({@link bindScopeParent}), which makes the mount's registrations visible to
* that agent's views and the mount's listeners receive that agent's events —
* and a host reader with no agent at all (a cold transcript read) resolves
* the same standing registrations by preset id.
@@ -24,7 +24,7 @@
import { stat } from 'node:fs/promises'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
@@ -202,6 +202,14 @@ export class AgentPresets extends Service {
*/
private readonly standing = new Map<string, Promise<StandingMount>>()
/**
* Parent bindings of the agents this roster composed, keyed by the agent's
* scope key. The binding is dsh-scope's only re-link capability; holding it
* here makes this service the sole authority that can move an agent between
* standing compositions. WeakMap: entries die with their agents.
*/
private readonly bindings = new WeakMap<ScopeKey, ScopeParentBinding>()
/**
* Compose one agent from a preset: ensure the preset's standing mount, then
* parent the agent's scope key to it so the mount's registrations and
@@ -222,7 +230,11 @@ export class AgentPresets extends Service {
}
const preset = await this.resolveMountable(id)
const standing = await this.ensureStanding(preset)
setScopeParent(agentKey, standing.key)
// The one bind of this agent's ancestry. The binding is the only re-link
// authority, held privately so nothing outside this roster can move a
// composed agent to another preset; a later recompose layer re-links
// through it under the caller-owned blank-session contract.
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
return preset
}
@@ -325,7 +337,10 @@ export class AgentPresets extends Service {
* and permanent, so the old composition stays for its other agents and the
* new one is ensured BEFORE the link moves. An unknown or unusable preset
* therefore throws with the agent exactly as it was — there is no torn-down
* state to restore.
* state to restore. The re-link runs through the binding this roster kept
* from the agent's mount — dsh-scope's only re-link authority. An agent
* that never composed one has nothing to re-link: the switch is then the
* agent's first bind, exactly a mount.
* @param agentCtx - the agent's scope context.
* @param id - the preset to compose the agent from instead.
* @returns the preset now installed.
@@ -338,7 +353,12 @@ export class AgentPresets extends Service {
}
const preset = await this.resolveMountable(id)
const standing = await this.ensureStanding(preset)
setScopeParent(agentKey, standing.key)
const binding = this.bindings.get(agentKey)
if (binding === undefined) {
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
} else {
binding.rebind(standing.key)
}
return preset
}

View File

@@ -16,7 +16,7 @@ import AgentPresets, {
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
} from '@deepseek-ai/dsh-agent-presets'
import type { Config } from '@deepseek-ai/dsh-agent-presets'
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
interface Context {
@@ -216,7 +216,7 @@ describe('rejecting a composition that cannot be used', () => {
const loner = createScope(ctx, { test: 'loner' })
expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
const orphan = createScope(ctx, { test: 'orphan' })
setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' })
bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' })
expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
})
@@ -410,8 +410,9 @@ describe('replacing a composition', () => {
})
it('composes an agent that had nothing installed', async () => {
// An agent created without a preset has no subtree to discard, so the
// swap is a plain mount rather than a restore-on-failure path.
// An agent created without a preset has no binding to re-link, so the
// switch is its first bind — exactly a mount — and once bound only the
// roster's kept binding can move it again.
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') })
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')

View File

@@ -95,7 +95,9 @@ type StubMode =
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
| 'incremental-fallback'
| 'empty-page-after-latest'
| 'paged-scrollback'
class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -165,6 +167,10 @@ class StubPtySession implements PtyBackendSession {
this.pendingText = ''
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
if (this.mode === 'incremental-fallback') {
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
}
if (this.mode === 'torn-status') {
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
this.scrollback += output
@@ -211,6 +217,19 @@ class StubPtySession implements PtyBackendSession {
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
}
const lines = this.scrollback.split('\n')
if (this.mode === 'paged-scrollback') {
const offset = request.offset ?? 0
const end = lines.length - offset
const start = Math.max(0, end - 3)
const returnedLines = end - start
return {
text: lines.slice(start, end).join('\n'),
totalLines: lines.length,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: this.historyTruncated,
}
}
return {
text: this.scrollback,
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
@@ -237,10 +256,10 @@ class StubPtySession implements PtyBackendSession {
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
}
private operation(done: Promise<ReturnType<StubPtySession['result']>>): PtySendOperation {
private operation(done: Promise<ReturnType<StubPtySession['result']>>, delta = ''): PtySendOperation {
return {
done,
readOutput: () => ({ delta: '', truncated: false }),
readOutput: () => ({ delta, truncated: false }),
cancel: () => false,
}
}
@@ -317,6 +336,10 @@ describe('tool-bash-persistent', () => {
session.mode = 'idle-then-normal'
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
@@ -402,6 +425,16 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
})
it('assembles retained output across backward scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'paged-scrollback'
session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')

View File

@@ -173,7 +173,8 @@ describe('DeepSeekHarness', () => {
it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
// vitest workers forbid chdir, so derive a RELATIVE path from the real
// process cwd to a temp worker dir; resolution is lexical either way.
const dir = await tempDir('sdk-client-relcwd-')
const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const recordFile = join(dir, 'init.jsonl')
const inner = join(dir, 'worker')
await mkdir(inner)
@@ -332,7 +333,11 @@ describe('HarnessClient', () => {
))
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
expect((await stat(sigtermFile)).isFile()).toBe(true)
if (process.platform === 'win32') {
await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' })
} else {
expect((await stat(sigtermFile)).isFile()).toBe(true)
}
})
it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {

View File

@@ -109,7 +109,7 @@ async function settleSubagent(
}
describe('HarnessSdkServer', () => {
it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => {
it('creates a harness agent and calls the configured OpenAI-compatible endpoint', { timeout: 15_000 }, async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
@@ -295,7 +295,7 @@ describe('HarnessSdkServer', () => {
}
})
it('creates an SDK session without an optional system prompt', async () => {
it('creates an SDK session without an optional system prompt', { timeout: 15_000 }, async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')

View File

@@ -128,7 +128,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async recompose(agentCtx: Context, id: string): Promise<AgentPreset>',
jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */',
jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore. The re-link runs through the binding this roster kept\n * from the agent\'s mount — dsh-scope\'s only re-link authority. An agent\n * that never composed one has nothing to re-link: the switch is then the\n * agent\'s first bind, exactly a mount.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */',
},
{
signature: 'async standingKeyFor(id?: string): Promise<ScopeKey>',

View File

@@ -1053,7 +1053,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', async () => {
it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', { timeout: 20_000 }, async () => {
const path = await temporaryPath()
const unchanged = header('unchanged')
const changed = header('changed')
@@ -1239,7 +1239,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
expect(ctx.sessionQuery).toBeUndefined()
})
it('resets a recognized incompatible schema but refuses unknown or foreign tables', async () => {
it('resets a recognized incompatible schema but refuses unknown or foreign tables', { timeout: 20_000 }, async () => {
const stalePath = await temporaryPath('stale.db')
const staleOwner = await liveContext({ path: stalePath })
await (staleOwner.sessionQuery as SessionQuerySqlite).close()

View File

@@ -31,7 +31,7 @@ function fakeAgent(session: Session): Agent {
}
describe('tool-session-query with the real SQLite provider', () => {
it('searches live prior-step history and a persisted same-workspace log', async () => {
it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-'))
temporaryDirectories.push(root)
const ctx = new Context()

View File

@@ -91,7 +91,10 @@ function isEEXIST(error: unknown): boolean {
async function assertDirectory(path: string): Promise<boolean> {
try {
const info = await stat(path)
// A bare drive root is already short, and Node rejects its extended-length
// spelling as EISDIR. Descendants retain the namespace for long-path probes.
const probe = path === parse(path).root ? path : toNamespacedPath(path)
const info = await stat(probe)
if (info.isDirectory()) return true
const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
@@ -141,7 +144,7 @@ export async function ensureDurableDirectoryWin32(target: string): Promise<void>
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
// Keep the staging component independent of the target basename so a legal
// 255-byte target component does not make mkdtemp's sibling name too long.
const staging = await mkdtemp(join(parent, '.dsh-mkdir-'))
const staging = await mkdtemp(toNamespacedPath(join(parent, '.dsh-mkdir-')))
try {
await publishNewFileWin32(staging, target)
} catch (error) {

View File

@@ -62,6 +62,17 @@ async function expectFlushError(promise: Promise<unknown>, message: RegExp): Pro
throw new Error('expected flush to reject')
}
async function expectFlushCode(promise: Promise<unknown>, codes: readonly string[]): Promise<void> {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(codes).toContain((error as NodeJS.ErrnoException).code)
return
}
throw new Error('expected flush to reject')
}
async function freshRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
dirs.push(dir)
@@ -1363,7 +1374,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
appendClosedTurn(s)
}, { inject: ['sessions'] }))
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/)
await expectFlushCode(ctx2.sessions.flush(s), ['EEXIST', 'ENOTDIR'])
await ctx2.fiber.dispose()
})

View File

@@ -92,11 +92,43 @@ async function importWithFilesystemMove(): Promise<typeof import('../src/win32.t
afterEach(async () => {
vi.doUnmock('koffi')
vi.doUnmock('node:fs/promises')
vi.doUnmock('node:path')
vi.resetModules()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
describe('Windows durable namespace helpers', () => {
it('keeps drive-root probes native while namespacing descendants', async () => {
const probes: string[] = []
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
stat: async (path: string) => {
probes.push(path)
return { isDirectory: () => true }
},
}
})
vi.doMock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>()
return {
...actual,
join: (...paths: string[]) => actual.win32.join(...paths),
parse: (path: string) => actual.win32.parse(path),
resolve: (...paths: string[]) => actual.win32.resolve(...paths),
toNamespacedPath: (path: string) => actual.win32.toNamespacedPath(path),
}
})
const { ensureDurableDirectoryWin32 } = await import('../src/win32.ts')
await ensureDurableDirectoryWin32('C:\\existing')
expect(probes).toEqual(['C:\\', '\\\\?\\C:\\existing'])
})
it('publishes a new file with write-through MoveFileExW semantics', async () => {
const { publishNewFileWin32 } = await importWithFilesystemMove()
const root = await tempRoot()

View File

@@ -1,4 +1,4 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -17,7 +17,6 @@ function tempHome(): string {
afterEach(() => {
for (const dir of dirs.splice(0)) {
chmodSync(dir, 0o700)
rmSync(dir, { recursive: true, force: true })
}
})
@@ -69,11 +68,10 @@ describe('getOrCreateAnonymousUserId', () => {
expect(id).toBe(winner)
})
it('returns a usable id when the home is unwritable, without persisting', () => {
it('returns a usable id when the home cannot contain files, without persisting', () => {
const home = tempHome()
const blocked = join(home, 'blocked')
mkdirSync(blocked)
chmodSync(blocked, 0o500)
writeFileSync(blocked, 'occupied\n')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } })
expect(id).toMatch(UUID)
expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4
README.zh.md: 2fe97435d56774351343b92fcbd5f80ee2680fd1
README.md: a0bc94630f78aa7c101e3eb0795a7d07e585e347
README.zh.md: 5d829effb2c2422e957b7b32c6e8a2cf715af659

View File

@@ -24,6 +24,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv.
- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.

View File

@@ -24,6 +24,7 @@
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值整体替换其中的注释随之一同被换掉。JSON 重新序列化,无注释。
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态因此其间写入的变更绝不会触发事件ready 时的对账补上这个启动缺口。
- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。
- **dispose资源释放在每种 watch 模式下都保证完全停稳。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
- **按内容抑制自写。** 提供方缓存最后可用文本watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath``resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。

View File

@@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Plugin config: file location and hot-reload behavior. */
@@ -235,7 +235,7 @@ export class SettingsLocal extends Settings {
// silently ignored or overwritten.
yield* super[Service.init]()
const watcher = this.spec.watch
? chokidarWatch(this.spec.filename, {
? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,

View File

@@ -86,7 +86,7 @@ describe('writer lock', () => {
expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n')
}, 10_000)
it('surfaces a non-contention lock failure as the write error', async () => {
it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
@@ -67,7 +67,7 @@ describe('boot and reads', () => {
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe('')
expect((await stat(path)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
@@ -128,7 +128,7 @@ describe('boot and reads', () => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('fails loud at boot when the document exists but is unreadable', async () => {
it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
@@ -137,6 +137,13 @@ describe('boot and reads', () => {
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
})
it('fails loud when the document path names a directory', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await mkdir(path)
await expect(boot({ path, watch: false })).rejects.toThrow(/EISDIR|directory/i)
})
it('fails loud on an unsupported extension', async () => {
const dir = await tempDir()
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
@@ -168,7 +175,7 @@ describe('persist', () => {
const written = await readFile(path, 'utf8')
expect(written).toContain('theme: light')
expect((await stat(path)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
// Atomic replace leaves no temp artifact behind.
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
@@ -203,7 +210,7 @@ describe('persist', () => {
expect(await readFile(victim, 'utf8')).toBe('precious')
expect((await lstat(path)).isSymbolicLink()).toBe(false)
expect((await stat(path)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await readFile(path, 'utf8')).toContain('theme: light')
})
@@ -337,16 +344,18 @@ describe('persist', () => {
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
it('rejects and recovers when the document path becomes a directory', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const backup = join(dir, 'settings.committed.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(dir, 0o500)
cleanups.push(() => chmod(dir, 0o700))
await rename(path, backup)
await mkdir(path)
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
await chmod(dir, 0o700)
await rm(path, { recursive: true })
await rename(backup, path)
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
expect(scope.get().theme).toBe('light')
// The failed persist must not poison the document write chain.
@@ -388,7 +397,9 @@ describe('watch', () => {
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await writeFile(path, 'ui-theme: [unclosed\n')
// Replace the external edit atomically so this case observes one complete
// invalid document instead of a transient empty file during truncation.
await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 })
// The bad edit must never take the live tree down or reset the value.
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
README.md: c130c8b525367d7a80e32f0684cd05c931b35944
README.zh.md: 878725e64413d3e17baf7693ee72b192a23292e6
README.md: aa25278750b5a1577eb567e50344fb3af425d71a
README.zh.md: 59abd5623da189d0b5d739eec56e034b553690b9

View File

@@ -44,11 +44,11 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
## Catalog Change Detection
Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks.
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks.
## Skill Format

View File

@@ -44,11 +44,11 @@
## 目录变更检测
现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;当 `watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents``skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
如果第一方文件系统 `write``edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录并返回其候选项供直接加载但会将观测标记为不完整因此不会缓存也不会作为权威模型目录发布。effect 释放会关闭所有 watcher并收束延迟回调。
如果第一方文件系统 `write``edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录并返回其候选项供直接加载但会将观测标记为不完整因此不会缓存也不会作为权威模型目录发布。effect 释放会关闭所有 watcher并收束延迟回调。
## skill 格式

View File

@@ -9,7 +9,7 @@
* @module @deepseek-ai/dsh-skill-local
*/
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { access, lstat, readdir, readFile, stat } from 'node:fs/promises'
import { unwatchFile, watchFile, type Stats } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { homedir } from 'node:os'
@@ -19,7 +19,7 @@ import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import {
BUNDLED_SKILL_RANK,
isSkillName,
@@ -394,7 +394,7 @@ class SkillWatchManager {
private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {
const watcher = state.watcher
if (watcher !== undefined && !state.unhealthy) {
const current = await resolveRootWatchMode(state.root.path)
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
// A child unlink can publish an empty catalog before root unlinkDir arrives.
// Discovery therefore revalidates the retained handle independently.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
@@ -436,11 +436,11 @@ class SkillWatchManager {
// service; keep skill filtering and invalidation here.
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
while (!this.closing && state.owners.size > 0) {
const mode = await resolveRootWatchMode(state.root.path)
const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
const watcher = mode.kind === 'ancestor'
? this.openAncestorWatcher(state, mode)
: await this.openRootWatcher(state, mode)
const current = await resolveRootWatchMode(state.root.path)
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
/* v8 ignore else -- A host path transition between the two probes is timing-dependent. */
if (sameWatchMode(mode, current)) return watcher
/* v8 ignore next -- Covered by the same host path transition guard. */
@@ -472,7 +472,7 @@ class SkillWatchManager {
): Promise<void> {
let current: RootWatchMode
try {
current = await resolveRootWatchMode(state.root.path)
current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
} catch (error) {
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
@@ -487,7 +487,9 @@ class SkillWatchManager {
private async openRootWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
const watcher = chokidar.watch(mode.anchor, {
persistent: false,
// Chokidar owns late native fs.watch errors only for persistent watchers;
// this provider's effect explicitly closes every handle at teardown.
persistent: true,
ignoreInitial: true,
depth: 1,
followSymlinks: this.config.followSymlinks,
@@ -525,7 +527,7 @@ class SkillWatchManager {
readiness.resolve(undefined)
})
for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) {
watcher.on(event, (path) => { this.handleWatchEvent(state, event, path) })
watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) })
}
try {
await readiness.promise
@@ -540,12 +542,14 @@ class SkillWatchManager {
private handleWatchEvent(
state: RootWatchState,
mode: Extract<RootWatchMode, { kind: 'root' }>,
event: SkillWatchEvent,
path: string,
): void {
if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return
const target = resolve(path)
if (this.closing || !isRelevantWatchEvent({ ...state.root, path: mode.anchor }, event, target)) return
this.queueInvalidation()
if (resolve(path) === state.root.path && event === 'unlinkDir') {
if (target === mode.anchor && event === 'unlinkDir') {
state.unhealthy = true
this.scheduleRewatch(state)
}
@@ -619,17 +623,21 @@ function resolveWatchConfig(config: Config): ResolvedWatchConfig {
}
}
async function resolveRootWatchMode(root: string): Promise<RootWatchMode> {
async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise<RootWatchMode> {
let candidate = root
while (true) {
try {
const info = await stat(candidate)
if (info.isDirectory()) {
if (candidate === root) return { kind: 'root', anchor: root }
const preserveRootLink = candidate === root
&& !followSymlinks
&& (await lstat(candidate)).isSymbolicLink()
const anchor = preserveRootLink ? resolve(candidate) : await canonicalizeWatchPath(candidate)
if (candidate === root) return { kind: 'root', anchor }
const firstSegment = relative(candidate, root).split(sep)[0]
/* v8 ignore next -- candidate is a strict ancestor of root. */
if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root }
return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) }
if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor }
return { kind: 'ancestor', anchor, nextPath: join(anchor, firstSegment) }
}
} catch (error) {
/* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'
import type { Stats } from 'node:fs'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,6 +11,7 @@ interface FakeWatcherControl {
emitter: EventEmitter
closeCalls: number
options: Record<string, unknown>
path: string
}
interface FakeWatchFileControl {
@@ -63,9 +64,9 @@ vi.mock('node:fs/promises', async (importOriginal) => {
vi.mock('chokidar', () => ({
default: {
watch(_path: unknown, options: Record<string, unknown>) {
watch(path: unknown, options: Record<string, unknown>) {
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
const control: FakeWatcherControl = { emitter, closeCalls: 0, options }
const control: FakeWatcherControl = { emitter, closeCalls: 0, options, path: String(path) }
emitter.close = async () => {
control.closeCalls += 1
if (watcherHarness.closeErrors > 0) {
@@ -114,6 +115,53 @@ beforeEach(() => {
})
describe('skill-local watcher failures', () => {
it('canonicalizes an existing root before opening its native watcher', async () => {
const target = await tempDir('skill-watch-canonical-target')
const aliasParent = await tempDir('skill-watch-canonical-alias')
const alias = join(aliasParent, 'alias')
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const root = join(alias, '.dsh/skills')
await writeSkill(root, 'canonical-skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(alias, '.dsh'),
agentsHome: join(alias, '.agents'),
watch: true,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill'])
expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root))
expect(watcherHarness.watchers[0]?.options.persistent).toBe(true)
await fiber.dispose()
})
it('preserves a symlink root when link following is disabled', async () => {
const target = await tempDir('skill-watch-link-target')
const aliasParent = await tempDir('skill-watch-link-alias')
const alias = join(aliasParent, 'skills')
await writeSkill(target, 'linked-skill')
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
includeDefaultRoots: false,
customSkillDirs: [alias],
watch: true,
watchFollowSymlinks: false,
})
try {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-skill'])
expect(watcherHarness.watchers[0]?.path).toBe(alias)
expect(watcherHarness.watchers[0]?.options.followSymlinks).toBe(false)
} finally {
await fiber.dispose()
await rm(aliasParent, { recursive: true, force: true })
await rm(target, { recursive: true, force: true })
}
})
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
const ctx = new Context()
@@ -205,24 +253,22 @@ describe('skill-local watcher failures', () => {
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected a root watcher')
first.emitter.emit('change', join(root, 'notes.txt'))
first.emitter.emit('change', join(first.path, 'notes.txt'))
first.emitter.emit('change', join(home, 'outside.md'))
first.emitter.emit('change', join(root, 'watched-skill/references.md'))
first.emitter.emit('change', join(root, '.system/SKILL.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/references.md'))
first.emitter.emit('change', join(first.path, '.system/SKILL.md'))
await settle()
expect(invalidations).toBe(0)
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
await settle()
expect(invalidations).toBe(1)
watcherHarness.closeErrors = 1
watcherHarness.startupErrors.push(new Error('runtime rewatch failed'))
first.emitter.emit('error', new Error('runtime watch failed'))
await settle()
await settle()
expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2)
await vi.waitFor(() => { expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) })
expect(invalidations).toBeGreaterThanOrEqual(2)
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'watched-skill' }],
@@ -230,7 +276,7 @@ describe('skill-local watcher failures', () => {
})
await fiber.dispose()
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
first.emitter.emit('error', new Error('late error'))
await settle()
})
@@ -254,9 +300,11 @@ describe('skill-local watcher failures', () => {
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlinkDir', root)
original.emitter.emit('unlinkDir', original.path)
await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) })
expect(watcherHarness.watchFiles.some(control => control.path === root)).toBe(true)
await vi.waitFor(() => {
expect(watcherHarness.watchFiles.some(control => control.path === original.path)).toBe(true)
})
await fiber.dispose()
})
@@ -280,11 +328,11 @@ describe('skill-local watcher failures', () => {
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md'))
original.emitter.emit('unlink', join(original.path, 'old-skill/SKILL.md'))
await settle()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
const missingRoot = watcherHarness.watchFiles.find(control => control.path === root)
const missingRoot = watcherHarness.watchFiles.find(control => control.path === original.path)
expect(missingRoot).toBeDefined()
await writeSkill(root, 'recreated-skill')
missingRoot!.listener({} as Stats, {} as Stats)

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import SkillService, {
isModelInvocable,
isUserInvocable,
@@ -1183,10 +1183,11 @@ describe('SkillService scoped layers', () => {
})
}
const agentKey = {}
setScopeParent(agentKey, scopeOf(presetA.ctx) as object)
const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object)
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a'])
// A blank-session recompose re-parents the same key without any registry write.
setScopeParent(agentKey, scopeOf(presetB.ctx) as object)
// A blank-session recompose re-links the same key through its binding
// without any registry write.
binding.rebind(scopeOf(presetB.ctx) as object)
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b'])
await presetA.dispose()
await presetB.dispose()

View File

@@ -1,4 +1,4 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
@@ -88,19 +88,23 @@ describe('json backend specifics', () => {
const unit = await backend.kv.open(descriptor)
await unit.putRecord('t', 'k', { v: 'committed' })
await unit.setGlobal({ g: 'committed' })
// Make every publish fail: revoke write permission on the root.
await chmod(root, 0o500)
const path = join(root, 'shape.json')
const backup = join(root, 'shape.committed.json')
// A directory at the publish target rejects atomic replacement on every host.
await rename(path, backup)
await mkdir(path)
await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow()
await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow()
await expect(unit.deleteRecord('t', 'k')).rejects.toThrow()
await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow()
await chmod(root, 0o700)
await rm(path, { recursive: true })
await rename(backup, path)
const snapshot = await unit.loadAll()
expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } })
expect(snapshot.global).toEqual({ g: 'committed' })
// The next successful publish must not carry rejected writes to disk.
await unit.putRecord('t', 'k3', { v: 'later' })
const text = await readFile(join(root, 'shape.json'), 'utf8')
const text = await readFile(path, 'utf8')
expect(text).not.toContain('rejected')
await backend.close()
})

View File

@@ -217,6 +217,13 @@ describe('sqlite backend specifics', () => {
await chmod(dir, 0o700)
})
it('propagates an invalid database filename before opening SQLite', async () => {
const path = await freshDbPath()
const backend = backendAt(`${path}\0invalid`)
await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/null bytes/i)
await backend.close()
})
it('preserves the mode of an existing database file', async () => {
if (process.platform === 'win32') return
const path = await freshDbPath()

View File

@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
@@ -108,14 +109,19 @@ describe('child env layering (through the subprocess seam)', () => {
// The spec.env layer merges after the seam's scrub, so the child's own
// explicitly-forwarded key survives while ambient credentials do not.
const running = spawnSubprocess({
argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'],
argv: [
process.execPath,
'--input-type=module',
'--eval',
'process.stdout.write(JSON.stringify([process.env.ACP_TEST_AMBIENT_SECRET_TOKEN ?? "absent", process.env.DEEPSEEK_API_KEY]))',
],
cwd: process.cwd(),
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 1000,
env: { DEEPSEEK_API_KEY: 'explicit' },
})
await running.done
expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]')
expect(running.collected.stdout!.readFrom(0).text).toBe('["absent","explicit"]')
} finally {
delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN
}
@@ -139,42 +145,50 @@ describe('child env layering (through the subprocess seam)', () => {
})
describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => {
const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
argv: ['bash', '-c', command],
const node = (source: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
argv: [process.execPath, '--input-type=module', '--eval', source],
cwd: process.cwd(),
stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
const expectHostTermination = (outcome: SubprocessOutcome, posixSignal: NodeJS.Signals): void => {
if (process.platform === 'win32') {
expect(outcome.signal).toBeNull()
expect(outcome.exitCode).not.toBe(0)
} else {
expect(outcome.signal).toBe(posixSignal)
}
}
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const child = bash('read -r line; exit 0')
const child = node('process.stdin.resume(); process.stdin.on("end", () => process.exit(0))')
await disposeAcpChild(child, 5_000)
const outcome = await child.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
})
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
const child = bash('sleep 60')
it('tier 2: an EOF-deaf child reaches the host terminate outcome', async () => {
const child = node('setInterval(() => {}, 60_000)')
await disposeAcpChild(child, 100)
const outcome = await child.done
expect(outcome.signal).toBe('SIGTERM')
expectHostTermination(outcome, 'SIGTERM')
})
it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => {
const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore')
it('tier 3: a TERM-trapping child reaches the host force-termination outcome', async () => {
const child = node('process.on("SIGTERM", () => {}); process.stdout.write("armed\\n"); setInterval(() => {}, 60_000)', 'ignore')
// Wait for the trap to arm so SIGTERM cannot race the default handler.
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await disposeAcpChild(child, 50)
const outcome = await child.done
expect(outcome.signal).toBe('SIGKILL')
expectHostTermination(outcome, 'SIGKILL')
})
it('observes a spawn-level rejection and returns without a process to reap', async () => {
const child = spawnSubprocess({
argv: ['bash', '-c', 'true'],
argv: [process.execPath, '--input-type=module', '--eval', ''],
cwd: '/nonexistent-dir-dsh-acp-ladder-test',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,

View File

@@ -4,10 +4,10 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -92,7 +92,7 @@ afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
}
observedSdkMessages.length = 0
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md
README.md: f10ebe0448b2942e2cad8efecb6be4681cf601a6
README.zh.md: ef107577afcdc81a64ea46b2e996d46a562d1505
README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4
README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b

View File

@@ -10,7 +10,7 @@ This package registers the fixed `codex` subagent provider. Each accepted run st
The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
@@ -48,7 +48,7 @@ Shipped profiles load this provider once on the host and start no Codex process
## Product compatibility and evidence
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`.
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`.
## Model Experience
@@ -84,7 +84,7 @@ Append-only: the new tool result follows the reusable parent request prefix.
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package.
- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.

View File

@@ -10,7 +10,7 @@
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"``agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应或是未知服务器请求此次运行就会失败。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应或是未知服务器请求此次运行就会失败。
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal``dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
@@ -48,7 +48,7 @@
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`
## 模型体验
@@ -84,7 +84,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **产品安装和账户状态由宿主管理**`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
- **仅返回最终文本**推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。

View File

@@ -49,7 +49,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@openai/codex": "0.146.0",
"@openai/codex": "0.147.0",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,5 +1,5 @@
/**
* Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC
* Minimal Codex app-server 0.147.0 protocol adapter. The shared JSON-RPC
* transport owns framing and request correlation; this module owns only the
* product methods, current thread/turn association, unattended approval
* responses, and terminal-answer selection.

View File

@@ -109,8 +109,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
env: { ...process.env, ...env },
})
expect(codexPackage.version).toBe('0.146.0')
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
expect(codexPackage.version).toBe('0.147.0')
expect(version.stdout.trim()).toBe('codex-cli 0.147.0')
const parent = {
id: 'deepseek-e2e-parent',

View File

@@ -27,6 +27,7 @@ import {
const execFileAsync = promisify(execFile)
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js')
const codexPackage = JSON.parse(readFileSync(
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
'utf8',
@@ -40,7 +41,7 @@ afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
@@ -139,18 +140,18 @@ function responseInputTexts(body: Record<string, unknown>): string[] {
})
}
describe('real @openai/codex 0.146.0 product', () => {
describe('real @openai/codex 0.147.0 product', () => {
it('passes the exact task and fake authentication to local Responses and returns exact text', async () => {
const sentinel = 'REAL_CODEX_SENTINEL_0_146_0'
const sentinel = 'REAL_CODEX_SENTINEL_0_147_0'
const task = 'Return the fixture sentinel exactly.'
const { harness, fixture } = await realHarness([
{ kind: 'complete', text: sentinel },
])
expect(codexPackage.version).toBe('0.146.0')
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
expect(codexPackage.version).toBe('0.147.0')
const version = await execFileAsync(process.execPath, [codexEntry, '--version'], {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
expect(version.stdout.trim()).toBe('codex-cli 0.147.0')
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: task }],
@@ -173,16 +174,32 @@ describe('real @openai/codex 0.146.0 product', () => {
}, 60_000)
it('cancels a real app-server command approval without executing the command', async () => {
const { harness, fixture } = await realHarness([
const command = process.platform === 'win32'
? 'cmd /c type nul > approval-side-effect'
: 'touch approval-side-effect'
const commandCalls = [
{
kind: 'functionCall',
name: 'exec_command',
arguments: {
cmd: 'touch approval-side-effect',
cmd: command,
sandbox_permissions: 'require_escalated',
justification: 'exercise the unattended approval boundary',
},
},
{
name: 'shell_command',
arguments: {
command,
sandbox_permissions: 'require_escalated',
justification: 'exercise the unattended approval boundary',
},
},
] as const
const { harness, fixture } = await realHarness([
{
kind: 'advertisedFunctionCall',
choices: commandCalls,
},
])
const sideEffect = join(harness.workspace, 'approval-side-effect')
const run = await harness.ctx.subagents.start('codex', {
@@ -199,9 +216,9 @@ describe('real @openai/codex 0.146.0 product', () => {
expect(existsSync(sideEffect)).toBe(false)
expect(fixture.requests).toHaveLength(1)
const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
expect(tools).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'function', name: 'exec_command' }),
]))
expect(commandCalls.some(call => tools.some(tool => (
tool.type === 'function' && tool.name === call.name
)))).toBe(true)
expect(fixture.requests.every(requestEntry =>
requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key',
)).toBe(true)

View File

@@ -22,6 +22,13 @@ export type ResponsesBehavior =
readonly name: string
readonly arguments: Record<string, unknown>
}
| {
readonly kind: 'advertisedFunctionCall'
readonly choices: readonly {
readonly name: string
readonly arguments: Record<string, unknown>
}[]
}
| { readonly kind: 'hold' }
/** Running package-private Responses fixture. */
@@ -86,7 +93,7 @@ function responseObject(text: string): Record<string, unknown> {
}
/**
* Build the minimal Responses SSE event sequence consumed by Codex 0.146.0.
* Build the minimal Responses SSE event sequence consumed by Codex 0.147.0.
* @param text - exact assistant answer.
* @returns ordered response lifecycle events.
*/
@@ -218,6 +225,18 @@ function closeServer(server: Server): Promise<void> {
})
}
function advertisedFunctionNames(body: Record<string, unknown>): Set<string> {
if (!Array.isArray(body.tools)) return new Set()
return new Set(body.tools.flatMap((tool): string[] => (
tool !== null
&& typeof tool === 'object'
&& (tool as Record<string, unknown>).type === 'function'
&& typeof (tool as Record<string, unknown>).name === 'string'
? [(tool as Record<string, unknown>).name as string]
: []
)))
}
/**
* Start a loopback-only Responses SSE fixture.
* @param script - one behavior per expected Responses request.
@@ -234,11 +253,12 @@ export async function startResponsesFixture(
openResponses.add(response)
response.on('close', () => { openResponses.delete(response) })
void readRequest(request).then((body) => {
const parsedBody = JSON.parse(body) as Record<string, unknown>
requests.push({
method: request.method,
path: request.url,
headers: request.headers,
body: JSON.parse(body) as Record<string, unknown>,
body: parsedBody,
})
started.resolve(undefined)
const behavior = behaviors.shift()
@@ -247,6 +267,14 @@ export async function startResponsesFixture(
response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
return
}
const advertisedCall = behavior.kind === 'advertisedFunctionCall'
? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name))
: undefined
if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) {
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } }))
return
}
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
@@ -254,9 +282,15 @@ export async function startResponsesFixture(
'x-request-id': 'req_fixture',
})
if (behavior.kind === 'hold') return
const events = behavior.kind === 'complete'
? completeResponsesEvents(behavior.text)
: functionCallEvents(behavior.name, behavior.arguments)
let events: Record<string, unknown>[]
if (behavior.kind === 'complete') {
events = completeResponsesEvents(behavior.text)
} else {
const call = behavior.kind === 'functionCall'
? behavior
: advertisedCall!
events = functionCallEvents(call.name, call.arguments)
}
for (const event of events) {
response.write(`data: ${JSON.stringify(event)}\n\n`)
}

View File

@@ -199,7 +199,7 @@ async function initializeWire(): Promise<{
wire.start()
const initializing = wire.initialize(new AbortController().signal)
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await initializing
expect(await child.peer.nextMethod('initialized')).toEqual({
jsonrpc: '2.0',
@@ -219,7 +219,7 @@ async function publishRun(
) {
const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await child.peer.nextMethod('initialized')
const threadStart = await child.peer.nextMethod('thread/start')
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
@@ -380,7 +380,7 @@ describe('CodexAppServerWire', () => {
requestAttestation: false,
},
})
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await initializing
await child.peer.nextMethod('initialized')
@@ -855,7 +855,7 @@ describe('run lifecycle and quiescence', () => {
void starting.then(() => { published = true })
const initialize = await child.peer.nextMethod('initialize')
expect(published).toBe(false)
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await child.peer.nextMethod('initialized')
const threadStart = await child.peer.nextMethod('thread/start')
expect(published).toBe(false)
@@ -962,7 +962,7 @@ describe('run lifecycle and quiescence', () => {
runSpec(child),
)
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await child.peer.nextMethod('initialized')
const threadStart = await child.peer.nextMethod('thread/start')
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
@@ -1032,7 +1032,7 @@ describe('run lifecycle and quiescence', () => {
signal: new AbortController().signal,
})
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await child.peer.nextMethod('initialized')
const threadStart = await child.peer.nextMethod('thread/start')
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })

View File

@@ -1055,7 +1055,7 @@ describe('SubagentService.listDescendants', () => {
})
it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => {
it('walks a deeply nested ordinary-session chain without consuming the call stack', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([])
const depth = 10_000
let parentId = parent.id
@@ -1078,7 +1078,7 @@ describe('SubagentService.listDescendants', () => {
}])
})
it('discovers continuable descendants below ordinary and one-shot intermediates', async () => {
it('discovers continuable descendants below ordinary and one-shot intermediates', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// An ordinary fork has no descriptor: omitted itself, subtree still walked.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))

View File

@@ -219,7 +219,9 @@ describe('dsh-tool-subagent-report', () => {
expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false)
adapter.release()
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
}, { timeout: 5_000 })
expect(reports(parent).map(report => report.text)).toEqual([
`Background subagent ${started.childId} reported:\nDURABLE_SELECTION`,
])
@@ -421,7 +423,9 @@ describe('dsh-tool-subagent-report result independence', () => {
const { ctx, parent, adapter } = await setup()
const { started } = await startChild(ctx, parent)
adapter.release()
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
}, { timeout: 5_000 })
expect(reports(parent)).toEqual([])
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])

View File

@@ -2507,9 +2507,10 @@ function mergeWorkspaceModels(models: readonly WorkspaceModel[]): WorkspaceModel
}
function parseConfig(path: string): ParsedConfig {
const read = ts.readConfigFile(path, file => ts.sys.readFile(file))
const compilerPath = path.split(sep).join('/')
const read = ts.readConfigFile(compilerPath, file => ts.sys.readFile(file))
if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error))
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path)
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(compilerPath), undefined, compilerPath)
if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n'))
return { path, parsed }
}

View File

@@ -10,6 +10,10 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
const temporaryRoots: string[] = []
function normalizedPath(path: string): string {
return path.replaceAll('\\', '/')
}
interface RuntimeSchema {
safeParse(value: unknown): { readonly success: boolean }
}
@@ -646,7 +650,8 @@ void navigated
const navigation = 'ctx.remote.goals.create'
const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1
const definitions = languageService.getDefinitionAtPosition(consumerPath, position)
const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath)
const generatedDefinition = definitions?.find(candidate =>
normalizedPath(candidate.fileName) === normalizedPath(declarationPath))
if (generatedDefinition === undefined) {
throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`)
}
@@ -661,7 +666,7 @@ void navigated
pos: generatedDefinition.textSpan.start,
})
languageService.dispose()
if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) {
if (definition === undefined || !normalizedPath(definition.fileName).endsWith('/packages/remote/src/index.ts')) {
throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`)
}
const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8')

View File

@@ -18,6 +18,11 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model')
const temporaryRoots: string[] = []
function normalizedPath(path: string): string {
return path.replaceAll('\\', '/')
}
const parseConfigHost: ts.ParseConfigFileHost = {
...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
@@ -733,8 +738,8 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
rootNames: packageConfig.fileNames,
options: aggregateConfig.options,
})
expect(diagnosticProgram.getSourceFiles().map(source => source.fileName))
.toContain(join(externalRoot, 'index.d.ts'))
expect(diagnosticProgram.getSourceFiles().map(source => normalizedPath(source.fileName)))
.toContain(normalizedPath(join(externalRoot, 'index.d.ts')))
const targets = new WorkspaceAnalyzer({ root }).analyze().faces
.flatMap(face => face.graph.nodes)

View File

@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
@@ -114,9 +114,9 @@ async function boot(): Promise<Context> {
async function linkZod(base: string): Promise<void> {
const { symlink } = await import('node:fs/promises')
const target = join(base, 'node_modules', 'zod')
const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '')
const source = fileURLToPath(new URL('.', import.meta.resolve('zod/package.json')))
await mkdir(join(base, 'node_modules'), { recursive: true })
await symlink(source, target, 'dir')
await symlink(source, target, process.platform === 'win32' ? 'junction' : 'dir')
}
function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType<Context['plugin']> {

View File

@@ -2,7 +2,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } fr
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { writeFileAtomic } from '../src/index.ts'
import { withFileLock, writeFileAtomic } from '../src/index.ts'
async function scratch(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
@@ -14,7 +14,7 @@ describe('writeFileAtomic', () => {
const target = join(dir, 'nested', 'deep', 'doc.yaml')
await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
expect(await readFile(target, 'utf8')).toBe('a: 1\n')
expect((await stat(target)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
})
it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
@@ -23,7 +23,7 @@ describe('writeFileAtomic', () => {
await writeFile(target, 'old', { mode: 0o644 })
await writeFileAtomic(target, 'new', { mode: 0o600 })
expect(await readFile(target, 'utf8')).toBe('new')
expect((await stat(target)).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
})
it('replaces a symlinked target itself without writing through to the referent', async () => {
@@ -46,3 +46,17 @@ describe('writeFileAtomic', () => {
expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
})
})
describe('withFileLock', () => {
it('rejects an invalid parent hierarchy before running the operation', async () => {
const dir = await scratch()
const parent = join(dir, 'not-a-directory')
await writeFile(parent, 'occupied')
let called = false
await expect(withFileLock(join(parent, 'document'), async () => {
called = true
})).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i)
expect(called).toBe(false)
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/paths/README.md
README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c
README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd
README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3
README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0

View File

@@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data.
`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched.
## Watch paths
`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend.
This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another.
## Known Limitations and Deferred Work
- **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged.
- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path.
- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path.

View File

@@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。
`expandHomePath()` 使用操作系统主目录展开 `~``~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。
## 监听路径
`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径再拼回缺失的后缀因此即使文件或目录尚未创建也仍可监听。尤其是Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。
该包刻意保持规模小且不依赖 harness以便产品包共享用户数据路径约定而不必彼此依赖。
## 已知限制与暂缓事项
- **展开范围刻意保持狭窄**:只有单独的 `~``~/...``~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。
- **辅助工具不会操作文件系统**调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。
- **规范化会读取,但绝不修改**`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。

View File

@@ -4,8 +4,9 @@
* @module @deepseek-ai/dsh-paths
*/
import { opendir, realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'
/** Directory name for the default DeepSeek Harness home under the OS home. */
export const DSH_HOME_DIR_NAME = '.dsh'
@@ -16,6 +17,43 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}`
/** Environment variable that overrides the default DeepSeek Harness home. */
export const DSH_HOME_ENV = 'DSH_HOME'
/**
* Give a native filesystem watcher one canonical spelling of a path, even
* when its final components do not exist yet. The deepest existing ancestor
* is resolved through {@link realpath}; when a suffix is missing, that
* ancestor is also proved to be an enumerable directory before the suffix is
* restored. This prevents Windows from treating a regular-file ancestor as
* ordinary absence, and prevents short-name aliases from being mixed with
* long paths emitted by the native watcher backend.
* @param path - Watch target or root, resolved against the current directory.
* @returns the target with its existing ancestor canonicalized.
* @throws when ancestor traversal encounters an error other than absence, or
* the existing ancestor of a missing suffix is not an enumerable directory.
*/
export async function canonicalizeWatchPath(path: string): Promise<string> {
let current = resolve(path)
const missing: string[] = []
while (true) {
try {
const canonical = await realpath(current)
if (missing.length > 0) {
// A Windows file-as-parent probe reports ENOENT. Opening the resolved
// ancestor preserves the cross-platform directory requirement.
const directory = await opendir(canonical)
await directory.close()
}
return join(canonical, ...missing.reverse())
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(current)
/* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */
if (parent === current) throw error
missing.push(basename(current))
current = parent
}
}
}
/**
* Resolve the default DeepSeek Harness home using Node's platform path rules.
* @returns the absolute default harness home path.

View File

@@ -1,9 +1,11 @@
import { homedir } from 'node:os'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_DSH_HOME_DISPLAY,
DSH_HOME_DIR_NAME,
canonicalizeWatchPath,
defaultDshHome,
dshHomeDisplay,
dshHomePath,
@@ -53,4 +55,22 @@ describe('dsh path helpers', () => {
expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh')
expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME')
})
it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-'))
const target = join(root, 'target')
const alias = join(root, 'alias')
try {
await mkdir(target)
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe(
join(await realpath(target), 'later', 'config.yml'),
)
const file = join(root, 'file')
await writeFile(file, 'not a directory')
await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
} finally {
await rm(root, { recursive: true, force: true })
}
})
})

View File

@@ -1239,22 +1239,18 @@ describe('dsh-workflow-workerthread', () => {
await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 })
const runEnds: WorkflowResultInfo[] = []
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
const childStarted = Promise.withResolvers<undefined>()
ctx.on('workflow/agent-start', () => { childStarted.resolve(undefined) })
const handle = ctx.workflows.start({
// The stray child's start RPC reaches the host, then the script kills
// its own worker through the documented vm escape — the host must
// settle `error` with the exit diagnostics and wind the child down.
...scripted(`
agent('doomed')
const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')()
await new Promise(resolve => st(resolve, 200))
proc.exit(7)
`),
...scripted("return await agent('doomed')"),
parent: fakeParent(),
})
const worker = (handle as unknown as { worker: Worker }).worker
await childStarted.promise
await worker.terminate()
const result = await handle.result
expect(result.stopReason).toBe('error')
expect(result.error).toContain('exit code 7')
expect(result.error).toContain('exit code 1')
expect(result.agentsStarted).toBe(1)
// A worker death is a stop reason like any other: workflow/end fires
// with the error outcome — for a bus observer it is the only obituary.
@@ -1306,26 +1302,22 @@ describe('dsh-workflow-workerthread', () => {
})
ctx.on('workflow/end', () => { order.push('run-end') })
const handle = ctx.workflows.start({
// Same choreography as the force-settle pairing test, but the worker
// DIES (the documented vm escape) instead of being terminated: the
// exit path must close slow's pair from the ledger too. The escaped
// setTimeout lets the already-posted messages flush before the kill.
...scripted(`
const p = agent('slow')
await agent('fast')
const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')()
await new Promise(resolve => st(resolve, 150))
proc.exit(7)
await new Promise(() => {})
`),
parent,
})
const worker = (handle as unknown as { worker: Worker }).worker
await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) })
const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')!
fast.settle(text('fast done'))
await waitFor(() => { expect(ends).toContainEqual({ seq: 2, outcome: 'completed' }) })
await worker.terminate()
const result = await handle.result
expect(result.stopReason).toBe('error')
expect(result.error).toContain('exit code 7')
expect(result.error).toContain('exit code 1')
expect(ends).toEqual([
{ seq: 2, outcome: 'completed' },
{ seq: 1, outcome: 'cancelled' },
@@ -1340,21 +1332,22 @@ describe('dsh-workflow-workerthread', () => {
// guard in post()).
const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 })
const handle = ctx.workflows.start({
// The STRAY child settles instantly, so its wrapper starts the slow
// host-side disposal concurrently while the script goes on to kill
// its own worker — the ack then resolves into a dead thread.
...scripted(`
agent('stray, never awaited')
const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')()
await new Promise(resolve => st(resolve, 150))
proc.exit(5)
await new Promise(() => {})
`),
parent,
})
const worker = (handle as unknown as { worker: Worker }).worker
await waitFor(() => {
expect(provider.runs).toHaveLength(1)
expect(provider.runs[0]!.disposeCalls).toBe(1)
expect(provider.runs[0]!.disposed).toBe(false)
})
await worker.terminate()
const result = await handle.result
expect(result.stopReason).toBe('error')
expect(result.error).toContain('exit code 5')
expect(result.error).toContain('exit code 1')
// Result already settled — this is the reap's promptness (bounded
// above the mock's fixed 300ms dispose delay, not a cold-start race);
// tight explicit bound (see the helper's doc comment).
@@ -1366,20 +1359,19 @@ describe('dsh-workflow-workerthread', () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } })
const handle = ctx.workflows.start({
...scripted(`
const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')()
log('armed')
await new Promise(resolve => st(resolve, 400))
proc.exit(3)
await new Promise(() => {})
`),
parent,
})
const worker = (handle as unknown as { worker: Worker }).worker
const logs: string[] = []
ctx.on('workflow/log', (_info, message) => { logs.push(message) })
await waitFor(() => { expect(logs).toContain('armed') })
handle.cancel('stop it')
// The grace is deliberately huge: only the worker's own death (exit 3,
// unreachable by the cancel — the script ignores hooks) settles this.
// The grace is deliberately huge: only the host-triggered worker death,
// not the cancellation timer, settles this.
await worker.terminate()
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('stop it')