composition.e2e.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { access } from 'node:fs/promises'
  2. import { join, posix } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { describe, expect, it } from 'vitest'
  6. import { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  9. import {
  10. FileNotFoundError,
  11. Sandbox,
  12. SandboxNotFoundError,
  13. } from '@deepseek-ai/dsh-e2b'
  14. import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
  15. import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
  16. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  17. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  18. import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
  19. const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
  20. const binScript = join(fixtureRoot, 'bin.ts')
  21. const configPath = join(fixtureRoot, 'cordis.yml')
  22. const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  23. describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
  24. it('scrubs credentials before actual E2B command and PTY login shells', async () => {
  25. const apiKey = process.env.E2B_API_KEY
  26. if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared before the PTY environment test')
  27. const sandbox = await Sandbox.create({
  28. apiKey,
  29. envs: { NPM_TOKEN: 'sentinel-secret', DSH_STALE: 'sentinel-stale', KEEP: 'visible' },
  30. timeoutMs: 60_000,
  31. secure: true,
  32. lifecycle: { onTimeout: 'kill' },
  33. })
  34. try {
  35. const profileLeakPath = '/home/user/dsh-e2b-bootstrap-profile-leak'
  36. const hostileProfile = [
  37. 'if [[ "${NPM_TOKEN-}" == "sentinel-secret" ]]; then',
  38. ` printf leaked > ${profileLeakPath}`,
  39. 'fi',
  40. '',
  41. ].join('\n')
  42. await sandbox.files.write([
  43. { path: '/home/user/.bash_profile', data: hostileProfile },
  44. { path: '/home/user/.profile', data: hostileProfile },
  45. { path: '/home/user/.bashrc', data: hostileProfile },
  46. ])
  47. const ctx = new Context()
  48. ctx.provide('e2b', {
  49. cwd: '/home/user',
  50. runtimeRoot: '/home/user/.dsh-e2b',
  51. getSandbox: async () => sandbox,
  52. } as never)
  53. const sandboxPolicyFiber = await ctx.plugin(SandboxPolicyService, {
  54. mode: 'danger-full-access',
  55. workspaceRoot: '/home/user',
  56. })
  57. const ptyFiber = await ctx.plugin(TerminalSessionService)
  58. const subprocessFiber = await ctx.plugin(E2BSubprocessRuntime)
  59. const node = await ctx.subprocess.resolveExecutable('node')
  60. const relativeNodePath = posix.relative(ctx.e2b.cwd, posix.dirname(node)) || '.'
  61. await expect(ctx.subprocess.resolveExecutable('node', { PATH: relativeNodePath })).resolves.toBe(node)
  62. await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
  63. const environmentProbe = ctx.subprocess.spawn({
  64. argv: ['/bin/bash', '-c', [
  65. 'dsh_leak=0',
  66. 'for dsh_pid in "$PPID" $(ps -o pid= --ppid "$PPID"); do',
  67. ' [[ "$dsh_pid" == "$$" ]] && continue',
  68. ' if tr "\\0" "\\n" < "/proc/$dsh_pid/environ" 2>/dev/null | grep -Fqx "NPM_TOKEN=sentinel-secret"; then dsh_leak=1; fi',
  69. 'done',
  70. 'printf "DIRECT=<%s> LEAK=<%s>\\n" "${NPM_TOKEN-}" "$dsh_leak"',
  71. ].join('\n')],
  72. cwd: '/home/user',
  73. stdio: { stdin: 'ignore', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } },
  74. graceMs: 500,
  75. env: {},
  76. })
  77. await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null })
  78. expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n')
  79. await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
  80. const ownerId = SessionId('e2b-pty-env-owner')
  81. const ownerSession = Session.create(ownerId)
  82. const owner: Agent = {
  83. id: ownerId,
  84. options: {},
  85. session: ownerSession,
  86. inbox: undefined as never,
  87. status: 'idle',
  88. ctx,
  89. send() {},
  90. followup() {},
  91. steer() {},
  92. inject() {},
  93. cancel() {},
  94. runMaintenance: task => task(new AbortController().signal),
  95. whenIdle: () => Promise.resolve(),
  96. }
  97. Object.assign(owner, { inbox: new Inbox(owner.ctx, owner.session, agentEvents(owner.ctx, owner)) })
  98. const backend = new BashTerminalBackend(ctx, {
  99. backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
  100. rows: 24, cols: 80,
  101. scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
  102. pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
  103. handoffGraceMs: 500, timeoutMs: 5_000, disposeGraceMs: 1_000,
  104. })
  105. const session = await backend.spawn({ sessionId: TerminalSessionId('env'), owner, type: 'shell' })
  106. const result = await session.startSend({
  107. text: "printf 'NPM=<%s> DSH=<%s> KEEP=<%s>\\n' \"$NPM_TOKEN\" \"$DSH_STALE\" \"$KEEP\"",
  108. submit: true,
  109. }).done
  110. expect(result.viewport).toContain('NPM=<> DSH=<> KEEP=<visible>')
  111. expect(result.viewport).not.toContain('sentinel-secret')
  112. expect(result.viewport).not.toContain('sentinel-stale')
  113. await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
  114. await session.close('environment test complete')
  115. await subprocessFiber.dispose()
  116. await ptyFiber.dispose()
  117. await sandboxPolicyFiber.dispose()
  118. } finally {
  119. await sandbox.kill().catch(() => false)
  120. }
  121. }, 70_000)
  122. it('runs FS, Bash, PTY, and LSP in one sandbox and deletes it', async () => {
  123. const { stdout, stderr } = await runLoaderSmoke({
  124. label: 'E2B composition',
  125. tempDirPrefix: 'dsh-e2b-composition-',
  126. binScript,
  127. libBinScript: binScript,
  128. configPath,
  129. tsconfigPath,
  130. env: {
  131. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  132. },
  133. processTimeoutMs: 180_000,
  134. inspect: async (cwd) => {
  135. for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte # file.ts', 'fixture-lsp.mjs']) {
  136. await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
  137. }
  138. },
  139. })
  140. expect(stderr).toBe('')
  141. const output = JSON.parse(stdout) as Record<string, unknown>
  142. expect(output).toMatchObject({
  143. bashRead: 'versioned-by-fs\n',
  144. fsRead: 'written-by-bash\n',
  145. explicitEnvironment: true,
  146. splitUtf8Output: '你好',
  147. hover: {
  148. kind: 'hover',
  149. hover: { contents: '**remote hover** 你好 café' },
  150. },
  151. definition: {
  152. kind: 'locations',
  153. locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
  154. },
  155. terminal: {
  156. echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
  157. signal: { delivered: true },
  158. interrupted: { sessionStatus: { kind: 'running' } },
  159. treeCleanup: true,
  160. },
  161. })
  162. const terminalMotd = (output.terminal as { motd: string }).motd
  163. expect(terminalMotd.length).toBeGreaterThan(0)
  164. expect(terminalMotd).not.toContain('exec /bin/bash')
  165. expect(terminalMotd).not.toContain('.dsh-e2b/terminals/')
  166. expect((output.terminal as { echo: { viewport: string } }).echo.viewport).toContain('PTY-你好')
  167. expect((output.terminal as { scrollback: string }).scrollback).toContain('PTY-你好')
  168. expect((output.terminal as { signal: { targetPgid: number } }).signal.targetPgid).toBeGreaterThan(0)
  169. expect(['stdin_read', 'inferred_idle']).toContain(
  170. (output.terminal as { interrupted: { waitReason: string } }).interrupted.waitReason,
  171. )
  172. const apiKey = process.env.E2B_API_KEY
  173. if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
  174. await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
  175. await expect.poll(async () => {
  176. const sandboxes = await Sandbox.list({ apiKey }).nextItems()
  177. return sandboxes.some(sandbox => sandbox.sandboxId === output.sandboxId)
  178. }, { interval: 250, timeout: 5_000 }).toBe(false)
  179. }, 195_000)
  180. })