Files
deepseek-harness/packages/bash/bash/tests/service.spec.ts
kingwl 99650a201b feat(mode): the access cap — plan mode composes with the sandbox instead of banning bash
A ModeDefinition may declare access: the widest sandbox access shell
commands run under while the mode holds, on the SANDBOX_MODES ladder.
The bash seam gains the resolution point to hang it on: BashExecutor.
resolveMode(session) folds override ?? default and dispatches the new
bash/resolve-mode waterfall; dsh-tool-bash consults it at both the
stamping site and the escalation baseline; dsh-mode's clamp listener
takes the ladder minimum per call. Two independent log folds compose at
read time — the mode never writes the sandbox knob, so the two switch
in any order and the knob re-emerges intact on exit.

The built-in plan definition ships access: read-only with the bash trio
allowlisted CONDITIONALLY: both policy layers admit bash/bash_output/
bash_kill only while a confining executor is mounted (an unconfinable
shell cannot honor the cap), and a bash call carrying sandbox_permissions
under a cap is denied at the gate — no widening mid-mode; the widened
step belongs in the plan.

examples/plan-acp-agent swaps bash-local for sandbox-local +
bash-sandbox (workspace-write default, clamped read-only inside plan)
plus the approval seam; the re-recorded plan-mode arc runs a real cat
inside plan under the clamped sandbox, and modes-advertise now pins the
sandbox-mode and approval config options. RFC amended to the landed
shape (access cap section, orthogonality FAQ, deferred item resolved
into effects self-declaration).
2026-07-12 22:51:09 +08:00

201 lines
6.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { BashExecutor, BashTaskId, OwnerToken, setSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Minimal concrete executor: records calls, lets tests drive completions. */
class StubExecutor extends BashExecutor {
tasks = new Map<BashTaskId, BashTask>()
private owners = new Map<BashTaskId, OwnerToken | undefined>()
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
sandboxMode: request.sandboxMode,
}
}
async run(_spec: BashExecSpec): Promise<BashRunResult> {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}
}
start(spec: BashExecSpec): BashTask {
const task: BashTask = {
id: BashTaskId(`stub-${this.tasks.size + 1}`),
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
this.tasks.set(task.id, task)
this.owners.set(task.id, spec.owner)
return task
}
get(id: BashTaskId): BashTask | undefined {
return this.tasks.get(id)
}
ownerOf(id: BashTaskId): OwnerToken | undefined {
return this.owners.get(id)
}
list(): BashTask[] {
return [...this.tasks.values()]
}
readOutput(id: BashTaskId): BashTaskRead {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
return { task, delta: '', lossy: false }
}
kill(id: BashTaskId): boolean {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
if (task.status !== 'running') return false
task.status = 'killed'
return true
}
/** Expose the protected notifier for tests. */
fire(task: BashTask): void {
this.notifyTaskDone(task)
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(StubExecutor)
// ctx.bash resolves to the registered implementation.
const bash = ctx.bash as StubExecutor
return { ctx, bash }
}
describe('BashExecutor service seam', () => {
it('registers as ctx.bash and serves the abstract API', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
expect(bash.get(task.id)).toBe(task)
expect(bash.list()).toEqual([task])
expect(bash.kill(task.id)).toBe(true)
expect(bash.kill(task.id)).toBe(false)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(0)
})
it('reports no default sandbox mode (composition truth: the base never confines)', async () => {
const { bash } = await setup()
expect(bash.sandboxMode).toBeUndefined()
})
it('onTaskDone delivers completions to registered listeners', async () => {
const { bash } = await setup()
const seen: string[] = []
bash.onTaskDone(task => void seen.push(task.id))
const task = bash.start(bash.resolve({ command: 'x' }))
bash.fire(task)
expect(seen).toEqual([task.id])
})
it('onTaskDone disposer unsubscribes the listener', async () => {
const { bash } = await setup()
const listener = vi.fn()
const dispose = bash.onTaskDone(listener)
dispose()
bash.fire(bash.start(bash.resolve({ command: 'x' })))
expect(listener).not.toHaveBeenCalled()
})
it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
const { ctx, bash } = await setup()
const listener = vi.fn()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.bash.onTaskDone(listener)
}, { inject: ['bash'] }))
bash.fire(bash.start(bash.resolve({ command: 'one' })))
expect(listener).toHaveBeenCalledTimes(1)
await fiber.dispose()
bash.fire(bash.start(bash.resolve({ command: 'two' })))
expect(listener).toHaveBeenCalledTimes(1)
})
it('silences listeners once the service fiber is disposed', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
await inner.plugin(StubExecutor)
}, {}))
const bash = ctx.bash as StubExecutor
const listener = vi.fn()
bash.onTaskDone(listener)
const task = bash.start(bash.resolve({ command: 'x' }))
await fiber.dispose()
bash.fire(task)
expect(listener).not.toHaveBeenCalled()
})
})
/** A confining stub: the same executor with a configured default sandbox mode. */
class ConfiningStub extends StubExecutor {
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
}
describe('resolveMode (the bash/resolve-mode seam)', () => {
it('resolves undefined for a never-confining executor without consulting the waterfall', async () => {
const { ctx, bash } = await setup()
const listener = vi.fn()
ctx.on('bash/resolve-mode', listener)
expect(await bash.resolveMode(new Session(SessionId('rm-none')))).toBeUndefined()
expect(listener).not.toHaveBeenCalled()
})
it('resolves the executor default without a session and without an override', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
expect(await ctx.bash.resolveMode(undefined)).toBe('workspace-write')
expect(await ctx.bash.resolveMode(new Session(SessionId('rm-default')))).toBe('workspace-write')
})
it('resolves the session override over the executor default', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
const session = new Session(SessionId('rm-override'))
setSandboxMode(session, 'danger-full-access')
expect(await ctx.bash.resolveMode(session)).toBe('danger-full-access')
})
it('a waterfall listener narrows the base per call and sees the session', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
const session = new Session(SessionId('rm-clamp'))
setSandboxMode(session, 'danger-full-access')
const seen: (Session | undefined)[] = []
ctx.on('bash/resolve-mode', async (sess, next) => {
seen.push(sess)
await next()
return 'read-only'
})
expect(await ctx.bash.resolveMode(session)).toBe('read-only')
expect(seen).toEqual([session])
})
})