test(sandbox): pin runner argv forms

This commit is contained in:
Hypatia May
2026-08-04 17:19:19 +08:00
parent c4e9893a87
commit ea762e3962
3 changed files with 34 additions and 21 deletions
+2
View File
@@ -124,6 +124,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
// `done` rejection; this covers alternatives that throw that shape synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
@@ -108,29 +108,38 @@ describe('partial Landlock runner-failure classification', () => {
expect(accounting.size).toBe(0)
})
it('classifies a bare-name runner whose shebang interpreter is missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-bare-sandbox-runner-'))
tempDirs.push(dir)
const runner = 'bare-missing-interpreter-runner'
await writeFile(join(dir, runner), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = { command: 'true', env: { PATH: dir } }
it.each(['bare-name', 'relative'] as const)(
'classifies a %s runner whose shebang interpreter is missing',
async (form) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
tempDirs.push(dir)
const filename = 'missing-interpreter-runner'
const runner = form === 'bare-name' ? filename : `./${filename}`
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = form === 'bare-name'
? { command: 'true', env: { PATH: dir } }
: { command: 'true', workdir: dir }
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
// argv[0] in this spawn error rather than resolving it to an absolute path.
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const task = bash.start(bash.resolve(request))
await task.done
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
})
const task = bash.start(bash.resolve(request))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
},
)
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
@@ -248,6 +248,8 @@ describe('fail closed', () => {
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
// This pins an alternative SubprocessService's synchronous seam, not the
// shipped local behavior.
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
})