fix(sandbox): resolve workspace roots per session

This commit is contained in:
Tianyi Cui
2026-07-21 00:44:28 +08:00
parent 171a0e2b30
commit ff21f91a39
54 changed files with 664 additions and 305 deletions

View File

@@ -45,11 +45,16 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
@@ -57,9 +62,11 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {

View File

@@ -0,0 +1,149 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import type { ToolResult } from '@deepseek-ai/dsh-tools'
import { launcherPath } from 'node-addon-landlock-run'
import * as agentSpine from '../src/index.ts'
const bwrapUsable = spawnSync('bwrap', [
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true',
], { timeout: 5_000, stdio: 'ignore' }).status === 0
const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const seatbeltUsable = process.platform === 'darwin'
&& spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable
let ctx: Context | undefined
let projectA: string
let projectB: string
const tempDirs: string[] = []
async function projectDir(label: string): Promise<string> {
const dir = await mkdtemp(join(homedir(), `dsh-${label}-`))
tempDirs.push(dir)
return dir
}
async function expectMissing(path: string): Promise<void> {
await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
}
function resultText(result: ToolResult): string {
return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
}
beforeEach(async () => {
projectA = await projectDir('project-a')
projectB = await projectDir('project-b')
const fallbackRoot = await projectDir('fallback')
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
await ctx.plugin(agentSpine, {
workspaceContext: false,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false,
})
await new Promise(resolve => setTimeout(resolve, 50))
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
})
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function agents() {
const active = ctx as Context
const [a, b] = await Promise.all([
active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }),
active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }),
])
return { active, agentA: a.agent, agentB: b.agent }
}
describe('one-context multi-project sandbox', () => {
it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => {
const { active, agentA, agentB } = await agents()
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
active.tools.execute({
callId: CallId('bash-a-own'), name: 'bash', agent: agentA,
arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' },
}),
active.tools.execute({
callId: CallId('bash-b-own'), name: 'bash', agent: agentB,
arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' },
}),
active.tools.execute({
callId: CallId('bash-a-cross'), name: 'bash', agent: agentA,
arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' },
}),
active.tools.execute({
callId: CallId('bash-b-cross'), name: 'bash', agent: agentB,
arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' },
}),
])
expect(aOwn.isError).toBe(false)
expect(bOwn.isError).toBe(false)
expect(aCross.isError).toBe(false)
expect(bCross.isError).toBe(false)
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
await expectMissing(join(projectB, 'from-a.txt'))
await expectMissing(join(projectA, 'from-b.txt'))
})
it('confines concurrent filesystem writes to each calling session workspace', async () => {
const { active, agentA, agentB } = await agents()
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
active.tools.execute({
callId: CallId('fs-a-own'), name: 'write', agent: agentA,
arguments: { file_path: 'a-owned.txt', content: 'a' },
}),
active.tools.execute({
callId: CallId('fs-b-own'), name: 'write', agent: agentB,
arguments: { file_path: 'b-owned.txt', content: 'b' },
}),
active.tools.execute({
callId: CallId('fs-a-cross'), name: 'write', agent: agentA,
arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' },
}),
active.tools.execute({
callId: CallId('fs-b-cross'), name: 'write', agent: agentB,
arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' },
}),
])
expect(aOwn.isError).toBe(false)
expect(bOwn.isError).toBe(false)
expect(aCross.isError).toBe(true)
expect(bCross.isError).toBe(true)
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
await expectMissing(join(projectB, 'from-a.txt'))
await expectMissing(join(projectA, 'from-b.txt'))
})
})