environment.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. /** Shared remote-environment scrubbing for E2B process and terminal launchers. */
  2. import { Buffer } from 'node:buffer'
  3. import { posix } from 'node:path'
  4. import { e2bControlEnvs } from '@deepseek-ai/dsh-e2b'
  5. import type { Sandbox } from '@deepseek-ai/dsh-e2b'
  6. import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
  7. const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
  8. function remoteEnvironmentEntries(raw: string): Array<readonly [string, string]> {
  9. const entries: Array<readonly [string, string]> = []
  10. for (const entry of raw.split('\0')) {
  11. if (entry.length === 0) continue
  12. const separator = entry.indexOf('=')
  13. if (separator <= 0) continue
  14. entries.push([entry.slice(0, separator), entry.slice(separator + 1)])
  15. }
  16. return entries
  17. }
  18. /**
  19. * Read the remote environment through ASCII base64 so SDK callback chunking cannot corrupt UTF-8.
  20. * @param sandbox - shared E2B execution world.
  21. * @param signal - optional cancellation for the control-plane request.
  22. * @returns the complete NUL-delimited UTF-8 environment.
  23. */
  24. export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise<string> {
  25. // TODO(e2b-replace-environment): Remove this ambient probe when E2B can start
  26. // a command with a replacement environment instead of merged overrides.
  27. const result = await sandbox.commands.run(
  28. 'set -o pipefail; dsh_e2b_passwd="$(getent passwd "$(id -u)")"; IFS=: read -r _ _ _ _ _ dsh_e2b_home _ <<<"$dsh_e2b_passwd"; test -n "$dsh_e2b_home" -a -d "$dsh_e2b_home"; printf \'%s\' "$dsh_e2b_home" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
  29. { envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) },
  30. )
  31. const lines = result.stdout.trim().split('\n')
  32. if (lines.length !== 2 || !lines.every(line => BASE64.test(line))) {
  33. throw new Error('subprocess-e2b: remote environment transport returned invalid base64')
  34. }
  35. const [encodedHome, encodedEnvironment] = lines as [string, string]
  36. let home: string
  37. let raw: string
  38. try {
  39. const decoder = new TextDecoder('utf-8', { fatal: true })
  40. home = decoder.decode(Buffer.from(encodedHome, 'base64'))
  41. raw = decoder.decode(Buffer.from(encodedEnvironment, 'base64'))
  42. } catch (error: unknown) {
  43. throw new Error('subprocess-e2b: remote environment is not valid UTF-8', { cause: error })
  44. }
  45. if (!posix.isAbsolute(home) || home.includes('\0')) {
  46. throw new Error(`subprocess-e2b: remote login home is invalid: ${JSON.stringify(home)}`)
  47. }
  48. const environment = new Map(remoteEnvironmentEntries(raw))
  49. environment.set('HOME', home)
  50. return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
  51. }
  52. /**
  53. * Parse an E2B NUL-delimited environment while removing harness-private and credential-shaped names.
  54. * @param raw - The complete NUL-delimited remote environment.
  55. * @returns Mutable retained entries for the caller to overlay and serialize.
  56. */
  57. export function scrubRemoteEnvironment(raw: string): Map<string, string> {
  58. const environment = new Map<string, string>()
  59. for (const [name, value] of remoteEnvironmentEntries(raw)) {
  60. if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
  61. environment.set(name, value)
  62. }
  63. return environment
  64. }
  65. /**
  66. * Isolate E2B's fixed login-shell bootstrap from user profiles and ambient credentials.
  67. * @param raw - The complete NUL-delimited remote environment.
  68. * @returns Explicit E2B command or PTY overrides for bootstrap-shell startup.
  69. */
  70. export function bootstrapEnvironment(raw: string): Record<string, string> {
  71. const environment: Record<string, string> = { TERM: 'dumb' }
  72. for (const [name] of remoteEnvironmentEntries(raw)) {
  73. if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) environment[name] = ''
  74. }
  75. return environment
  76. }
  77. /**
  78. * Overlay explicit entries and serialize one validated E2B environment.
  79. * @param raw - The complete NUL-delimited remote environment.
  80. * @param explicit - Deliberate caller overrides applied after ambient scrubbing; an `undefined` tombstone removes an ambient entry.
  81. * @returns NUL-delimited `name=value` entries accepted by `env -i`.
  82. */
  83. export function serializeRemoteEnvironment(
  84. raw: string,
  85. explicit: Readonly<NodeJS.ProcessEnv> | undefined,
  86. ): string {
  87. const environment = scrubRemoteEnvironment(raw)
  88. for (const [name, value] of Object.entries(explicit ?? {})) {
  89. if (name.length === 0 || name.includes('=') || name.includes('\0') || value?.includes('\0') === true) {
  90. throw new Error('subprocess-e2b: environment entries require non-empty NUL-free names without = and NUL-free values')
  91. }
  92. // An explicit undefined is the seam's tombstone: remove the ambient entry.
  93. if (value === undefined) environment.delete(name)
  94. else environment.set(name, value)
  95. }
  96. return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
  97. }