|
|
@@ -0,0 +1,354 @@
|
|
|
+import { Duplex, PassThrough } from 'node:stream'
|
|
|
+import { setImmediate } from 'node:timers/promises'
|
|
|
+import { Context } from '@deepseek-ai/cordis'
|
|
|
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
|
+import type { CodeBindingFunction, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
|
|
|
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
|
|
+import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
|
|
+import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
|
|
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
|
+import type { Config } from '../src/index.ts'
|
|
|
+import { JsonChannel } from '../src/channel.ts'
|
|
|
+import { encodeCodeJsonWire } from '../src/json-wire.ts'
|
|
|
+import { mountRuntime } from './setup.ts'
|
|
|
+
|
|
|
+const request: CodeRunRequest = { program: 'return 1', bindings: [] }
|
|
|
+const NO_INITIAL_FRAME = Symbol('no initial frame')
|
|
|
+
|
|
|
+async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-access' = 'danger-full-access') {
|
|
|
+ const ctx = new Context()
|
|
|
+ const runtime = await mountRuntime(ctx, config, { mode, workspaceRoot: process.cwd() })
|
|
|
+ const control = new Duplex({
|
|
|
+ read() {},
|
|
|
+ write(chunk: Buffer, _encoding, callback) {
|
|
|
+ if (childControl.destroyed) { callback(new Error('peer closed')); return }
|
|
|
+ childControl.push(chunk)
|
|
|
+ callback()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const childControl = new Duplex({
|
|
|
+ read() {},
|
|
|
+ write(chunk: Buffer, _encoding, callback) {
|
|
|
+ if (control.destroyed) { callback(new Error('peer closed')); return }
|
|
|
+ control.push(chunk)
|
|
|
+ callback()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const stdout = new PassThrough()
|
|
|
+ const stderr = new PassThrough()
|
|
|
+ const direct = Promise.withResolvers<SubprocessOutcome>()
|
|
|
+ const messages: unknown[] = []
|
|
|
+ const handle: SubprocessHandle = {
|
|
|
+ stdin: undefined,
|
|
|
+ stdout,
|
|
|
+ stderr,
|
|
|
+ control,
|
|
|
+ collected: {},
|
|
|
+ done: direct.promise,
|
|
|
+ terminate: vi.fn(() => {
|
|
|
+ stdout.end()
|
|
|
+ stderr.end()
|
|
|
+ direct.resolve({ exitCode: 0, signal: null })
|
|
|
+ }),
|
|
|
+ waitForExit: vi.fn(async () => true),
|
|
|
+ }
|
|
|
+ const writes = new Set<Promise<void>>()
|
|
|
+ let receive: (message: unknown) => void = () => {}
|
|
|
+ const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
|
|
|
+ messages.push(message)
|
|
|
+ receive(message)
|
|
|
+ }, () => {})
|
|
|
+ const emit = (message: unknown): void => {
|
|
|
+ const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
|
|
|
+ writes.add(write)
|
|
|
+ }
|
|
|
+ const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
|
|
|
+ const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
|
|
|
+ onTestFinished(async () => {
|
|
|
+ peer.close()
|
|
|
+ control.destroy()
|
|
|
+ stdout.destroy()
|
|
|
+ stderr.destroy()
|
|
|
+ direct.resolve({ exitCode: 0, signal: null })
|
|
|
+ await Promise.all(writes)
|
|
|
+ })
|
|
|
+ const start = (input: CodeRunRequest = request, first: unknown = { type: 'ready' }) => {
|
|
|
+ const result = runtime.run(runtime.resolve(input))
|
|
|
+ if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
|
|
|
+ return result
|
|
|
+ }
|
|
|
+ const onBoot = (callback: () => void): void => {
|
|
|
+ receive = (message) => {
|
|
|
+ if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ ctx, runtime, handle, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot,
|
|
|
+ receive: (callback: typeof receive) => { receive = callback },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function call(id: number, value: string = '') {
|
|
|
+ return { type: 'call', id, global: 'tools', name: 'test', args: encodeCodeJsonWire(value) }
|
|
|
+}
|
|
|
+
|
|
|
+function withBinding(fn: CodeBindingFunction): CodeRunRequest {
|
|
|
+ return { ...request, bindings: [{ global: 'tools', functions: { test: fn } }] }
|
|
|
+}
|
|
|
+
|
|
|
+function confinement(argv: string[]): ConfinedArgv {
|
|
|
+ return { argv, enforcement: 'partial', denialSignatures: ['EACCES'], runnerFailureRules: [{ fatalSignatures: ['sandbox-fatal:'] }] }
|
|
|
+}
|
|
|
+
|
|
|
+describe('Node runtime host failures', () => {
|
|
|
+ it.each<[Config, string]>([
|
|
|
+ [{ timeoutMs: 0 }, 'timeoutMs'],
|
|
|
+ [{ maxPendingCalls: -1 }, 'maxPendingCalls'],
|
|
|
+ [{ maxOldGenerationSizeMb: Infinity }, 'maxOldGenerationSizeMb'],
|
|
|
+ [{ timeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'timeoutMs'],
|
|
|
+ [{ maxTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'maxTimeoutMs'],
|
|
|
+ [{ graceMs: MAX_TIMER_DELAY_MS + 1 }, 'graceMs'],
|
|
|
+ [{ maxOutputBytes: 3 }, 'maxOutputBytes'],
|
|
|
+ [{ maxOutputBytes: 4.5 }, 'maxOutputBytes'],
|
|
|
+ [{ maxMessageBytes: 1.5 }, 'maxMessageBytes'],
|
|
|
+ [{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
|
|
|
+ [{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
|
|
|
+ [{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
|
|
|
+ [{ nodeExecutable: '' }, 'nodeExecutable'],
|
|
|
+ [{ bootstrapPath: 'relative-bootstrap.js' }, 'bootstrapPath'],
|
|
|
+ ])('rejects deployment configuration %j', async (config, field) => {
|
|
|
+ await expect(mountRuntime(new Context(), config)).rejects.toThrow(field)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports the deployment mode and rejects unsupported resolved inputs before spawning', async () => {
|
|
|
+ const h = await setup({}, 'read-only')
|
|
|
+ expect(h.runtime.sandboxMode).toBe('read-only')
|
|
|
+ expect(() => h.runtime.resolve({ ...request, cwd: 'relative' })).toThrow('cwd must be absolute')
|
|
|
+ await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: 1 })).rejects.toThrow('resolved sandbox policy')
|
|
|
+ const spec = h.runtime.resolve(request)
|
|
|
+ await expect(h.runtime.run({ ...spec, timeoutMs: Infinity })).rejects.toThrow('resolved cwd and timeout')
|
|
|
+ expect(h.spawn).not.toHaveBeenCalled()
|
|
|
+ await h.ctx.fiber.dispose()
|
|
|
+ expect(() => h.runtime.resolve(request)).toThrow('resolve after disposal')
|
|
|
+ await expect(h.runtime.run(spec)).rejects.toThrow('run after disposal')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails a required unavailable sandbox before spawning', async () => {
|
|
|
+ const h = await setup({}, 'read-only')
|
|
|
+ vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
|
|
|
+ expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
|
|
|
+ expect(h.spawn).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps partial enforcement separate from a successful program', async () => {
|
|
|
+ const h = await setup({}, 'read-only')
|
|
|
+ vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
|
|
+ h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
|
|
+ expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
|
|
|
+ const h = await setup({}, 'read-only')
|
|
|
+ vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
|
|
+ h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
|
|
|
+ const result = await h.start()
|
|
|
+ expect(result.error).toEqual({ kind: 'exception', message })
|
|
|
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('distinguishes fatal sandbox startup output from a program denial', async () => {
|
|
|
+ const h = await setup({}, 'read-only')
|
|
|
+ vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
|
|
+ h.onBoot(() => {
|
|
|
+ h.stderr.write('sandbox-fatal: runner could not initialize')
|
|
|
+ h.direct.resolve({ exitCode: 1, signal: null })
|
|
|
+ })
|
|
|
+ const result = await h.start()
|
|
|
+ expect(result.error?.kind).toBe('sandbox-unavailable')
|
|
|
+ expect(result.sandbox?.denied).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports bootstrap assets that cannot map into the execution world', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ vi.spyOn(h.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
|
|
+ expect((await h.start()).error?.message).toContain('bootstrap is unavailable')
|
|
|
+ expect(h.spawn).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('cleans up a provider that fails to supply its requested control pipe', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.spawn.mockReturnValue({ ...h.handle, control: undefined })
|
|
|
+ expect((await h.start()).error?.message).toContain('did not supply the requested control')
|
|
|
+ expect(h.handle.terminate).toHaveBeenCalledOnce()
|
|
|
+ expect(h.handle.waitForExit).toHaveBeenCalledOnce()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports an executable lookup failure without allocating a process', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.resolveExecutable.mockRejectedValue(new Error('Node executable missing'))
|
|
|
+ expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node executable missing' })
|
|
|
+ expect(h.spawn).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('honors an already-aborted run without beginning executable lookup', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ const result = await h.start({ ...request, signal: AbortSignal.abort('already stopped') }, NO_INITIAL_FRAME)
|
|
|
+ expect(result.error).toEqual({ kind: 'abort', message: 'already stopped' })
|
|
|
+ expect(h.resolveExecutable).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not launch after cancellation races executable lookup completion', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ const controller = new AbortController()
|
|
|
+ h.resolveExecutable.mockImplementation(async () => {
|
|
|
+ controller.abort('lookup canceled')
|
|
|
+ return process.execPath
|
|
|
+ })
|
|
|
+ expect((await h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)).error?.kind).toBe('abort')
|
|
|
+ expect(h.spawn).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports an early control EOF using the direct process result', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.spawn.mockImplementation(() => {
|
|
|
+ queueMicrotask(() => {
|
|
|
+ h.control.push(null)
|
|
|
+ h.direct.resolve({ exitCode: 7, signal: null })
|
|
|
+ })
|
|
|
+ return h.handle
|
|
|
+ })
|
|
|
+ expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
|
|
|
+ kind: 'worker-exit', message: 'Node process exited before completing (7)',
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports startup transport loss when the direct process outcome rejects', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.spawn.mockImplementation(() => {
|
|
|
+ queueMicrotask(() => {
|
|
|
+ h.control.emit('error', new Error('control transport closed'))
|
|
|
+ h.direct.reject(new Error('runner connection broken'))
|
|
|
+ })
|
|
|
+ return h.handle
|
|
|
+ })
|
|
|
+ expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({ kind: 'worker-exit', message: 'runner connection broken' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports control loss after readiness as a substrate failure', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.onBoot(() => { h.control.emit('error', new Error('control transport closed')) })
|
|
|
+ expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
|
|
|
+ expect((await h.start()).error?.kind).toBe('protocol')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects traffic before readiness without invoking host bindings', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ const binding = vi.fn(async () => null)
|
|
|
+ const result = await h.start(withBinding(binding), call(1))
|
|
|
+ expect(result.error?.message).toContain('before bootstrap readiness')
|
|
|
+ expect(binding).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each<[unknown, string]>([
|
|
|
+ [null, 'invalid control frame'],
|
|
|
+ [{ type: 'log', text: 1 }, 'invalid log frame'],
|
|
|
+ [{ type: 'done', error: null }, 'invalid terminal error'],
|
|
|
+ [{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'],
|
|
|
+ [{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'],
|
|
|
+ [{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'],
|
|
|
+ [{ ...call(1), name: 'constructor' }, 'undeclared binding'],
|
|
|
+ [{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'],
|
|
|
+ [{ type: 'unknown' }, 'unknown control message'],
|
|
|
+ ])('rejects malformed program frame %j', async (frame, message) => {
|
|
|
+ const h = await setup()
|
|
|
+ const binding = vi.fn(async () => null)
|
|
|
+ h.onBoot(() => { h.emit(frame) })
|
|
|
+ const result = await h.start(withBinding(binding))
|
|
|
+ expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) })
|
|
|
+ expect(binding).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('refuses a repeated call id instead of dispatching it twice', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ const binding = vi.fn(async () => null)
|
|
|
+ h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) })
|
|
|
+ expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol')
|
|
|
+ expect(binding).toHaveBeenCalledOnce()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each<Config>([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => {
|
|
|
+ const h = await setup(config)
|
|
|
+ const release = Promise.withResolvers<null>()
|
|
|
+ onTestFinished(() => { release.resolve(null) })
|
|
|
+ const binding = vi.fn(async () => await release.promise)
|
|
|
+ h.onBoot(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
|
|
|
+ expect((await h.start(withBinding(binding))).error?.message).toContain('pending binding calls exceed')
|
|
|
+ expect(binding).toHaveBeenCalledOnce()
|
|
|
+ release.resolve(null)
|
|
|
+ await setImmediate()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects an untransferable completion instead of returning a substituted value', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) })
|
|
|
+ expect((await h.start()).error?.kind).toBe('invalid-output')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('returns non-lossless binding resolutions as program-visible binding failures', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.receive((message) => {
|
|
|
+ if (typeof message !== 'object' || message === null || !('type' in message)) return
|
|
|
+ if (message.type === 'boot') h.emit(call(1))
|
|
|
+ if (message.type === 'reply') h.emit({ type: 'done' })
|
|
|
+ })
|
|
|
+ expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined()
|
|
|
+ expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not publish a late binding reply after the program has settled', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ const release = Promise.withResolvers<null>()
|
|
|
+ onTestFinished(() => { release.resolve(null) })
|
|
|
+ h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
|
|
|
+ expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined()
|
|
|
+ release.resolve(null)
|
|
|
+ await setImmediate()
|
|
|
+ expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' }))
|
|
|
+ expect(h.control.destroyed).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports failed managed cleanup even when the program returns successfully', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ vi.mocked(h.handle.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
|
|
|
+ h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
|
|
+ expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails when the configured frame budget cannot carry bootstrap data', async () => {
|
|
|
+ const h = await setup({ maxMessageBytes: 32 })
|
|
|
+ expect((await h.start()).error?.message).toContain('control output exceeds 32 queued bytes')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('turns an oversized binding reply into a protocol failure', async () => {
|
|
|
+ const h = await setup({ maxMessageBytes: 256 })
|
|
|
+ h.onBoot(() => { h.emit(call(1)) })
|
|
|
+ const result = await h.start(withBinding(async () => 'x'.repeat(512)))
|
|
|
+ expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains admitted logs when the program reports its own output limit', async () => {
|
|
|
+ const h = await setup()
|
|
|
+ h.onBoot(() => {
|
|
|
+ h.emit({ type: 'log', text: 'retained' })
|
|
|
+ h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } })
|
|
|
+ })
|
|
|
+ const result = await h.start()
|
|
|
+ expect(result.error?.kind).toBe('output-limit')
|
|
|
+ expect(result.logs).toEqual(['retained'])
|
|
|
+ })
|
|
|
+})
|