windows-shell.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * The shipped shell composition: the base bundle gates both shell stacks by
  3. * platform on its own rows (`disabled: !!js process.platform`), so exactly
  4. * one shell stack mounts per host and no separate platform layer exists —
  5. * the launcher applies nothing beyond the bundle layers. The spec composes
  6. * the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the
  7. * app installation anchor) through the boot's patch algorithm and pins the
  8. * effective per-platform roster, the preset-level gates that keep tool-bash
  9. * out of win32 sessions and tool-pwsh out of POSIX sessions, and the
  10. * cold-start resolution closure for the pwsh rows' bare plugin names.
  11. */
  12. import { afterEach, describe, expect, it } from 'vitest'
  13. import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
  14. import { tmpdir } from 'node:os'
  15. import { join } from 'node:path'
  16. import { fileURLToPath } from 'node:url'
  17. import yaml from 'js-yaml'
  18. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  19. import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
  20. import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
  21. import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
  22. /**
  23. * The effective disabled state of one row on one platform: a `!!js` expression
  24. * evaluates with a platform-scoped `process` so both outcomes pin on any host.
  25. */
  26. function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean {
  27. const value = row.disabled
  28. if (value !== null && typeof value === 'object' && '__jsExpr' in value) {
  29. return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr))
  30. }
  31. return value === true
  32. }
  33. describe('the shipped shell composition (real bundle layers)', () => {
  34. let home: string
  35. afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
  36. // The app installation anchor, mirroring profile-boot.ts: the bundle layers
  37. // resolve from the REAL dsh-base/dsh-web-app packages through it, so this
  38. // suite composes the shipped patch files, not test fixtures.
  39. const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
  40. it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => {
  41. home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
  42. initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
  43. const profile = loadProfile('dsh', 'web', anchor, home)
  44. const warnings: string[] = []
  45. const rows = composeEntries(
  46. profile.layers.map(layer => layer.patches),
  47. message => warnings.push(message),
  48. )
  49. const byId = new Map(rows.map(row => [row.id, row]))
  50. // One shared patch set, two rosters: the shell stacks gate themselves.
  51. for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-bash', 'tool-pwsh']) {
  52. expect(byId.has(id), `row ${id}`).toBe(true)
  53. }
  54. expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true)
  55. expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false)
  56. expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false)
  57. expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true)
  58. // Host shell-tool rows are disabled on every platform; sessions mount
  59. // their own rows instead.
  60. expect(byId.get('tool-bash')?.disabled).toBe(true)
  61. expect(byId.get('tool-pwsh')?.disabled).toBe(true)
  62. // The permission surface never moves: the sandbox/policy rows, the
  63. // permission switcher, fs-sandbox, and the approval service stay enabled
  64. // exactly as on POSIX — the confined pwsh executor is what changes.
  65. for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
  66. expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
  67. }
  68. // The launcher's cold-start module fallback BFS-links the apps/cli
  69. // dependency closure into the profile's node_modules, so every bare
  70. // plugin name in the base patch must resolve from there.
  71. const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
  72. for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
  73. expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
  74. }
  75. expect(warnings).toEqual([])
  76. })
  77. it('base-only profiles carry both stacks with the same platform gating', () => {
  78. home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
  79. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
  80. const profile = loadProfile('dsh', 'base-only', anchor, home)
  81. const warnings: string[] = []
  82. const rows = composeEntries(
  83. profile.layers.map(layer => layer.patches),
  84. message => warnings.push(message),
  85. )
  86. const byId = new Map(rows.map(row => [row.id, row]))
  87. for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) {
  88. expect(byId.has(id), `row ${id}`).toBe(true)
  89. }
  90. // No web overlay: the tool rows keep their own gating too.
  91. expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true)
  92. expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false)
  93. expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false)
  94. expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true)
  95. expect(warnings).toEqual([])
  96. })
  97. })
  98. describe('shipped agent presets gate both shell tools by platform', () => {
  99. const presetRoot = SHIPPED_PRESET_ROOT
  100. it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => {
  101. const entries: unknown = yaml.load(
  102. readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'),
  103. { schema: entryListSchema },
  104. )
  105. if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`)
  106. for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) {
  107. const row = entries.find((entry): entry is Record<string, unknown> => (
  108. typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
  109. ))
  110. if (row === undefined) throw new TypeError(`preset ${preset} must mount ${id}`)
  111. expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string })
  112. // A platform-scoped context pins both outcomes on every host.
  113. const expression = (row.disabled as { __jsExpr: string }).__jsExpr
  114. expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
  115. expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(!win32)
  116. }
  117. })
  118. it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => {
  119. const entries: unknown = yaml.load(
  120. readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
  121. { schema: entryListSchema },
  122. )
  123. if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array')
  124. for (const id of ['tool-bash', 'tool-pwsh']) {
  125. expect(entries.some(entry => (
  126. typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
  127. )), `${id} must be absent from minimal`).toBe(false)
  128. }
  129. const group = entries.find((entry): entry is Record<string, unknown> => (
  130. typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === 'persistent-shell'
  131. ))
  132. if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell')
  133. const rows = group.config as unknown[]
  134. if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list')
  135. const byId = new Map(rows
  136. .filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
  137. .map(entry => [entry.id, entry]))
  138. // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the
  139. // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on
  140. // win32 only — exactly one persistent shell per host.
  141. for (const id of ['terminal-bash', 'persistent-bash']) {
  142. expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true)
  143. expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false)
  144. }
  145. for (const id of ['terminal-pwsh', 'persistent-pwsh']) {
  146. expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false)
  147. expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true)
  148. }
  149. expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' })
  150. })
  151. })