fix(sandbox): preserve valueless spawn failures (round 2)

This commit is contained in:
Hypatia May
2026-08-04 12:16:21 +08:00
parent e36d040d0a
commit 319376ef1c
3 changed files with 39 additions and 7 deletions

View File

@@ -247,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote, error)
this.onProcessDone(proc, spawnFailureNote, true, error)
}),
readOutput: (): BashProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -292,9 +292,10 @@ export class LocalBashExecutor extends BashExecutor {
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnError - the original spawn rejection; absent after a process successfully started.
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnError?: unknown): void {}
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
export default LocalBashExecutor

View File

@@ -124,13 +124,13 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: BashProcess, stderr: string, spawnError?: unknown): void {
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnError !== undefined
const runnerFailed = spawnFailed
|| classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
@@ -139,7 +139,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnError)
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**

View File

@@ -16,6 +16,7 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -372,6 +373,36 @@ describe('background sandbox facts', () => {
expect(accounting.size).toBe(0)
})
it('classifies a spawn rejection whose reason is undefined', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
pid: -1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
done: Promise.reject(undefined),
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.readOutput().delta).toContain('spawn failed: undefined')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))