multi-project-sandbox.e2e.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { spawnSync } from 'node:child_process'
  2. import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
  3. import { homedir } from 'node:os'
  4. import { basename, join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  6. import { Context } from 'cordis'
  7. import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
  8. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  9. import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
  10. import { CallId } from '@deepseek-ai/dsh-llm'
  11. import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
  12. import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
  13. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  14. import { SessionId } from '@deepseek-ai/dsh-session'
  15. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  16. import type { ToolResult } from '@deepseek-ai/dsh-tools'
  17. import { launcherPath } from 'node-addon-landlock-run'
  18. import * as agentSpine from '../src/index.ts'
  19. const bwrapUsable = spawnSync('bwrap', [
  20. '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true',
  21. ], { timeout: 5_000, stdio: 'ignore' }).status === 0
  22. const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0
  23. const seatbeltUsable = process.platform === 'darwin'
  24. && spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
  25. const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable
  26. let ctx: Context | undefined
  27. let projectA: string
  28. let projectB: string
  29. const tempDirs: string[] = []
  30. async function projectDir(label: string): Promise<string> {
  31. const dir = await mkdtemp(join(homedir(), `dsh-${label}-`))
  32. tempDirs.push(dir)
  33. return dir
  34. }
  35. async function expectMissing(path: string): Promise<void> {
  36. await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  37. }
  38. function resultText(result: ToolResult): string {
  39. return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
  40. }
  41. beforeEach(async () => {
  42. projectA = await projectDir('project-a')
  43. projectB = await projectDir('project-b')
  44. const fallbackRoot = await projectDir('fallback')
  45. ctx = new Context()
  46. await ctx.plugin(LocalSandboxProvider, {})
  47. await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
  48. await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
  49. await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
  50. await ctx.plugin(agentSpine, {
  51. workspaceContext: false,
  52. skills: { enabled: false },
  53. toolBash: { enableRunInBackground: false },
  54. toolTasks: false,
  55. })
  56. await new Promise(resolve => setTimeout(resolve, 50))
  57. await ctx.plugin(FsPolicy)
  58. await ctx.plugin(ToolFs)
  59. })
  60. afterEach(async () => {
  61. await ctx?.fiber.dispose()
  62. ctx = undefined
  63. await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
  64. })
  65. async function agents() {
  66. const active = ctx as Context
  67. const [a, b] = await Promise.all([
  68. active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }),
  69. active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }),
  70. ])
  71. return { active, agentA: a.agent, agentB: b.agent }
  72. }
  73. describe('one-context multi-project sandbox', () => {
  74. it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => {
  75. const { active, agentA, agentB } = await agents()
  76. const [aOwn, bOwn, aCross, bCross] = await Promise.all([
  77. active.tools.execute({
  78. callId: CallId('bash-a-own'), name: 'bash', agent: agentA,
  79. signal: new AbortController().signal,
  80. arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' },
  81. }),
  82. active.tools.execute({
  83. callId: CallId('bash-b-own'), name: 'bash', agent: agentB,
  84. signal: new AbortController().signal,
  85. arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' },
  86. }),
  87. active.tools.execute({
  88. callId: CallId('bash-a-cross'), name: 'bash', agent: agentA,
  89. signal: new AbortController().signal,
  90. arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' },
  91. }),
  92. active.tools.execute({
  93. callId: CallId('bash-b-cross'), name: 'bash', agent: agentB,
  94. signal: new AbortController().signal,
  95. arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' },
  96. }),
  97. ])
  98. expect(aOwn.isError).toBe(false)
  99. expect(bOwn.isError).toBe(false)
  100. expect(aCross.isError).toBe(false)
  101. expect(bCross.isError).toBe(false)
  102. expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
  103. expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
  104. expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
  105. expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
  106. await expectMissing(join(projectB, 'from-a.txt'))
  107. await expectMissing(join(projectA, 'from-b.txt'))
  108. })
  109. it('confines concurrent filesystem writes to each calling session workspace', async () => {
  110. const { active, agentA, agentB } = await agents()
  111. const [aOwn, bOwn, aCross, bCross] = await Promise.all([
  112. active.tools.execute({
  113. callId: CallId('fs-a-own'), name: 'write', agent: agentA,
  114. signal: new AbortController().signal,
  115. arguments: { file_path: 'a-owned.txt', content: 'a' },
  116. }),
  117. active.tools.execute({
  118. callId: CallId('fs-b-own'), name: 'write', agent: agentB,
  119. signal: new AbortController().signal,
  120. arguments: { file_path: 'b-owned.txt', content: 'b' },
  121. }),
  122. active.tools.execute({
  123. callId: CallId('fs-a-cross'), name: 'write', agent: agentA,
  124. signal: new AbortController().signal,
  125. arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' },
  126. }),
  127. active.tools.execute({
  128. callId: CallId('fs-b-cross'), name: 'write', agent: agentB,
  129. signal: new AbortController().signal,
  130. arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' },
  131. }),
  132. ])
  133. expect(aOwn.isError).toBe(false)
  134. expect(bOwn.isError).toBe(false)
  135. expect(aCross.isError).toBe(true)
  136. expect(bCross.isError).toBe(true)
  137. expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
  138. expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
  139. expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
  140. expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
  141. await expectMissing(join(projectB, 'from-a.txt'))
  142. await expectMissing(join(projectA, 'from-b.txt'))
  143. })
  144. it.skipIf(!processSandboxUsable)('keeps symlink-sensitive session cwd semantics aligned across bash, fs, and policy', async () => {
  145. const active = ctx as Context
  146. const lexicalRoot = await projectDir('lexical-workspace')
  147. const physicalRoot = await projectDir('physical-workspace')
  148. const physicalChild = join(physicalRoot, 'child')
  149. await mkdir(physicalChild)
  150. const link = join(lexicalRoot, 'link')
  151. await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
  152. const sessionCwd = `${link}/..`
  153. const handle = await active.agents.create({
  154. sessionId: SessionId('symlink-parent-session'),
  155. meta: { cwd: sessionCwd },
  156. })
  157. const [bashOwn, bashLexical, fsOwn, fsLexical] = await Promise.all([
  158. active.tools.execute({
  159. callId: CallId('bash-symlink-own'), name: 'bash', agent: handle.agent,
  160. signal: new AbortController().signal,
  161. arguments: { command: 'printf bash > bash-owned.txt', description: 'Write physical workspace marker' },
  162. }),
  163. active.tools.execute({
  164. callId: CallId('bash-symlink-lexical'), name: 'bash', agent: handle.agent,
  165. signal: new AbortController().signal,
  166. arguments: { command: `printf escaped > ${join(lexicalRoot, 'bash-escaped.txt')}`, description: 'Attempt lexical workspace write' },
  167. }),
  168. active.tools.execute({
  169. callId: CallId('fs-symlink-own'), name: 'write', agent: handle.agent,
  170. signal: new AbortController().signal,
  171. arguments: { file_path: 'fs-owned.txt', content: 'fs' },
  172. }),
  173. active.tools.execute({
  174. callId: CallId('fs-symlink-lexical'), name: 'write', agent: handle.agent,
  175. signal: new AbortController().signal,
  176. arguments: { file_path: join(lexicalRoot, 'fs-escaped.txt'), content: 'escaped' },
  177. }),
  178. ])
  179. expect(bashOwn.isError).toBe(false)
  180. expect(resultText(bashOwn)).not.toContain('[sandbox:')
  181. expect(bashLexical.isError).toBe(false)
  182. expect(resultText(bashLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
  183. expect(fsOwn.isError).toBe(false)
  184. expect(fsLexical.isError).toBe(true)
  185. expect(resultText(fsLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
  186. expect(await readFile(join(physicalRoot, 'bash-owned.txt'), 'utf8')).toBe('bash')
  187. expect(await readFile(join(physicalRoot, 'fs-owned.txt'), 'utf8')).toBe('fs')
  188. await expectMissing(join(lexicalRoot, 'bash-escaped.txt'))
  189. await expectMissing(join(lexicalRoot, 'fs-escaped.txt'))
  190. })
  191. it.skipIf(!processSandboxUsable)('resolves parent traversal from a symlinked session root consistently', async () => {
  192. const active = ctx as Context
  193. const lexicalRoot = await projectDir('lexical-parent')
  194. const physicalRoot = await projectDir('physical-parent')
  195. const physicalChild = join(physicalRoot, 'child')
  196. await mkdir(physicalChild)
  197. const link = join(lexicalRoot, 'link')
  198. await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
  199. await writeFile(join(lexicalRoot, 'shared.txt'), 'from-lexical-parent')
  200. await writeFile(join(physicalRoot, 'shared.txt'), 'from-physical-parent')
  201. const handle = await active.agents.create({
  202. sessionId: SessionId('symlink-root-parent-path-session'),
  203. meta: { cwd: link },
  204. })
  205. const [bashRead, fsRead] = await Promise.all([
  206. active.tools.execute({
  207. callId: CallId('bash-symlink-parent-read'), name: 'bash', agent: handle.agent,
  208. signal: new AbortController().signal,
  209. arguments: { command: 'cat ../shared.txt', description: 'Read through the physical parent' },
  210. }),
  211. active.tools.execute({
  212. callId: CallId('fs-symlink-parent-read'), name: 'read', agent: handle.agent,
  213. signal: new AbortController().signal,
  214. arguments: { file_path: '../shared.txt' },
  215. }),
  216. ])
  217. expect(bashRead.isError).toBe(false)
  218. expect(fsRead.isError).toBe(false)
  219. expect(resultText(bashRead)).toContain('from-physical-parent')
  220. expect(resultText(fsRead)).toContain('from-physical-parent')
  221. expect(resultText(bashRead)).not.toContain('from-lexical-parent')
  222. expect(resultText(fsRead)).not.toContain('from-lexical-parent')
  223. })
  224. })