fix(e2b): harden remote teardown transactions
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: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2
|
||||
README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671
|
||||
README.md: 01c7ade2f5f31e34091ad0ae910287e659f15549
|
||||
README.zh.md: fb95a12cbbbd431ccb3283a14bb63b324f9bdea6
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(资源释放)。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/],
|
||||
|
||||
Reference in New Issue
Block a user