81e2e1f647
E2B starts command and PTY requests through login shells, so isolate each control shell behind a fresh randomized HOME and blank sandbox credential names before mutable profiles can run. Preserve the real remote HOME only for the requested argv. Collapse duplicate termination state, keep failed force cleanup retryable until quiescence is observed, and make terminal state allocation cancellable. Leave numeric PGID reuse as an explicit provider-level TODO because a userspace precheck would remain TOCTOU.
227 lines
9.7 KiB
TypeScript
227 lines
9.7 KiB
TypeScript
import { access } from 'node:fs/promises'
|
|
import { join, posix } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Context } from 'cordis'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
|
import E2BSandboxService, {
|
|
e2bControlEnvs,
|
|
FileNotFoundError,
|
|
Sandbox,
|
|
SandboxNotFoundError,
|
|
} from '@deepseek-ai/dsh-e2b'
|
|
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
|
|
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
|
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
|
|
|
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
|
|
const binScript = join(fixtureRoot, 'bin.ts')
|
|
const configPath = join(fixtureRoot, 'cordis.yml')
|
|
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
|
|
|
describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
|
it('scrubs credentials before actual E2B command and PTY login shells', async () => {
|
|
const apiKey = process.env.E2B_API_KEY
|
|
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared before the PTY environment test')
|
|
const sandbox = await Sandbox.create({
|
|
apiKey,
|
|
envs: { NPM_TOKEN: 'sentinel-secret', DSH_STALE: 'sentinel-stale', KEEP: 'visible' },
|
|
timeoutMs: 60_000,
|
|
secure: true,
|
|
lifecycle: { onTimeout: 'kill' },
|
|
})
|
|
try {
|
|
const profileLeakPath = '/home/user/dsh-e2b-bootstrap-profile-leak'
|
|
const hostileProfile = [
|
|
'if [[ "${NPM_TOKEN-}" == "sentinel-secret" ]]; then',
|
|
` printf leaked > ${profileLeakPath}`,
|
|
'fi',
|
|
'',
|
|
].join('\n')
|
|
await sandbox.files.write([
|
|
{ path: '/home/user/.bash_profile', data: hostileProfile },
|
|
{ path: '/home/user/.profile', data: hostileProfile },
|
|
{ path: '/home/user/.bashrc', data: hostileProfile },
|
|
])
|
|
const ctx = new Context()
|
|
ctx.provide('e2b', {
|
|
cwd: '/home/user',
|
|
runtimeRoot: '/home/user/.dsh-e2b',
|
|
getSandbox: async () => sandbox,
|
|
} as never)
|
|
ctx.provide('sandboxPolicy', {
|
|
defaultMode: 'danger-full-access',
|
|
workspaceRoot: '/home/user',
|
|
} as never)
|
|
const ptyFiber = await ctx.plugin(PtyService)
|
|
const subprocessFiber = await ctx.plugin(E2BSubprocessService)
|
|
const node = await ctx.subprocess.resolveExecutable('node')
|
|
const relativeNodePath = posix.relative(ctx.subprocess.cwd, posix.dirname(node)) || '.'
|
|
await expect(ctx.subprocess.resolveExecutable('node', { PATH: relativeNodePath })).resolves.toBe(node)
|
|
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
|
|
const environmentProbe = ctx.subprocess.spawn({
|
|
argv: ['/bin/bash', '-c', [
|
|
'dsh_leak=0',
|
|
'for dsh_pid in "$PPID" $(ps -o pid= --ppid "$PPID"); do',
|
|
' [[ "$dsh_pid" == "$$" ]] && continue',
|
|
' if tr "\\0" "\\n" < "/proc/$dsh_pid/environ" 2>/dev/null | grep -Fqx "NPM_TOKEN=sentinel-secret"; then dsh_leak=1; fi',
|
|
'done',
|
|
'printf "DIRECT=<%s> LEAK=<%s>\\n" "${NPM_TOKEN-}" "$dsh_leak"',
|
|
].join('\n')],
|
|
cwd: '/home/user',
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } },
|
|
graceMs: 500,
|
|
env: {},
|
|
})
|
|
await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null })
|
|
expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n')
|
|
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
|
|
const ownerId = SessionId('e2b-pty-env-owner')
|
|
const owner: Agent = {
|
|
id: ownerId,
|
|
options: {},
|
|
session: new Session(ownerId),
|
|
status: 'idle',
|
|
acceptsNextStep: false,
|
|
ctx,
|
|
followup() {},
|
|
steer() {},
|
|
inject() {},
|
|
send() {},
|
|
cancel() {},
|
|
whenIdle: () => Promise.resolve(),
|
|
}
|
|
const backend = new LocalPtyBackend(ctx, {
|
|
backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
|
|
rows: 24, cols: 80,
|
|
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
|
|
pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
|
|
handoffGraceMs: 500, timeoutMs: 5_000, disposeGraceMs: 1_000,
|
|
})
|
|
const session = await backend.spawn({ sessionId: PtySessionId('env'), owner, type: 'shell' })
|
|
const result = await session.startSend({
|
|
text: "printf 'NPM=<%s> DSH=<%s> KEEP=<%s>\\n' \"$NPM_TOKEN\" \"$DSH_STALE\" \"$KEEP\"",
|
|
submit: true,
|
|
}).done
|
|
expect(result.viewport).toContain('NPM=<> DSH=<> KEEP=<visible>')
|
|
expect(result.viewport).not.toContain('sentinel-secret')
|
|
expect(result.viewport).not.toContain('sentinel-stale')
|
|
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
|
|
await session.close('environment test complete')
|
|
await subprocessFiber.dispose()
|
|
await ptyFiber.dispose()
|
|
|
|
await sandbox.commands.run([
|
|
'rm -rf -- /home/user/.dsh-e2b /home/user/dsh-e2b-runtime-target',
|
|
'mkdir -p -- /home/user/dsh-e2b-runtime-target',
|
|
'chmod 755 -- /home/user/dsh-e2b-runtime-target',
|
|
'ln -s -- /home/user/dsh-e2b-runtime-target /home/user/.dsh-e2b',
|
|
].join('\n'), { envs: e2bControlEnvs({ NPM_TOKEN: '' }) })
|
|
const linkedCtx = new Context()
|
|
const linkedFiber = await linkedCtx.plugin(E2BSandboxService, {
|
|
apiKey,
|
|
sandboxId: sandbox.sandboxId,
|
|
cwd: '/home/user',
|
|
onDispose: 'leave',
|
|
})
|
|
try {
|
|
await expect(linkedCtx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
|
|
const target = await sandbox.files.getInfo('/home/user/dsh-e2b-runtime-target')
|
|
expect(target.mode & 0o777).toBe(0o755)
|
|
} finally {
|
|
await linkedFiber.dispose()
|
|
}
|
|
} finally {
|
|
await sandbox.kill().catch(() => false)
|
|
}
|
|
}, 70_000)
|
|
|
|
it('runs FS, Bash, PTY, LSP, and Code Runtime in one sandbox and deletes it', async () => {
|
|
const { stdout, stderr } = await runLoaderSmoke({
|
|
label: 'E2B composition',
|
|
tempDirPrefix: 'dsh-e2b-composition-',
|
|
binScript,
|
|
libBinScript: binScript,
|
|
configPath,
|
|
tsconfigPath,
|
|
env: {
|
|
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
|
},
|
|
processTimeoutMs: 180_000,
|
|
inspect: async (cwd) => {
|
|
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte # file.ts', 'fixture-lsp.mjs']) {
|
|
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
|
|
}
|
|
},
|
|
})
|
|
|
|
expect(stderr).toBe('')
|
|
const output = JSON.parse(stdout) as Record<string, unknown>
|
|
expect(output).toMatchObject({
|
|
bashRead: 'written-by-fs\n',
|
|
fsRead: 'written-by-bash\n',
|
|
explicitEnvironment: true,
|
|
splitUtf8Output: '你好',
|
|
outputDrain: {
|
|
outcome: { exitCode: 0, signal: null },
|
|
text: 'leader-done\n',
|
|
exited: true,
|
|
clean: true,
|
|
},
|
|
publicationRollback: true,
|
|
spill: {
|
|
liveBytes: 6,
|
|
outcome: { exitCode: null, signal: 'SIGTERM' },
|
|
read: { text: '6789', nextOffset: 10, lossy: true },
|
|
},
|
|
hover: {
|
|
kind: 'hover',
|
|
hover: { contents: '**remote hover** 你好 café' },
|
|
},
|
|
definition: {
|
|
kind: 'locations',
|
|
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
|
|
},
|
|
lspContainment: true,
|
|
lspDocumentBound: true,
|
|
terminal: {
|
|
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
|
|
signal: { delivered: true },
|
|
interrupted: { sessionStatus: { kind: 'running' } },
|
|
interruptIdentitySafe: true,
|
|
treeCleanup: true,
|
|
},
|
|
hostileOutput: { error: { kind: 'output-limit' } },
|
|
nativeOutput: { error: { kind: 'output-limit' } },
|
|
descriptorOutput: { error: { kind: 'output-limit' } },
|
|
inheritedOutput: { error: { kind: 'output-limit' } },
|
|
descendantPipe: { value: true, logs: [] },
|
|
descendantCleanup: true,
|
|
timedOut: { error: { kind: 'timeout' } },
|
|
aborted: { error: { kind: 'abort', message: 'live abort' } },
|
|
oversizedBoot: { error: { kind: 'worker-exit' } },
|
|
oversizedReply: { error: { kind: 'worker-exit' } },
|
|
lingeringCodeRunners: 0,
|
|
})
|
|
const terminalMotd = (output.terminal as { motd: string }).motd
|
|
expect(terminalMotd.length).toBeGreaterThan(0)
|
|
expect(terminalMotd).not.toContain('exec /bin/bash')
|
|
expect(terminalMotd).not.toContain('.dsh-e2b/terminals/')
|
|
expect((output.terminal as { echo: { viewport: string } }).echo.viewport).toContain('PTY-你好')
|
|
expect((output.terminal as { scrollback: string }).scrollback).toContain('PTY-你好')
|
|
expect((output.terminal as { signal: { targetPgid: number } }).signal.targetPgid).toBeGreaterThan(0)
|
|
expect(['stdin_read', 'inferred_idle']).toContain(
|
|
(output.terminal as { interrupted: { waitReason: string } }).interrupted.waitReason,
|
|
)
|
|
expect(output.code).toEqual({
|
|
value: { doubled: 42, typed: true },
|
|
logs: ['remote-log 你好 42', 'post-mutation'],
|
|
})
|
|
const apiKey = process.env.E2B_API_KEY
|
|
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
|
|
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
|
|
}, 195_000)
|
|
})
|