Merge remote-tracking branch 'origin/master' into codex/pr-555-ci-fix

This commit is contained in:
creatixchu
2026-08-10 11:28:25 +08:00
86 changed files with 1026 additions and 370 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

@@ -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

@@ -167,7 +167,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

@@ -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. */

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

@@ -343,7 +343,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

@@ -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

@@ -1050,7 +1050,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')
@@ -1236,7 +1236,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)
@@ -1342,7 +1353,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

@@ -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,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

@@ -3,9 +3,9 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -90,7 +90,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: c75a98550894f180d0f37e9cdd135b961488f726
README.zh.md: 0dd3280b363c1a26250aa00cfeba5398114a2e3e
README.md: 686c1f4d47f9024bfe66a4b85490bf0f84610b61
README.zh.md: afe5433a1d0453b25e346bd7a8a33006a055309c

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.
@@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe
## 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
@@ -83,7 +83,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 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
@@ -47,7 +47,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`
## 模型体验
@@ -83,7 +83,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

@@ -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')