multi-project-sandbox.e2e.ts 11 KB

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