app-boot.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858
  1. import { mkdtempSync, mkdirSync, readFileSync, 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 { 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 tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
  15. describe('resolveConfigPath', () => {
  16. it('resolves relative to the given cwd outside replay mode', () => {
  17. expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
  18. expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  19. })
  20. it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
  21. expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
  22. expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  23. })
  24. it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
  25. expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
  26. expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  27. })
  28. })
  29. describe('loadEnv', () => {
  30. it('loads variables from .env in the given dir', () => {
  31. const dir = tmp()
  32. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  33. const warn = vi.fn()
  34. loadEnv(NAME, dir, warn)
  35. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  36. expect(warn).not.toHaveBeenCalled()
  37. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  38. })
  39. it('stays silent when no .env exists (ambient environment wins)', () => {
  40. const warn = vi.fn()
  41. loadEnv(NAME, tmp(), warn)
  42. expect(warn).not.toHaveBeenCalled()
  43. })
  44. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  45. const dir = tmp()
  46. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  47. const warn = vi.fn()
  48. loadEnv(NAME, dir, warn)
  49. expect(warn).toHaveBeenCalledTimes(1)
  50. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  51. })
  52. it('defaults dir to the process cwd and warn to a stderr write', () => {
  53. const dir = tmp()
  54. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  55. const previous = process.cwd()
  56. process.chdir(dir)
  57. try {
  58. loadEnv(NAME) // happy path: the default warn sink is never invoked
  59. } finally {
  60. process.chdir(previous)
  61. }
  62. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  63. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  64. // The default warn sink itself: point it at a broken .env with stderr
  65. // spied, so the arrow body runs without polluting the test output.
  66. const broken = tmp()
  67. mkdirSync(join(broken, '.env'))
  68. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  69. let written: string[]
  70. try {
  71. loadEnv(NAME, broken)
  72. written = write.mock.calls.map(call => String(call[0]))
  73. } finally {
  74. write.mockRestore()
  75. }
  76. expect(written).toHaveLength(1)
  77. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  78. })
  79. })
  80. describe('loadLayeredEnv', () => {
  81. const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
  82. function clear(): void {
  83. for (const name of NAMES) Reflect.deleteProperty(process.env, name)
  84. }
  85. it('layers user under project under the inherited environment', () => {
  86. const home = tmp()
  87. const project = tmp()
  88. writeFileSync(join(home, '.env'), [
  89. `${NAMES[0]}=user`,
  90. `${NAMES[1]}=user-only`,
  91. 'APP_BOOT_LAYERED_INHERITED=user-loses',
  92. '',
  93. ].join('\n'))
  94. writeFileSync(join(project, '.env'), [
  95. `${NAMES[0]}=project`,
  96. `${NAMES[2]}=project-only`,
  97. 'APP_BOOT_LAYERED_INHERITED=project-loses',
  98. '',
  99. ].join('\n'))
  100. clear()
  101. vi.stubEnv('DSH_HOME', home)
  102. vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
  103. const warn = vi.fn()
  104. try {
  105. loadLayeredEnv(NAME, project, warn)
  106. expect(process.env[NAMES[0]]).toBe('project')
  107. expect(process.env[NAMES[1]]).toBe('user-only')
  108. expect(process.env[NAMES[2]]).toBe('project-only')
  109. expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
  110. expect(warn).not.toHaveBeenCalled()
  111. } finally {
  112. clear()
  113. vi.unstubAllEnvs()
  114. }
  115. })
  116. it.each([
  117. ['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
  118. ['the executable search path', 'PATH=/tmp/evil\n'],
  119. ['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
  120. ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
  121. ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
  122. ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
  123. ['a browser command', 'BROWSER=./script\n'],
  124. ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
  125. const home = tmp()
  126. const project = tmp()
  127. writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
  128. clear()
  129. vi.stubEnv('DSH_HOME', home)
  130. try {
  131. expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
  132. expect(process.env[NAMES[1]]).toBeUndefined()
  133. } finally {
  134. clear()
  135. vi.unstubAllEnvs()
  136. }
  137. })
  138. it('reports each file value with its absolute path', () => {
  139. const home = tmp()
  140. const project = tmp()
  141. writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
  142. writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
  143. clear()
  144. vi.stubEnv('DSH_HOME', home)
  145. try {
  146. const snapshot = loadLayeredEnv(NAME, project, vi.fn())
  147. expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
  148. expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') })
  149. expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
  150. } finally {
  151. clear()
  152. vi.unstubAllEnvs()
  153. }
  154. })
  155. it('resolves the harness home from the inherited environment, never from a file', () => {
  156. const home = tmp()
  157. const project = tmp()
  158. writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
  159. writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
  160. clear()
  161. vi.stubEnv('DSH_HOME', home)
  162. try {
  163. loadLayeredEnv(NAME, project, vi.fn())
  164. expect(process.env[NAMES[1]]).toBe('real-home')
  165. expect(process.env[NAMES[2]]).toBe('set-by-project')
  166. } finally {
  167. clear()
  168. vi.unstubAllEnvs()
  169. }
  170. })
  171. it('warns and continues when a layer exists but cannot be read', () => {
  172. const home = tmp()
  173. const project = tmp()
  174. // A directory named `.env` is a present-but-unreadable layer.
  175. mkdirSync(join(home, '.env'))
  176. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  177. clear()
  178. vi.stubEnv('DSH_HOME', home)
  179. const warn = vi.fn()
  180. try {
  181. const snapshot = loadLayeredEnv(NAME, project, warn)
  182. expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
  183. expect(snapshot.get(NAMES[1])).toBeUndefined()
  184. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  185. expect(process.env[NAMES[2]]).toBe('project-only')
  186. } finally {
  187. clear()
  188. vi.unstubAllEnvs()
  189. }
  190. })
  191. it('reports to stderr when the caller supplies no reporter', () => {
  192. const home = tmp()
  193. const project = tmp()
  194. mkdirSync(join(home, '.env'))
  195. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  196. clear()
  197. vi.stubEnv('DSH_HOME', home)
  198. const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
  199. try {
  200. const snapshot = loadLayeredEnv(NAME, project)
  201. expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
  202. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  203. expect(process.env[NAMES[2]]).toBe('project-only')
  204. } finally {
  205. write.mockRestore()
  206. clear()
  207. vi.unstubAllEnvs()
  208. }
  209. })
  210. it('passes over an absent layer without reporting it', () => {
  211. const home = tmp()
  212. const project = tmp()
  213. writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
  214. clear()
  215. vi.stubEnv('DSH_HOME', home)
  216. const warn = vi.fn()
  217. try {
  218. const snapshot = loadLayeredEnv(NAME, project, warn)
  219. expect(warn).not.toHaveBeenCalled()
  220. expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
  221. } finally {
  222. clear()
  223. vi.unstubAllEnvs()
  224. }
  225. })
  226. it('carries only the inherited environment when neither file exists', () => {
  227. const home = tmp()
  228. const project = tmp()
  229. clear()
  230. vi.stubEnv('DSH_HOME', home)
  231. vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
  232. try {
  233. const snapshot = loadLayeredEnv(NAME, project, vi.fn())
  234. expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' })
  235. } finally {
  236. clear()
  237. vi.unstubAllEnvs()
  238. }
  239. })
  240. it('reads a harness home that is also the invocation directory exactly once', () => {
  241. const both = tmp()
  242. writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`)
  243. clear()
  244. vi.stubEnv('DSH_HOME', both)
  245. try {
  246. const snapshot = loadLayeredEnv(NAME, both, vi.fn())
  247. expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') })
  248. } finally {
  249. clear()
  250. vi.unstubAllEnvs()
  251. }
  252. })
  253. })
  254. describe('installFailLoud', () => {
  255. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  256. const handlers: Array<(err: unknown) => void> = []
  257. const written: string[] = []
  258. const exits: number[] = []
  259. return {
  260. handlers, written, exits,
  261. on: (_event, handler) => { handlers.push(handler) },
  262. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  263. stderr: { write: (chunk: string) => { written.push(chunk) } },
  264. exit: (code: number) => { exits.push(code) },
  265. }
  266. }
  267. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  268. const proc = fakeProc()
  269. installFailLoud(NAME, proc)
  270. const error = new Error('boom')
  271. proc.handlers[0]!(error)
  272. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  273. expect(proc.written[0]).toContain(error.stack)
  274. expect(proc.exits).toEqual([1])
  275. })
  276. // One rejection is reported per install: the first is the diagnosis, so each
  277. // formatting case needs its own handler rather than reusing a latched one.
  278. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  279. const plain = fakeProc()
  280. installFailLoud(NAME, plain)
  281. plain.handlers[0]!('plain failure')
  282. expect(plain.written[0]).toContain('plain failure')
  283. expect(plain.exits).toEqual([1])
  284. const stackless = new Error('no stack')
  285. delete (stackless as { stack?: string }).stack
  286. const bare = fakeProc()
  287. installFailLoud(NAME, bare)
  288. bare.handlers[0]!(stackless)
  289. expect(bare.written[0]).toContain('no stack')
  290. expect(bare.exits).toEqual([1])
  291. })
  292. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  293. const proc = fakeProc()
  294. const uninstall = installFailLoud(NAME, proc)
  295. expect(proc.handlers).toHaveLength(1)
  296. uninstall()
  297. expect(proc.handlers).toHaveLength(0)
  298. // Default-proc arm: install on the real process, then immediately uninstall
  299. // so the suite leaks no handler and can never exit the runner.
  300. const before = process.listenerCount('unhandledRejection')
  301. const uninstallReal = installFailLoud(NAME)
  302. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  303. uninstallReal()
  304. expect(process.listenerCount('unhandledRejection')).toBe(before)
  305. })
  306. it('does not report an activation rejection shared by entries in the boot audit', async () => {
  307. const proc = fakeProc()
  308. installFailLoud(NAME, proc)
  309. const error = new Error('assembled activation failure')
  310. const audit = assertEntriesActivated({
  311. loader: {
  312. entries: () => ['broken-a', 'broken-b'].map(name => ({
  313. options: { name },
  314. fiber: {
  315. state: 3,
  316. inject: {},
  317. ctx: { get: () => undefined },
  318. await: async () => { throw error },
  319. },
  320. })),
  321. },
  322. } as unknown as Context, NAME)
  323. await Promise.resolve()
  324. await Promise.resolve()
  325. proc.handlers[0]!(error)
  326. expect(proc.written).toEqual([])
  327. expect(proc.exits).toEqual([])
  328. await expect(audit).rejects.toThrow('assembled activation failure')
  329. proc.handlers[0]!(error)
  330. expect(proc.exits).toEqual([1])
  331. })
  332. // The Loader mounts entries concurrently, so a terminal-owning surface can
  333. // already hold raw mode when a sibling entry rejects. Exiting without running
  334. // its teardown strands the terminal on the user's shell.
  335. it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
  336. const proc = fakeProc()
  337. const order: string[] = []
  338. installFailLoud(NAME, proc, async () => {
  339. await Promise.resolve()
  340. order.push('released')
  341. })
  342. proc.handlers[0]!(new Error('sibling entry rejected'))
  343. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  344. // The release is in flight, so the exit has not committed yet.
  345. expect(proc.exits).toEqual([])
  346. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  347. expect(order).toEqual(['released'])
  348. })
  349. it('still exits when the release hook rejects', async () => {
  350. const proc = fakeProc()
  351. installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
  352. proc.handlers[0]!(new Error('boom'))
  353. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  354. })
  355. it('exits without waiting when a release hook never settles', async () => {
  356. vi.useFakeTimers()
  357. try {
  358. const proc = fakeProc()
  359. installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
  360. proc.handlers[0]!(new Error('boom'))
  361. expect(proc.exits).toEqual([])
  362. await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
  363. expect(proc.exits).toEqual([1])
  364. } finally {
  365. vi.useRealTimers()
  366. }
  367. })
  368. // Loader failures arrive in bursts, and teardown's own disposers may reject.
  369. // Only the first rejection is the diagnosis; the handler must stay installed
  370. // so a later one cannot become uncaught and kill the process mid-teardown.
  371. it('reports only the first rejection and keeps handling later ones during the release', async () => {
  372. const proc = fakeProc()
  373. let released = false
  374. installFailLoud(NAME, proc, async () => {
  375. await Promise.resolve()
  376. released = true
  377. })
  378. proc.handlers[0]!(new Error('first rejection'))
  379. proc.handlers[0]!(new Error('second rejection'))
  380. expect(proc.handlers).toHaveLength(1)
  381. expect(proc.written).toHaveLength(1)
  382. expect(proc.written[0]).toContain('first rejection')
  383. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  384. expect(released).toBe(true)
  385. })
  386. })
  387. describe('assertEntriesLoaded', () => {
  388. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  389. ({ loader: { entries: () => entries } }) as unknown as Context
  390. it('passes when every enabled entry has a fiber', () => {
  391. expect(() => { assertEntriesLoaded(ctxWith([
  392. { fiber: {}, options: { name: 'a' } },
  393. { disabled: true, options: { name: 'off' } },
  394. ]), NAME) }).not.toThrow()
  395. })
  396. it('throws naming every enabled fiber-less entry', () => {
  397. expect(() => { assertEntriesLoaded(ctxWith([
  398. { fiber: {}, options: { name: 'ok' } },
  399. { options: { name: 'broken-a' } },
  400. { options: { name: 'broken-b' } },
  401. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  402. })
  403. })
  404. describe('assertEntriesActivated', () => {
  405. interface FakeFiber {
  406. state: number
  407. inject: Record<string, unknown>
  408. ctx: { get(name: string): unknown }
  409. await(): Promise<unknown>
  410. }
  411. const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
  412. loader: { entries: () => entries },
  413. }) as unknown as Context
  414. const fiber = (
  415. state: number,
  416. error?: unknown,
  417. inject: Record<string, unknown> = {},
  418. services: string[] = [],
  419. ): FakeFiber => ({
  420. state,
  421. inject,
  422. ctx: { get: name => services.includes(name) ? {} : undefined },
  423. await: error === undefined ? async () => undefined : async () => { throw error },
  424. })
  425. it('passes active entries and ignores disabled entries', async () => {
  426. let awaitCalls = 0
  427. const active = fiber(2)
  428. active.await = async () => {
  429. awaitCalls++
  430. return undefined
  431. }
  432. const disabled = fiber(3, new Error('disabled failure'))
  433. disabled.await = async () => {
  434. awaitCalls++
  435. throw new Error('disabled failure')
  436. }
  437. await expect(assertEntriesActivated(ctxWith([
  438. { fiber: active, options: { name: 'active' } },
  439. { fiber: disabled, disabled: true, options: { name: 'disabled' } },
  440. ]), NAME)).resolves.toBeUndefined()
  441. expect(awaitCalls).toBe(0)
  442. })
  443. it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
  444. const original = new Error('actual plugin failure')
  445. await expect(assertEntriesActivated(ctxWith([
  446. { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
  447. ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
  448. })
  449. it('formats stackless and non-Error activation failures', async () => {
  450. const stackless = new Error('stackless failure')
  451. delete (stackless as { stack?: string }).stack
  452. await expect(assertEntriesActivated(ctxWith([
  453. { fiber: fiber(3, stackless), options: { name: 'stackless' } },
  454. { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
  455. ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
  456. })
  457. it('reports unresolved services for pending entries', async () => {
  458. let awaitCalls = 0
  459. const expected = [
  460. `${NAME}: 3 entries did not activate`,
  461. 'waiting: pending (waiting for services: missingA, missingB)',
  462. 'single-wait: pending (waiting for service: missing)',
  463. 'unknown-wait: pending (waiting for services: unknown)',
  464. ].join('\n')
  465. const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
  466. const singleWait = fiber(0, undefined, { missing: {} })
  467. const unknownWait = fiber(0)
  468. for (const item of [waiting, singleWait, unknownWait]) {
  469. item.await = async () => {
  470. awaitCalls++
  471. return undefined
  472. }
  473. }
  474. await expect(assertEntriesActivated(ctxWith([
  475. { fiber: waiting, options: { name: 'waiting' } },
  476. { fiber: singleWait, options: { name: 'single-wait' } },
  477. { fiber: unknownWait, options: { name: 'unknown-wait' } },
  478. ]), NAME)).rejects.toThrow(expected)
  479. expect(awaitCalls).toBe(0)
  480. })
  481. it('retains the numeric diagnostic for a settled unexpected state', async () => {
  482. await expect(assertEntriesActivated(ctxWith([
  483. { fiber: fiber(4), options: { name: 'disposed' } },
  484. ]), NAME)).rejects.toThrow('disposed: fiber state 4')
  485. })
  486. })
  487. describe('loadOverlayPatches', () => {
  488. it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
  489. const dir = tmp()
  490. const valid = join(dir, 'valid.yml')
  491. writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
  492. expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
  493. expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
  494. const malformed = join(dir, 'malformed.yml')
  495. writeFileSync(malformed, ': bad')
  496. expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
  497. const mapping = join(dir, 'mapping.yml')
  498. writeFileSync(mapping, 'id: target\n')
  499. expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
  500. const scalar = join(dir, 'scalar.yml')
  501. writeFileSync(scalar, '- scalar\n')
  502. expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  503. })
  504. })
  505. describe('boot', () => {
  506. it('boots a leaf config through the real Loader and settles the tree', async () => {
  507. const dir = tmp()
  508. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  509. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  510. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  511. try {
  512. const entries = [...ctx.loader.entries()]
  513. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  514. } finally {
  515. await ctx.fiber.dispose()
  516. }
  517. })
  518. it('can resolve bare plugins from the harness when the config project shadows their package name', async () => {
  519. const dir = tmp()
  520. const harness = tmp()
  521. const absolutePlugin = join(dir, 'absolute.mjs')
  522. const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  523. const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
  524. mkdirSync(shadow, { recursive: true })
  525. mkdirSync(harnessPlugin, { recursive: true })
  526. writeFileSync(join(shadow, 'package.json'), JSON.stringify({
  527. name: '@deepseek-ai/dsh-system-prompt',
  528. type: 'module',
  529. exports: './index.mjs',
  530. }))
  531. writeFileSync(join(shadow, 'index.mjs'), [
  532. 'export function apply(ctx) {',
  533. ' ctx.provide("shadowPluginLoaded", true)',
  534. '}',
  535. '',
  536. ].join('\n'))
  537. writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({
  538. name: '@deepseek-ai/dsh-system-prompt',
  539. type: 'module',
  540. exports: './index.mjs',
  541. }))
  542. writeFileSync(join(harnessPlugin, 'index.mjs'), [
  543. 'export function apply(ctx) {',
  544. ' ctx.provide("harnessPluginLoaded", true)',
  545. '}',
  546. '',
  547. ].join('\n'))
  548. writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n')
  549. writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n')
  550. const entries = [
  551. '- id: prompt',
  552. " name: '@deepseek-ai/dsh-system-prompt'",
  553. '- id: relative',
  554. " name: './relative.mjs'",
  555. ]
  556. const configOwnedPath = join(dir, 'config-owned.cordis.yml')
  557. writeFileSync(configOwnedPath, [...entries, ''].join('\n'))
  558. const hostOwnedPath = join(dir, 'host-owned.cordis.yml')
  559. writeFileSync(hostOwnedPath, [
  560. ...entries,
  561. '- id: absolute',
  562. ` name: ${JSON.stringify(absolutePlugin)}`,
  563. '',
  564. ].join('\n'))
  565. const configOwned = await boot(NAME, configOwnedPath)
  566. try {
  567. expect(configOwned.get('shadowPluginLoaded')).toBe(true)
  568. expect(configOwned.get('systemPrompt')).toBeUndefined()
  569. expect(configOwned.get('relativePluginLoaded')).toBe(true)
  570. } finally {
  571. await configOwned.fiber.dispose()
  572. }
  573. const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href
  574. const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl)
  575. try {
  576. expect(ctx.get('harnessPluginLoaded')).toBe(true)
  577. expect(ctx.get('shadowPluginLoaded')).toBeUndefined()
  578. expect(ctx.get('relativePluginLoaded')).toBe(true)
  579. expect(ctx.get('absolutePluginLoaded')).toBe(true)
  580. } finally {
  581. await ctx.fiber.dispose()
  582. }
  583. })
  584. it('runs host preparation before the Loader tree mounts', async () => {
  585. const dir = tmp()
  586. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  587. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  588. const prepared: Context[] = []
  589. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
  590. expect(hostCtx.loader).toBeDefined()
  591. expect([...hostCtx.loader.entries()]).toEqual([])
  592. prepared.push(hostCtx)
  593. })
  594. try {
  595. expect(prepared).toEqual([ctx])
  596. } finally {
  597. await ctx.fiber.dispose()
  598. }
  599. })
  600. it('disposes partial host setup and labels non-Error preparation failures', async () => {
  601. const dir = tmp()
  602. const failure = 42
  603. let disposed = false
  604. const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
  605. ctx.effect(() => () => { disposed = true })
  606. throw failure
  607. })
  608. await expect(task).rejects.toMatchObject({
  609. message: `${NAME}: host preparation failed: ${failure}`,
  610. cause: failure,
  611. })
  612. expect(disposed).toBe(true)
  613. })
  614. it('exposes dshHomePath to Loader config expressions', async () => {
  615. const dir = tmp()
  616. const dshHome = join(dir, 'home')
  617. vi.stubEnv('DSH_HOME', dshHome)
  618. writeFileSync(join(dir, 'capture.mjs'), [
  619. 'export const name = "capture"',
  620. 'export function apply(ctx, config) {',
  621. ' ctx.provide("capturedPath", config.path)',
  622. '}',
  623. '',
  624. ].join('\n'))
  625. writeFileSync(join(dir, 'cordis.yml'), [
  626. '- id: capture',
  627. ' name: ./capture.mjs',
  628. ' config:',
  629. " path: !!js dshHomePath('sessions')",
  630. '',
  631. ].join('\n'))
  632. let ctx: Context | undefined
  633. try {
  634. ctx = await boot(NAME, join(dir, 'cordis.yml'))
  635. expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
  636. } finally {
  637. await ctx?.fiber.dispose()
  638. vi.unstubAllEnvs()
  639. }
  640. })
  641. it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
  642. // A surface can dispose the root fiber while boot() is still awaiting the
  643. // Loader, before the last entry settles. The Loader service goes with the
  644. // tree, so reading it for the post-boot assertions would crash an app that
  645. // exited exactly as the user asked.
  646. const dir = tmp()
  647. writeFileSync(join(dir, 'exiting.mjs'), [
  648. 'export const name = "exiting"',
  649. 'export function apply(ctx) {',
  650. ' void ctx.root.fiber.dispose()',
  651. '}',
  652. '',
  653. ].join('\n'))
  654. writeFileSync(join(dir, 'delayed.mjs'), [
  655. 'await new Promise(resolve => setTimeout(resolve, 10))',
  656. 'export function apply() {}',
  657. '',
  658. ].join('\n'))
  659. writeFileSync(join(dir, 'cordis.yml'), [
  660. '- id: exiting',
  661. ' name: ./exiting.mjs',
  662. '- id: delayed',
  663. ' name: ./delayed.mjs',
  664. '',
  665. ].join('\n'))
  666. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  667. expect(ctx.get('loader')).toBeUndefined()
  668. })
  669. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  670. const dir = tmp()
  671. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  672. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
  673. `${NAME}: plugin tree failed to load: failed to apply loader entry`,
  674. )
  675. })
  676. it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
  677. const dir = tmp()
  678. const configPath = join(dir, 'cordis.yml')
  679. const config = [
  680. '- id: invalid-config',
  681. ' name: ./noop.mjs',
  682. ' config:',
  683. ' value: !!js "JSON.parse(\'invalid\')"',
  684. '',
  685. ].join('\n')
  686. writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
  687. writeFileSync(configPath, config)
  688. await expect(boot(NAME, configPath)).rejects.toThrow(
  689. 'failed to apply loader entry invalid-config (./noop.mjs)',
  690. )
  691. expect(readFileSync(configPath, 'utf8')).toBe(config)
  692. })
  693. it('appends the deepest cause with its original stack to the load failure', async () => {
  694. const dir = tmp()
  695. writeFileSync(join(dir, 'failing.mjs'), [
  696. 'export function apply() {',
  697. " const failure = new Error('pinned activation failure')",
  698. " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
  699. ' throw failure',
  700. '}',
  701. '',
  702. ].join('\n'))
  703. writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
  704. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
  705. String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
  706. String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
  707. ].join('')))
  708. })
  709. it('falls back to the deepest cause message when its stack was erased', async () => {
  710. const dir = tmp()
  711. const deepest = new Error('stackless deep failure')
  712. delete (deepest as { stack?: string }).stack
  713. await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  714. throw new Error('wrapped setup failure', { cause: deepest })
  715. })).rejects.toThrow(
  716. `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
  717. )
  718. })
  719. it('expands a stackless aggregate at the deepest activation cause', async () => {
  720. const dir = tmp()
  721. const aggregate = new AggregateError([
  722. new Error('first aggregate member'),
  723. 'second aggregate member',
  724. ], 'aggregate activation failure')
  725. delete (aggregate as { stack?: string }).stack
  726. try {
  727. await boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  728. throw new Error('wrapped aggregate failure', { cause: aggregate })
  729. })
  730. expect.fail('boot should reject the aggregate activation failure')
  731. } catch (error) {
  732. expect(error).toBeInstanceOf(Error)
  733. const message = (error as Error).message
  734. expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`)
  735. expect(message).toContain('aggregate activation failure')
  736. expect(message).toContain('first aggregate member')
  737. expect(message).toContain('second aggregate member')
  738. }
  739. })
  740. it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
  741. const dir = tmp()
  742. writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
  743. writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
  744. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
  745. `${NAME}: 1 entry did not activate`,
  746. './waiting.mjs: pending (waiting for service: neverProvided)',
  747. ].join('\n'))
  748. })
  749. })
  750. describe('addHarnessSourceSection', () => {
  751. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  752. 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.`
  753. it('distinguishes the source path from the current workdir between identity and persona', async () => {
  754. const ctx = new Context()
  755. try {
  756. await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
  757. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  758. expect(dispose).toBeTypeOf('function')
  759. const systemPrompt = ctx.get('systemPrompt')!
  760. const rendered = renderPrompt(await systemPrompt.assemble())
  761. expect(rendered).toContain(EXPECTED)
  762. // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
  763. // keep a drifted opener/persona string from a false pass through `-1 < n`.
  764. const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.')
  765. const sourceAt = rendered.indexOf(EXPECTED)
  766. const personaAt = rendered.indexOf('You are a coding agent.')
  767. expect(identityAt).toBeGreaterThanOrEqual(0)
  768. expect(personaAt).toBeGreaterThanOrEqual(0)
  769. expect(identityAt).toBeLessThan(sourceAt)
  770. expect(sourceAt).toBeLessThan(personaAt)
  771. } finally {
  772. await ctx.fiber.dispose()
  773. }
  774. })
  775. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  776. const ctx = new Context()
  777. try {
  778. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  779. } finally {
  780. await ctx.fiber.dispose()
  781. }
  782. })
  783. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  784. const ctx = new Context()
  785. try {
  786. await ctx.plugin(SystemPrompt, {})
  787. const systemPrompt = ctx.get('systemPrompt')!
  788. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  789. const present = await systemPrompt.assemble()
  790. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  791. dispose()
  792. const gone = await systemPrompt.assemble()
  793. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  794. } finally {
  795. await ctx.fiber.dispose()
  796. }
  797. })
  798. })