fix(e2b): harden remote teardown transactions

This commit is contained in:
Tianyi Cui
2026-07-29 07:03:04 +08:00
parent 62df6d0c09
commit ec0310ca1b
19 changed files with 712 additions and 135 deletions

View File

@@ -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: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2
README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671
README.md: 01c7ade2f5f31e34091ad0ae910287e659f15549
README.zh.md: fb95a12cbbbd431ccb3283a14bb63b324f9bdea6

View File

@@ -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, then sets that directory 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`. `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` means a kill-on-timeout sandbox is already quiescent; every other disposition failure rejects teardown. A newly created sandbox is killed when initial directory setup fails; 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.

View File

@@ -28,7 +28,7 @@
## 生命周期与所有权
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。

View File

@@ -7,7 +7,7 @@
import { posix } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Sandbox, SandboxNotFoundError } from 'e2b'
import { FileType, Sandbox, SandboxNotFoundError } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export {
@@ -232,6 +232,10 @@ export class E2BSandboxService extends Service {
try {
await sandbox.files.makeDir(this.cwd)
await sandbox.files.makeDir(this.runtimeRoot)
const runtimeRoot = await sandbox.files.getInfo(this.runtimeRoot)
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)}`)
return sandbox
} catch (error: unknown) {

View File

@@ -5,7 +5,7 @@ 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 { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
import E2BSandboxService, { 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'
@@ -92,6 +92,27 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
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'))
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)
}
@@ -123,6 +144,12 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
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,

View File

@@ -3,6 +3,7 @@ import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BSandboxId,
FileType,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
@@ -33,6 +34,7 @@ vi.mock('e2b', async (importOriginal) => {
interface SandboxFixture {
sandbox: SandboxType
makeDir: ReturnType<typeof vi.fn>
getInfo: ReturnType<typeof vi.fn>
run: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
@@ -40,17 +42,18 @@ interface SandboxFixture {
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 kill = vi.fn().mockResolvedValue(undefined)
const pause = vi.fn().mockResolvedValue(true)
const sandbox = {
sandboxId: id,
files: { makeDir },
files: { makeDir, getInfo },
commands: { run },
kill,
pause,
} as unknown as SandboxType
return { sandbox, makeDir, run, kill, pause }
return { sandbox, makeDir, getInfo, run, kill, pause }
}
beforeEach(() => {
@@ -82,6 +85,7 @@ 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'")
await fiber.dispose()
@@ -220,6 +224,21 @@ describe('E2BSandboxService', () => {
expect(fixture.kill).not.toHaveBeenCalled()
})
it.each([
['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }],
['regular file', { type: FileType.FILE }],
])('rejects a reserved runtime root that is a %s', async (_label, info) => {
const fixture = fakeSandbox()
fixture.getInfo.mockResolvedValueOnce(info)
sdk.connect.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key', sandboxId: 'existing' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
expect(fixture.run).not.toHaveBeenCalled()
expect(fixture.kill).not.toHaveBeenCalled()
})
it.each([
[{ apiKey: '' }, /configure apiKey/],
[{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/],