Files
deepseek-harness/packages/shell/bash-sandbox/tests/foreground-timeout.spec.ts
T
Yichen Jiang c2fb2bd4e8 Merge branch 'worktree/jobs-api-review-3052' into worktree/jobs-api-review-3127
# Conflicts:
#	.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
#	docs/config-catalog.i18n.yaml
#	docs/event-producer-consumer.i18n.yaml
#	docs/module-graph.i18n.yaml
#	docs/subsystems/jobs.i18n.yaml
#	docs/subsystems/shell.i18n.yaml
#	docs/subsystems/shell.md
#	docs/subsystems/shell.zh.md
#	docs/tool-catalog.i18n.yaml
#	packages/api/session-controller/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/e2b/e2b/tests/fixtures/composition/bin.ts
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/shell/bash-local/README.i18n.yaml
#	packages/shell/bash-local/README.md
#	packages/shell/bash-local/README.zh.md
#	packages/shell/bash-local/src/index.ts
#	packages/shell/bash-local/tests/executor.spec.ts
#	packages/shell/bash-sandbox/README.i18n.yaml
#	packages/shell/bash-sandbox/README.md
#	packages/shell/bash-sandbox/README.zh.md
#	packages/shell/bash-sandbox/src/index.ts
#	packages/shell/bash-sandbox/tests/bwrap.e2e.ts
#	packages/shell/bash-sandbox/tests/landlock.e2e.ts
#	packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
#	packages/shell/bash-sandbox/tests/sandbox.spec.ts
#	packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
#	packages/shell/pwsh-local/README.i18n.yaml
#	packages/shell/pwsh-local/README.md
#	packages/shell/pwsh-local/README.zh.md
#	packages/shell/pwsh-local/src/index.ts
#	packages/shell/pwsh-local/tests/executor.spec.ts
#	packages/shell/pwsh-sandbox/README.i18n.yaml
#	packages/shell/pwsh-sandbox/README.md
#	packages/shell/pwsh-sandbox/README.zh.md
#	packages/shell/pwsh-sandbox/src/index.ts
#	packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
#	packages/shell/shell/README.i18n.yaml
#	packages/shell/shell/README.md
#	packages/shell/shell/README.zh.md
#	packages/shell/shell/src/index.ts
#	packages/shell/shell/tests/service.spec.ts
#	packages/shell/tool-bash/README.i18n.yaml
#	packages/shell/tool-bash/src/index.ts
#	packages/shell/tool-bash/tests/tools.spec.ts
#	packages/shell/tool-pwsh/README.i18n.yaml
#	packages/shell/tool-pwsh/src/index.ts
#	packages/shell/tool-pwsh/tests/background.spec.ts
#	packages/shell/tool-pwsh/tests/tools.spec.ts
#	packages/workflow/tool-workflow/README.i18n.yaml
#	packages/workflow/tool-workflow/package.json
#	packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
#	snapshots/session/ptc-python-turn/system-prompt.expected.md
2026-09-17 16:48:06 +08:00

163 lines
7.7 KiB
TypeScript

