built-bin.e2e.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { createInterface } from 'node:readline'
  5. import { Readable, Writable } from 'node:stream'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import {
  8. client as createAcpClientApp,
  9. methods,
  10. ndJsonStream,
  11. PROTOCOL_VERSION,
  12. type SessionNotification,
  13. } from '@agentclientprotocol/sdk'
  14. import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  15. import { execa } from 'execa'
  16. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  17. /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
  18. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  19. // The release version, including a prerelease such as 0.0.1-rc.1: `--version`
  20. // prints what this manifest carries, so no test may pin it to a literal.
  21. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
  22. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  23. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  24. async function runBuiltBin(
  25. args: readonly string[] = [],
  26. env: Readonly<Record<string, string | undefined>> = {},
  27. cwd?: string,
  28. ): Promise<{ stdout: string; code: number; stderr: string }> {
  29. const childEnv = Object.fromEntries(
  30. Object.entries({ ...process.env, ...env })
  31. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  32. )
  33. const result = await execa(process.execPath, [dshBin, ...args], {
  34. input: '',
  35. timeout: 25_000,
  36. killSignal: 'SIGKILL',
  37. reject: false,
  38. env: childEnv,
  39. extendEnv: false,
  40. ...cwd === undefined ? {} : { cwd },
  41. })
  42. if (result.timedOut) {
  43. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  44. }
  45. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  46. }
  47. async function waitForFile(file: string): Promise<void> {
  48. const deadline = Date.now() + 20_000
  49. while (!existsSync(file)) {
  50. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  51. await new Promise(resolve => setTimeout(resolve, 20))
  52. }
  53. }
  54. interface ProfileLifecycleFixture {
  55. home: string
  56. ready: string
  57. settled: string
  58. disposed: string
  59. interrupt: string
  60. }
  61. /**
  62. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  63. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  64. * booting the entire product tree.
  65. */
  66. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  67. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  68. const ready = join(home, 'ready')
  69. const settled = join(home, 'settled')
  70. const disposed = join(home, 'disposed')
  71. const interrupt = join(home, 'interrupt')
  72. const bundleDir = join(home, 'lifecycle-bundle')
  73. mkdirSync(bundleDir, { recursive: true })
  74. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  75. "import { existsSync, writeFileSync } from 'node:fs'",
  76. "import { join } from 'node:path'",
  77. "export const name = 'profile-lifecycle-fixture'",
  78. 'export function apply(ctx, config = {}) {',
  79. ' let active = true',
  80. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  81. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  82. ' let interrupted = false',
  83. ' const heartbeat = setInterval(() => {',
  84. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  85. ' interrupted = true',
  86. " process.emit('SIGTERM')",
  87. ' }, 20)',
  88. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  89. ' // applied override and its removal reverting to this bundle default.',
  90. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  91. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  92. ' void ctx.loader.await().then(() => {',
  93. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  94. ' })',
  95. ' ctx.effect(() => () => {',
  96. ' active = false',
  97. ' clearInterval(heartbeat)',
  98. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  99. ' })',
  100. '}',
  101. '',
  102. ].join('\n'))
  103. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  104. '- insert:',
  105. ' - id: profile-lifecycle-fixture',
  106. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  107. '',
  108. ].join('\n'))
  109. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  110. name: 'dsh-lifecycle-bundle',
  111. version: '0.0.0',
  112. type: 'module',
  113. dsh: { bundle: { patch: './cordis.patch.yml' } },
  114. }, undefined, 2))
  115. const profileDir = join(home, 'profiles', 'lifecycle')
  116. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  117. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  118. name: 'dsh-profile-lifecycle',
  119. private: true,
  120. dependencies: {},
  121. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  122. }, undefined, 2))
  123. // Hand-place the "installed" bundle where profile resolution finds it.
  124. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  125. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  126. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  127. try {
  128. rmSync(linkTarget, { recursive: true, force: true })
  129. } catch { /* fresh dir */ }
  130. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  131. mkdirSync(linkTarget, { recursive: true })
  132. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  133. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  134. }
  135. return { home, ready, settled, disposed, interrupt }
  136. }
  137. function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
  138. return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
  139. cwd: fixture.home,
  140. input: '',
  141. reject: false,
  142. env: {
  143. DSH_HOME: fixture.home,
  144. RAW_READY_FILE: fixture.ready,
  145. RAW_SETTLED_FILE: fixture.settled,
  146. RAW_DISPOSED_FILE: fixture.disposed,
  147. RAW_INTERRUPT_FILE: fixture.interrupt,
  148. },
  149. })
  150. }
  151. function requestProfileShutdown(
  152. child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
  153. fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
  154. ): void {
  155. if (process.platform === 'win32') {
  156. writeFileSync(fixture.interrupt, 'interrupt')
  157. return
  158. }
  159. child.kill('SIGTERM')
  160. }
  161. function createEnvironmentProbeProfile(home: string, project: string): void {
  162. const pluginFile = join(project, 'environment-probe.mjs')
  163. writeFileSync(pluginFile, [
  164. "export const name = 'environment-probe'",
  165. "export const inject = ['llm']",
  166. 'export function apply(ctx) {',
  167. ' void ctx.loader.await().then(async () => {',
  168. " let text = ''",
  169. ' for await (const chunk of ctx.llm.stream({',
  170. " provider: 'deepseek-official',",
  171. " model: 'deepseek-v4-flash',",
  172. ' messages: [],',
  173. ' maxTokens: 32,',
  174. ' })) {',
  175. " if (chunk.type === 'text-delta') text += chunk.text",
  176. ' }',
  177. ' process.stdout.write(`${text}\\n`)',
  178. " if (process.platform === 'win32') process.emit('SIGTERM')",
  179. " else process.kill(process.pid, 'SIGTERM')",
  180. ' })',
  181. '}',
  182. '',
  183. ].join('\n'))
  184. const profileDir = join(home, 'profiles', 'environment-probe')
  185. mkdirSync(profileDir, { recursive: true })
  186. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  187. name: 'dsh-profile-environment-probe',
  188. private: true,
  189. dependencies: {},
  190. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  191. }, undefined, 2))
  192. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  193. '- insert:',
  194. ' - id: environment-probe',
  195. ` name: ${pathToFileURL(pluginFile).href}`,
  196. '',
  197. ].join('\n'))
  198. }
  199. interface StartupFixture {
  200. home: string
  201. ready: string
  202. echo: string
  203. interrupt: string
  204. /** An always-running row's echo, used to observe that a user patch reload landed. */
  205. witness: string
  206. }
  207. /**
  208. * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
  209. * a row that reads its app-owned service through a `!!js` config expression.
  210. * Both plugin modules resolve
  211. * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
  212. * fallback, exactly as an installed out-of-tree bundle does.
  213. */
  214. function createStartupFixture(): StartupFixture {
  215. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
  216. const profileDir = join(home, 'profiles', 'startup')
  217. // Written straight into the installed location: a row module resolves its
  218. // own imports from where it is installed, and only inside the profile does
  219. // Node's parent walk reach the installation fallback these plugins need.
  220. const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
  221. mkdirSync(bundleDir, { recursive: true })
  222. writeFileSync(join(bundleDir, 'startup.mjs'), [
  223. "import { Command } from 'commander'",
  224. "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
  225. "export const name = 'fixture-startup'",
  226. "export const inject = ['cmdlineArgs']",
  227. 'export function apply(ctx) {',
  228. " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
  229. " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))",
  230. ' parseCmdline(ctx, program)',
  231. '}',
  232. '',
  233. ].join('\n'))
  234. writeFileSync(join(bundleDir, 'waiting.mjs'), [
  235. "import { existsSync, writeFileSync } from 'node:fs'",
  236. "import { join } from 'node:path'",
  237. "export const name = 'startup-fixture'",
  238. 'export function apply(ctx, config = {}) {',
  239. ' let interrupted = false',
  240. ' const heartbeat = setInterval(() => {',
  241. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  242. ' interrupted = true',
  243. " process.emit('SIGTERM')",
  244. ' }, 20)',
  245. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  246. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  247. ' ctx.effect(() => () => { clearInterval(heartbeat) })',
  248. '}',
  249. '',
  250. ].join('\n'))
  251. writeFileSync(join(bundleDir, 'witness.mjs'), [
  252. "import { writeFileSync } from 'node:fs'",
  253. "import { join } from 'node:path'",
  254. "export const name = 'reload-witness'",
  255. 'export function apply(ctx, config = {}) {',
  256. " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
  257. '}',
  258. '',
  259. ].join('\n'))
  260. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  261. '- insert:',
  262. ' - id: startup-fixture',
  263. ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
  264. ' inject: [fixtureStartup]',
  265. ' config:',
  266. // Lazy interpolation runs only after the provider's service is injected.
  267. " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
  268. ' - id: fixture-startup',
  269. ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
  270. ' - id: reload-witness',
  271. ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
  272. '',
  273. ].join('\n'))
  274. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  275. name: 'dsh-startup-bundle',
  276. version: '0.0.0',
  277. type: 'module',
  278. dsh: { bundle: { patch: './cordis.patch.yml' } },
  279. }, undefined, 2))
  280. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  281. name: 'dsh-profile-startup',
  282. private: true,
  283. dependencies: {},
  284. dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
  285. }, undefined, 2))
  286. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  287. return {
  288. home,
  289. ready: join(home, 'ready'),
  290. echo: join(home, 'config-echo'),
  291. interrupt: join(home, 'interrupt'),
  292. witness: join(home, 'witness'),
  293. }
  294. }
  295. function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
  296. return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
  297. cwd: fixture.home,
  298. input: '',
  299. reject: false,
  300. timeout: 25_000,
  301. killSignal: 'SIGKILL',
  302. env: {
  303. DSH_HOME: fixture.home,
  304. RAW_READY_FILE: fixture.ready,
  305. RAW_INTERRUPT_FILE: fixture.interrupt,
  306. },
  307. })
  308. }
  309. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  310. it('requires --profile and rejects removed commands', async () => {
  311. const bare = await runBuiltBin()
  312. expect(bare.code).toBe(1)
  313. expect(bare.stdout).toBe('')
  314. expect(bare.stderr).toContain('--profile <name> is required')
  315. const help = await runBuiltBin(['--help'])
  316. expect(help.code).toBe(0)
  317. expect(help.stdout).toContain('dsh --profile web')
  318. expect(help.stdout).toContain('dsh plugin --profile')
  319. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  320. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
  321. const result = await runBuiltBin(removed)
  322. expect(result.code).toBe(1)
  323. }
  324. }, 30_000)
  325. it('routes help and usage errors without activating startup-dependent rows', async () => {
  326. const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
  327. try {
  328. const web = await runBuiltBin(['--profile', 'web', '--help'], {
  329. DSH_HOME: home,
  330. DSH_TELEMETRY_DISABLED: '1',
  331. })
  332. expect(web.code).toBe(0)
  333. expect(web.stderr).toBe('')
  334. expect(web.stdout).toContain('Usage: dsh --profile web')
  335. expect(web.stdout).toContain('--port <port>')
  336. expect(web.stdout).not.toContain('dsh web: http://')
  337. const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
  338. DSH_HOME: home,
  339. DSH_TELEMETRY_DISABLED: '1',
  340. })
  341. expect(wildcardHost.code).toBe(1)
  342. expect(wildcardHost.stdout).toBe('')
  343. expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
  344. expect(wildcardHost.stderr).not.toContain('dsh web: http://')
  345. const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
  346. DSH_HOME: home,
  347. DSH_TELEMETRY_DISABLED: '1',
  348. })
  349. expect(headlessHelp.code).toBe(0)
  350. expect(headlessHelp.stderr).toBe('')
  351. expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
  352. const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
  353. DSH_HOME: home,
  354. DSH_TELEMETRY_DISABLED: '1',
  355. })
  356. expect(sdkHelp.code).toBe(0)
  357. expect(sdkHelp.stderr).toBe('')
  358. expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
  359. const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
  360. DSH_HOME: home,
  361. DSH_TELEMETRY_DISABLED: '1',
  362. })
  363. expect(acpHelp.code).toBe(0)
  364. expect(acpHelp.stderr).toBe('')
  365. expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
  366. const missingTask = await runBuiltBin(['--profile', 'headless'], {
  367. DSH_HOME: home,
  368. DSH_TELEMETRY_DISABLED: '1',
  369. })
  370. expect(missingTask.code).toBe(1)
  371. expect(missingTask.stderr).toContain('a task is required')
  372. } finally {
  373. rmSync(home, { recursive: true, force: true })
  374. }
  375. }, 30_000)
  376. it('reports SDK startup failure when stdin reaches EOF first', async () => {
  377. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
  378. const patch = join(home, 'broken-sdk.cordis.yml')
  379. writeFileSync(patch, [
  380. '- insert:',
  381. ' - id: missing-sdk-startup-plugin',
  382. ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
  383. '',
  384. ].join('\n'))
  385. try {
  386. const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
  387. DSH_HOME: home,
  388. DSH_TELEMETRY_DISABLED: '1',
  389. DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
  390. }, home)
  391. expect(result.code).toBe(1)
  392. expect(result.stdout).toBe('')
  393. expect(result.stderr).toContain('plugin tree failed to load')
  394. expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
  395. } finally {
  396. rmSync(home, { recursive: true, force: true })
  397. }
  398. }, 30_000)
  399. it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
  400. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
  401. const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
  402. cwd: home,
  403. reject: false,
  404. timeout: 25_000,
  405. killSignal: 'SIGKILL',
  406. env: {
  407. ...process.env,
  408. DSH_HOME: home,
  409. DSH_TELEMETRY_DISABLED: '1',
  410. DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
  411. },
  412. extendEnv: false,
  413. })
  414. const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
  415. let stderr = ''
  416. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  417. const response = async (id: number): Promise<Record<string, unknown>> => {
  418. for (;;) {
  419. const line = await stdoutLines.next()
  420. if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
  421. let value: Record<string, unknown>
  422. try {
  423. value = JSON.parse(line.value) as Record<string, unknown>
  424. } catch {
  425. throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
  426. }
  427. if (value.id === id) return value
  428. }
  429. }
  430. try {
  431. child.stdin.write(`${JSON.stringify({
  432. jsonrpc: '2.0',
  433. id: 1,
  434. method: 'initialize',
  435. params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  436. })}\n`)
  437. expect(await response(1)).toMatchObject({
  438. jsonrpc: '2.0',
  439. id: 1,
  440. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  441. })
  442. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
  443. expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
  444. const result = await child
  445. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0)
  446. expect(stderr).toBe('')
  447. } finally {
  448. child.kill('SIGKILL')
  449. await child
  450. rmSync(home, { recursive: true, force: true })
  451. }
  452. }, 30_000)
  453. it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
  454. const apiKey = 'built-acp-profile-key'
  455. const server = await startMockLlmServer({
  456. sequence: ['success'],
  457. apiKey,
  458. successText: 'ACP BUILT PROFILE OK',
  459. })
  460. const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
  461. const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
  462. cwd: home,
  463. reject: false,
  464. timeout: 25_000,
  465. killSignal: 'SIGKILL',
  466. env: {
  467. ...process.env,
  468. DSH_HOME: home,
  469. DSH_TELEMETRY_DISABLED: '1',
  470. DEEPSEEK_API_KEY: apiKey,
  471. DEEPSEEK_BASE_URL: server.baseURL,
  472. DSH_PERMISSION_MODE: 'danger-full-access',
  473. },
  474. extendEnv: false,
  475. })
  476. const rawOut: string[] = []
  477. const passthrough = new Readable({ read() {} })
  478. child.stdout.on('data', (chunk: Buffer) => {
  479. rawOut.push(chunk.toString('utf8'))
  480. passthrough.push(chunk)
  481. })
  482. child.stdout.on('end', () => { passthrough.push(null) })
  483. const stream = ndJsonStream(
  484. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  485. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  486. )
  487. const updates: SessionNotification['update'][] = []
  488. const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' })
  489. .onNotification(methods.client.session.update, ({ params }) => {
  490. updates.push(params.update)
  491. return Promise.resolve()
  492. })
  493. .onRequest(methods.client.session.requestPermission, () => {
  494. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  495. })
  496. const client = clientApp.connect(stream).agent
  497. try {
  498. const initialized = await client.request(methods.agent.initialize, {
  499. protocolVersion: PROTOCOL_VERSION,
  500. clientCapabilities: {},
  501. })
  502. expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' })
  503. expect(initialized.agentCapabilities).toEqual({
  504. mcpCapabilities: { http: true },
  505. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  506. sessionCapabilities: { close: {}, list: {}, resume: {} },
  507. })
  508. expect('_meta' in initialized).toBe(false)
  509. const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] })
  510. expect(session.sessionId).toBeTruthy()
  511. expect(await client.request(methods.agent.session.prompt, {
  512. sessionId: session.sessionId,
  513. prompt: [{ type: 'text', text: 'reply from the built ACP profile' }],
  514. })).toEqual({ stopReason: 'end_turn' })
  515. expect(updates).toContainEqual(expect.objectContaining({
  516. sessionUpdate: 'agent_message_chunk',
  517. content: { type: 'text', text: 'ACP BUILT PROFILE OK' },
  518. }))
  519. const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk')
  520. expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
  521. expect(server.requests).toHaveLength(1)
  522. child.stdin.end()
  523. const result = await child
  524. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
  525. expect(result.stderr).toBe('')
  526. for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
  527. expect(() => JSON.parse(line) as unknown).not.toThrow()
  528. }
  529. } finally {
  530. child.kill('SIGKILL')
  531. await child
  532. await server.close()
  533. rmSync(home, { recursive: true, force: true })
  534. }
  535. }, 30_000)
  536. it('runs the headless profile through its app-owned task positional', async () => {
  537. const apiKey = 'built-dsh-headless-key'
  538. const server = await startMockLlmServer({
  539. sequence: ['success'],
  540. apiKey,
  541. successText: 'published headless profile reached the mock',
  542. })
  543. const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
  544. try {
  545. const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
  546. DSH_HOME: home,
  547. DSH_TELEMETRY_DISABLED: '1',
  548. DEEPSEEK_API_KEY: apiKey,
  549. DEEPSEEK_BASE_URL: server.baseURL,
  550. })
  551. expect(result.code, result.stderr).toBe(0)
  552. expect(result.stdout).toBe('published headless profile reached the mock')
  553. expect(result.stderr).toBe('')
  554. expect(server.requests.length).toBeGreaterThan(0)
  555. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  556. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  557. } finally {
  558. await server.close()
  559. rmSync(home, { recursive: true, force: true })
  560. }
  561. }, 30_000)
  562. it('does not load a project environment for --version', async () => {
  563. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  564. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  565. try {
  566. const result = await runBuiltBin(['--version'], {}, project)
  567. expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
  568. } finally {
  569. rmSync(project, { recursive: true, force: true })
  570. }
  571. })
  572. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  573. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  574. try {
  575. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  576. expect(result.code).toBe(1)
  577. expect(result.stderr).toContain('profile "nope" does not exist')
  578. expect(result.stderr).toContain('dsh plugin --profile nope add')
  579. } finally {
  580. rmSync(home, { recursive: true, force: true })
  581. }
  582. }, 30_000)
  583. it('uses the launching endpoint and managed credential through the published entry', async () => {
  584. const apiKey = 'built-home-layer-key'
  585. const server = await startMockLlmServer({
  586. sequence: ['success'],
  587. apiKey,
  588. successText: 'launching endpoint reached the mock',
  589. })
  590. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  591. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  592. writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  593. createEnvironmentProbeProfile(home, project)
  594. try {
  595. const result = await runBuiltBin(
  596. ['--profile', 'environment-probe'],
  597. {
  598. DSH_HOME: home,
  599. DSH_TELEMETRY_DISABLED: '1',
  600. DEEPSEEK_API_KEY: undefined,
  601. DEEPSEEK_BASE_URL: server.baseURL,
  602. },
  603. project,
  604. )
  605. expect(
  606. result.code,
  607. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  608. ).toBe(0)
  609. expect(result.stdout).toBe('launching endpoint reached the mock')
  610. expect(result.stdout).not.toContain(apiKey)
  611. expect(result.stderr).not.toContain(apiKey)
  612. expect(server.requests).toHaveLength(1)
  613. expect(server.requests[0]?.path).toBe('/chat/completions')
  614. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  615. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  616. } finally {
  617. await server.close()
  618. rmSync(home, { recursive: true, force: true })
  619. rmSync(project, { recursive: true, force: true })
  620. }
  621. }, 30_000)
  622. it('reports a patch-overlay boot failure without hanging', async () => {
  623. // The HMR main watcher's initial scan once refreshed the include
  624. // mid-initial-apply, deadlocking the failing apply's rollback against the
  625. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  626. // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
  627. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  628. try {
  629. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  630. DSH_HOME: home,
  631. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  632. DSH_TELEMETRY_DISABLED: '1',
  633. })
  634. expect(result.code).toBe(1)
  635. expect(result.stdout).toBe('')
  636. expect(result.stderr).toContain('llm-pi-ai')
  637. } finally {
  638. rmSync(home, { recursive: true, force: true })
  639. }
  640. }, 30_000)
  641. it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
  642. const fixture = createProfileLifecycleFixture()
  643. const child = startProfileLifecycle(fixture, ['--unclaimed'])
  644. try {
  645. await waitForFile(fixture.ready)
  646. requestProfileShutdown(child, fixture)
  647. const result = await child
  648. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  649. expect(result.signal).toBeUndefined()
  650. expect(existsSync(fixture.disposed)).toBe(true)
  651. } finally {
  652. child.kill('SIGKILL')
  653. rmSync(fixture.home, { recursive: true, force: true })
  654. }
  655. }, 30_000)
  656. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  657. const fixture = createProfileLifecycleFixture()
  658. const child = startProfileLifecycle(fixture)
  659. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  660. const configFile = join(fixture.home, 'config-echo')
  661. try {
  662. await waitForFile(fixture.settled)
  663. // The live profile layer: even without an hmr row in the composition,
  664. // the launcher mounts a config-only watcher, so an edited
  665. // cordis.patch.yml lands in the running tree (the reload disposes the
  666. // patched row's old fiber — observable as the disposed marker — and
  667. // mounts the new config, which echoes its generation and re-writes the
  668. // ready marker).
  669. rmSync(fixture.ready)
  670. writeFileSync(profilePatch, [
  671. '- id: profile-lifecycle-fixture',
  672. ' config:',
  673. ' generation: 2',
  674. '',
  675. ].join('\n'))
  676. await waitForFile(fixture.ready)
  677. expect(readFileSync(configFile, 'utf8')).toBe('2')
  678. // Removal reverts: the bundle's inserted row must return to its own
  679. // default config, not keep the removed override — the insert-aliasing
  680. // regression (a shared patch object mutated in place by a former
  681. // generation would make this impossible).
  682. rmSync(fixture.ready)
  683. writeFileSync(profilePatch, '[]\n')
  684. await waitForFile(fixture.ready)
  685. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  686. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  687. // and outranks the per-profile layer.
  688. rmSync(fixture.ready)
  689. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  690. '- id: profile-lifecycle-fixture',
  691. ' config:',
  692. ' generation: home',
  693. '',
  694. ].join('\n'))
  695. await waitForFile(fixture.ready)
  696. expect(readFileSync(configFile, 'utf8')).toBe('home')
  697. requestProfileShutdown(child, fixture)
  698. const result = await child
  699. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  700. expect(result.signal).toBeUndefined()
  701. expect(existsSync(fixture.disposed)).toBe(true)
  702. } finally {
  703. child.kill('SIGKILL')
  704. rmSync(fixture.home, { recursive: true, force: true })
  705. }
  706. }, 30_000)
  707. it('hands the app arguments to the profile, which applies them before its rows start', async () => {
  708. const fixture = createStartupFixture()
  709. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  710. try {
  711. await waitForFile(fixture.ready)
  712. // The consumer started once, already carrying the flag value: the
  713. // launcher never saw --generation, and the app provider resolved it first.
  714. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  715. requestProfileShutdown(child, fixture)
  716. expect((await child).exitCode).toBe(0)
  717. } finally {
  718. child.kill('SIGKILL')
  719. rmSync(fixture.home, { recursive: true, force: true })
  720. }
  721. }, 30_000)
  722. it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
  723. const fixture = createStartupFixture()
  724. const child = startStartupProfile(fixture, [])
  725. try {
  726. await waitForFile(fixture.ready)
  727. expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
  728. requestProfileShutdown(child, fixture)
  729. expect((await child).exitCode).toBe(0)
  730. } finally {
  731. child.kill('SIGKILL')
  732. rmSync(fixture.home, { recursive: true, force: true })
  733. }
  734. }, 30_000)
  735. it('keeps the app arguments across a user patch reload', async () => {
  736. // A live edit recomposes every row while the provider service remains
  737. // active, so each config expression reads the same invocation value (a
  738. // served port does not move back to its composed fallback).
  739. const fixture = createStartupFixture()
  740. const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
  741. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  742. try {
  743. // Both rows: the waiting one carries the flag value, and the witness is
  744. // what a reload will re-mount. They start independently, so neither
  745. // marker implies the other.
  746. await waitForFile(fixture.ready)
  747. await waitForFile(fixture.witness)
  748. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  749. // An edit to an unrelated row: the witness re-mounts, which is how this
  750. // test knows the whole tree was recomposed.
  751. rmSync(fixture.witness)
  752. writeFileSync(profilePatch, [
  753. '- id: reload-witness',
  754. ' config:',
  755. ' generation: reloaded',
  756. '',
  757. ].join('\n'))
  758. await waitForFile(fixture.witness)
  759. expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
  760. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  761. requestProfileShutdown(child, fixture)
  762. expect((await child).exitCode).toBe(0)
  763. } finally {
  764. child.kill('SIGKILL')
  765. rmSync(fixture.home, { recursive: true, force: true })
  766. }
  767. }, 30_000)
  768. it("prints the app's own help, starts none of its rows, and exits", async () => {
  769. const fixture = createStartupFixture()
  770. try {
  771. const result = await startStartupProfile(fixture, ['--help'])
  772. expect(result.exitCode).toBe(0)
  773. expect(result.stdout).toContain('Usage: fixture')
  774. expect(result.stdout).toContain('--generation')
  775. expect(existsSync(fixture.ready)).toBe(false)
  776. } finally {
  777. rmSync(fixture.home, { recursive: true, force: true })
  778. }
  779. }, 30_000)
  780. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  781. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  782. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  783. // would self-link the profile.
  784. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  785. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  786. try {
  787. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  788. name: 'anchored-bundle',
  789. version: '1.0.0',
  790. dsh: { bundle: { patch: './cordis.patch.yml' } },
  791. }))
  792. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  793. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  794. cwd: checkout,
  795. input: '',
  796. timeout: 60_000,
  797. killSignal: 'SIGKILL',
  798. reject: false,
  799. env: { DSH_HOME: home },
  800. })
  801. expect(result.exitCode).toBe(0)
  802. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  803. dependencies: Record<string, string>
  804. dsh: { profile: { bundles: string[] } }
  805. }
  806. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  807. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  808. const removed = await runBuiltBin(
  809. ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'],
  810. { DSH_HOME: home },
  811. checkout,
  812. )
  813. expect(removed.code).toBe(0)
  814. const afterRemove = JSON.parse(
  815. readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'),
  816. ) as {
  817. dependencies?: Record<string, string>
  818. dsh: { profile: { bundles: string[] } }
  819. }
  820. expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([])
  821. expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle')
  822. } finally {
  823. rmSync(home, { recursive: true, force: true })
  824. rmSync(checkout, { recursive: true, force: true })
  825. }
  826. }, 90_000)
  827. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  828. // Reconcile runs against the INSTALLED state on every successful pnpm
  829. // run, so `update` (not only `add`) activates a package whose newer
  830. // version declares dsh.bundle. Simulated without a registry: hand-place
  831. // the installed package, flip its manifest, and run a benign pnpm verb.
  832. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  833. try {
  834. const profileDir = join(home, 'profiles', 'up')
  835. const installed = join(profileDir, 'node_modules', 'late-bundle')
  836. mkdirSync(installed, { recursive: true })
  837. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  838. name: 'dsh-profile-up',
  839. private: true,
  840. dependencies: { 'late-bundle': 'file:./late-bundle' },
  841. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  842. }))
  843. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  844. // v1: no dsh manifest — a plain dependency.
  845. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  846. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  847. expect(first.code).toBe(0)
  848. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  849. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  850. // v2: the installed package now declares dsh.bundle (an update landed).
  851. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  852. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  853. }))
  854. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  855. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  856. expect(second.code).toBe(0)
  857. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  858. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  859. } finally {
  860. rmSync(home, { recursive: true, force: true })
  861. }
  862. }, 30_000)
  863. describe('config dump', () => {
  864. let home: string
  865. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  866. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  867. it('prints the web profile bundle layers without a user layer', async () => {
  868. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  869. expect(code).toBe(0)
  870. expect(stderr).toBe('')
  871. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  872. expect(stdout).toContain('agents: []')
  873. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  874. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  875. }, 30_000)
  876. it('prints the headless profile without Host or browser layers', async () => {
  877. const { stdout, code, stderr } = await runBuiltBin(
  878. ['--profile', 'headless', '--dump-default-config'],
  879. { DSH_HOME: home },
  880. )
  881. expect(code).toBe(0)
  882. expect(stderr).toBe('')
  883. expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
  884. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
  885. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
  886. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
  887. }, 30_000)
  888. it('composes the profile user layer and a --patch overlay in order', async () => {
  889. // Auto-init the web profile first, then write its user layer.
  890. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  891. expect(init.code).toBe(0)
  892. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  893. writeFileSync(profilePatch, [
  894. '- id: agent-loop',
  895. ' config:',
  896. ' agents:',
  897. ' - id: personal',
  898. ' provider: personal-provider',
  899. ' model: personal-model',
  900. '- id: absent-row',
  901. ' config:',
  902. ' x: 1',
  903. '',
  904. ].join('\n'))
  905. const overlay = join(home, 'overlay.cordis.yml')
  906. writeFileSync(overlay, [
  907. '- id: agent-loop',
  908. ' config:',
  909. ' agents:',
  910. ' - id: configured',
  911. ' provider: configured-provider',
  912. ' model: configured-model',
  913. '',
  914. ].join('\n'))
  915. const { stdout, code, stderr } = await runBuiltBin(
  916. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  917. { DSH_HOME: home },
  918. )
  919. expect(code).toBe(0)
  920. expect(stdout).toContain('provider: configured-provider')
  921. expect(stdout).not.toContain('personal-provider')
  922. // Both layers patched the row; the comment lists them in application order.
  923. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  924. expect(stderr).toContain('patch: entry "absent-row" not found')
  925. }, 30_000)
  926. })
  927. })