@runko/sandbox-e2b 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @runko/sandbox-e2b
2
+
3
+ 把一个已创建好的 [E2B](https://e2b.dev)(真实 Firecracker microVM)沙盒包成 `RunkoFS & RunkoExec`,一次注入 `createSession(agent, { workspace })`——"模式 A 同源工作区"落在真实云沙盒上的实现(对照 [core-sdk · 技术方案 §4.5a](../../docs/logic/engine/tech/core-sdk.md) / [沙盒工作区(Sandbox Workspace) · 技术方案 §8](../../docs/host/contract/tech/sandbox.md))。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ pnpm add @runko/sandbox-e2b e2b
9
+ ```
10
+
11
+ `e2b` 需要单独装——本包运行时**不 import** `e2b`(见下方"结构化接口"),但宿主要创建真实沙盒实例(`Sandbox.create()`)离不开它。本包不随 `@runko/sdk` 一起装,属于按需显式安装的可选集成。
12
+
13
+ ## 快速上手
14
+
15
+ ```ts
16
+ import { Sandbox } from "e2b";
17
+ import { e2bWorkspace } from "@runko/sandbox-e2b";
18
+ import { createSession, defineAgent } from "@runko/sdk";
19
+
20
+ const sandbox = await Sandbox.create(); // BYO:本包不创建/销毁沙盒
21
+ try {
22
+ const workspace = e2bWorkspace(sandbox); // 默认 root: "/home/user"
23
+ const session = createSession(defineAgent({ model: "anthropic/claude-sonnet-5" }), { workspace });
24
+ const result = await session.send("在 notes.txt 里写一句问候语,然后用 bash 验证内容。");
25
+ console.log(result.finalResponse);
26
+ } finally {
27
+ await sandbox.kill();
28
+ }
29
+ ```
30
+
31
+ 完整可跑示例(含零凭证的 fake 沙盒演示,证明"BYO 实例 + 结构化接口"不需要真实网络)见 [examples/09-sandbox-e2b.ts](../../examples/src/09-sandbox-e2b.ts)。
32
+
33
+ ## 结构化接口 / BYO
34
+
35
+ ```ts
36
+ function e2bWorkspace(
37
+ sandbox: E2bSandboxLike,
38
+ opts?: { root?: string; keepAlive?: KeepAliveOptions },
39
+ ): E2bWorkspace;
40
+ ```
41
+
42
+ - `E2bSandboxLike` 是以 e2b 官方 d.ts 为蓝本手写的结构化最小子集(`files.read/write/list/remove/makeDir/getInfo` + `commands.run`)——本包运行时零 `import "e2b"`,`e2bWorkspace()` 收的是这份结构接口而非具体类,真实 e2b `Sandbox` 实例无需任何转换即可直接传入(`test/type-conformance.ts` 的编译期证明),任何实现了同一结构的对象(例如测试/演示用的进程内 fake)同样可以传入。
43
+ - **BYO 实例**是唯一入口:`e2bWorkspace()` 不创建、不销毁沙盒——创建(`Sandbox.create()`)、暂停/恢复、`kill()` 完全由宿主自己管理。唯一的例外是[保活](../../docs/terms.md),见下。
44
+ - `opts.root`(默认 `/home/user`,e2b 官方模板的登录用户主目录):虚拟绝对路径 `/` 锚定到的沙盒内真实目录。
45
+
46
+ ## 保活(keepalive)
47
+
48
+ **沙盒的存活时长是倒计时,在里面跑命令不会把它往后推。** 所以一轮 agent 只要跑得比沙盒超时长,就会在跑到一半时被平台暂停掉。传 `keepAlive` 即可让适配器自动续期:
49
+
50
+ ```ts
51
+ const sandbox = await Sandbox.create({ timeoutMs: 300_000 });
52
+ const workspace = e2bWorkspace(sandbox, {
53
+ keepAlive: { idleTimeoutMs: 300_000 }, // 与建盒 timeoutMs 保持一致
54
+ });
55
+ ```
56
+
57
+ - **不传 `keepAlive` = 完全不保活**,一次网络调用都不会发生,行为与没有这个功能时一致。保活会花钱,不该在你没要求时悄悄发生。
58
+ - 续期是**补足**语义(补到至少 `idleTimeoutMs`,够了就什么都不做),不是无脑加时。
59
+ - 这一轮真的卡死时信号自然停止,沙盒会正常休眠——它不会给一个已经死掉的任务无限续命。
60
+ - 其余可调项(`maxTurnMs` 单轮上限、`approvalBudgetMs` 审批预算、`onRenew` 观测回调)见 `@runko/core` 的 `KeepAliveOptions` 与 [沙盒保活(keepalive) · 功能](../../docs/logic/orchestration/features/sandbox-keepalive.md)。
61
+
62
+ ⚠️ 两个要注意的:
63
+
64
+ - **`idleTimeoutMs` 要和建盒时的 `timeoutMs` 一致。** 不一致很难查——比如建盒 5 分钟、这里按 10 分钟补足,就永远补不上去。
65
+ - **E2B 有硬上限**:Pro 账户 24 小时、Hobby 账户 1 小时(`Sandbox.setTimeout` 的官方文档)。超了会报错。保活不是长生药。
66
+
67
+ ## 已知限制(docs/host/contract/tech/sandbox.md §8.2 / docs/logic/engine/plans/core-sdk.md P10-1 实际改动,如实照抄不发明)
68
+
69
+ - **取消 = 放弃等待,不是真的杀掉远程进程**:`exec()` 的 abort/超时只保证及时返回(退出码 124/130),命令本身可能在沙盒里继续跑到自然结束——真正杀掉远程进程需要 e2b 的 `background: true` + `CommandHandle.kill()`,是另一条调用路径,不在本适配器 v1 范围内。
70
+ - **bash 越出工作区根**:文件工具(`read_file`/`write_file`/...)锚定在 `root` 之下,但 `bash` 是沙盒里的真实 shell,能读写/`cd` 到磁盘任何位置——绝对路径的 bash 命令(如 `cat /notes.txt`)落在沙盒真实文件系统根,而不是 `root`;同一份文件要在文件工具和 bash 之间互通,bash 侧要用相对路径(沙盒默认 cwd 与 `root` 一致时,`cat notes.txt` 即可命中文件工具写的同一个文件)。
71
+ - **每次 RunkoFS 文件工具调用都是一次网络往返**(几十到几百毫秒)——扫描类操作(大量 glob、grep-like 搜索)优先用一条 bash 命令,而不是逐个 `glob`/`read_file` 调用。
72
+ - **非递归删除非空目录**:e2b 的 `remove()` 本身恒递归,适配器自己先 `list` 一层子项判断目录是否为空再决定要不要拒绝(`DirectoryNotEmptyError`),比原生多一次调用。
73
+ - **沙盒已停止/过期**:v1 = 报错指导(`exec()`/文件方法翻译成带操作建议的失败结果),不做自动恢复——宿主需要自己延长沙盒超时或重新创建实例并重新调用 `e2bWorkspace()`。
package/dist/index.cjs ADDED
@@ -0,0 +1,310 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _runko_virtual_fs = require("@runko/virtual-fs");
3
+ let _runko_core = require("@runko/core");
4
+ //#region src/errors.ts
5
+ /**
6
+ * e2b 错误的结构判别(docs/tech/sandbox.md §8.1:"错误识别一律结构判别不用 instanceof"——
7
+ * 宿主与本包各自安装的 `e2b` 副本类不同一,`instanceof` 天然不可靠)。
8
+ * `instanceof Error` 本身没问题(`Error` 是当前 realm 的全局内置类,不是
9
+ * e2b 的导出类),只有对 e2b 自家导出的错误类才不能用 instanceof。
10
+ */
11
+ /** e2b 的具名错误(`SandboxError` 及其子类)都在构造函数里把 `this.name` 设成子类名——按这个字符串字段判别,不按类名/`instanceof`。 */
12
+ function isE2bErrorNamed(error, ...names) {
13
+ return error instanceof Error && names.includes(error.name);
14
+ }
15
+ function describeError(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ function isCommandExitErrorLike(error) {
19
+ if (typeof error !== "object" || error === null) return false;
20
+ if (!("exitCode" in error) || typeof error.exitCode !== "number") return false;
21
+ if (!("stdout" in error) || typeof error.stdout !== "string") return false;
22
+ if (!("stderr" in error) || typeof error.stderr !== "string") return false;
23
+ return true;
24
+ }
25
+ //#endregion
26
+ //#region src/exec.ts
27
+ const DESCRIBE = [
28
+ "e2b: a real Firecracker microVM sandbox (E2B cloud) — full Linux userspace, not a virtual filesystem.",
29
+ "Mode A same-source workspace: this RunkoFS view and bash commands share the exact same filesystem inside",
30
+ "the sandbox, so there is nothing to reconcile and no snapshot lag between the two.",
31
+ "The sandbox is a real machine: commands can read, write, and cd anywhere on its disk, not just under this",
32
+ "workspace's root — isolation (the whole VM is disposable) is the security boundary here, not path confinement",
33
+ "the way an in-process VirtualFS enforces it.",
34
+ "Each RunkoFS file-tool call is a network round trip (tens to hundreds of milliseconds). Prefer running",
35
+ "scan-heavy work (grep/find over many files, recursive listings) as a single bash command inside the sandbox",
36
+ "rather than many individual file-tool calls."
37
+ ].join(" ");
38
+ var E2bExecAbortedError = class extends Error {
39
+ constructor(reason) {
40
+ super(reason === "timeout" ? "e2b: command timed out" : "e2b: aborted");
41
+ this.name = "E2bExecAbortedError";
42
+ }
43
+ };
44
+ /**
45
+ * `signal` 先触发就立刻 reject,不等待 `work`(远程 `commands.run()`)真正
46
+ * 落定——`work` 可能因为网络问题永久悬挂,这正是需要独立赛跑的原因。给
47
+ * `work` 补一个空 catch,避免它在赛跑结束后才 reject 时产生 unhandled
48
+ * rejection(`@runko/just-bash`/`@runko/mini-bash` 的同款 `raceAbort`)。
49
+ */
50
+ function raceAbort(work, signal) {
51
+ return new Promise((resolve, reject) => {
52
+ const settleAborted = () => {
53
+ work.catch(() => {});
54
+ reject(new E2bExecAbortedError("signal"));
55
+ };
56
+ if (signal.aborted) {
57
+ settleAborted();
58
+ return;
59
+ }
60
+ const onAbort = () => settleAborted();
61
+ signal.addEventListener("abort", onAbort, { once: true });
62
+ work.then((value) => {
63
+ signal.removeEventListener("abort", onAbort);
64
+ resolve(value);
65
+ }, (error) => {
66
+ signal.removeEventListener("abort", onAbort);
67
+ reject(error instanceof Error ? error : new Error(String(error)));
68
+ });
69
+ });
70
+ }
71
+ function createE2bExec(sandbox, anchor, keepAlive) {
72
+ return {
73
+ defaultApproval: "allow",
74
+ describe() {
75
+ return DESCRIBE;
76
+ },
77
+ async exec(req, opts) {
78
+ const start = Date.now();
79
+ const cwd = req.cwd !== void 0 ? anchor.toReal(req.cwd) : anchor.rootReal;
80
+ const timeoutController = new AbortController();
81
+ const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
82
+ const combined = AbortSignal.any([req.signal, timeoutController.signal]);
83
+ /**
84
+ * [保活](../../../docs/terms.md)的第二个信号源(docs/tech/sandbox-keepalive.md §3.1):
85
+ * 命令跑起来之后 core **一个 chunk 都不会产出**——`@runko/core` 的 `loop.ts`
86
+ * 把工具进度先缓冲、等命令 resolve 之后才重放。所以一条跑十分钟的命令,这十分钟
87
+ * 里 core 侧的活动信号是零。而这恰恰是最容易把沙盒跑没的场景。
88
+ *
89
+ * 补法就在这里:适配器自己就是那个在 `await` 的人,手里握着一个没落定的远程调用,
90
+ * 这是比 chunk 更硬的「还活着」证据,根本不需要谁来通知。
91
+ */
92
+ const stopKeepAlive = keepAlive?.beginExec();
93
+ const work = sandbox.commands.run(req.command, {
94
+ cwd,
95
+ timeoutMs: req.timeoutMs,
96
+ onStdout: (data) => opts?.onOutput?.({
97
+ stream: "stdout",
98
+ data
99
+ }),
100
+ onStderr: (data) => opts?.onOutput?.({
101
+ stream: "stderr",
102
+ data
103
+ })
104
+ });
105
+ try {
106
+ const result = await raceAbort(work, combined);
107
+ return {
108
+ exitCode: result.exitCode,
109
+ stdout: result.stdout,
110
+ stderr: result.stderr,
111
+ durationMs: Date.now() - start
112
+ };
113
+ } catch (error) {
114
+ if (error instanceof E2bExecAbortedError) {
115
+ const timedOut = timeoutController.signal.aborted;
116
+ return {
117
+ exitCode: timedOut ? 124 : 130,
118
+ stdout: "",
119
+ stderr: timedOut ? `e2b: command timed out after ${String(req.timeoutMs)}ms` : "e2b: aborted",
120
+ durationMs: Date.now() - start
121
+ };
122
+ }
123
+ if (isCommandExitErrorLike(error)) return {
124
+ exitCode: error.exitCode,
125
+ stdout: error.stdout,
126
+ stderr: error.stderr,
127
+ durationMs: Date.now() - start
128
+ };
129
+ if (isE2bErrorNamed(error, "TimeoutError")) return {
130
+ exitCode: 124,
131
+ stdout: "",
132
+ stderr: `e2b: command timed out: ${describeError(error)}`,
133
+ durationMs: Date.now() - start
134
+ };
135
+ return {
136
+ exitCode: 1,
137
+ stdout: "",
138
+ stderr: `e2b: sandbox command failed: ${describeError(error)}. The sandbox may have stopped or its connection was lost (e.g. it hit its idle/session timeout) — extend the sandbox's timeout or recreate it and pass a fresh instance to e2bWorkspace().`,
139
+ durationMs: Date.now() - start
140
+ };
141
+ } finally {
142
+ if (timer !== void 0) clearTimeout(timer);
143
+ stopKeepAlive?.();
144
+ }
145
+ }
146
+ };
147
+ }
148
+ //#endregion
149
+ //#region src/fs.ts
150
+ /**
151
+ * e2b 的"未找到"错误家族:`FileNotFoundError` 是当前版本实测抛出的具体子类,
152
+ * `NotFoundError`(e2b 自家、已废弃)是历史/兼容路径可能出现的旧变体——两者
153
+ * 都翻译成 `@runko/virtual-fs` 的 `NotFoundError`(同名不同类,跨包结构判别,
154
+ * 不是 `instanceof`)。
155
+ */
156
+ const NOT_FOUND_NAMES = ["FileNotFoundError", "NotFoundError"];
157
+ /** `list(root, { depth })` 用于 glob 的递归深度——远超"典型工作区目录嵌套层数"的量级,避免深层子目录被漏扫。 */
158
+ const GLOB_LIST_DEPTH = 64;
159
+ /** `EntryInfo.type` 非 `"file"` 一律视为目录(docs/tech/sandbox.md §8.2:"FileType 非 file 视为 dir");`size` 只在文件类型下有意义,仿 MemoryFS 用条件展开省略而非填 0/undefined。 */
160
+ function toFileStat(entry) {
161
+ const type = entry.type === "file" ? "file" : "dir";
162
+ const mtime = entry.modifiedTime?.getTime();
163
+ return {
164
+ type,
165
+ ...type === "file" ? { size: entry.size } : {},
166
+ ...mtime !== void 0 ? { mtime } : {}
167
+ };
168
+ }
169
+ /** e2b 的 `write()` 不接受 `Uint8Array`(只收 `string | ArrayBuffer | Blob | ReadableStream`)——转成一份拷贝的 `ArrayBuffer`,避免子视图的 `byteOffset` 被忽略。 */
170
+ function toArrayBuffer(data) {
171
+ return data.slice().buffer;
172
+ }
173
+ function createE2bFs(sandbox, anchor) {
174
+ return {
175
+ async readFile(path) {
176
+ const real = anchor.toReal(path);
177
+ try {
178
+ return await sandbox.files.read(real, { format: "bytes" });
179
+ } catch (error) {
180
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new _runko_virtual_fs.NotFoundError(path);
181
+ throw error;
182
+ }
183
+ },
184
+ async writeFile(path, data) {
185
+ const payload = typeof data === "string" ? data : toArrayBuffer(data);
186
+ await sandbox.files.write(anchor.toReal(path), payload);
187
+ },
188
+ async rm(path, opts) {
189
+ const real = anchor.toReal(path);
190
+ let info;
191
+ try {
192
+ info = await sandbox.files.getInfo(real);
193
+ } catch (error) {
194
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new _runko_virtual_fs.NotFoundError(path);
195
+ throw error;
196
+ }
197
+ if (opts?.recursive !== true && info.type !== "file") {
198
+ if ((await sandbox.files.list(real, { depth: 1 })).length > 0) throw new _runko_virtual_fs.DirectoryNotEmptyError(path);
199
+ }
200
+ await sandbox.files.remove(real);
201
+ },
202
+ async mkdir(path) {
203
+ await sandbox.files.makeDir(anchor.toReal(path));
204
+ },
205
+ async readdir(path) {
206
+ const real = anchor.toReal(path);
207
+ let entries;
208
+ try {
209
+ entries = await sandbox.files.list(real, { depth: 1 });
210
+ } catch (error) {
211
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new _runko_virtual_fs.NotFoundError(path);
212
+ throw error;
213
+ }
214
+ return entries.map((entry) => ({
215
+ name: entry.name,
216
+ ...toFileStat(entry)
217
+ })).sort((a, b) => a.name.localeCompare(b.name));
218
+ },
219
+ async stat(path) {
220
+ const real = anchor.toReal(path);
221
+ try {
222
+ return toFileStat(await sandbox.files.getInfo(real));
223
+ } catch (error) {
224
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new _runko_virtual_fs.NotFoundError(path);
225
+ throw error;
226
+ }
227
+ },
228
+ async glob(pattern) {
229
+ let entries;
230
+ try {
231
+ entries = await sandbox.files.list(anchor.rootReal, { depth: GLOB_LIST_DEPTH });
232
+ } catch (error) {
233
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new _runko_virtual_fs.NotFoundError("/");
234
+ throw error;
235
+ }
236
+ const matches = [];
237
+ for (const entry of entries) {
238
+ if (entry.type !== "file") continue;
239
+ const virtual = anchor.toVirtual(entry.path);
240
+ if ((0, _runko_virtual_fs.matchesGlob)(pattern, virtual)) matches.push(virtual);
241
+ }
242
+ return matches.sort();
243
+ }
244
+ };
245
+ }
246
+ //#endregion
247
+ //#region src/keepalive.ts
248
+ /** 沙盒实例没有 `setTimeout` 时的指引——出现在这里说明宿主传了保活配置,但传进来的对象补不了期。 */
249
+ const KEEPALIVE_UNSUPPORTED_MESSAGE = "e2bWorkspace({ keepAlive }) needs the sandbox instance to expose setTimeout(timeoutMs) — a real e2b Sandbox does; a hand-written fake may not. Either pass a real sandbox, add setTimeout to the fake, or drop the keepAlive option (keepalive is entirely opt-in).";
250
+ function createDriver(sandbox) {
251
+ return {
252
+ remainingMs: async () => void 0,
253
+ renew: async (targetMs) => {
254
+ if (sandbox.setTimeout === void 0) throw new Error(KEEPALIVE_UNSUPPORTED_MESSAGE);
255
+ await sandbox.setTimeout(targetMs);
256
+ }
257
+ };
258
+ }
259
+ /**
260
+ * 造一个绑好这个沙盒的保活器。`opts` 为 `undefined`(宿主没开保活)时返回
261
+ * `undefined`——调用方据此整段跳过,**不开就是一次网络调用都不会发生**。
262
+ */
263
+ function createE2bKeepAlive(sandbox, opts) {
264
+ if (opts === void 0) return;
265
+ if (sandbox.setTimeout === void 0) throw new Error(KEEPALIVE_UNSUPPORTED_MESSAGE);
266
+ return (0, _runko_core.createKeepAlive)(createDriver(sandbox), opts);
267
+ }
268
+ //#endregion
269
+ //#region src/path.ts
270
+ /**
271
+ * 虚拟绝对路径 ↔ 沙盒内真实路径的锚定换算(`E2bWorkspaceOptions.root`)。
272
+ * `..` 越界的拒绝完全交给 `normalizePath` 的既有语义(抛 `PathEscapesRootError`)
273
+ * ——沙盒工作区不重复实现这层校验,只负责把规范化后的虚拟路径接到 `root` 之下。
274
+ */
275
+ function createPathAnchor(root) {
276
+ const rootReal = root === "/" ? "/" : root.replace(/\/+$/, "");
277
+ const childPrefix = rootReal === "/" ? "" : rootReal;
278
+ return {
279
+ rootReal,
280
+ toReal(virtualPath) {
281
+ const normalized = (0, _runko_virtual_fs.normalizePath)(virtualPath);
282
+ return normalized === "/" ? rootReal : `${childPrefix}${normalized}`;
283
+ },
284
+ toVirtual(realPath) {
285
+ if (realPath === rootReal) return "/";
286
+ if (childPrefix.length > 0 && realPath.startsWith(`${childPrefix}/`)) return realPath.slice(childPrefix.length);
287
+ return realPath;
288
+ }
289
+ };
290
+ }
291
+ //#endregion
292
+ //#region src/workspace.ts
293
+ /** e2b 官方模板默认的登录用户主目录——多数模板下也是命令的默认 cwd。 */
294
+ const DEFAULT_ROOT = "/home/user";
295
+ function e2bWorkspace(sandbox, opts = {}) {
296
+ const anchor = createPathAnchor(opts.root ?? DEFAULT_ROOT);
297
+ const keepAlive = createE2bKeepAlive(sandbox, opts.keepAlive);
298
+ const workspace = {
299
+ ...createE2bFs(sandbox, anchor),
300
+ ...createE2bExec(sandbox, anchor, keepAlive)
301
+ };
302
+ if (keepAlive !== void 0) {
303
+ workspace.onActivity = (signal) => keepAlive.onActivity(signal);
304
+ workspace.keepAlive = (targetMs) => keepAlive.keepAlive(targetMs);
305
+ }
306
+ return workspace;
307
+ }
308
+ //#endregion
309
+ exports.KEEPALIVE_UNSUPPORTED_MESSAGE = KEEPALIVE_UNSUPPORTED_MESSAGE;
310
+ exports.e2bWorkspace = e2bWorkspace;
@@ -0,0 +1,105 @@
1
+ import { KeepAliveOptions, RunkoActivityAware, RunkoExec, RunkoFS, RunkoKeepAliveCapable } from "@runko/core";
2
+ //#region src/types.d.ts
3
+ /**
4
+ * `e2b` SDK 面的最小结构化子集(docs/tech/core-sdk.md §4.5a 施工依据 docs/tech/sandbox.md §8.1
5
+ * "依赖策略"):只声明适配器实际调用到的方法/字段,以官方 d.ts
6
+ * (`e2b@2.32.0`,`node_modules/e2b/dist/index.d.ts`)为蓝本手写。`e2bWorkspace()`
7
+ * 收的是这份结构接口而非 `import("e2b").Sandbox` 具体类——宿主与本包各自安装的
8
+ * `e2b` 副本互不知晓对方存在,结构类型让真实 `Sandbox` 免转换即可传入
9
+ * (见 `test/type-conformance.ts` 的对照证明),也让契约测试免真实网络即可
10
+ * 全覆盖(`test/helpers.ts` 的 `FakeE2bSandbox` 实现同一接口)。
11
+ */
12
+ /**
13
+ * 对应真实 `EntryInfo`(`Filesystem.list/getInfo` 的返回值)。`type` 字段真实
14
+ * 类型是 e2b 的字符串枚举 `FileType`;这里放宽成 `string` 而非照抄该枚举——
15
+ * 枚举成员本就能单向宽化赋值给 `string`,没有必要为了对照真实类型而在这个
16
+ * "结构最小面"里引入一个额外的类型名。运行时按字面量值 `"file"` 比较即可
17
+ * (非 `"file"` 一律视为目录,见 `fs.ts`)。
18
+ */
19
+ interface E2bEntryInfo {
20
+ name: string;
21
+ type?: string;
22
+ path: string;
23
+ size: number;
24
+ modifiedTime?: Date;
25
+ }
26
+ /** `Filesystem.write()` 的返回值:适配器不消费它,只声明会被结构对照到的字段。 */
27
+ interface E2bWriteInfo {
28
+ name: string;
29
+ path: string;
30
+ }
31
+ interface E2bFilesystemListOpts {
32
+ depth?: number;
33
+ }
34
+ interface E2bFilesystemLike {
35
+ read(path: string, opts: {
36
+ format: "bytes";
37
+ }): Promise<Uint8Array>;
38
+ /**
39
+ * 真实 `Filesystem.write()` 接受 `string | ArrayBuffer | Blob | ReadableStream`
40
+ * ——**不**接受 `Uint8Array` 直接传入。`RunkoFS.writeFile` 的入参是
41
+ * `Uint8Array | string`,所以适配器(`fs.ts`)在调用这里之前把 `Uint8Array`
42
+ * 转成 `ArrayBuffer`(拷贝一份,避免 `byteOffset`/子视图语义出错)。
43
+ */
44
+ write(path: string, data: string | ArrayBuffer): Promise<E2bWriteInfo>;
45
+ list(path: string, opts?: E2bFilesystemListOpts): Promise<E2bEntryInfo[]>;
46
+ remove(path: string): Promise<void>;
47
+ makeDir(path: string): Promise<boolean>;
48
+ getInfo(path: string): Promise<E2bEntryInfo>;
49
+ }
50
+ interface E2bCommandResult {
51
+ exitCode: number;
52
+ stdout: string;
53
+ stderr: string;
54
+ }
55
+ interface E2bCommandRunOpts {
56
+ cwd?: string;
57
+ timeoutMs?: number;
58
+ onStdout?: (data: string) => void;
59
+ onStderr?: (data: string) => void;
60
+ }
61
+ interface E2bCommandsLike {
62
+ run(command: string, opts?: E2bCommandRunOpts): Promise<E2bCommandResult>;
63
+ }
64
+ /**
65
+ * `e2bWorkspace()` 接受的最小面——BYO 已创建好的 e2b `Sandbox` 实例即满足这个形状。
66
+ *
67
+ * `setTimeout` 是**可选**的,这一点是刻意的:它只有开[保活](../../../docs/terms.md)
68
+ * 时才用得上,而把它列成必填会当场打死所有手写 fake(`examples/09` 的假沙盒、
69
+ * `test/helpers.ts` 的 `FakeE2bSandbox`),违背 [BYO 实例](../../../docs/terms.md)
70
+ * 与「最小结构面」两条纪律。真实 `Sandbox` 天然带这个方法,所以宿主零改动即可获得能力。
71
+ */
72
+ interface E2bSandboxLike {
73
+ files: E2bFilesystemLike;
74
+ commands: E2bCommandsLike;
75
+ /**
76
+ * 把沙盒的存活时长**重置**为「从现在起 `timeoutMs`」(不是加时——E2B 与 Vercel
77
+ * 在这里语义相反,见 docs/tech/sandbox-keepalive.md §1)。
78
+ *
79
+ * 上限:Pro 账户 24 小时、Hobby 账户 1 小时(`e2b@2.32.0` 的 `Sandbox.setTimeout`
80
+ * 文档注释),超了会报错。
81
+ */
82
+ setTimeout?(timeoutMs: number): Promise<void>;
83
+ }
84
+ //#endregion
85
+ //#region src/workspace.d.ts
86
+ interface E2bWorkspaceOptions {
87
+ /** 沙盒内锚定虚拟根 `/` 的真实目录,默认 `/home/user`。 */
88
+ root?: string;
89
+ /**
90
+ * 开启[保活](../../../docs/terms.md)。**不传 = 不保活**(默认行为不变)。
91
+ *
92
+ * `idleTimeoutMs` 应与建盒时 `Sandbox.create({ timeoutMs })` 的值一致——两处不一致
93
+ * 很难查(比如建盒 5 分钟、这里按 10 分钟补足,就永远补不上去)。
94
+ */
95
+ keepAlive?: KeepAliveOptions;
96
+ }
97
+ /** `e2bWorkspace()` 的返回形状:工作区两面 + 两个可选的保活面(只在开了 `keepAlive` 时真的存在)。 */
98
+ type E2bWorkspace = RunkoFS & RunkoExec & RunkoActivityAware & RunkoKeepAliveCapable;
99
+ declare function e2bWorkspace(sandbox: E2bSandboxLike, opts?: E2bWorkspaceOptions): E2bWorkspace;
100
+ //#endregion
101
+ //#region src/keepalive.d.ts
102
+ /** 沙盒实例没有 `setTimeout` 时的指引——出现在这里说明宿主传了保活配置,但传进来的对象补不了期。 */
103
+ declare const KEEPALIVE_UNSUPPORTED_MESSAGE: string;
104
+ //#endregion
105
+ export { type E2bCommandResult, type E2bCommandRunOpts, type E2bCommandsLike, type E2bEntryInfo, type E2bFilesystemLike, type E2bFilesystemListOpts, type E2bSandboxLike, type E2bWorkspace, type E2bWorkspaceOptions, type E2bWriteInfo, KEEPALIVE_UNSUPPORTED_MESSAGE, e2bWorkspace };
@@ -0,0 +1,105 @@
1
+ import { KeepAliveOptions, RunkoActivityAware, RunkoExec, RunkoFS, RunkoKeepAliveCapable } from "@runko/core";
2
+ //#region src/types.d.ts
3
+ /**
4
+ * `e2b` SDK 面的最小结构化子集(docs/tech/core-sdk.md §4.5a 施工依据 docs/tech/sandbox.md §8.1
5
+ * "依赖策略"):只声明适配器实际调用到的方法/字段,以官方 d.ts
6
+ * (`e2b@2.32.0`,`node_modules/e2b/dist/index.d.ts`)为蓝本手写。`e2bWorkspace()`
7
+ * 收的是这份结构接口而非 `import("e2b").Sandbox` 具体类——宿主与本包各自安装的
8
+ * `e2b` 副本互不知晓对方存在,结构类型让真实 `Sandbox` 免转换即可传入
9
+ * (见 `test/type-conformance.ts` 的对照证明),也让契约测试免真实网络即可
10
+ * 全覆盖(`test/helpers.ts` 的 `FakeE2bSandbox` 实现同一接口)。
11
+ */
12
+ /**
13
+ * 对应真实 `EntryInfo`(`Filesystem.list/getInfo` 的返回值)。`type` 字段真实
14
+ * 类型是 e2b 的字符串枚举 `FileType`;这里放宽成 `string` 而非照抄该枚举——
15
+ * 枚举成员本就能单向宽化赋值给 `string`,没有必要为了对照真实类型而在这个
16
+ * "结构最小面"里引入一个额外的类型名。运行时按字面量值 `"file"` 比较即可
17
+ * (非 `"file"` 一律视为目录,见 `fs.ts`)。
18
+ */
19
+ interface E2bEntryInfo {
20
+ name: string;
21
+ type?: string;
22
+ path: string;
23
+ size: number;
24
+ modifiedTime?: Date;
25
+ }
26
+ /** `Filesystem.write()` 的返回值:适配器不消费它,只声明会被结构对照到的字段。 */
27
+ interface E2bWriteInfo {
28
+ name: string;
29
+ path: string;
30
+ }
31
+ interface E2bFilesystemListOpts {
32
+ depth?: number;
33
+ }
34
+ interface E2bFilesystemLike {
35
+ read(path: string, opts: {
36
+ format: "bytes";
37
+ }): Promise<Uint8Array>;
38
+ /**
39
+ * 真实 `Filesystem.write()` 接受 `string | ArrayBuffer | Blob | ReadableStream`
40
+ * ——**不**接受 `Uint8Array` 直接传入。`RunkoFS.writeFile` 的入参是
41
+ * `Uint8Array | string`,所以适配器(`fs.ts`)在调用这里之前把 `Uint8Array`
42
+ * 转成 `ArrayBuffer`(拷贝一份,避免 `byteOffset`/子视图语义出错)。
43
+ */
44
+ write(path: string, data: string | ArrayBuffer): Promise<E2bWriteInfo>;
45
+ list(path: string, opts?: E2bFilesystemListOpts): Promise<E2bEntryInfo[]>;
46
+ remove(path: string): Promise<void>;
47
+ makeDir(path: string): Promise<boolean>;
48
+ getInfo(path: string): Promise<E2bEntryInfo>;
49
+ }
50
+ interface E2bCommandResult {
51
+ exitCode: number;
52
+ stdout: string;
53
+ stderr: string;
54
+ }
55
+ interface E2bCommandRunOpts {
56
+ cwd?: string;
57
+ timeoutMs?: number;
58
+ onStdout?: (data: string) => void;
59
+ onStderr?: (data: string) => void;
60
+ }
61
+ interface E2bCommandsLike {
62
+ run(command: string, opts?: E2bCommandRunOpts): Promise<E2bCommandResult>;
63
+ }
64
+ /**
65
+ * `e2bWorkspace()` 接受的最小面——BYO 已创建好的 e2b `Sandbox` 实例即满足这个形状。
66
+ *
67
+ * `setTimeout` 是**可选**的,这一点是刻意的:它只有开[保活](../../../docs/terms.md)
68
+ * 时才用得上,而把它列成必填会当场打死所有手写 fake(`examples/09` 的假沙盒、
69
+ * `test/helpers.ts` 的 `FakeE2bSandbox`),违背 [BYO 实例](../../../docs/terms.md)
70
+ * 与「最小结构面」两条纪律。真实 `Sandbox` 天然带这个方法,所以宿主零改动即可获得能力。
71
+ */
72
+ interface E2bSandboxLike {
73
+ files: E2bFilesystemLike;
74
+ commands: E2bCommandsLike;
75
+ /**
76
+ * 把沙盒的存活时长**重置**为「从现在起 `timeoutMs`」(不是加时——E2B 与 Vercel
77
+ * 在这里语义相反,见 docs/tech/sandbox-keepalive.md §1)。
78
+ *
79
+ * 上限:Pro 账户 24 小时、Hobby 账户 1 小时(`e2b@2.32.0` 的 `Sandbox.setTimeout`
80
+ * 文档注释),超了会报错。
81
+ */
82
+ setTimeout?(timeoutMs: number): Promise<void>;
83
+ }
84
+ //#endregion
85
+ //#region src/workspace.d.ts
86
+ interface E2bWorkspaceOptions {
87
+ /** 沙盒内锚定虚拟根 `/` 的真实目录,默认 `/home/user`。 */
88
+ root?: string;
89
+ /**
90
+ * 开启[保活](../../../docs/terms.md)。**不传 = 不保活**(默认行为不变)。
91
+ *
92
+ * `idleTimeoutMs` 应与建盒时 `Sandbox.create({ timeoutMs })` 的值一致——两处不一致
93
+ * 很难查(比如建盒 5 分钟、这里按 10 分钟补足,就永远补不上去)。
94
+ */
95
+ keepAlive?: KeepAliveOptions;
96
+ }
97
+ /** `e2bWorkspace()` 的返回形状:工作区两面 + 两个可选的保活面(只在开了 `keepAlive` 时真的存在)。 */
98
+ type E2bWorkspace = RunkoFS & RunkoExec & RunkoActivityAware & RunkoKeepAliveCapable;
99
+ declare function e2bWorkspace(sandbox: E2bSandboxLike, opts?: E2bWorkspaceOptions): E2bWorkspace;
100
+ //#endregion
101
+ //#region src/keepalive.d.ts
102
+ /** 沙盒实例没有 `setTimeout` 时的指引——出现在这里说明宿主传了保活配置,但传进来的对象补不了期。 */
103
+ declare const KEEPALIVE_UNSUPPORTED_MESSAGE: string;
104
+ //#endregion
105
+ export { type E2bCommandResult, type E2bCommandRunOpts, type E2bCommandsLike, type E2bEntryInfo, type E2bFilesystemLike, type E2bFilesystemListOpts, type E2bSandboxLike, type E2bWorkspace, type E2bWorkspaceOptions, type E2bWriteInfo, KEEPALIVE_UNSUPPORTED_MESSAGE, e2bWorkspace };
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ import { DirectoryNotEmptyError, NotFoundError, matchesGlob, normalizePath } from "@runko/virtual-fs";
2
+ import { createKeepAlive } from "@runko/core";
3
+ //#region src/errors.ts
4
+ /**
5
+ * e2b 错误的结构判别(docs/tech/sandbox.md §8.1:"错误识别一律结构判别不用 instanceof"——
6
+ * 宿主与本包各自安装的 `e2b` 副本类不同一,`instanceof` 天然不可靠)。
7
+ * `instanceof Error` 本身没问题(`Error` 是当前 realm 的全局内置类,不是
8
+ * e2b 的导出类),只有对 e2b 自家导出的错误类才不能用 instanceof。
9
+ */
10
+ /** e2b 的具名错误(`SandboxError` 及其子类)都在构造函数里把 `this.name` 设成子类名——按这个字符串字段判别,不按类名/`instanceof`。 */
11
+ function isE2bErrorNamed(error, ...names) {
12
+ return error instanceof Error && names.includes(error.name);
13
+ }
14
+ function describeError(error) {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+ function isCommandExitErrorLike(error) {
18
+ if (typeof error !== "object" || error === null) return false;
19
+ if (!("exitCode" in error) || typeof error.exitCode !== "number") return false;
20
+ if (!("stdout" in error) || typeof error.stdout !== "string") return false;
21
+ if (!("stderr" in error) || typeof error.stderr !== "string") return false;
22
+ return true;
23
+ }
24
+ //#endregion
25
+ //#region src/exec.ts
26
+ const DESCRIBE = [
27
+ "e2b: a real Firecracker microVM sandbox (E2B cloud) — full Linux userspace, not a virtual filesystem.",
28
+ "Mode A same-source workspace: this RunkoFS view and bash commands share the exact same filesystem inside",
29
+ "the sandbox, so there is nothing to reconcile and no snapshot lag between the two.",
30
+ "The sandbox is a real machine: commands can read, write, and cd anywhere on its disk, not just under this",
31
+ "workspace's root — isolation (the whole VM is disposable) is the security boundary here, not path confinement",
32
+ "the way an in-process VirtualFS enforces it.",
33
+ "Each RunkoFS file-tool call is a network round trip (tens to hundreds of milliseconds). Prefer running",
34
+ "scan-heavy work (grep/find over many files, recursive listings) as a single bash command inside the sandbox",
35
+ "rather than many individual file-tool calls."
36
+ ].join(" ");
37
+ var E2bExecAbortedError = class extends Error {
38
+ constructor(reason) {
39
+ super(reason === "timeout" ? "e2b: command timed out" : "e2b: aborted");
40
+ this.name = "E2bExecAbortedError";
41
+ }
42
+ };
43
+ /**
44
+ * `signal` 先触发就立刻 reject,不等待 `work`(远程 `commands.run()`)真正
45
+ * 落定——`work` 可能因为网络问题永久悬挂,这正是需要独立赛跑的原因。给
46
+ * `work` 补一个空 catch,避免它在赛跑结束后才 reject 时产生 unhandled
47
+ * rejection(`@runko/just-bash`/`@runko/mini-bash` 的同款 `raceAbort`)。
48
+ */
49
+ function raceAbort(work, signal) {
50
+ return new Promise((resolve, reject) => {
51
+ const settleAborted = () => {
52
+ work.catch(() => {});
53
+ reject(new E2bExecAbortedError("signal"));
54
+ };
55
+ if (signal.aborted) {
56
+ settleAborted();
57
+ return;
58
+ }
59
+ const onAbort = () => settleAborted();
60
+ signal.addEventListener("abort", onAbort, { once: true });
61
+ work.then((value) => {
62
+ signal.removeEventListener("abort", onAbort);
63
+ resolve(value);
64
+ }, (error) => {
65
+ signal.removeEventListener("abort", onAbort);
66
+ reject(error instanceof Error ? error : new Error(String(error)));
67
+ });
68
+ });
69
+ }
70
+ function createE2bExec(sandbox, anchor, keepAlive) {
71
+ return {
72
+ defaultApproval: "allow",
73
+ describe() {
74
+ return DESCRIBE;
75
+ },
76
+ async exec(req, opts) {
77
+ const start = Date.now();
78
+ const cwd = req.cwd !== void 0 ? anchor.toReal(req.cwd) : anchor.rootReal;
79
+ const timeoutController = new AbortController();
80
+ const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
81
+ const combined = AbortSignal.any([req.signal, timeoutController.signal]);
82
+ /**
83
+ * [保活](../../../docs/terms.md)的第二个信号源(docs/tech/sandbox-keepalive.md §3.1):
84
+ * 命令跑起来之后 core **一个 chunk 都不会产出**——`@runko/core` 的 `loop.ts`
85
+ * 把工具进度先缓冲、等命令 resolve 之后才重放。所以一条跑十分钟的命令,这十分钟
86
+ * 里 core 侧的活动信号是零。而这恰恰是最容易把沙盒跑没的场景。
87
+ *
88
+ * 补法就在这里:适配器自己就是那个在 `await` 的人,手里握着一个没落定的远程调用,
89
+ * 这是比 chunk 更硬的「还活着」证据,根本不需要谁来通知。
90
+ */
91
+ const stopKeepAlive = keepAlive?.beginExec();
92
+ const work = sandbox.commands.run(req.command, {
93
+ cwd,
94
+ timeoutMs: req.timeoutMs,
95
+ onStdout: (data) => opts?.onOutput?.({
96
+ stream: "stdout",
97
+ data
98
+ }),
99
+ onStderr: (data) => opts?.onOutput?.({
100
+ stream: "stderr",
101
+ data
102
+ })
103
+ });
104
+ try {
105
+ const result = await raceAbort(work, combined);
106
+ return {
107
+ exitCode: result.exitCode,
108
+ stdout: result.stdout,
109
+ stderr: result.stderr,
110
+ durationMs: Date.now() - start
111
+ };
112
+ } catch (error) {
113
+ if (error instanceof E2bExecAbortedError) {
114
+ const timedOut = timeoutController.signal.aborted;
115
+ return {
116
+ exitCode: timedOut ? 124 : 130,
117
+ stdout: "",
118
+ stderr: timedOut ? `e2b: command timed out after ${String(req.timeoutMs)}ms` : "e2b: aborted",
119
+ durationMs: Date.now() - start
120
+ };
121
+ }
122
+ if (isCommandExitErrorLike(error)) return {
123
+ exitCode: error.exitCode,
124
+ stdout: error.stdout,
125
+ stderr: error.stderr,
126
+ durationMs: Date.now() - start
127
+ };
128
+ if (isE2bErrorNamed(error, "TimeoutError")) return {
129
+ exitCode: 124,
130
+ stdout: "",
131
+ stderr: `e2b: command timed out: ${describeError(error)}`,
132
+ durationMs: Date.now() - start
133
+ };
134
+ return {
135
+ exitCode: 1,
136
+ stdout: "",
137
+ stderr: `e2b: sandbox command failed: ${describeError(error)}. The sandbox may have stopped or its connection was lost (e.g. it hit its idle/session timeout) — extend the sandbox's timeout or recreate it and pass a fresh instance to e2bWorkspace().`,
138
+ durationMs: Date.now() - start
139
+ };
140
+ } finally {
141
+ if (timer !== void 0) clearTimeout(timer);
142
+ stopKeepAlive?.();
143
+ }
144
+ }
145
+ };
146
+ }
147
+ //#endregion
148
+ //#region src/fs.ts
149
+ /**
150
+ * e2b 的"未找到"错误家族:`FileNotFoundError` 是当前版本实测抛出的具体子类,
151
+ * `NotFoundError`(e2b 自家、已废弃)是历史/兼容路径可能出现的旧变体——两者
152
+ * 都翻译成 `@runko/virtual-fs` 的 `NotFoundError`(同名不同类,跨包结构判别,
153
+ * 不是 `instanceof`)。
154
+ */
155
+ const NOT_FOUND_NAMES = ["FileNotFoundError", "NotFoundError"];
156
+ /** `list(root, { depth })` 用于 glob 的递归深度——远超"典型工作区目录嵌套层数"的量级,避免深层子目录被漏扫。 */
157
+ const GLOB_LIST_DEPTH = 64;
158
+ /** `EntryInfo.type` 非 `"file"` 一律视为目录(docs/tech/sandbox.md §8.2:"FileType 非 file 视为 dir");`size` 只在文件类型下有意义,仿 MemoryFS 用条件展开省略而非填 0/undefined。 */
159
+ function toFileStat(entry) {
160
+ const type = entry.type === "file" ? "file" : "dir";
161
+ const mtime = entry.modifiedTime?.getTime();
162
+ return {
163
+ type,
164
+ ...type === "file" ? { size: entry.size } : {},
165
+ ...mtime !== void 0 ? { mtime } : {}
166
+ };
167
+ }
168
+ /** e2b 的 `write()` 不接受 `Uint8Array`(只收 `string | ArrayBuffer | Blob | ReadableStream`)——转成一份拷贝的 `ArrayBuffer`,避免子视图的 `byteOffset` 被忽略。 */
169
+ function toArrayBuffer(data) {
170
+ return data.slice().buffer;
171
+ }
172
+ function createE2bFs(sandbox, anchor) {
173
+ return {
174
+ async readFile(path) {
175
+ const real = anchor.toReal(path);
176
+ try {
177
+ return await sandbox.files.read(real, { format: "bytes" });
178
+ } catch (error) {
179
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new NotFoundError(path);
180
+ throw error;
181
+ }
182
+ },
183
+ async writeFile(path, data) {
184
+ const payload = typeof data === "string" ? data : toArrayBuffer(data);
185
+ await sandbox.files.write(anchor.toReal(path), payload);
186
+ },
187
+ async rm(path, opts) {
188
+ const real = anchor.toReal(path);
189
+ let info;
190
+ try {
191
+ info = await sandbox.files.getInfo(real);
192
+ } catch (error) {
193
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new NotFoundError(path);
194
+ throw error;
195
+ }
196
+ if (opts?.recursive !== true && info.type !== "file") {
197
+ if ((await sandbox.files.list(real, { depth: 1 })).length > 0) throw new DirectoryNotEmptyError(path);
198
+ }
199
+ await sandbox.files.remove(real);
200
+ },
201
+ async mkdir(path) {
202
+ await sandbox.files.makeDir(anchor.toReal(path));
203
+ },
204
+ async readdir(path) {
205
+ const real = anchor.toReal(path);
206
+ let entries;
207
+ try {
208
+ entries = await sandbox.files.list(real, { depth: 1 });
209
+ } catch (error) {
210
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new NotFoundError(path);
211
+ throw error;
212
+ }
213
+ return entries.map((entry) => ({
214
+ name: entry.name,
215
+ ...toFileStat(entry)
216
+ })).sort((a, b) => a.name.localeCompare(b.name));
217
+ },
218
+ async stat(path) {
219
+ const real = anchor.toReal(path);
220
+ try {
221
+ return toFileStat(await sandbox.files.getInfo(real));
222
+ } catch (error) {
223
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new NotFoundError(path);
224
+ throw error;
225
+ }
226
+ },
227
+ async glob(pattern) {
228
+ let entries;
229
+ try {
230
+ entries = await sandbox.files.list(anchor.rootReal, { depth: GLOB_LIST_DEPTH });
231
+ } catch (error) {
232
+ if (isE2bErrorNamed(error, ...NOT_FOUND_NAMES)) throw new NotFoundError("/");
233
+ throw error;
234
+ }
235
+ const matches = [];
236
+ for (const entry of entries) {
237
+ if (entry.type !== "file") continue;
238
+ const virtual = anchor.toVirtual(entry.path);
239
+ if (matchesGlob(pattern, virtual)) matches.push(virtual);
240
+ }
241
+ return matches.sort();
242
+ }
243
+ };
244
+ }
245
+ //#endregion
246
+ //#region src/keepalive.ts
247
+ /** 沙盒实例没有 `setTimeout` 时的指引——出现在这里说明宿主传了保活配置,但传进来的对象补不了期。 */
248
+ const KEEPALIVE_UNSUPPORTED_MESSAGE = "e2bWorkspace({ keepAlive }) needs the sandbox instance to expose setTimeout(timeoutMs) — a real e2b Sandbox does; a hand-written fake may not. Either pass a real sandbox, add setTimeout to the fake, or drop the keepAlive option (keepalive is entirely opt-in).";
249
+ function createDriver(sandbox) {
250
+ return {
251
+ remainingMs: async () => void 0,
252
+ renew: async (targetMs) => {
253
+ if (sandbox.setTimeout === void 0) throw new Error(KEEPALIVE_UNSUPPORTED_MESSAGE);
254
+ await sandbox.setTimeout(targetMs);
255
+ }
256
+ };
257
+ }
258
+ /**
259
+ * 造一个绑好这个沙盒的保活器。`opts` 为 `undefined`(宿主没开保活)时返回
260
+ * `undefined`——调用方据此整段跳过,**不开就是一次网络调用都不会发生**。
261
+ */
262
+ function createE2bKeepAlive(sandbox, opts) {
263
+ if (opts === void 0) return;
264
+ if (sandbox.setTimeout === void 0) throw new Error(KEEPALIVE_UNSUPPORTED_MESSAGE);
265
+ return createKeepAlive(createDriver(sandbox), opts);
266
+ }
267
+ //#endregion
268
+ //#region src/path.ts
269
+ /**
270
+ * 虚拟绝对路径 ↔ 沙盒内真实路径的锚定换算(`E2bWorkspaceOptions.root`)。
271
+ * `..` 越界的拒绝完全交给 `normalizePath` 的既有语义(抛 `PathEscapesRootError`)
272
+ * ——沙盒工作区不重复实现这层校验,只负责把规范化后的虚拟路径接到 `root` 之下。
273
+ */
274
+ function createPathAnchor(root) {
275
+ const rootReal = root === "/" ? "/" : root.replace(/\/+$/, "");
276
+ const childPrefix = rootReal === "/" ? "" : rootReal;
277
+ return {
278
+ rootReal,
279
+ toReal(virtualPath) {
280
+ const normalized = normalizePath(virtualPath);
281
+ return normalized === "/" ? rootReal : `${childPrefix}${normalized}`;
282
+ },
283
+ toVirtual(realPath) {
284
+ if (realPath === rootReal) return "/";
285
+ if (childPrefix.length > 0 && realPath.startsWith(`${childPrefix}/`)) return realPath.slice(childPrefix.length);
286
+ return realPath;
287
+ }
288
+ };
289
+ }
290
+ //#endregion
291
+ //#region src/workspace.ts
292
+ /** e2b 官方模板默认的登录用户主目录——多数模板下也是命令的默认 cwd。 */
293
+ const DEFAULT_ROOT = "/home/user";
294
+ function e2bWorkspace(sandbox, opts = {}) {
295
+ const anchor = createPathAnchor(opts.root ?? DEFAULT_ROOT);
296
+ const keepAlive = createE2bKeepAlive(sandbox, opts.keepAlive);
297
+ const workspace = {
298
+ ...createE2bFs(sandbox, anchor),
299
+ ...createE2bExec(sandbox, anchor, keepAlive)
300
+ };
301
+ if (keepAlive !== void 0) {
302
+ workspace.onActivity = (signal) => keepAlive.onActivity(signal);
303
+ workspace.keepAlive = (targetMs) => keepAlive.keepAlive(targetMs);
304
+ }
305
+ return workspace;
306
+ }
307
+ //#endregion
308
+ export { KEEPALIVE_UNSUPPORTED_MESSAGE, e2bWorkspace };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@runko/sandbox-e2b",
3
+ "version": "0.1.0",
4
+ "description": "RunkoFS & RunkoExec 同源工作区:适配 E2B 云沙盒(e2b SDK 仅类型依赖,BYO sandbox 实例)",
5
+ "type": "module",
6
+ "license": "ISC",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "main": "./dist/index.cjs",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "import": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "require": {
24
+ "types": "./dist/index.d.cts",
25
+ "default": "./dist/index.cjs"
26
+ }
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@runko/core": "^0.1.0",
35
+ "@runko/virtual-fs": "^0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^24.13.3",
39
+ "ai": "^7.0.20",
40
+ "e2b": "^2.32.0",
41
+ "tsdown": "^0.22.4",
42
+ "typescript": "^7.0.2",
43
+ "vitest": "^4.1.10"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "typecheck": "tsc --noEmit",
48
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
49
+ "lint:fix": "eslint . --fix",
50
+ "test": "vitest run"
51
+ }
52
+ }