built-bin.e2e.ts 39 KB

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