/** One foreground deadline covers sandbox preparation and native execution. */
import { Context } from '@deepseek-ai/cordis'
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { SandboxBashExecutor } from '../src/index.ts'
async function setup() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const ctx = new Context()
const prepared = Promise.withResolvers<ConfinedArgv>()
const entered = Promise.withResolvers<AbortSignal | undefined>()
const spawned = Promise.withResolvers<SubprocessSpawnSpec>()
const completion = Promise.withResolvers<SubprocessOutcome>()
const runs: Promise<ShellRunResult>[] = []
const listeners: Array<() => void> = []
const wrap: ConfinedArgv = { argv: ['fixture-runner'], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
const confine = vi.fn(async (_argv: readonly string[], _policy: SandboxPolicy, signal?: AbortSignal) => {
entered.resolve(signal)
return prepared.promise
})
class ControlledSandbox extends SandboxProvider {
override confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
return confine(argv, policy, signal)
}
}
const terminate = vi.fn(() => { completion.resolve({ exitCode: null, signal: 'SIGTERM' }) })
const output = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }
const handle: SubprocessHandle = {
stdin: undefined, stdout: undefined, stderr: undefined, control: undefined,
collected: { stdout: output, stderr: output }, done: completion.promise,
terminate, waitForExit: async () => { await completion.promise; return true },
}
onTestFinished(async () => {
prepared.resolve(wrap)
completion.resolve({ exitCode: 0, signal: null })
await Promise.allSettled(runs)
for (const detach of listeners) detach()
try { await ctx.fiber.dispose() }
finally { vi.restoreAllMocks(); vi.useRealTimers() }
})
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(ControlledSandbox)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { timeoutMs: 100, graceMs: 100 })
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
spawned.resolve(spec)
spec.signal?.addEventListener('abort', terminate, { once: true })
listeners.push(() => { spec.signal?.removeEventListener('abort', terminate) })
if (spec.signal?.aborted) terminate()
return handle
})
const start = (timeoutMs = 10, signal?: AbortSignal, onExpiry: 'kill' | 'offer' = 'kill') => {
const observed: { done: boolean; result?: ShellRunResult; error?: unknown } = { done: false }
const execution = ctx.shell.execute(ctx.shell.resolve({ command: 'fixture command', timeoutMs, signal, onExpiry }))
const promise = execution.then(process => process.result())
runs.push(promise)
void promise.then(
(result) => { observed.done = true; observed.result = result },
(error: unknown) => { observed.done = true; observed.error = error },
)
return { promise, observed, execution }
}
return { ctx, prepared, entered, spawned, completion, wrap, confine, spawn, terminate, start }
}
describe('bash preparation deadline', () => {
it.each([
{ late: 'success', onExpiry: 'kill' }, { late: 'rejection', onExpiry: 'kill' },
{ late: 'success', onExpiry: 'offer' }, { late: 'rejection', onExpiry: 'offer' },
] as const)('times out $onExpiry preparation and prevents late $late from spawning', async ({ late, onExpiry }) => {
const test = await setup()
const run = test.start(10, undefined, onExpiry)
const signal = await test.entered.promise
await vi.advanceTimersByTimeAsync(10)
expect(run.observed.done).toBe(true)
expect(signal?.aborted).toBe(true)
expect(run.observed.result).toEqual({
exitCode: null, signal: null, timedOut: true, aborted: false, timeoutMs: 10,
stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false },
sandbox: { mode: 'read-only', denied: false },
})
expect(test.spawn).not.toHaveBeenCalled()
await expect((await run.execution).promotion).resolves.toBeUndefined()
if (late === 'success') test.prepared.resolve(test.wrap)
else test.prepared.reject(new Error('late preparation rejection'))
await vi.advanceTimersByTimeAsync(0)
expect(test.spawn).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('passes the remaining deadline to native execution instead of restarting it', async () => {
const test = await setup()
const run = test.start(100)
const preparationSignal = await test.entered.promise
await vi.advanceTimersByTimeAsync(60)
test.prepared.resolve(test.wrap)
const spawn = await test.spawned.promise
expect(spawn.signal).toBe(preparationSignal)
await vi.advanceTimersByTimeAsync(39)
expect(run.observed.done).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false, signal: 'SIGTERM', sandbox: { enforcement: 'full' } })
expect(test.terminate).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('preserves upstream cancellation when it wins during preparation', async () => {
const test = await setup()
const controller = new AbortController()
const reason = new Error('caller stopped preparation')
const run = test.start(10, controller.signal)
await test.entered.promise
controller.abort(reason)
await vi.advanceTimersByTimeAsync(20)
expect(run.observed.done).toBe(true)
expect(run.observed.error).toBe(reason)
expect(test.spawn).not.toHaveBeenCalled()
test.prepared.resolve(test.wrap)
await vi.advanceTimersByTimeAsync(0)
expect(test.spawn).not.toHaveBeenCalled()
})
it('keeps timeout as the first cause when upstream cancellation follows its notification', async () => {
const test = await setup()
const controller = new AbortController()
const run = test.start(10, controller.signal)
const signal = await test.entered.promise
signal?.addEventListener('abort', () => { controller.abort(new Error('later caller abort')) }, { once: true })
await vi.advanceTimersByTimeAsync(10)
expect(controller.signal.aborted).toBe(true)
expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false })
expect(test.spawn).not.toHaveBeenCalled()
})
it('does not prepare after a pre-existing caller cancellation', async () => {
const test = await setup()
const reason = new Error('already cancelled')
const run = test.start(10, AbortSignal.abort(reason))
await vi.advanceTimersByTimeAsync(0)
expect(run.observed.error).toBe(reason)
expect(test.confine).not.toHaveBeenCalled()
expect(test.spawn).not.toHaveBeenCalled()
})
it('preserves preparation failures and disposes the deadline', async () => {
const test = await setup()
const run = test.start()
await test.entered.promise
const error = new Error('confinement unavailable')
test.prepared.reject(error)
await expect(run.promise).rejects.toBe(error)
expect(test.spawn).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
})