app-boot.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949
  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, assertEntriesActivated, assertEntriesLoaded, 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 audit = assertEntriesActivated({
  391. loader: {
  392. entries: () => ['broken-a', 'broken-b'].map(name => ({
  393. options: { name },
  394. fiber: {
  395. state: 3,
  396. inject: {},
  397. ctx: { get: () => undefined },
  398. await: async () => { throw error },
  399. },
  400. })),
  401. },
  402. } as unknown as Context, NAME)
  403. await Promise.resolve()
  404. await Promise.resolve()
  405. proc.handlers[0]!(error)
  406. expect(proc.written).toEqual([])
  407. expect(proc.exits).toEqual([])
  408. await expect(audit).rejects.toThrow('assembled activation failure')
  409. proc.handlers[0]!(error)
  410. expect(proc.exits).toEqual([1])
  411. })
  412. // The Loader mounts entries concurrently, so a terminal-owning surface can
  413. // already hold raw mode when a sibling entry rejects. Exiting without running
  414. // its teardown strands the terminal on the user's shell.
  415. it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
  416. const proc = fakeProc()
  417. const order: string[] = []
  418. installFailLoud(NAME, proc, async () => {
  419. await Promise.resolve()
  420. order.push('released')
  421. })
  422. proc.handlers[0]!(new Error('sibling entry rejected'))
  423. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  424. // The release is in flight, so the exit has not committed yet.
  425. expect(proc.exits).toEqual([])
  426. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  427. expect(order).toEqual(['released'])
  428. })
  429. it('still exits when the release hook rejects', async () => {
  430. const proc = fakeProc()
  431. installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
  432. proc.handlers[0]!(new Error('boom'))
  433. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  434. })
  435. it('exits without waiting when a release hook never settles', async () => {
  436. vi.useFakeTimers()
  437. try {
  438. const proc = fakeProc()
  439. installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
  440. proc.handlers[0]!(new Error('boom'))
  441. expect(proc.exits).toEqual([])
  442. await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
  443. expect(proc.exits).toEqual([1])
  444. } finally {
  445. vi.useRealTimers()
  446. }
  447. })
  448. // Loader failures arrive in bursts, and teardown's own disposers may reject.
  449. // Only the first rejection is the diagnosis; the handler must stay installed
  450. // so a later one cannot become uncaught and kill the process mid-teardown.
  451. it('reports only the first rejection and keeps handling later ones during the release', async () => {
  452. const proc = fakeProc()
  453. let released = false
  454. installFailLoud(NAME, proc, async () => {
  455. await Promise.resolve()
  456. released = true
  457. })
  458. proc.handlers[0]!(new Error('first rejection'))
  459. proc.handlers[0]!(new Error('second rejection'))
  460. expect(proc.handlers).toHaveLength(1)
  461. expect(proc.written).toHaveLength(1)
  462. expect(proc.written[0]).toContain('first rejection')
  463. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  464. expect(released).toBe(true)
  465. })
  466. })
  467. describe('assertEntriesLoaded', () => {
  468. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  469. ({ loader: { entries: () => entries } }) as unknown as Context
  470. it('passes when every enabled entry has a fiber', () => {
  471. expect(() => { assertEntriesLoaded(ctxWith([
  472. { fiber: {}, options: { name: 'a' } },
  473. { disabled: true, options: { name: 'off' } },
  474. ]), NAME) }).not.toThrow()
  475. })
  476. it('throws naming every enabled fiber-less entry', () => {
  477. expect(() => { assertEntriesLoaded(ctxWith([
  478. { fiber: {}, options: { name: 'ok' } },
  479. { options: { name: 'broken-a' } },
  480. { options: { name: 'broken-b' } },
  481. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  482. })
  483. })
  484. describe('assertEntriesActivated', () => {
  485. interface FakeFiber {
  486. state: number
  487. inject: Record<string, unknown>
  488. ctx: { get(name: string): unknown }
  489. await(): Promise<unknown>
  490. }
  491. const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
  492. loader: { entries: () => entries },
  493. }) as unknown as Context
  494. const fiber = (
  495. state: number,
  496. error?: unknown,
  497. inject: Record<string, unknown> = {},
  498. services: string[] = [],
  499. ): FakeFiber => ({
  500. state,
  501. inject,
  502. ctx: { get: name => services.includes(name) ? {} : undefined },
  503. await: error === undefined ? async () => undefined : async () => { throw error },
  504. })
  505. it('passes active entries and ignores disabled entries', async () => {
  506. let awaitCalls = 0
  507. const active = fiber(2)
  508. active.await = async () => {
  509. awaitCalls++
  510. return undefined
  511. }
  512. const disabled = fiber(3, new Error('disabled failure'))
  513. disabled.await = async () => {
  514. awaitCalls++
  515. throw new Error('disabled failure')
  516. }
  517. await expect(assertEntriesActivated(ctxWith([
  518. { fiber: active, options: { name: 'active' } },
  519. { fiber: disabled, disabled: true, options: { name: 'disabled' } },
  520. ]), NAME)).resolves.toBeUndefined()
  521. expect(awaitCalls).toBe(0)
  522. })
  523. it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
  524. const original = new Error('actual plugin failure')
  525. await expect(assertEntriesActivated(ctxWith([
  526. { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
  527. ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
  528. })
  529. it('formats stackless and non-Error activation failures', async () => {
  530. const stackless = new Error('stackless failure')
  531. delete (stackless as { stack?: string }).stack
  532. await expect(assertEntriesActivated(ctxWith([
  533. { fiber: fiber(3, stackless), options: { name: 'stackless' } },
  534. { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
  535. ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
  536. })
  537. it('reports unresolved services for pending entries', async () => {
  538. let awaitCalls = 0
  539. const expected = [
  540. `${NAME}: 3 entries did not activate`,
  541. 'waiting: pending (waiting for services: missingA, missingB)',
  542. 'single-wait: pending (waiting for service: missing)',
  543. 'unknown-wait: pending (waiting for services: unknown)',
  544. ].join('\n')
  545. const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
  546. const singleWait = fiber(0, undefined, { missing: {} })
  547. const unknownWait = fiber(0)
  548. for (const item of [waiting, singleWait, unknownWait]) {
  549. item.await = async () => {
  550. awaitCalls++
  551. return undefined
  552. }
  553. }
  554. await expect(assertEntriesActivated(ctxWith([
  555. { fiber: waiting, options: { name: 'waiting' } },
  556. { fiber: singleWait, options: { name: 'single-wait' } },
  557. { fiber: unknownWait, options: { name: 'unknown-wait' } },
  558. ]), NAME)).rejects.toThrow(expected)
  559. expect(awaitCalls).toBe(0)
  560. })
  561. it('retains the numeric diagnostic for a settled unexpected state', async () => {
  562. await expect(assertEntriesActivated(ctxWith([
  563. { fiber: fiber(4), options: { name: 'disposed' } },
  564. ]), NAME)).rejects.toThrow('disposed: fiber state 4')
  565. })
  566. })
  567. describe('loadOverlayPatches', () => {
  568. it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
  569. const dir = tmp()
  570. const valid = join(dir, 'valid.yml')
  571. writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
  572. expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
  573. expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
  574. const malformed = join(dir, 'malformed.yml')
  575. writeFileSync(malformed, ': bad')
  576. expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
  577. const mapping = join(dir, 'mapping.yml')
  578. writeFileSync(mapping, 'id: target\n')
  579. expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
  580. const scalar = join(dir, 'scalar.yml')
  581. writeFileSync(scalar, '- scalar\n')
  582. expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  583. })
  584. })
  585. describe('boot', () => {
  586. it('boots a leaf config through the real Loader and settles the tree', async () => {
  587. const dir = tmp()
  588. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  589. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  590. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  591. try {
  592. const entries = [...ctx.loader.entries()]
  593. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  594. } finally {
  595. await ctx.fiber.dispose()
  596. }
  597. })
  598. it('can resolve bare plugins from the harness when the config project shadows their package name', async () => {
  599. const dir = tmp()
  600. const harness = tmp()
  601. const absolutePlugin = join(dir, 'absolute.mjs')
  602. const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  603. const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  604. mkdirSync(shadow, { recursive: true })
  605. mkdirSync(harnessPlugin, { recursive: true })
  606. writeFileSync(join(shadow, 'package.json'), JSON.stringify({
  607. name: '@deepseek-ai/dsh-system-prompt',
  608. type: 'module',
  609. exports: './index.mjs',
  610. }))
  611. writeFileSync(join(shadow, 'index.mjs'), [
  612. 'export function apply(ctx) {',
  613. ' ctx.provide("shadowPluginLoaded", true)',
  614. '}',
  615. '',
  616. ].join('\n'))
  617. writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({
  618. name: '@deepseek-ai/dsh-system-prompt',
  619. type: 'module',
  620. exports: './index.mjs',
  621. }))
  622. writeFileSync(join(harnessPlugin, 'index.mjs'), [
  623. 'export function apply(ctx) {',
  624. ' ctx.provide("harnessPluginLoaded", true)',
  625. '}',
  626. '',
  627. ].join('\n'))
  628. writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n')
  629. writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n')
  630. const entries = [
  631. '- id: prompt',
  632. " name: '@deepseek-ai/dsh-system-prompt'",
  633. '- id: relative',
  634. " name: './relative.mjs'",
  635. ]
  636. const configOwnedPath = join(dir, 'config-owned.cordis.yml')
  637. writeFileSync(configOwnedPath, [...entries, ''].join('\n'))
  638. const hostOwnedPath = join(dir, 'host-owned.cordis.yml')
  639. writeFileSync(hostOwnedPath, [
  640. ...entries,
  641. '- id: absolute',
  642. ` name: ${JSON.stringify(absolutePlugin)}`,
  643. '',
  644. ].join('\n'))
  645. const configOwned = await boot(NAME, configOwnedPath)
  646. try {
  647. expect(configOwned.get('shadowPluginLoaded')).toBe(true)
  648. expect(configOwned.get('systemPrompt')).toBeUndefined()
  649. expect(configOwned.get('relativePluginLoaded')).toBe(true)
  650. } finally {
  651. await configOwned.fiber.dispose()
  652. }
  653. const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href
  654. const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl)
  655. try {
  656. expect(ctx.get('harnessPluginLoaded')).toBe(true)
  657. expect(ctx.get('shadowPluginLoaded')).toBeUndefined()
  658. expect(ctx.get('relativePluginLoaded')).toBe(true)
  659. expect(ctx.get('absolutePluginLoaded')).toBe(true)
  660. } finally {
  661. await ctx.fiber.dispose()
  662. }
  663. })
  664. it('runs host preparation before the Loader tree mounts', async () => {
  665. const dir = tmp()
  666. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  667. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  668. const prepared: Context[] = []
  669. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
  670. expect(hostCtx.loader).toBeDefined()
  671. expect([...hostCtx.loader.entries()]).toEqual([])
  672. prepared.push(hostCtx)
  673. })
  674. try {
  675. expect(prepared).toEqual([ctx])
  676. } finally {
  677. await ctx.fiber.dispose()
  678. }
  679. })
  680. it('disposes partial host setup and labels non-Error preparation failures', async () => {
  681. const dir = tmp()
  682. const failure = 42
  683. let disposed = false
  684. const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
  685. ctx.effect(() => () => { disposed = true })
  686. throw failure
  687. })
  688. await expect(task).rejects.toMatchObject({
  689. message: `${NAME}: host preparation failed: ${failure}`,
  690. cause: failure,
  691. })
  692. expect(disposed).toBe(true)
  693. })
  694. it('exposes dshHomePath to Loader config expressions', async () => {
  695. const dir = tmp()
  696. const dshHome = join(dir, 'home')
  697. vi.stubEnv('DSH_HOME', dshHome)
  698. writeFileSync(join(dir, 'capture.mjs'), [
  699. 'export const name = "capture"',
  700. 'export function apply(ctx, config) {',
  701. ' ctx.provide("capturedPath", config.path)',
  702. '}',
  703. '',
  704. ].join('\n'))
  705. writeFileSync(join(dir, 'cordis.yml'), [
  706. '- id: capture',
  707. ' name: ./capture.mjs',
  708. ' config:',
  709. " path: !!js dshHomePath('sessions')",
  710. '',
  711. ].join('\n'))
  712. let ctx: Context | undefined
  713. try {
  714. ctx = await boot(NAME, join(dir, 'cordis.yml'))
  715. expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
  716. } finally {
  717. await ctx?.fiber.dispose()
  718. vi.unstubAllEnvs()
  719. }
  720. })
  721. it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
  722. // A surface can dispose the root fiber while boot() is still awaiting the
  723. // Loader, before the last entry settles. The Loader service goes with the
  724. // tree, so reading it for the post-boot assertions would crash an app that
  725. // exited exactly as the user asked.
  726. const dir = tmp()
  727. writeFileSync(join(dir, 'exiting.mjs'), [
  728. 'export const name = "exiting"',
  729. 'export function apply(ctx) {',
  730. ' void ctx.root.fiber.dispose()',
  731. '}',
  732. '',
  733. ].join('\n'))
  734. writeFileSync(join(dir, 'delayed.mjs'), [
  735. 'await new Promise(resolve => setTimeout(resolve, 10))',
  736. 'export function apply() {}',
  737. '',
  738. ].join('\n'))
  739. writeFileSync(join(dir, 'cordis.yml'), [
  740. '- id: exiting',
  741. ' name: ./exiting.mjs',
  742. '- id: delayed',
  743. ' name: ./delayed.mjs',
  744. '',
  745. ].join('\n'))
  746. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  747. expect(ctx.get('loader')).toBeUndefined()
  748. })
  749. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  750. const dir = tmp()
  751. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  752. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
  753. `${NAME}: plugin tree failed to load: failed to apply loader entry`,
  754. )
  755. })
  756. it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
  757. const dir = tmp()
  758. const configPath = join(dir, 'cordis.yml')
  759. const config = [
  760. '- id: invalid-config',
  761. ' name: ./noop.mjs',
  762. ' config:',
  763. ' value: !!js "JSON.parse(\'invalid\')"',
  764. '',
  765. ].join('\n')
  766. writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
  767. writeFileSync(configPath, config)
  768. await expect(boot(NAME, configPath)).rejects.toThrow(
  769. 'failed to apply loader entry invalid-config (./noop.mjs)',
  770. )
  771. expect(readFileSync(configPath, 'utf8')).toBe(config)
  772. })
  773. it('appends the deepest cause with its original stack to the load failure', async () => {
  774. const dir = tmp()
  775. writeFileSync(join(dir, 'failing.mjs'), [
  776. 'export function apply() {',
  777. " const failure = new Error('pinned activation failure')",
  778. " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
  779. ' throw failure',
  780. '}',
  781. '',
  782. ].join('\n'))
  783. writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
  784. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
  785. String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
  786. String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
  787. ].join('')))
  788. })
  789. it('falls back to the deepest cause message when its stack was erased', async () => {
  790. const dir = tmp()
  791. const deepest = new Error('stackless deep failure')
  792. delete (deepest as { stack?: string }).stack
  793. await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  794. throw new Error('wrapped setup failure', { cause: deepest })
  795. })).rejects.toThrow(
  796. `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
  797. )
  798. })
  799. it('expands a stackless aggregate at the deepest activation cause', async () => {
  800. const dir = tmp()
  801. const aggregate = new AggregateError([
  802. new Error('first aggregate member'),
  803. 'second aggregate member',
  804. ], 'aggregate activation failure')
  805. delete (aggregate as { stack?: string }).stack
  806. try {
  807. await boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  808. throw new Error('wrapped aggregate failure', { cause: aggregate })
  809. })
  810. expect.fail('boot should reject the aggregate activation failure')
  811. } catch (error) {
  812. expect(error).toBeInstanceOf(Error)
  813. const message = (error as Error).message
  814. expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`)
  815. expect(message).toContain('aggregate activation failure')
  816. expect(message).toContain('first aggregate member')
  817. expect(message).toContain('second aggregate member')
  818. }
  819. })
  820. it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
  821. const dir = tmp()
  822. writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
  823. writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
  824. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
  825. `${NAME}: 1 entry did not activate`,
  826. './waiting.mjs: pending (waiting for service: neverProvided)',
  827. ].join('\n'))
  828. })
  829. })
  830. describe('addHarnessSourceSection', () => {
  831. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  832. 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.`
  833. it('distinguishes the source path from the current workdir after reusable instructions', async () => {
  834. const ctx = new Context()
  835. try {
  836. await ctx.plugin(SystemPrompt, { personaPrefix: 'You are a coding agent.' })
  837. ctx.systemPrompt.section({
  838. name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: 'Reusable tool SDK.',
  839. })
  840. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  841. expect(dispose).toBeTypeOf('function')
  842. const systemPrompt = ctx.get('systemPrompt')!
  843. const rendered = renderPrompt(await systemPrompt.assemble())
  844. expect(rendered).toContain(EXPECTED)
  845. // The >= 0 guards keep a drifted opener/persona string from a false pass
  846. // through `-1 < n`.
  847. const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.')
  848. const sourceAt = rendered.indexOf(EXPECTED)
  849. const personaAt = rendered.indexOf('You are a coding agent.')
  850. expect(identityAt).toBeGreaterThanOrEqual(0)
  851. expect(personaAt).toBeGreaterThanOrEqual(0)
  852. const sdkAt = rendered.indexOf('Reusable tool SDK.')
  853. expect(personaAt).toBeGreaterThan(identityAt)
  854. expect(sdkAt).toBeGreaterThan(personaAt)
  855. expect(sdkAt).toBeLessThan(sourceAt)
  856. } finally {
  857. await ctx.fiber.dispose()
  858. }
  859. })
  860. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  861. const ctx = new Context()
  862. try {
  863. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  864. } finally {
  865. await ctx.fiber.dispose()
  866. }
  867. })
  868. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  869. const ctx = new Context()
  870. try {
  871. await ctx.plugin(SystemPrompt, {})
  872. const systemPrompt = ctx.get('systemPrompt')!
  873. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  874. const present = await systemPrompt.assemble()
  875. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  876. dispose()
  877. const gone = await systemPrompt.assemble()
  878. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  879. } finally {
  880. await ctx.fiber.dispose()
  881. }
  882. })
  883. })