app-boot.spec.ts 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123
  1. import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve, sep } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { afterAll, describe, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  8. import {
  9. addHarnessSourceSection, auditStartupEntries, boot,
  10. FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
  11. installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
  12. } from '../src/index.ts'
  13. const NAME = 'dsh-test-bin'
  14. const tempRoots: string[] = []
  15. afterAll(() => {
  16. for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  17. })
  18. const tmp = (): string => {
  19. const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
  20. tempRoots.push(dir)
  21. return dir
  22. }
  23. describe('resolveConfigPath', () => {
  24. it('resolves relative to the given cwd outside replay mode', () => {
  25. expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
  26. expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  27. })
  28. it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
  29. expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
  30. expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  31. })
  32. it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
  33. expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
  34. expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  35. })
  36. })
  37. describe('loadEnv', () => {
  38. it('loads variables from .env in the given dir', () => {
  39. const dir = tmp()
  40. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  41. const warn = vi.fn()
  42. loadEnv(NAME, dir, warn)
  43. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  44. expect(warn).not.toHaveBeenCalled()
  45. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  46. })
  47. it('stays silent when no .env exists (ambient environment wins)', () => {
  48. const warn = vi.fn()
  49. loadEnv(NAME, tmp(), warn)
  50. expect(warn).not.toHaveBeenCalled()
  51. })
  52. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  53. const dir = tmp()
  54. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  55. const warn = vi.fn()
  56. loadEnv(NAME, dir, warn)
  57. expect(warn).toHaveBeenCalledTimes(1)
  58. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  59. })
  60. it('defaults dir to the process cwd and warn to a stderr write', () => {
  61. const dir = tmp()
  62. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  63. const previous = process.cwd()
  64. process.chdir(dir)
  65. try {
  66. loadEnv(NAME) // happy path: the default warn sink is never invoked
  67. } finally {
  68. process.chdir(previous)
  69. }
  70. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  71. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  72. // The default warn sink itself: point it at a broken .env with stderr
  73. // spied, so the arrow body runs without polluting the test output.
  74. const broken = tmp()
  75. mkdirSync(join(broken, '.env'))
  76. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  77. let written: string[]
  78. try {
  79. loadEnv(NAME, broken)
  80. written = write.mock.calls.map(call => String(call[0]))
  81. } finally {
  82. write.mockRestore()
  83. }
  84. expect(written).toHaveLength(1)
  85. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  86. })
  87. })
  88. describe('loadLayeredEnv', () => {
  89. const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
  90. function clear(): void {
  91. for (const name of NAMES) Reflect.deleteProperty(process.env, name)
  92. }
  93. it('layers user under project under the inherited environment', () => {
  94. const home = tmp()
  95. const project = tmp()
  96. writeFileSync(join(home, '.env'), [
  97. `${NAMES[0]}=user`,
  98. `${NAMES[1]}=user-only`,
  99. 'APP_BOOT_LAYERED_INHERITED=user-loses',
  100. '',
  101. ].join('\n'))
  102. writeFileSync(join(project, '.env'), [
  103. `${NAMES[0]}=project`,
  104. `${NAMES[2]}=project-only`,
  105. 'APP_BOOT_LAYERED_INHERITED=project-loses',
  106. '',
  107. ].join('\n'))
  108. clear()
  109. vi.stubEnv('DSH_HOME', home)
  110. vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
  111. const warn = vi.fn()
  112. try {
  113. loadLayeredEnv(NAME, project, warn)
  114. expect(process.env[NAMES[0]]).toBe('project')
  115. expect(process.env[NAMES[1]]).toBe('user-only')
  116. expect(process.env[NAMES[2]]).toBe('project-only')
  117. expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
  118. expect(warn).not.toHaveBeenCalled()
  119. } finally {
  120. clear()
  121. vi.unstubAllEnvs()
  122. }
  123. })
  124. it.each([
  125. ['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
  126. ['the executable search path', 'PATH=/tmp/evil\n'],
  127. ['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
  128. ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
  129. ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
  130. ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
  131. ['a browser command', 'BROWSER=./script\n'],
  132. ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
  133. const home = tmp()
  134. const project = tmp()
  135. writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
  136. clear()
  137. vi.stubEnv('DSH_HOME', home)
  138. try {
  139. expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
  140. expect(process.env[NAMES[1]]).toBeUndefined()
  141. } finally {
  142. clear()
  143. vi.unstubAllEnvs()
  144. }
  145. })
  146. const PROXY = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const
  147. function clearProxy(): void {
  148. for (const name of PROXY) Reflect.deleteProperty(process.env, name)
  149. }
  150. it('accepts the proxy names from the Harness-home .env, below an exported one', () => {
  151. const home = tmp()
  152. const project = tmp()
  153. // Both casings, because a shell profile writes either and the rejection matches both. Each
  154. // spelling gets its own name here: Windows folds `https_proxy` and `HTTPS_PROXY` into one
  155. // variable, so which spelling a value lands under is the platform's to decide — that the file
  156. // supplies it, and that the launching shell outranks the file, is not.
  157. writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\nno_proxy=example.com\nHTTPS_PROXY=http://from-home:8443\n')
  158. clear(); clearProxy()
  159. vi.stubEnv('DSH_HOME', home)
  160. vi.stubEnv('HTTPS_PROXY', 'http://exported:8080')
  161. try {
  162. const snapshot = loadLayeredEnv(NAME, project, vi.fn())
  163. expect(snapshot.get('HTTP_PROXY')).toEqual({ value: 'http://from-home:8080', source: 'user-env', path: join(home, '.env') })
  164. expect(snapshot.get('no_proxy')).toEqual({ value: 'example.com', source: 'user-env', path: join(home, '.env') })
  165. // The launching shell still outranks the file for the same variable.
  166. expect(snapshot.get('HTTPS_PROXY')).toEqual({ value: 'http://exported:8080', source: 'process' })
  167. expect(process.env.HTTP_PROXY).toBe('http://from-home:8080')
  168. expect(process.env.HTTPS_PROXY).toBe('http://exported:8080')
  169. } finally {
  170. clear(); clearProxy()
  171. vi.unstubAllEnvs()
  172. }
  173. })
  174. it('still refuses every other bootstrap name in the Harness-home .env', () => {
  175. const home = tmp()
  176. const project = tmp()
  177. // A CA path sits in the same network group as the proxy names and changes what is trusted,
  178. // not where traffic goes; the exemption must not widen to it.
  179. writeFileSync(join(home, '.env'), 'SSL_CERT_FILE=/tmp/ca.pem\n')
  180. clear()
  181. vi.stubEnv('DSH_HOME', home)
  182. try {
  183. expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
  184. } finally {
  185. clear()
  186. vi.unstubAllEnvs()
  187. }
  188. })
  189. it('names the Harness-home file as the way out when a project .env sets a proxy', () => {
  190. const home = tmp()
  191. const project = tmp()
  192. writeFileSync(join(project, '.env'), 'HTTP_PROXY=http://attacker.example\n')
  193. clear(); clearProxy()
  194. vi.stubEnv('DSH_HOME', home)
  195. try {
  196. expect(() => loadLayeredEnv(NAME, project, vi.fn()))
  197. .toThrow(`export HTTP_PROXY, or put it in ${join(home, '.env')}, which does not travel with a repository`)
  198. expect(process.env.HTTP_PROXY).toBeUndefined()
  199. } finally {
  200. clear(); clearProxy()
  201. vi.unstubAllEnvs()
  202. }
  203. })
  204. it('treats the invoking directory as the Harness home when they are the same directory', () => {
  205. const home = tmp()
  206. writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\n')
  207. clear(); clearProxy()
  208. vi.stubEnv('DSH_HOME', home)
  209. try {
  210. // Launched from inside the home itself, its one file is read as the project layer; the
  211. // exemption follows the directory, not the layer name.
  212. expect(loadLayeredEnv(NAME, home, vi.fn()).get('HTTP_PROXY')?.value).toBe('http://from-home:8080')
  213. } finally {
  214. clear(); clearProxy()
  215. vi.unstubAllEnvs()
  216. }
  217. })
  218. it('reports each file value with its absolute path', () => {
  219. const home = tmp()
  220. const project = tmp()
  221. writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
  222. writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
  223. clear()
  224. vi.stubEnv('DSH_HOME', home)
  225. try {
  226. const snapshot = loadLayeredEnv(NAME, project, vi.fn())
  227. expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
  228. expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') })
  229. expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
  230. } finally {
  231. clear()
  232. vi.unstubAllEnvs()
  233. }
  234. })
  235. it('resolves the harness home from the inherited environment, never from a file', () => {
  236. const home = tmp()
  237. const project = tmp()
  238. writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
  239. writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
  240. clear()
  241. vi.stubEnv('DSH_HOME', home)
  242. try {
  243. loadLayeredEnv(NAME, project, vi.fn())
  244. expect(process.env[NAMES[1]]).toBe('real-home')
  245. expect(process.env[NAMES[2]]).toBe('set-by-project')
  246. } finally {
  247. clear()
  248. vi.unstubAllEnvs()
  249. }
  250. })
  251. it('warns and continues when a layer exists but cannot be read', () => {
  252. const home = tmp()
  253. const project = tmp()
  254. // A directory named `.env` is a present-but-unreadable layer.
  255. mkdirSync(join(home, '.env'))
  256. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  257. clear()
  258. vi.stubEnv('DSH_HOME', home)
  259. const warn = vi.fn()
  260. try {
  261. const snapshot = loadLayeredEnv(NAME, project, warn)
  262. expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
  263. expect(snapshot.get(NAMES[1])).toBeUndefined()
  264. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  265. expect(process.env[NAMES[2]]).toBe('project-only')
  266. } finally {
  267. clear()
  268. vi.unstubAllEnvs()
  269. }
  270. })
  271. it('reports to stderr when the caller supplies no reporter', () => {
  272. const home = tmp()
  273. const project = tmp()
  274. mkdirSync(join(home, '.env'))
  275. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  276. clear()
  277. vi.stubEnv('DSH_HOME', home)
  278. const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
  279. try {
  280. const snapshot = loadLayeredEnv(NAME, project)
  281. expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
  282. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  283. expect(process.env[NAMES[2]]).toBe('project-only')
  284. } finally {
  285. write.mockRestore()
  286. clear()
  287. vi.unstubAllEnvs()
  288. }
  289. })
  290. it('passes over an absent layer without reporting it', () => {
  291. const home = tmp()
  292. const project = tmp()
  293. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  294. clear()
  295. vi.stubEnv('DSH_HOME', home)
  296. const warn = vi.fn()
  297. try {
  298. const snapshot = loadLayeredEnv(NAME, project, warn)
  299. expect(warn).not.toHaveBeenCalled()
  300. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  301. } finally {
  302. clear()
  303. vi.unstubAllEnvs()
  304. }
  305. })
  306. it('carries only the inherited environment when neither file exists', () => {
  307. const home = tmp()
  308. const project = tmp()
  309. clear()
  310. vi.stubEnv('DSH_HOME', home)
  311. vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
  312. try {
  313. const snapshot = loadLayeredEnv(NAME, project, vi.fn())
  314. expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' })
  315. } finally {
  316. clear()
  317. vi.unstubAllEnvs()
  318. }
  319. })
  320. it('reads a harness home that is also the invocation directory exactly once', () => {
  321. const both = tmp()
  322. writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`)
  323. clear()
  324. vi.stubEnv('DSH_HOME', both)
  325. try {
  326. const snapshot = loadLayeredEnv(NAME, both, vi.fn())
  327. expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') })
  328. } finally {
  329. clear()
  330. vi.unstubAllEnvs()
  331. }
  332. })
  333. })
  334. describe('installFailLoud', () => {
  335. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  336. const handlers: Array<(err: unknown) => void> = []
  337. const written: string[] = []
  338. const exits: number[] = []
  339. return {
  340. handlers, written, exits,
  341. on: (_event, handler) => { handlers.push(handler) },
  342. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  343. stderr: { write: (chunk: string) => { written.push(chunk) } },
  344. exit: (code: number) => { exits.push(code) },
  345. }
  346. }
  347. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  348. const proc = fakeProc()
  349. installFailLoud(NAME, proc)
  350. const error = new Error('boom')
  351. proc.handlers[0]!(error)
  352. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  353. expect(proc.written[0]).toContain(error.stack)
  354. expect(proc.exits).toEqual([1])
  355. })
  356. // One rejection is reported per install: the first is the diagnosis, so each
  357. // formatting case needs its own handler rather than reusing a latched one.
  358. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  359. const plain = fakeProc()
  360. installFailLoud(NAME, plain)
  361. plain.handlers[0]!('plain failure')
  362. expect(plain.written[0]).toContain('plain failure')
  363. expect(plain.exits).toEqual([1])
  364. const stackless = new Error('no stack')
  365. delete (stackless as { stack?: string }).stack
  366. const bare = fakeProc()
  367. installFailLoud(NAME, bare)
  368. bare.handlers[0]!(stackless)
  369. expect(bare.written[0]).toContain('no stack')
  370. expect(bare.exits).toEqual([1])
  371. })
  372. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  373. const proc = fakeProc()
  374. const uninstall = installFailLoud(NAME, proc)
  375. expect(proc.handlers).toHaveLength(1)
  376. uninstall()
  377. expect(proc.handlers).toHaveLength(0)
  378. // Default-proc arm: install on the real process, then immediately uninstall
  379. // so the suite leaks no handler and can never exit the runner.
  380. const before = process.listenerCount('unhandledRejection')
  381. const uninstallReal = installFailLoud(NAME)
  382. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  383. uninstallReal()
  384. expect(process.listenerCount('unhandledRejection')).toBe(before)
  385. })
  386. it('does not report an activation rejection shared by entries in the boot audit', async () => {
  387. const proc = fakeProc()
  388. installFailLoud(NAME, proc)
  389. const error = new Error('assembled activation failure')
  390. const warn = vi.fn()
  391. const audit = auditStartupEntries({
  392. loader: {
  393. entries: () => ['broken-a', 'broken-b'].map(name => ({
  394. options: { id: name, name },
  395. fiber: {
  396. state: 3,
  397. inject: {},
  398. ctx: { get: () => undefined },
  399. await: async () => { throw error },
  400. },
  401. })),
  402. },
  403. } as unknown as Context, NAME, warn)
  404. await Promise.resolve()
  405. await Promise.resolve()
  406. proc.handlers[0]!(error)
  407. expect(proc.written).toEqual([])
  408. expect(proc.exits).toEqual([])
  409. await audit
  410. expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
  411. proc.handlers[0]!(error)
  412. expect(proc.exits).toEqual([1])
  413. })
  414. // The Loader mounts entries concurrently, so a terminal-owning surface can
  415. // already hold raw mode when a sibling entry rejects. Exiting without running
  416. // its teardown strands the terminal on the user's shell.
  417. it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
  418. const proc = fakeProc()
  419. const order: string[] = []
  420. installFailLoud(NAME, proc, async () => {
  421. await Promise.resolve()
  422. order.push('released')
  423. })
  424. proc.handlers[0]!(new Error('sibling entry rejected'))
  425. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  426. // The release is in flight, so the exit has not committed yet.
  427. expect(proc.exits).toEqual([])
  428. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  429. expect(order).toEqual(['released'])
  430. })
  431. it('still exits when the release hook rejects', async () => {
  432. const proc = fakeProc()
  433. installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
  434. proc.handlers[0]!(new Error('boom'))
  435. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  436. })
  437. it('exits without waiting when a release hook never settles', async () => {
  438. vi.useFakeTimers()
  439. try {
  440. const proc = fakeProc()
  441. installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
  442. proc.handlers[0]!(new Error('boom'))
  443. expect(proc.exits).toEqual([])
  444. await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
  445. expect(proc.exits).toEqual([1])
  446. } finally {
  447. vi.useRealTimers()
  448. }
  449. })
  450. // Loader failures arrive in bursts, and teardown's own disposers may reject.
  451. // Only the first rejection is the diagnosis; the handler must stay installed
  452. // so a later one cannot become uncaught and kill the process mid-teardown.
  453. it('reports only the first rejection and keeps handling later ones during the release', async () => {
  454. const proc = fakeProc()
  455. let released = false
  456. installFailLoud(NAME, proc, async () => {
  457. await Promise.resolve()
  458. released = true
  459. })
  460. proc.handlers[0]!(new Error('first rejection'))
  461. proc.handlers[0]!(new Error('second rejection'))
  462. expect(proc.handlers).toHaveLength(1)
  463. expect(proc.written).toHaveLength(1)
  464. expect(proc.written[0]).toContain('first rejection')
  465. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  466. expect(released).toBe(true)
  467. })
  468. })
  469. describe('auditStartupEntries', () => {
  470. const requiredIds = [
  471. 'agent-loop',
  472. 'webserver',
  473. 'modules',
  474. 'connection',
  475. 'headless-runner',
  476. 'acp',
  477. 'sdk-jsonrpc-server',
  478. ]
  479. interface FakeEntry {
  480. fiber?: {
  481. state: number
  482. inject: Record<string, unknown>
  483. ctx: { get(name: string): unknown }
  484. await(): Promise<unknown>
  485. }
  486. disabled?: boolean
  487. options: { id: string; name: string }
  488. }
  489. const ctxWith = (entries: FakeEntry[]): Context => ({
  490. loader: { entries: () => entries.values() },
  491. }) as unknown as Context
  492. const fiber = (
  493. state: number,
  494. error?: unknown,
  495. inject: Record<string, unknown> = {},
  496. services: string[] = [],
  497. ): NonNullable<FakeEntry['fiber']> => ({
  498. state,
  499. inject,
  500. ctx: { get: name => services.includes(name) ? {} : undefined },
  501. await: error === undefined ? async () => undefined : async () => { throw error },
  502. })
  503. it('ignores active, disabled, and absent required entries', async () => {
  504. const warn = vi.fn()
  505. await expect(auditStartupEntries(ctxWith([]), NAME, warn)).resolves.toBeUndefined()
  506. for (const disabled of [false, true]) {
  507. await expect(auditStartupEntries(ctxWith(requiredIds.map(id => ({
  508. fiber: disabled ? fiber(3, new Error('disabled failure')) : fiber(2),
  509. disabled,
  510. options: { id, name: './required.mjs' },
  511. }))), NAME, warn)).resolves.toBeUndefined()
  512. }
  513. expect(warn).not.toHaveBeenCalled()
  514. })
  515. it('warns once for optional import, apply, and dependency failures', async () => {
  516. const warn = vi.fn()
  517. const original = new Error('todo apply failure')
  518. await auditStartupEntries(ctxWith([
  519. { options: { id: 'missing-tool', name: './missing.mjs' } },
  520. { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
  521. {
  522. fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']),
  523. options: { id: 'waiting-tool', name: './waiting.mjs' },
  524. },
  525. ]), NAME, warn)
  526. expect(warn).toHaveBeenCalledOnce()
  527. expect(warn).toHaveBeenCalledWith([
  528. `${NAME}: warning: 3 entries did not activate`,
  529. 'missing-tool (./missing.mjs): failed to import',
  530. `tool-todo (@deepseek-ai/dsh-tool-todo): ${original.stack!}`,
  531. 'waiting-tool (./waiting.mjs): pending (waiting for service: missing)',
  532. '',
  533. ].join('\n'))
  534. })
  535. it.each([
  536. { id: 'tool-todo', required: false },
  537. { id: 'webserver', required: true },
  538. ])('reports a throwing disabled expression on $id (required: $required)', async ({ id, required }) => {
  539. const error = new Error('disabled evaluation failed')
  540. const warn = vi.fn()
  541. const result = auditStartupEntries(ctxWith([{
  542. options: { id, name: './plugin.mjs' },
  543. get disabled(): boolean { throw error },
  544. }]), NAME, warn)
  545. const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}`
  546. if (required) {
  547. await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`)
  548. expect(warn).not.toHaveBeenCalled()
  549. } else {
  550. await expect(result).resolves.toBeUndefined()
  551. expect(warn).toHaveBeenCalledExactlyOnceWith(`${NAME}: warning: 1 entry did not activate\n${detail}\n`)
  552. }
  553. })
  554. it('preserves nested activation causes and aggregate member failures', async () => {
  555. const warn = vi.fn()
  556. const original = new Error('tool discovery failed')
  557. const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', {
  558. cause: new Error('server rejected discovery'),
  559. })
  560. const wrapper = new Error('plugin activation failed', { cause: aggregate })
  561. await auditStartupEntries(ctxWith([
  562. { fiber: fiber(3, wrapper), options: { id: 'wrapped-plugin', name: './wrapped.mjs' } },
  563. ]), NAME, warn)
  564. expect(warn).toHaveBeenCalledWith([
  565. `${NAME}: warning: 1 entry did not activate`,
  566. `wrapped-plugin (./wrapped.mjs): ${wrapper.stack!}`,
  567. aggregate.stack!,
  568. (aggregate.cause as Error).stack!,
  569. original.stack!,
  570. 'transport closed',
  571. '',
  572. ].join('\n'))
  573. })
  574. it('describes nested, stackless, non-error, pending, and unexpected failures', async () => {
  575. const warn = vi.fn()
  576. const circular = new Error('circular failure')
  577. ;(circular as { cause?: unknown }).cause = circular
  578. const stackless = new Error('stackless failure')
  579. delete (stackless as { stack?: string }).stack
  580. const deepestWithStack = new Error('deep failure with stack')
  581. const deepestWithoutStack = new Error('deep failure without stack')
  582. delete (deepestWithoutStack as { stack?: string }).stack
  583. const wrappedStack = new Error('wrapped stack', { cause: deepestWithStack })
  584. const wrappedStackless = new Error('wrapped stackless', { cause: deepestWithoutStack })
  585. const wrappedValue = new Error('wrapped value', { cause: 'plain cause' })
  586. await auditStartupEntries(ctxWith([
  587. { fiber: fiber(3, circular), options: { id: 'circular', name: './circular.mjs' } },
  588. { fiber: fiber(3, stackless), options: { id: 'stackless', name: './stackless.mjs' } },
  589. {
  590. fiber: fiber(3, wrappedStack),
  591. options: { id: 'deep-stack', name: './deep-stack.mjs' },
  592. },
  593. {
  594. fiber: fiber(3, wrappedStackless),
  595. options: { id: 'deep-stackless', name: './deep-stackless.mjs' },
  596. },
  597. {
  598. fiber: fiber(3, wrappedValue),
  599. options: { id: 'plain-cause', name: './plain-cause.mjs' },
  600. },
  601. { fiber: fiber(3, 42), options: { id: 'number-error', name: './number-error.mjs' } },
  602. {
  603. fiber: fiber(0, undefined, { first: {}, second: {} }),
  604. options: { id: 'multiple-dependencies', name: './multiple-dependencies.mjs' },
  605. },
  606. {
  607. fiber: fiber(0),
  608. options: { id: 'unknown-dependency', name: './unknown-dependency.mjs' },
  609. },
  610. { fiber: fiber(1), options: { id: 'unexpected-state', name: './unexpected-state.mjs' } },
  611. ]), NAME, warn)
  612. expect(warn).toHaveBeenCalledOnce()
  613. const diagnostic = String(warn.mock.calls[0]![0])
  614. expect(diagnostic).toContain(`${NAME}: warning: 9 entries did not activate`)
  615. expect(diagnostic).toContain(`circular (./circular.mjs): ${circular.stack!}`)
  616. expect(diagnostic).toContain('stackless (./stackless.mjs): stackless failure')
  617. expect(diagnostic).toContain(`deep-stack (./deep-stack.mjs): ${wrappedStack.stack!}\n${deepestWithStack.stack!}`)
  618. expect(diagnostic).toContain(`deep-stackless (./deep-stackless.mjs): ${wrappedStackless.stack!}\ndeep failure without stack`)
  619. expect(diagnostic).toContain(`plain-cause (./plain-cause.mjs): ${wrappedValue.stack!}\nplain cause`)
  620. expect(diagnostic).toContain('number-error (./number-error.mjs): 42')
  621. expect(diagnostic).toContain('multiple-dependencies (./multiple-dependencies.mjs): pending (waiting for services: first, second)')
  622. expect(diagnostic).toContain('unknown-dependency (./unknown-dependency.mjs): pending (waiting for services: unknown)')
  623. expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
  624. })
  625. it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => {
  626. const warn = vi.fn()
  627. const requiredError = new Error('address already in use')
  628. const optionalError = new Error('todo unavailable')
  629. await expect(auditStartupEntries(ctxWith([
  630. { fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
  631. { fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
  632. ]), NAME, warn)).rejects.toThrow([
  633. 'required startup failure: 1 entry did not activate',
  634. `${id} (./required.mjs): ${requiredError.stack!}`,
  635. ].join('\n'))
  636. expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
  637. })
  638. it('rejects a required entry pending on an injected service', async () => {
  639. await expect(auditStartupEntries(ctxWith([{
  640. fiber: fiber(0, undefined, { headlessStartup: {} }),
  641. options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' },
  642. }]), NAME, vi.fn())).rejects.toThrow(
  643. 'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)',
  644. )
  645. })
  646. })
  647. describe('loadOverlayPatches', () => {
  648. it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
  649. const dir = tmp()
  650. const valid = join(dir, 'valid.yml')
  651. writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
  652. expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
  653. expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
  654. const malformed = join(dir, 'malformed.yml')
  655. writeFileSync(malformed, ': bad')
  656. expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
  657. const mapping = join(dir, 'mapping.yml')
  658. writeFileSync(mapping, 'id: target\n')
  659. expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
  660. const scalar = join(dir, 'scalar.yml')
  661. writeFileSync(scalar, '- scalar\n')
  662. expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  663. })
  664. })
  665. describe('boot', () => {
  666. it('boots a leaf config through the real Loader and settles the tree', async () => {
  667. const dir = tmp()
  668. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  669. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  670. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  671. try {
  672. const entries = [...ctx.loader.entries()]
  673. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  674. } finally {
  675. await ctx.fiber.dispose()
  676. }
  677. })
  678. it('can resolve bare plugins from the harness when the config project shadows their package name', async () => {
  679. const dir = tmp()
  680. const harness = tmp()
  681. const absolutePlugin = join(dir, 'absolute.mjs')
  682. const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  683. const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  684. mkdirSync(shadow, { recursive: true })
  685. mkdirSync(harnessPlugin, { recursive: true })
  686. writeFileSync(join(shadow, 'package.json'), JSON.stringify({
  687. name: '@deepseek-ai/dsh-system-prompt',
  688. type: 'module',
  689. exports: './index.mjs',
  690. }))
  691. writeFileSync(join(shadow, 'index.mjs'), [
  692. 'export function apply(ctx) {',
  693. ' ctx.provide("shadowPluginLoaded", true)',
  694. '}',
  695. '',
  696. ].join('\n'))
  697. writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({
  698. name: '@deepseek-ai/dsh-system-prompt',
  699. type: 'module',
  700. exports: './index.mjs',
  701. }))
  702. writeFileSync(join(harnessPlugin, 'index.mjs'), [
  703. 'export function apply(ctx) {',
  704. ' ctx.provide("harnessPluginLoaded", true)',
  705. '}',
  706. '',
  707. ].join('\n'))
  708. writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n')
  709. writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n')
  710. const entries = [
  711. '- id: prompt',
  712. " name: '@deepseek-ai/dsh-system-prompt'",
  713. '- id: relative',
  714. " name: './relative.mjs'",
  715. ]
  716. const configOwnedPath = join(dir, 'config-owned.cordis.yml')
  717. writeFileSync(configOwnedPath, [...entries, ''].join('\n'))
  718. const hostOwnedPath = join(dir, 'host-owned.cordis.yml')
  719. writeFileSync(hostOwnedPath, [
  720. ...entries,
  721. '- id: absolute',
  722. ` name: ${JSON.stringify(absolutePlugin)}`,
  723. '',
  724. ].join('\n'))
  725. const configOwned = await boot(NAME, configOwnedPath)
  726. try {
  727. expect(configOwned.get('shadowPluginLoaded')).toBe(true)
  728. expect(configOwned.get('systemPrompt')).toBeUndefined()
  729. expect(configOwned.get('relativePluginLoaded')).toBe(true)
  730. } finally {
  731. await configOwned.fiber.dispose()
  732. }
  733. const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href
  734. const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl)
  735. try {
  736. expect(ctx.get('harnessPluginLoaded')).toBe(true)
  737. expect(ctx.get('shadowPluginLoaded')).toBeUndefined()
  738. expect(ctx.get('relativePluginLoaded')).toBe(true)
  739. expect(ctx.get('absolutePluginLoaded')).toBe(true)
  740. } finally {
  741. await ctx.fiber.dispose()
  742. }
  743. })
  744. it('runs host preparation before the Loader tree mounts', async () => {
  745. const dir = tmp()
  746. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  747. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  748. const prepared: Context[] = []
  749. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
  750. expect(hostCtx.loader).toBeDefined()
  751. expect([...hostCtx.loader.entries()]).toEqual([])
  752. prepared.push(hostCtx)
  753. })
  754. try {
  755. expect(prepared).toEqual([ctx])
  756. } finally {
  757. await ctx.fiber.dispose()
  758. }
  759. })
  760. it('exposes dshHomePath to Loader config expressions', async () => {
  761. const dir = tmp()
  762. const dshHome = join(dir, 'home')
  763. vi.stubEnv('DSH_HOME', dshHome)
  764. writeFileSync(join(dir, 'capture.mjs'), [
  765. 'export const name = "capture"',
  766. 'export function apply(ctx, config) {',
  767. ' ctx.provide("capturedPath", config.path)',
  768. '}',
  769. '',
  770. ].join('\n'))
  771. writeFileSync(join(dir, 'cordis.yml'), [
  772. '- id: capture',
  773. ' name: ./capture.mjs',
  774. ' config:',
  775. " path: !!js dshHomePath('sessions')",
  776. '',
  777. ].join('\n'))
  778. let ctx: Context | undefined
  779. try {
  780. ctx = await boot(NAME, join(dir, 'cordis.yml'))
  781. expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
  782. } finally {
  783. await ctx?.fiber.dispose()
  784. vi.unstubAllEnvs()
  785. }
  786. })
  787. it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
  788. // A surface can dispose the root fiber while boot() is still awaiting the
  789. // Loader, before the last entry settles. The Loader service goes with the
  790. // tree, so reading it for the post-boot assertions would crash an app that
  791. // exited exactly as the user asked.
  792. const dir = tmp()
  793. writeFileSync(join(dir, 'exiting.mjs'), [
  794. 'export const name = "exiting"',
  795. 'export function apply(ctx) {',
  796. ' void ctx.root.fiber.dispose()',
  797. '}',
  798. '',
  799. ].join('\n'))
  800. writeFileSync(join(dir, 'delayed.mjs'), [
  801. 'await new Promise(resolve => setTimeout(resolve, 10))',
  802. 'export function apply() {}',
  803. '',
  804. ].join('\n'))
  805. writeFileSync(join(dir, 'cordis.yml'), [
  806. '- id: exiting',
  807. ' name: ./exiting.mjs',
  808. '- id: delayed',
  809. ' name: ./delayed.mjs',
  810. '',
  811. ].join('\n'))
  812. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  813. expect(ctx.get('loader')).toBeUndefined()
  814. })
  815. it('returns when disposal completes before root entry creation returns', async () => {
  816. const dir = tmp()
  817. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  818. const ctx = await boot(NAME, join(dir, 'cordis.yml'), [], (ctx) => {
  819. const create = ctx.loader.create.bind(ctx.loader)
  820. vi.spyOn(ctx.loader, 'create').mockImplementation(async (...args) => {
  821. const id = await create(...args)
  822. await ctx.fiber.dispose()
  823. return id
  824. })
  825. })
  826. expect(ctx.get('loader')).toBeUndefined()
  827. })
  828. it('keeps successful entries and warns about optional import, config, disabled, sync apply, async apply, and dependency failures', async () => {
  829. const dir = tmp()
  830. const configPath = join(dir, 'cordis.yml')
  831. const config = [
  832. '- id: good',
  833. ' name: ./good.mjs',
  834. '- id: import-failure',
  835. ' name: ./missing.mjs',
  836. '- id: invalid-config',
  837. ' name: ./noop.mjs',
  838. ' config:',
  839. ' value: !!js "JSON.parse(\'invalid\')"',
  840. '- id: disabled-failure',
  841. ' name: ./noop.mjs',
  842. ' disabled: !!js "JSON.parse(\'invalid\')"',
  843. '- id: sync-failure',
  844. ' name: ./sync-failure.mjs',
  845. '- id: async-failure',
  846. ' name: ./async-failure.mjs',
  847. '- id: waiting',
  848. ' name: ./waiting.mjs',
  849. '',
  850. ].join('\n')
  851. writeFileSync(join(dir, 'good.mjs'), 'export function apply(ctx) { ctx.provide("goodStarted", true) }\n')
  852. writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
  853. writeFileSync(join(dir, 'sync-failure.mjs'), 'export function apply() { throw new Error("sync apply failure") }\n')
  854. writeFileSync(join(dir, 'async-failure.mjs'), 'export async function apply() { throw new Error("async apply failure") }\n')
  855. writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
  856. writeFileSync(configPath, config)
  857. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  858. let ctx: Context | undefined
  859. try {
  860. ctx = await boot(NAME, configPath)
  861. expect(ctx.get('goodStarted')).toBe(true)
  862. const entries = [...ctx.loader.entries()]
  863. expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)
  864. expect(entries.find(entry => entry.options.id === 'import-failure')?.fiber).toBeUndefined()
  865. expect(entries.find(entry => entry.options.id === 'disabled-failure')?.fiber).toBeUndefined()
  866. for (const id of ['invalid-config', 'sync-failure', 'async-failure']) {
  867. expect(entries.find(entry => entry.options.id === id)?.fiber?.state).toBe(3)
  868. }
  869. expect(entries.find(entry => entry.options.id === 'waiting')?.fiber?.state).toBe(0)
  870. const warning = write.mock.calls.map(call => String(call[0])).join('')
  871. expect(warning).toContain(`${NAME}: warning: 6 entries did not activate`)
  872. expect(warning).toContain('import-failure (./missing.mjs): failed to import')
  873. expect(warning).toContain('disabled-failure (./noop.mjs): disabled expression failed: SyntaxError')
  874. expect(warning).toContain('SyntaxError: Unexpected token')
  875. expect(warning).toContain('sync apply failure')
  876. expect(warning).toContain('async apply failure')
  877. expect(warning).toContain('waiting for service: neverProvided')
  878. expect(readFileSync(configPath, 'utf8')).toBe(config)
  879. } finally {
  880. write.mockRestore()
  881. await ctx?.fiber.dispose()
  882. }
  883. })
  884. it.each([
  885. ['missing', undefined, 'config file not found'],
  886. ['malformed', 'invalid: [unclosed\n', 'unexpected end'],
  887. ['non-array', 'entries: []\n', 'top-level array'],
  888. ])('rejects a %s root configuration', async (_kind, content, message) => {
  889. const dir = tmp()
  890. const configPath = join(dir, 'cordis.yml')
  891. if (content !== undefined) writeFileSync(configPath, content)
  892. let ctx: Context | undefined
  893. try {
  894. await expect(boot(NAME, configPath).then((value) => { ctx = value })).rejects.toThrow(message)
  895. } finally {
  896. await ctx?.fiber.dispose()
  897. }
  898. })
  899. it.each([
  900. ['import', undefined, '', 'failed to import'],
  901. ['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'],
  902. ['config expression', 'export function apply() {}\n', ' config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'],
  903. ['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: SyntaxError'],
  904. ['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'],
  905. ['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'],
  906. ['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'],
  907. ])('disposes startup after a required %s failure', async (_kind, source, config, message) => {
  908. const dir = tmp()
  909. if (source !== undefined) writeFileSync(join(dir, 'required.mjs'), source)
  910. writeFileSync(join(dir, 'cordis.yml'), `- id: webserver\n name: ./required.mjs\n${config}`)
  911. let disposed = false
  912. await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
  913. ctx.effect(() => () => { disposed = true })
  914. })).rejects.toThrow(message)
  915. expect(disposed).toBe(true)
  916. })
  917. it('disposes successful entries and rejects when a required entry fails', async () => {
  918. const dir = tmp()
  919. let disposed = false
  920. writeFileSync(join(dir, 'good.mjs'), [
  921. 'export function apply(ctx) {',
  922. ' globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = false',
  923. ' ctx.effect(() => () => { globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = true })',
  924. '}',
  925. '',
  926. ].join('\n'))
  927. writeFileSync(join(dir, 'required-failure.mjs'), 'export function apply() { throw new Error("required apply failure") }\n')
  928. writeFileSync(join(dir, 'cordis.yml'), [
  929. '- id: good',
  930. ' name: ./good.mjs',
  931. '- id: webserver',
  932. ' name: ./required-failure.mjs',
  933. '',
  934. ].join('\n'))
  935. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
  936. 'plugin tree failed to load: required startup failure: 1 entry did not activate',
  937. String.raw`webserver \(\.\/required-failure\.mjs\):`,
  938. 'required apply failure',
  939. ].join(String.raw`[\s\S]*`)))
  940. disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
  941. delete (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__
  942. expect(disposed).toBe(true)
  943. })
  944. it('falls back to the deepest cause message when its stack was erased', async () => {
  945. const dir = tmp()
  946. const deepest = new Error('stackless deep failure')
  947. delete (deepest as { stack?: string }).stack
  948. await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  949. throw new Error('wrapped setup failure', { cause: deepest })
  950. })).rejects.toThrow(
  951. `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
  952. )
  953. })
  954. it('reports a non-Error rejection from host preparation', async () => {
  955. await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], () => {
  956. throw 'host refused'
  957. })).rejects.toThrow(`${NAME}: host preparation failed: host refused`)
  958. })
  959. it.each([false, true])('rejects and disposes when an error cause is cyclic (indirect: %s)', async (indirect) => {
  960. const failure = new Error('cyclic setup failure')
  961. const next = indirect ? new Error('nested failure', { cause: failure }) : failure
  962. let reads = 0
  963. Object.defineProperty(failure, 'cause', {
  964. get() {
  965. // Bound a regressed synchronous traversal so it cannot hang the test worker.
  966. if (++reads > 10) throw new Error('cause traversal did not terminate')
  967. return next
  968. },
  969. })
  970. const dispose = vi.fn()
  971. await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], (ctx) => {
  972. ctx.effect(() => dispose)
  973. throw failure
  974. })).rejects.toThrow(`${NAME}: host preparation failed: cyclic setup failure`)
  975. expect(dispose).toHaveBeenCalledOnce()
  976. })
  977. it('expands a stackless aggregate at the deepest activation cause', async () => {
  978. const dir = tmp()
  979. const aggregate = new AggregateError([
  980. new Error('first aggregate member'),
  981. 'second aggregate member',
  982. ], 'aggregate activation failure')
  983. delete (aggregate as { stack?: string }).stack
  984. try {
  985. await boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  986. throw new Error('wrapped aggregate failure', { cause: aggregate })
  987. })
  988. expect.fail('boot should reject the aggregate activation failure')
  989. } catch (error) {
  990. expect(error).toBeInstanceOf(Error)
  991. const message = (error as Error).message
  992. expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`)
  993. expect(message).toContain('aggregate activation failure')
  994. expect(message).toContain('first aggregate member')
  995. expect(message).toContain('second aggregate member')
  996. }
  997. })
  998. })
  999. describe('addHarnessSourceSection', () => {
  1000. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  1001. const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
  1002. it('distinguishes the source path from the current workdir after reusable instructions', async () => {
  1003. const ctx = new Context()
  1004. try {
  1005. await ctx.plugin(SystemPrompt, { personaPrefix: 'You are a coding agent.' })
  1006. ctx.systemPrompt.section({
  1007. name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: 'Reusable tool SDK.',
  1008. })
  1009. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  1010. expect(dispose).toBeTypeOf('function')
  1011. const systemPrompt = ctx.get('systemPrompt')!
  1012. const rendered = renderPrompt(await systemPrompt.assemble())
  1013. expect(rendered).toContain(EXPECTED)
  1014. // The >= 0 guards keep a drifted opener/persona string from a false pass
  1015. // through `-1 < n`.
  1016. const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.')
  1017. const sourceAt = rendered.indexOf(EXPECTED)
  1018. const personaAt = rendered.indexOf('You are a coding agent.')
  1019. expect(identityAt).toBeGreaterThanOrEqual(0)
  1020. expect(personaAt).toBeGreaterThanOrEqual(0)
  1021. const sdkAt = rendered.indexOf('Reusable tool SDK.')
  1022. expect(personaAt).toBeGreaterThan(identityAt)
  1023. expect(sdkAt).toBeGreaterThan(personaAt)
  1024. expect(sdkAt).toBeLessThan(sourceAt)
  1025. } finally {
  1026. await ctx.fiber.dispose()
  1027. }
  1028. })
  1029. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  1030. const ctx = new Context()
  1031. try {
  1032. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  1033. } finally {
  1034. await ctx.fiber.dispose()
  1035. }
  1036. })
  1037. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  1038. const ctx = new Context()
  1039. try {
  1040. await ctx.plugin(SystemPrompt, {})
  1041. const systemPrompt = ctx.get('systemPrompt')!
  1042. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  1043. const present = await systemPrompt.assemble()
  1044. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  1045. dispose()
  1046. const gone = await systemPrompt.assemble()
  1047. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  1048. } finally {
  1049. await ctx.fiber.dispose()
  1050. }
  1051. })
  1052. })