app-boot.spec.ts 54 KB

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