windows-shell.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join, resolve } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import yaml from 'js-yaml'
  7. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  8. import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
  9. import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
  10. import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
  11. import {
  12. BASE_BUNDLE,
  13. resolveWindowsShellLayer,
  14. WINDOWS_SHELL_PATCH_FILENAME,
  15. } from '../src/windows-shell.ts'
  16. const WINDOWS_PATCH = `- id: bash-sandbox
  17. disabled: true
  18. - insert:
  19. - id: pwsh-sandbox
  20. name: '@deepseek-ai/dsh-pwsh-sandbox'
  21. `
  22. /** One fake bundle layer rooted in a temp directory. */
  23. function fakeLayer(packageName: string, dir: string): ProfileLayer {
  24. return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
  25. }
  26. /** A base bundle layer whose package carries the Windows shell patch. */
  27. function baseLayerWithPatch(dir: string): ProfileLayer {
  28. writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
  29. return fakeLayer(BASE_BUNDLE, dir)
  30. }
  31. describe('resolveWindowsShellLayer', () => {
  32. let base: string
  33. afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
  34. const tempBase = (): string => {
  35. base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
  36. return base
  37. }
  38. it('never applies on POSIX hosts', () => {
  39. expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
  40. expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
  41. })
  42. it('defaults Windows hosts to the pwsh platform layer', () => {
  43. const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
  44. expect(layer).toBeDefined()
  45. expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
  46. expect(layer?.patches).toEqual([
  47. { id: 'bash-sandbox', disabled: true },
  48. { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
  49. ])
  50. })
  51. it('skips custom profiles without a base bundle', () => {
  52. const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
  53. expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
  54. })
  55. it('fails loud when the base bundle ships no Windows shell patch', () => {
  56. const base = tempBase()
  57. mkdirSync(base, { recursive: true })
  58. // The overlay loader owns the fail-loud contract: the caller named this
  59. // file, so its absence is a misconfiguration, not "no overlay".
  60. expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
  61. .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
  62. })
  63. })
  64. describe('the shipped Windows composition (real bundle layers)', () => {
  65. let home: string
  66. afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
  67. // The app installation anchor, mirroring profile-boot.ts: the bundle layers
  68. // resolve from the REAL dsh-base/dsh-web-app packages through it, so this
  69. // suite composes the shipped patch files, not test fixtures.
  70. const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
  71. it('composes the win32 confined roster through the real patch layers', () => {
  72. home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
  73. initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
  74. const profile = loadProfile('dsh', 'web', anchor, home)
  75. const warnings: string[] = []
  76. const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
  77. expect(win32).toBeDefined()
  78. const rows = composeEntries(
  79. [...profile.layers.map(layer => layer.patches), win32!.patches],
  80. message => warnings.push(message),
  81. )
  82. const byId = new Map(rows.map(row => [row.id, row]))
  83. // Only the POSIX bash stack leaves the roster: the permission surface
  84. // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
  85. // exactly as on POSIX — the confined pwsh executor is what changes.
  86. for (const id of ['bash-sandbox', 'tool-bash']) {
  87. expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
  88. }
  89. for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
  90. expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
  91. }
  92. for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
  93. expect(byId.has(id), `inserted row ${id}`).toBe(true)
  94. }
  95. // The launcher's cold-start module fallback BFS-links the apps/cli
  96. // dependency closure into the profile's node_modules (the pwsh-local
  97. // precedent), so every inserted bare plugin must resolve from there.
  98. const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
  99. for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
  100. expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
  101. }
  102. // The patch touches only base-owned rows plus inserts, so the full web
  103. // profile composes without any no-match warning.
  104. expect(warnings).toEqual([])
  105. })
  106. it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
  107. home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
  108. initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
  109. const profile = loadProfile('dsh', 'web', anchor, home)
  110. // POSIX: no platform layer, the bash stack stays enabled.
  111. const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
  112. const posixById = new Map(posixRows.map(row => [row.id, row]))
  113. expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
  114. expect(posixById.has('pwsh-local')).toBe(false)
  115. expect(posixById.has('pwsh-sandbox')).toBe(false)
  116. // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
  117. // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
  118. // inserts, so the composition produces no no-match warning.
  119. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
  120. const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
  121. const baseWarnings: string[] = []
  122. const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
  123. expect(win32).toBeDefined()
  124. composeEntries(
  125. [...baseOnly.layers.map(layer => layer.patches), win32!.patches],
  126. message => baseWarnings.push(message),
  127. )
  128. expect(baseWarnings).toEqual([])
  129. })
  130. })
  131. describe('shipped agent presets keep tool-bash off the win32 roster', () => {
  132. const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets')
  133. it.each(['standard', 'code', 'cordis'])('preset %s gates its tool-bash row by platform', (preset) => {
  134. const entries: unknown = yaml.load(
  135. readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'),
  136. { schema: entryListSchema },
  137. )
  138. if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`)
  139. const row = entries.find((entry): entry is Record<string, unknown> => (
  140. typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === 'tool-bash'
  141. ))
  142. if (row === undefined) throw new TypeError(`preset ${preset} must mount tool-bash`)
  143. expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string })
  144. // The platform patch disables the host's tool-bash row on win32; the
  145. // preset row must not re-enable it there. Evaluate the shipped expression
  146. // with a platform-scoped context (the `with` scope shadows the global
  147. // `process`) so both outcomes pin on every host.
  148. const expression = (row.disabled as { __jsExpr: string }).__jsExpr
  149. expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression))).toBe(true)
  150. expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression))).toBe(false)
  151. })
  152. it('minimal mounts no tool-bash row at all (its shell is the PTY stack)', () => {
  153. const entries: unknown = yaml.load(
  154. readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
  155. { schema: entryListSchema },
  156. )
  157. if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array')
  158. expect(entries.some(entry => (
  159. typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === 'tool-bash'
  160. ))).toBe(false)
  161. })
  162. })