1
0

host-failures.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { Duplex, PassThrough } from 'node:stream'
  2. import { setImmediate } from 'node:timers/promises'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  5. import type { CodeBindingFunction, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
  6. import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
  7. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  8. import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
  9. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  10. import type { Config } from '../src/index.ts'
  11. import { JsonChannel } from '../src/channel.ts'
  12. import { encodeCodeJsonWire } from '../src/json-wire.ts'
  13. import { mountRuntime } from './setup.ts'
  14. const request: CodeRunRequest = { program: 'return 1', bindings: [] }
  15. const NO_INITIAL_FRAME = Symbol('no initial frame')
  16. async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-access' = 'danger-full-access') {
  17. const ctx = new Context()
  18. const runtime = await mountRuntime(ctx, config, { mode, workspaceRoot: process.cwd() })
  19. const control = new Duplex({
  20. read() {},
  21. write(chunk: Buffer, _encoding, callback) {
  22. if (childControl.destroyed) { callback(new Error('peer closed')); return }
  23. childControl.push(chunk)
  24. callback()
  25. },
  26. })
  27. const childControl = new Duplex({
  28. read() {},
  29. write(chunk: Buffer, _encoding, callback) {
  30. if (control.destroyed) { callback(new Error('peer closed')); return }
  31. control.push(chunk)
  32. callback()
  33. },
  34. })
  35. const stdout = new PassThrough()
  36. const stderr = new PassThrough()
  37. const direct = Promise.withResolvers<SubprocessOutcome>()
  38. const messages: unknown[] = []
  39. const terminate = vi.fn(() => {
  40. stdout.end()
  41. stderr.end()
  42. direct.resolve({ exitCode: 0, signal: null })
  43. })
  44. const waitForExit = vi.fn(async () => true)
  45. const handle: SubprocessHandle = {
  46. stdin: undefined,
  47. stdout,
  48. stderr,
  49. control,
  50. collected: {},
  51. done: direct.promise,
  52. terminate,
  53. waitForExit,
  54. }
  55. const writes = new Set<Promise<void>>()
  56. let receive: (message: unknown) => void = () => {}
  57. const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
  58. messages.push(message)
  59. receive(message)
  60. }, () => {})
  61. const emit = (message: unknown): void => {
  62. const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
  63. writes.add(write)
  64. }
  65. const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
  66. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
  67. onTestFinished(async () => {
  68. peer.close()
  69. control.destroy()
  70. stdout.destroy()
  71. stderr.destroy()
  72. direct.resolve({ exitCode: 0, signal: null })
  73. await Promise.all(writes)
  74. })
  75. const start = (input: CodeRunRequest = request, first: unknown = { type: 'ready' }) => {
  76. const result = runtime.run(runtime.resolve(input))
  77. if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
  78. return result
  79. }
  80. const onBoot = (callback: () => void): void => {
  81. receive = (message) => {
  82. if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback()
  83. }
  84. }
  85. return {
  86. ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages,
  87. spawn, resolveExecutable, emit, start, onBoot,
  88. receive: (callback: typeof receive) => { receive = callback },
  89. }
  90. }
  91. function call(id: number, value: string = '') {
  92. return { type: 'call', id, global: 'tools', name: 'test', args: encodeCodeJsonWire(value) }
  93. }
  94. function withBinding(fn: CodeBindingFunction): CodeRunRequest {
  95. return { ...request, bindings: [{ global: 'tools', functions: { test: fn } }] }
  96. }
  97. function confinement(argv: string[]): ConfinedArgv {
  98. return { argv, enforcement: 'partial', denialSignatures: ['EACCES'], runnerFailureRules: [{ fatalSignatures: ['sandbox-fatal:'] }] }
  99. }
  100. describe('Node runtime host failures', () => {
  101. it.each<[Config, string]>([
  102. [{ timeoutMs: 0 }, 'timeoutMs'],
  103. [{ maxPendingCalls: -1 }, 'maxPendingCalls'],
  104. [{ maxOldGenerationSizeMb: Infinity }, 'maxOldGenerationSizeMb'],
  105. [{ timeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'timeoutMs'],
  106. [{ maxTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'maxTimeoutMs'],
  107. [{ graceMs: MAX_TIMER_DELAY_MS + 1 }, 'graceMs'],
  108. [{ maxOutputBytes: 3 }, 'maxOutputBytes'],
  109. [{ maxOutputBytes: 4.5 }, 'maxOutputBytes'],
  110. [{ maxMessageBytes: 1.5 }, 'maxMessageBytes'],
  111. [{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
  112. [{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
  113. [{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
  114. [{ nodeExecutable: '' }, 'nodeExecutable'],
  115. [{ bootstrapPath: 'relative-bootstrap.js' }, 'bootstrapPath'],
  116. ])('rejects deployment configuration %j', async (config, field) => {
  117. await expect(mountRuntime(new Context(), config)).rejects.toThrow(field)
  118. })
  119. it('reports the deployment mode and rejects unsupported resolved inputs before spawning', async () => {
  120. const h = await setup({}, 'read-only')
  121. expect(h.runtime.sandboxMode).toBe('read-only')
  122. expect(() => h.runtime.resolve({ ...request, cwd: 'relative' })).toThrow('cwd must be absolute')
  123. await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: 1 })).rejects.toThrow('resolved sandbox policy')
  124. const spec = h.runtime.resolve(request)
  125. await expect(h.runtime.run({ ...spec, timeoutMs: Infinity })).rejects.toThrow('resolved cwd and timeout')
  126. expect(h.spawn).not.toHaveBeenCalled()
  127. await h.ctx.fiber.dispose()
  128. expect(() => h.runtime.resolve(request)).toThrow('resolve after disposal')
  129. await expect(h.runtime.run(spec)).rejects.toThrow('run after disposal')
  130. })
  131. it('fails a required unavailable sandbox before spawning', async () => {
  132. const h = await setup({}, 'read-only')
  133. vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
  134. expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
  135. expect(h.spawn).not.toHaveBeenCalled()
  136. })
  137. it('keeps partial enforcement separate from a successful program', async () => {
  138. const h = await setup({}, 'read-only')
  139. vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
  140. h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
  141. expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
  142. })
  143. it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
  144. const h = await setup({}, 'read-only')
  145. vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
  146. h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
  147. const result = await h.start()
  148. expect(result.error).toEqual({ kind: 'exception', message })
  149. expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' })
  150. })
  151. it('distinguishes fatal sandbox startup output from a program denial', async () => {
  152. const h = await setup({}, 'read-only')
  153. vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
  154. h.onBoot(() => {
  155. h.stderr.write('sandbox-fatal: runner could not initialize')
  156. h.direct.resolve({ exitCode: 1, signal: null })
  157. })
  158. const result = await h.start()
  159. expect(result.error?.kind).toBe('sandbox-unavailable')
  160. expect(result.sandbox?.denied).toBe(false)
  161. })
  162. it('reports bootstrap assets that cannot map into the execution world', async () => {
  163. const h = await setup()
  164. vi.spyOn(h.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
  165. expect((await h.start()).error?.message).toContain('bootstrap is unavailable')
  166. expect(h.spawn).not.toHaveBeenCalled()
  167. })
  168. it('cleans up a provider that fails to supply its requested control pipe', async () => {
  169. const h = await setup()
  170. h.spawn.mockReturnValue({ ...h.handle, control: undefined })
  171. expect((await h.start()).error?.message).toContain('did not supply the requested control')
  172. expect(h.terminate).toHaveBeenCalledOnce()
  173. expect(h.waitForExit).toHaveBeenCalledOnce()
  174. })
  175. it('reports an executable lookup failure without allocating a process', async () => {
  176. const h = await setup()
  177. h.resolveExecutable.mockRejectedValue(new Error('Node executable missing'))
  178. expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node executable missing' })
  179. expect(h.spawn).not.toHaveBeenCalled()
  180. })
  181. it('honors an already-aborted run without beginning executable lookup', async () => {
  182. const h = await setup()
  183. const result = await h.start({ ...request, signal: AbortSignal.abort('already stopped') }, NO_INITIAL_FRAME)
  184. expect(result.error).toEqual({ kind: 'abort', message: 'already stopped' })
  185. expect(h.resolveExecutable).not.toHaveBeenCalled()
  186. })
  187. it('does not launch after cancellation races executable lookup completion', async () => {
  188. const h = await setup()
  189. const controller = new AbortController()
  190. h.resolveExecutable.mockImplementation(async () => {
  191. controller.abort('lookup canceled')
  192. return process.execPath
  193. })
  194. expect((await h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)).error?.kind).toBe('abort')
  195. expect(h.spawn).not.toHaveBeenCalled()
  196. })
  197. it('reports an early control EOF using the direct process result', async () => {
  198. const h = await setup()
  199. h.spawn.mockImplementation(() => {
  200. queueMicrotask(() => {
  201. h.control.push(null)
  202. h.direct.resolve({ exitCode: 7, signal: null })
  203. })
  204. return h.handle
  205. })
  206. expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
  207. kind: 'worker-exit', message: 'Node process exited before completing (7)',
  208. })
  209. })
  210. it('reports startup transport loss when the direct process outcome rejects', async () => {
  211. const h = await setup()
  212. h.spawn.mockImplementation(() => {
  213. queueMicrotask(() => {
  214. h.control.emit('error', new Error('control transport closed'))
  215. h.direct.reject(new Error('runner connection broken'))
  216. })
  217. return h.handle
  218. })
  219. expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({ kind: 'worker-exit', message: 'runner connection broken' })
  220. })
  221. it('reports control loss after readiness as a substrate failure', async () => {
  222. const h = await setup()
  223. h.onBoot(() => { h.control.emit('error', new Error('control transport closed')) })
  224. expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
  225. })
  226. it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
  227. const h = await setup()
  228. h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
  229. expect((await h.start()).error?.kind).toBe('protocol')
  230. })
  231. it('rejects traffic before readiness without invoking host bindings', async () => {
  232. const h = await setup()
  233. const binding = vi.fn(async () => null)
  234. const result = await h.start(withBinding(binding), call(1))
  235. expect(result.error?.message).toContain('before bootstrap readiness')
  236. expect(binding).not.toHaveBeenCalled()
  237. })
  238. it.each<[unknown, string]>([
  239. [null, 'invalid control frame'],
  240. [{ type: 'log', text: 1 }, 'invalid log frame'],
  241. [{ type: 'done', error: null }, 'invalid terminal error'],
  242. [{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'],
  243. [{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'],
  244. [{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'],
  245. [{ ...call(1), name: 'constructor' }, 'undeclared binding'],
  246. [{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'],
  247. [{ type: 'unknown' }, 'unknown control message'],
  248. ])('rejects malformed program frame %j', async (frame, message) => {
  249. const h = await setup()
  250. const binding = vi.fn(async () => null)
  251. h.onBoot(() => { h.emit(frame) })
  252. const result = await h.start(withBinding(binding))
  253. expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) as unknown })
  254. expect(binding).not.toHaveBeenCalled()
  255. })
  256. it('refuses a repeated call id instead of dispatching it twice', async () => {
  257. const h = await setup()
  258. const binding = vi.fn(async () => null)
  259. h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) })
  260. expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol')
  261. expect(binding).toHaveBeenCalledOnce()
  262. })
  263. it.each<Config>([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => {
  264. const h = await setup(config)
  265. const release = Promise.withResolvers<null>()
  266. onTestFinished(() => { release.resolve(null) })
  267. const binding = vi.fn(async () => await release.promise)
  268. h.onBoot(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
  269. expect((await h.start(withBinding(binding))).error?.message).toContain('pending binding calls exceed')
  270. expect(binding).toHaveBeenCalledOnce()
  271. release.resolve(null)
  272. await setImmediate()
  273. })
  274. it('rejects an untransferable completion instead of returning a substituted value', async () => {
  275. const h = await setup()
  276. h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) })
  277. expect((await h.start()).error?.kind).toBe('invalid-output')
  278. })
  279. it('returns non-lossless binding resolutions as program-visible binding failures', async () => {
  280. const h = await setup()
  281. h.receive((message) => {
  282. if (typeof message !== 'object' || message === null || !('type' in message)) return
  283. if (message.type === 'boot') h.emit(call(1))
  284. if (message.type === 'reply') h.emit({ type: 'done' })
  285. })
  286. expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined()
  287. expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') as unknown })
  288. })
  289. it('does not publish a late binding reply after the program has settled', async () => {
  290. const h = await setup()
  291. const release = Promise.withResolvers<null>()
  292. onTestFinished(() => { release.resolve(null) })
  293. h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
  294. expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined()
  295. release.resolve(null)
  296. await setImmediate()
  297. expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' }))
  298. expect(h.control.destroyed).toBe(true)
  299. })
  300. it('reports failed managed cleanup even when the program returns successfully', async () => {
  301. const h = await setup()
  302. vi.mocked(h.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
  303. h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
  304. expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
  305. })
  306. it('fails when the configured frame budget cannot carry bootstrap data', async () => {
  307. const h = await setup({ maxMessageBytes: 32 })
  308. expect((await h.start()).error?.message).toContain('control output exceeds 32 queued bytes')
  309. })
  310. it('turns an oversized binding reply into a protocol failure', async () => {
  311. const h = await setup({ maxMessageBytes: 256 })
  312. h.onBoot(() => { h.emit(call(1)) })
  313. const result = await h.start(withBinding(async () => 'x'.repeat(512)))
  314. expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
  315. })
  316. it('retains admitted logs when the program reports its own output limit', async () => {
  317. const h = await setup()
  318. h.onBoot(() => {
  319. h.emit({ type: 'log', text: 'retained' })
  320. h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } })
  321. })
  322. const result = await h.start()
  323. expect(result.error?.kind).toBe('output-limit')
  324. expect(result.logs).toEqual(['retained'])
  325. })
  326. it.each([undefined, { kind: 'exception', message: 'program failed' }] as const)('bounds incomplete raw output after completion with %j', async (failure) => {
  327. const h = await setup({ graceMs: 5 })
  328. const cleaning = Promise.withResolvers<undefined>()
  329. vi.mocked(h.terminate).mockImplementation(() => { h.direct.resolve({ exitCode: 0, signal: null }) })
  330. vi.mocked(h.waitForExit).mockImplementation(async () => { cleaning.resolve(undefined); return true })
  331. h.onBoot(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) })
  332. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  333. try {
  334. const pending = h.start()
  335. await cleaning.promise
  336. await vi.advanceTimersByTimeAsync(5)
  337. expect((await pending).error).toEqual(failure ?? { kind: 'worker-exit', message: 'Node process output did not close cleanly' })
  338. expect(h.stdout.destroyed).toBe(true)
  339. expect(h.stderr.destroyed).toBe(true)
  340. } finally { vi.useRealTimers() }
  341. })
  342. it('decodes fragmented native output and preserves incomplete final UTF-8 bytes', async () => {
  343. const h = await setup()
  344. h.onBoot(() => {
  345. for (const stream of [h.stdout, h.stderr]) {
  346. stream.write(Buffer.from([0xef, 0xbb, 0xbf]))
  347. stream.write(Buffer.from([0xe2]))
  348. stream.write(Buffer.from([0x82, 0xac]))
  349. stream.end(Buffer.from([0xe2]))
  350. }
  351. h.emit({ type: 'done' })
  352. })
  353. const result = await h.start()
  354. expect(result.error).toBeUndefined()
  355. expect(result.logs.filter(text => text === '\uFEFF')).toHaveLength(2)
  356. expect(result.logs.filter(text => text === '€')).toHaveLength(2)
  357. expect(result.logs.filter(text => text === '�')).toHaveLength(2)
  358. })
  359. it('keeps the fitting native-output prefix and ignores later overflow chunks', async () => {
  360. const h = await setup({ maxOutputBytes: 256 })
  361. vi.mocked(h.terminate).mockImplementation(() => {
  362. h.stderr.end('later output')
  363. h.stdout.end()
  364. h.direct.resolve({ exitCode: 0, signal: null })
  365. })
  366. h.onBoot(() => { h.stdout.write('x'.repeat(1024)) })
  367. const result = await h.start()
  368. expect(result.error?.kind).toBe('output-limit')
  369. expect(result.logs.join('')).toMatch(/^x+$/)
  370. expect(Buffer.byteLength(JSON.stringify(result.logs))).toBeLessThanOrEqual(256)
  371. })
  372. it.each(['stdout', 'stderr'] as const)('reports a broken raw %s pipe', async (name) => {
  373. const h = await setup()
  374. h.onBoot(() => { h[name].emit('error', new Error(`${name} closed`)) })
  375. expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: `${name} closed` })
  376. })
  377. it('retains stderr when a ready process exits without a completion frame', async () => {
  378. const h = await setup()
  379. h.onBoot(() => {
  380. h.stderr.write('native fatal detail')
  381. h.direct.resolve({ exitCode: 9, signal: null })
  382. })
  383. expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node process exited before completing (9): native fatal detail' })
  384. })
  385. it.each([true, false])('attributes a failed confined spawn only with runner evidence (%s)', async (runnerFailed) => {
  386. const h = await setup({}, 'read-only')
  387. const runner = '/sandbox-runner'
  388. vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([runner, ...argv]))
  389. h.onBoot(() => {
  390. h.direct.reject(Object.assign(new Error('spawn rejected'), runnerFailed ? { code: 'ENOENT', path: runner, syscall: `spawn ${runner}` } : {}))
  391. })
  392. expect((await h.start()).error).toEqual({ kind: runnerFailed ? 'sandbox-unavailable' : 'worker-exit', message: 'spawn rejected' })
  393. })
  394. it('retains distinct native temp paths for the trusted launcher while removing other ambient values', async () => {
  395. const h = await setup()
  396. onTestFinished(() => { vi.unstubAllEnvs() })
  397. vi.stubEnv('TEMP', 'fixture-temp-first')
  398. vi.stubEnv('TMP', 'fixture-tmp-second')
  399. vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
  400. h.onBoot(() => { h.emit({ type: 'done' }) })
  401. expect((await h.start()).error).toBeUndefined()
  402. const env = h.spawn.mock.calls[0]?.[0].env ?? {}
  403. expect(Object.hasOwn(env, 'TEMP')).toBe(false)
  404. expect(Object.hasOwn(env, 'TMP')).toBe(false)
  405. expect(Object.hasOwn(env, 'DSH_TEST_RUNTIME_SECRET')).toBe(true)
  406. expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined()
  407. })
  408. it('selects the private packaged bootstrap without leaking ambient environment', async () => {
  409. const h = await setup()
  410. h.onBoot(() => { h.emit({ type: 'done' }) })
  411. const prior = Object.getOwnPropertyDescriptor(process, 'pkg')
  412. try {
  413. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  414. expect((await h.start()).error).toBeUndefined()
  415. const spec = h.spawn.mock.calls[0]?.[0]
  416. expect(spec?.env?.DSH_CODE_RUNTIME_NODE).toBe('1')
  417. expect(Object.hasOwn(spec?.env ?? {}, 'PATH')).toBe(false)
  418. expect(spec?.argv).toEqual([process.execPath, '134217728'])
  419. expect(Object.fromEntries(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)))
  420. .toEqual({ DSH_CODE_RUNTIME_NODE: '1', NODE_OPTIONS: '--max-old-space-size=512' })
  421. } finally {
  422. if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
  423. else Object.defineProperty(process, 'pkg', prior)
  424. }
  425. })
  426. })