fix(e2b): harden SDK shell and cleanup boundaries
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.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
|
||||
README.md: 6eb7d69dd6355de870db6bf05540f2629f464bc8
|
||||
README.zh.md: b2e2616cf779bce659fdef58840aa51f095a73cf
|
||||
README.md: 402903184934903eceb36a04d670e4490879ac65
|
||||
README.zh.md: 1fec6ab66858f04a47c8a2a7cb5f6a8907ab6cac
|
||||
|
||||
@@ -28,7 +28,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one
|
||||
|
||||
## Lifecycle and ownership
|
||||
|
||||
Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
|
||||
Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
|
||||
|
||||
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` is accepted when disposal requests `kill`, or when this service created a sandbox with `onTimeout: kill`; otherwise, a not-found error from a requested `pause` rejects teardown because retention was not proved. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 生命周期与所有权
|
||||
|
||||
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
|
||||
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
|
||||
|
||||
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 仅在资源释放请求 `kill`,或本服务创建了配置为 `onTimeout: kill` 的沙箱时才可接受;否则,`pause` 请求返回的未找到错误会导致 teardown 拒绝,因为无法证明保留成功。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-e2b
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
@@ -42,6 +43,17 @@ export function quoteE2BShellArg(value: string): string {
|
||||
return `'${value.replaceAll('\'', "'\"'\"'")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolate E2B's hard-coded login shell behind a fresh randomized home path.
|
||||
* @param overrides - Additional environment entries for the internal command.
|
||||
* @returns A fresh mutable map that the E2B SDK may extend.
|
||||
*/
|
||||
export function e2bControlEnvs(
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
): Record<string, string> {
|
||||
return { ...overrides, HOME: `/.dsh-e2b-control-${randomUUID()}` }
|
||||
}
|
||||
|
||||
/** Action taken on the owned sandbox when the Cordis service is disposed. */
|
||||
export type E2BDisposeMode = 'kill' | 'pause' | 'leave'
|
||||
|
||||
@@ -249,7 +261,10 @@ export class E2BSandboxService extends Service {
|
||||
if (runtimeRoot.type !== FileType.DIR || runtimeRoot.symlinkTarget !== undefined) {
|
||||
throw new Error(`dsh-e2b: runtime root must be a real directory: ${this.runtimeRoot}`)
|
||||
}
|
||||
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`)
|
||||
await sandbox.commands.run(
|
||||
`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`,
|
||||
{ envs: e2bControlEnvs() },
|
||||
)
|
||||
return sandbox
|
||||
} catch (error: unknown) {
|
||||
if (this.created) {
|
||||
|
||||
@@ -5,7 +5,12 @@ 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, { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
|
||||
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'
|
||||
@@ -17,7 +22,7 @@ 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 sandbox-default credentials from an actual E2B PTY', async () => {
|
||||
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({
|
||||
@@ -28,6 +33,18 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
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',
|
||||
@@ -43,6 +60,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
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',
|
||||
@@ -59,6 +77,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
})
|
||||
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,
|
||||
@@ -89,6 +108,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
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()
|
||||
@@ -98,7 +118,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
'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'))
|
||||
].join('\n'), { envs: e2bControlEnvs({ NPM_TOKEN: '' }) })
|
||||
const linkedCtx = new Context()
|
||||
const linkedFiber = await linkedCtx.plugin(E2BSandboxService, {
|
||||
apiKey,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Sandbox as SandboxType } from 'e2b'
|
||||
import E2BSandboxService, {
|
||||
e2bControlEnvs,
|
||||
E2BSandboxId,
|
||||
FileType,
|
||||
SandboxNotFoundError,
|
||||
@@ -35,15 +37,20 @@ interface SandboxFixture {
|
||||
sandbox: SandboxType
|
||||
makeDir: ReturnType<typeof vi.fn>
|
||||
getInfo: ReturnType<typeof vi.fn>
|
||||
run: ReturnType<typeof vi.fn>
|
||||
run: Mock<RunCommand>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type RunCommand = (
|
||||
command: string,
|
||||
options?: { envs?: Record<string, string> },
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
|
||||
function fakeSandbox(id = 'sandbox-1'): SandboxFixture {
|
||||
const makeDir = vi.fn().mockResolvedValue(true)
|
||||
const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR })
|
||||
const run = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||
const run = vi.fn<RunCommand>().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||
const kill = vi.fn().mockResolvedValue(undefined)
|
||||
const pause = vi.fn().mockResolvedValue(true)
|
||||
const sandbox = {
|
||||
@@ -63,6 +70,15 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('E2BSandboxService', () => {
|
||||
it('gives each SDK login shell a fresh non-overridable control home', () => {
|
||||
const first = e2bControlEnvs({ HOME: '/hostile', NPM_TOKEN: '' })
|
||||
const second = e2bControlEnvs()
|
||||
|
||||
expect(first.HOME).toMatch(/^\/\.dsh-e2b-control-/)
|
||||
expect(first).toEqual({ HOME: first.HOME, NPM_TOKEN: '' })
|
||||
expect(first.HOME).not.toBe(second.HOME)
|
||||
})
|
||||
|
||||
it('creates one protected shared sandbox and kills it on default disposal', async () => {
|
||||
const fixture = fakeSandbox()
|
||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||
@@ -86,7 +102,12 @@ describe('E2BSandboxService', () => {
|
||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
|
||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
|
||||
expect(fixture.getInfo).toHaveBeenCalledWith('/home/user/workspace/.dsh-e2b')
|
||||
expect(fixture.run).toHaveBeenCalledWith("chmod 700 -- '/home/user/workspace/.dsh-e2b'")
|
||||
const runOptions = fixture.run.mock.calls[0]?.[1]
|
||||
expect(runOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
|
||||
expect(fixture.run).toHaveBeenCalledWith(
|
||||
"chmod 700 -- '/home/user/workspace/.dsh-e2b'",
|
||||
{ envs: { HOME: runOptions?.envs?.HOME } },
|
||||
)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||
|
||||
Reference in New Issue
Block a user