fix(sandbox): resolve workspace roots per session
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user