@capybara-hq/plugin-sandbox-e2b 0.0.0-stage → 0.1.0-rc.1

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/dist/index.mjs ADDED
@@ -0,0 +1,806 @@
1
+ import { createRequire as __capybaraCreateRequire } from "node:module";
2
+ import { fileURLToPath as __capybaraFileURLToPath } from "node:url";
3
+ import { dirname as __capybaraPathDirname } from "node:path";
4
+ const require = __capybaraCreateRequire(import.meta.url);
5
+ const __capybara_filename = __capybaraFileURLToPath(import.meta.url);
6
+ const __capybara_dirname = __capybaraPathDirname(__capybara_filename);
7
+
8
+ // src/index.ts
9
+ import { defineExtension, SANDBOX_EXTENSION_POINT, secret, ui } from "@capybara-hq/plugin-sdk";
10
+ import { z } from "zod";
11
+
12
+ // src/provider.ts
13
+ import { randomUUID } from "node:crypto";
14
+ import {
15
+ SANDBOX_TAG_RE,
16
+ SandboxGoneError,
17
+ validateAllowHost,
18
+ validateSandboxPath
19
+ } from "@capybara-hq/plugin-sdk";
20
+
21
+ // src/e2b-api.ts
22
+ var ENVD_PORT = 49983;
23
+ var API_TIMEOUT_MS = 6e4;
24
+ var KEEPALIVE_SECONDS = 50;
25
+ var E2BHttpError = class extends Error {
26
+ constructor(status, message, code) {
27
+ super(message);
28
+ this.status = status;
29
+ this.code = code;
30
+ this.name = "E2BHttpError";
31
+ }
32
+ status;
33
+ code;
34
+ };
35
+ function isStatus(e, ...status) {
36
+ return e instanceof E2BHttpError && status.includes(e.status);
37
+ }
38
+ function isE2BNotFound(e) {
39
+ return e instanceof E2BHttpError && e.status === 404 && e.code === 404;
40
+ }
41
+ function abortReason(signal) {
42
+ return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
43
+ }
44
+ function rethrow(e, signal) {
45
+ if (signal.aborted) throw abortReason(signal);
46
+ throw e;
47
+ }
48
+ async function errorOf(res, what) {
49
+ let message = "";
50
+ let code;
51
+ try {
52
+ const text = await res.text();
53
+ try {
54
+ const j = JSON.parse(text);
55
+ message = typeof j.message === "string" ? j.message : text;
56
+ if (typeof j.code === "number" || typeof j.code === "string") code = j.code;
57
+ } catch {
58
+ message = text;
59
+ }
60
+ } catch {
61
+ }
62
+ return new E2BHttpError(
63
+ res.status,
64
+ `E2B ${what} failed: HTTP ${res.status}${message ? `: ${message.slice(0, 300)}` : ""}`,
65
+ code
66
+ );
67
+ }
68
+ function createE2BApi(apiUrl, apiKey) {
69
+ const base = apiUrl.replace(/\/+$/, "");
70
+ return {
71
+ async json(method, path, opts) {
72
+ opts.signal.throwIfAborted();
73
+ const url = new URL(base + path);
74
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
75
+ if (v !== void 0) url.searchParams.set(k, v);
76
+ }
77
+ let res;
78
+ try {
79
+ res = await fetch(url, {
80
+ method,
81
+ headers: {
82
+ "X-API-KEY": apiKey,
83
+ ...opts.body !== void 0 && { "content-type": "application/json" }
84
+ },
85
+ ...opts.body !== void 0 && { body: JSON.stringify(opts.body) },
86
+ redirect: "error",
87
+ signal: AbortSignal.any([opts.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
88
+ });
89
+ } catch (e) {
90
+ rethrow(e, opts.signal);
91
+ }
92
+ if (!res.ok) throw await errorOf(res, `${method} ${path}`);
93
+ const text = await res.text().catch((e) => rethrow(e, opts.signal));
94
+ return {
95
+ status: res.status,
96
+ data: text === "" ? void 0 : JSON.parse(text),
97
+ headers: res.headers
98
+ };
99
+ }
100
+ };
101
+ }
102
+ var basic = (user) => `Basic ${Buffer.from(`${user}:`).toString("base64")}`;
103
+ function envdHeaders(t, extra = {}) {
104
+ return {
105
+ "E2b-Sandbox-Id": t.sandboxId,
106
+ "E2b-Sandbox-Port": String(ENVD_PORT),
107
+ "X-Access-Token": t.accessToken,
108
+ ...extra
109
+ };
110
+ }
111
+ function envelope(msg) {
112
+ const json = Buffer.from(JSON.stringify(msg), "utf8");
113
+ const out = Buffer.alloc(5 + json.byteLength);
114
+ out.writeUInt8(0, 0);
115
+ out.writeUInt32BE(json.byteLength, 1);
116
+ json.copy(out, 5);
117
+ return out;
118
+ }
119
+ async function envdUpload(t, path, user, data, signal) {
120
+ const url = new URL(`${t.url}/files`);
121
+ url.searchParams.set("path", path);
122
+ url.searchParams.set("username", user);
123
+ const form = new FormData();
124
+ form.append("file", new Blob([new Uint8Array(data)]), path.split("/").pop() ?? "file");
125
+ let res;
126
+ try {
127
+ res = await fetch(url, {
128
+ method: "POST",
129
+ headers: envdHeaders(t),
130
+ body: form,
131
+ redirect: "error",
132
+ signal
133
+ });
134
+ } catch (e) {
135
+ rethrow(e, signal);
136
+ }
137
+ if (!res.ok) throw await errorOf(res, "file upload");
138
+ await res.body?.cancel().catch(() => void 0);
139
+ }
140
+ async function envdKill(t, pid, signal) {
141
+ let res;
142
+ try {
143
+ res = await fetch(`${t.url}/process.Process/SendSignal`, {
144
+ method: "POST",
145
+ headers: envdHeaders(t, {
146
+ "content-type": "application/json",
147
+ "Connect-Protocol-Version": "1"
148
+ }),
149
+ body: JSON.stringify({ process: { pid }, signal: "SIGNAL_SIGKILL" }),
150
+ redirect: "error",
151
+ signal
152
+ });
153
+ } catch (e) {
154
+ rethrow(e, signal);
155
+ }
156
+ if (!res.ok) {
157
+ const e = await errorOf(res, "process signal");
158
+ if (e.status === 404 && e.code === "not_found") return;
159
+ throw e;
160
+ }
161
+ await res.body?.cancel().catch(() => void 0);
162
+ }
163
+ async function envdStart(t, user, req, onEvent, signal) {
164
+ let res;
165
+ try {
166
+ res = await fetch(`${t.url}/process.Process/Start`, {
167
+ method: "POST",
168
+ headers: envdHeaders(t, {
169
+ "content-type": "application/connect+json",
170
+ "Connect-Protocol-Version": "1",
171
+ Authorization: basic(user),
172
+ "Keepalive-Ping-Interval": String(KEEPALIVE_SECONDS)
173
+ }),
174
+ body: envelope({ process: req, stdin: false }),
175
+ redirect: "error",
176
+ signal
177
+ });
178
+ } catch (e) {
179
+ rethrow(e, signal);
180
+ }
181
+ if (res.status !== 200 || !res.body) throw await errorOf(res, "process start");
182
+ const reader = res.body.getReader();
183
+ let buf = Buffer.alloc(0);
184
+ try {
185
+ for (; ; ) {
186
+ const { done, value } = await reader.read();
187
+ if (done) break;
188
+ buf = buf.byteLength === 0 ? Buffer.from(value) : Buffer.concat([buf, value]);
189
+ while (buf.byteLength >= 5) {
190
+ const len = buf.readUInt32BE(1);
191
+ if (buf.byteLength < 5 + len) break;
192
+ const flags = buf.readUInt8(0);
193
+ const msg = JSON.parse(buf.subarray(5, 5 + len).toString("utf8"));
194
+ buf = buf.subarray(5 + len);
195
+ if (flags & 2) {
196
+ if (msg.error) {
197
+ throw new Error(
198
+ `E2B process stream failed: ${msg.error.code ?? "unknown"}: ${msg.error.message ?? ""}`
199
+ );
200
+ }
201
+ return;
202
+ }
203
+ if (msg.event) onEvent(msg.event);
204
+ }
205
+ }
206
+ } catch (e) {
207
+ await reader.cancel().catch(() => void 0);
208
+ rethrow(e, signal);
209
+ }
210
+ throw new Error("E2B process stream ended without an end-of-stream message");
211
+ }
212
+
213
+ // src/provider.ts
214
+ var METADATA_NAME = "capybara-sandbox-name";
215
+ var SANDBOX_USER = "sandbox";
216
+ var ROOT_USER = "root";
217
+ var UPLOAD_DIR = "/opt/capybara/upload";
218
+ var MAX_TIMEOUT_SECONDS = 86400;
219
+ var TIMEOUT_MARGIN_SECONDS = 120;
220
+ var KILL_GRACE_MS = 3e3;
221
+ var STDERR_ROOM = 64 * 1024;
222
+ var FILE_OP_TIMEOUT_MS = 12e4;
223
+ var CONNECT_CONFLICT_RETRIES = 5;
224
+ var CONNECT_CONFLICT_WAIT_MS = 500;
225
+ var LIST_LIMIT = 100;
226
+ var NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/;
227
+ var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
228
+ var HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
229
+ var SANDBOX_ID_RE = /^[a-zA-Z0-9-]{1,128}$/;
230
+ var IN_SANDBOX_GRACE_SECONDS = 5;
231
+ var EXEC_WRAPPER = 'c=$1; echo $$ > "$c.pid" 2>/dev/null; if [ -e "$c.kill" ]; then exit 137; fi; timeout -s KILL "$3" /bin/bash -c "$2"; s=$?; rm -f "$c.pid"; exit $s';
232
+ function execArgs(ctl, command, timeoutMs) {
233
+ const seconds = Math.ceil(timeoutMs / 1e3) + IN_SANDBOX_GRACE_SECONDS;
234
+ return ["-w", "/bin/sh", "-c", EXEC_WRAPPER, "capybara-exec", ctl, command, String(seconds)];
235
+ }
236
+ var KILL_TREE = 'k() { for t in $(pgrep -P "$1"); do kill -9 -"$t" 2>/dev/null; done; kill -9 -"$1" 2>/dev/null; pkill -9 -s "$1" 2>/dev/null; }; ';
237
+ var KILL_ALL_SCRIPT = `${KILL_TREE}for q in $(pgrep -f -- "capybara-exec /tmp/[.]capybara-exec-"); do k "$q"; kill -9 "$q" 2>/dev/null; done; exit 0`;
238
+ var KILL_TAGGED_SCRIPT = `${KILL_TREE}u='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; for t in "$@"; do for q in $(pgrep -f -- "capybara-exec /tmp/[.]capybara-exec-$t-$u( |$)"); do k "$q"; kill -9 "$q" 2>/dev/null; done; done; exit 0`;
239
+ function execControlPath(tag) {
240
+ if (tag !== void 0 && !SANDBOX_TAG_RE.test(tag)) throw new Error("invalid sandbox tag");
241
+ return tag === void 0 ? `/tmp/.capybara-exec-${randomUUID()}` : `/tmp/.capybara-exec-${tag}-${randomUUID()}`;
242
+ }
243
+ var KILL_SCRIPT = `${KILL_TREE}c=$1; : > "$c.kill" 2>/dev/null; i=0; while [ ! -s "$c.pid" ] && [ $i -lt 20 ]; do sleep 0.05; i=$((i+1)); done; p=$(cat "$c.pid" 2>/dev/null); if [ -n "$p" ]; then k "$p"; rm -f "$c.kill"; fi; for q in $(pgrep -f -- "capybara-exec $c"); do k "$q"; kill -9 "$q" 2>/dev/null; done; rm -f "$c.pid"; exit 0`;
244
+ var READ_SCRIPT = 'if [ ! -e "$1" ]; then exit 3; fi; if [ -d "$1" ]; then exit 4; fi; exec head -c "$2" -- "$1"';
245
+ var STAT_SCRIPT = 'if [ ! -e "$1" ]; then exit 3; fi; s=$(stat -L -c %s -- "$1" 2>/dev/null) || s=$(($(wc -c < "$1"))); if [ -d "$1" ]; then echo "$s directory"; else echo "$s file"; fi';
246
+ var USER_WRITE_SCRIPT = `s=1; runuser -u ${SANDBOX_USER} -- /bin/sh -c 'set -e; mkdir -p -- "$(dirname -- "$1")"; cat > "$1"' capybara-write "$1" < "$2" && s=0; rm -f -- "$2"; exit $s`;
247
+ var SKILLS_WRITE_SCRIPT = 'umask 022; s=1; mkdir -p -- "$(dirname -- "$1")" && cat -- "$2" > "$1" && chmod 0644 "$1" && s=0; rm -f -- "$2"; exit $s';
248
+ function after(ms, fn) {
249
+ const t = AbortSignal.timeout(ms);
250
+ t.addEventListener("abort", fn, { once: true });
251
+ return { cancel: () => t.removeEventListener("abort", fn) };
252
+ }
253
+ function sleep(ms, signal) {
254
+ return new Promise((resolve, reject) => {
255
+ if (signal.aborted) {
256
+ reject(signal.reason);
257
+ return;
258
+ }
259
+ const onAbort = () => {
260
+ timer.cancel();
261
+ reject(signal.reason);
262
+ };
263
+ const timer = after(ms, () => {
264
+ signal.removeEventListener("abort", onAbort);
265
+ resolve();
266
+ });
267
+ signal.addEventListener("abort", onAbort, { once: true });
268
+ });
269
+ }
270
+ function checkPath(p) {
271
+ const errors = validateSandboxPath(p);
272
+ if (errors.length > 0)
273
+ throw new Error(`invalid sandbox path ${JSON.stringify(p)}: ${errors.join("; ")}`);
274
+ }
275
+ function createE2BSandbox(cfg, deps, test = {}) {
276
+ const api = createE2BApi(cfg.apiUrl ?? `https://api.${cfg.domain}`, cfg.apiKey);
277
+ const enc = encodeURIComponent;
278
+ const checkId = (id) => {
279
+ if (!SANDBOX_ID_RE.test(id)) throw new Error(`invalid E2B sandbox id: ${JSON.stringify(id)}`);
280
+ };
281
+ const targetOf = (info) => {
282
+ checkId(info.sandboxID);
283
+ const domain = info.domain || cfg.domain;
284
+ if (!HOSTNAME_RE.test(domain)) {
285
+ throw new Error(
286
+ `E2B returned an invalid sandbox domain: ${JSON.stringify(domain).slice(0, 100)}`
287
+ );
288
+ }
289
+ if (typeof info.envdAccessToken !== "string" || info.envdAccessToken === "") {
290
+ throw new SandboxGoneError(
291
+ `E2B returned no envd access token for sandbox ${info.sandboxID}; refusing to use an unauthenticated sandbox (it will be recreated)`
292
+ );
293
+ }
294
+ return {
295
+ url: test.envdUrl ?? `https://${ENVD_PORT}-${info.sandboxID}.${domain}`,
296
+ sandboxId: info.sandboxID,
297
+ accessToken: info.envdAccessToken
298
+ };
299
+ };
300
+ const attach = async (id, signal, minSeconds = 0) => {
301
+ signal.throwIfAborted();
302
+ checkId(id);
303
+ const timeout = Math.min(MAX_TIMEOUT_SECONDS, Math.max(cfg.timeoutSeconds, minSeconds));
304
+ for (let i = 0; ; i++) {
305
+ try {
306
+ const r = await api.json("POST", `/v2/sandboxes/${enc(id)}/connect`, {
307
+ body: { timeout },
308
+ signal
309
+ });
310
+ return targetOf({ ...r.data, sandboxID: r.data?.sandboxID ?? id });
311
+ } catch (e) {
312
+ if (isE2BNotFound(e)) {
313
+ throw new SandboxGoneError(`E2B sandbox ${id} no longer exists`, { cause: e });
314
+ }
315
+ if (!isStatus(e, 409) || i >= CONNECT_CONFLICT_RETRIES) throw e;
316
+ await sleep(CONNECT_CONFLICT_WAIT_MS, signal);
317
+ }
318
+ }
319
+ };
320
+ const destroyByName = async (name, signal) => {
321
+ signal.throwIfAborted();
322
+ const ids = [];
323
+ let nextToken;
324
+ do {
325
+ const r = await api.json(
326
+ "GET",
327
+ "/v2/sandboxes",
328
+ {
329
+ query: {
330
+ metadata: new URLSearchParams({ [enc(METADATA_NAME)]: enc(name) }).toString(),
331
+ state: "running,paused",
332
+ limit: String(LIST_LIMIT),
333
+ nextToken
334
+ },
335
+ signal
336
+ }
337
+ );
338
+ for (const s of r.data ?? []) {
339
+ if (s.metadata?.[METADATA_NAME] === name) ids.push(s.sandboxID);
340
+ }
341
+ nextToken = r.headers.get("x-next-token") || void 0;
342
+ } while (nextToken);
343
+ for (const id of ids) await kill(id, signal);
344
+ };
345
+ const kill = async (id, signal) => {
346
+ checkId(id);
347
+ try {
348
+ await api.json("DELETE", `/sandboxes/${enc(id)}`, { signal });
349
+ } catch (e) {
350
+ if (!isE2BNotFound(e)) throw e;
351
+ }
352
+ };
353
+ async function run(t, o) {
354
+ o.signal.throwIfAborted();
355
+ const out = [];
356
+ const err = [];
357
+ let used = 0;
358
+ let truncated = false;
359
+ let timedOut = false;
360
+ let aborted = false;
361
+ let stopping = false;
362
+ let pid;
363
+ let exitCode;
364
+ const collect = (kind, b64) => {
365
+ const data = Buffer.from(b64, "base64");
366
+ const room = o.maxOutputBytes - used;
367
+ if (data.byteLength > room) truncated = true;
368
+ if (room <= 0) return;
369
+ const keep = data.byteLength > room ? data.subarray(0, room) : data;
370
+ used += keep.byteLength;
371
+ (kind === "out" ? out : err).push(keep);
372
+ };
373
+ const conn = new AbortController();
374
+ let grace;
375
+ let killing;
376
+ let wedged = false;
377
+ const stop = () => {
378
+ if (stopping) return;
379
+ stopping = true;
380
+ const kill2 = o.kill;
381
+ if (!kill2) {
382
+ conn.abort();
383
+ return;
384
+ }
385
+ killing = (async () => {
386
+ const ok = await kill2(pid).catch(() => false);
387
+ if (!ok) {
388
+ wedged = true;
389
+ conn.abort();
390
+ return;
391
+ }
392
+ grace = after(KILL_GRACE_MS, () => {
393
+ wedged = true;
394
+ conn.abort();
395
+ });
396
+ })();
397
+ };
398
+ const timer = o.timeoutMs === void 0 ? void 0 : after(o.timeoutMs, () => {
399
+ timedOut = true;
400
+ stop();
401
+ });
402
+ const onAbort = () => {
403
+ aborted = true;
404
+ stop();
405
+ };
406
+ o.signal.addEventListener("abort", onAbort, { once: true });
407
+ const req = {
408
+ cmd: o.cmd,
409
+ args: o.args,
410
+ ...o.env && { envs: o.env },
411
+ ...o.cwd !== void 0 && { cwd: o.cwd }
412
+ };
413
+ let streamError;
414
+ try {
415
+ await envdStart(
416
+ t,
417
+ o.user,
418
+ req,
419
+ (e) => {
420
+ if ("start" in e) pid = e.start.pid;
421
+ else if ("data" in e) {
422
+ if (e.data.stdout) collect("out", e.data.stdout);
423
+ if (e.data.stderr) collect("err", e.data.stderr);
424
+ } else if ("end" in e) exitCode = exitCodeOf(e.end);
425
+ },
426
+ conn.signal
427
+ );
428
+ } catch (e) {
429
+ streamError = e;
430
+ }
431
+ await killing;
432
+ grace?.cancel();
433
+ if (wedged && o.recover) await o.recover().catch(() => void 0);
434
+ timer?.cancel();
435
+ o.signal.removeEventListener("abort", onAbort);
436
+ const result = {
437
+ stdout: Buffer.concat(out),
438
+ stderr: Buffer.concat(err),
439
+ truncated,
440
+ timedOut,
441
+ aborted
442
+ };
443
+ if (timedOut || aborted) return { ...result, exitCode: null };
444
+ if (streamError !== void 0) throw streamError;
445
+ if (exitCode === void 0) throw new Error("E2B process stream ended without an exit status");
446
+ return { ...result, exitCode };
447
+ }
448
+ const killer = (t, ctl) => async (pid) => {
449
+ const ok = await run(t, {
450
+ user: SANDBOX_USER,
451
+ cmd: "/bin/sh",
452
+ args: ["-c", KILL_SCRIPT, "capybara-kill", ctl],
453
+ cwd: "/",
454
+ maxOutputBytes: 4096,
455
+ timeoutMs: 15e3,
456
+ signal: AbortSignal.timeout(2e4)
457
+ }).then(
458
+ (r) => r.exitCode === 0,
459
+ () => false
460
+ );
461
+ if (pid !== void 0)
462
+ await envdKill(t, pid, AbortSignal.timeout(15e3)).catch(() => void 0);
463
+ return ok;
464
+ };
465
+ const reboot = async (id) => {
466
+ deps.log.warn("sandbox-e2b: could not stop a command cleanly; rebooting the sandbox", {
467
+ sandboxId: id
468
+ });
469
+ const signal = AbortSignal.timeout(12e4);
470
+ try {
471
+ await api.json("POST", `/sandboxes/${enc(id)}/pause`, { body: {}, signal });
472
+ } catch (e) {
473
+ if (!isStatus(e, 409)) throw e;
474
+ }
475
+ await api.json("POST", `/v2/sandboxes/${enc(id)}/connect`, {
476
+ body: { timeout: cfg.timeoutSeconds, memory: false },
477
+ signal
478
+ });
479
+ };
480
+ const fileOp = async (t, o, signal) => {
481
+ const r = await run(t, {
482
+ ...o,
483
+ cmd: "/bin/sh",
484
+ cwd: "/",
485
+ timeoutMs: FILE_OP_TIMEOUT_MS,
486
+ signal
487
+ });
488
+ if (r.aborted) throw abortReason(signal);
489
+ if (r.timedOut) throw new Error("sandbox file operation timed out");
490
+ return r;
491
+ };
492
+ const stderrOf = (r) => r.stderr.toString("utf8").trim().slice(0, 500);
493
+ return {
494
+ // envd runs every process.Start independently; connect on a running sandbox only extends its
495
+ // timeout, so concurrent auto-starts are idempotent (a 409 from a resume in flight is retried)
496
+ concurrentExec: true,
497
+ async create({ name, limits, signal }) {
498
+ signal.throwIfAborted();
499
+ if (!NAME_RE.test(name)) throw new Error(`invalid sandbox name: ${name}`);
500
+ checkLimits(limits);
501
+ if (limits.egress === "open" && !cfg.allowOpenEgress) {
502
+ throw new Error(
503
+ 'Sandbox egress "open" (sandbox.limits.egress) is refused: the sandbox-e2b plugin setting allowOpenEgress is false. Set extensionConfig."sandbox-e2b".allowOpenEgress to true (trusted environments only), or set sandbox.limits.egress to "allowlist" or "none".'
504
+ );
505
+ }
506
+ let egress = limits.egress;
507
+ if (egress === "allowlist" && !cfg.domainAllowlist) {
508
+ deps.log.warn(
509
+ "sandbox-e2b: domain allowlists are disabled for this deployment (domainAllowlist is false); egress allowlist degraded to none",
510
+ { name }
511
+ );
512
+ egress = "none";
513
+ }
514
+ await destroyByName(name, signal);
515
+ const network = egress === "allowlist" && limits.allowHosts.length > 0 ? { network: { allowOut: [...limits.allowHosts], denyOut: ["0.0.0.0/0"] } } : { allow_internet_access: egress === "open" };
516
+ const r = await api.json("POST", "/v2/sandboxes", {
517
+ body: {
518
+ templateID: cfg.template,
519
+ metadata: { [METADATA_NAME]: name },
520
+ timeout: cfg.timeoutSeconds,
521
+ // 到点自动暂停而不是删除:内核来不及 suspend(如内核整个挂了)时文件也不丢
522
+ autoPause: true,
523
+ // envd 要求访问令牌(与 e2b SDK 的默认一致);没拿到令牌时 targetOf 拒绝连接
524
+ secure: true,
525
+ ...network
526
+ },
527
+ signal
528
+ });
529
+ if (!r.data?.sandboxID) throw new Error("E2B create sandbox returned no sandboxID");
530
+ checkId(r.data.sandboxID);
531
+ return { id: r.data.sandboxID };
532
+ },
533
+ async resume(h, signal) {
534
+ try {
535
+ await attach(h.id, signal);
536
+ } catch (e) {
537
+ if (e instanceof SandboxGoneError) return "gone";
538
+ throw e;
539
+ }
540
+ return "resumed";
541
+ },
542
+ async suspend(h, signal) {
543
+ signal.throwIfAborted();
544
+ checkId(h.id);
545
+ try {
546
+ await api.json("POST", `/sandboxes/${enc(h.id)}/pause`, { body: {}, signal });
547
+ } catch (e) {
548
+ if (!isE2BNotFound(e) && !isStatus(e, 409)) throw e;
549
+ }
550
+ },
551
+ async destroy(h, signal) {
552
+ signal.throwIfAborted();
553
+ await kill(h.id, signal);
554
+ },
555
+ destroyByName,
556
+ async killAll(h, signal) {
557
+ signal.throwIfAborted();
558
+ const t = await attach(h.id, signal);
559
+ const r = await fileOp(
560
+ t,
561
+ {
562
+ user: SANDBOX_USER,
563
+ args: ["-c", KILL_ALL_SCRIPT, "capybara-killall"],
564
+ maxOutputBytes: STDERR_ROOM
565
+ },
566
+ signal
567
+ );
568
+ if (r.exitCode !== 0) throw new Error(`cannot stop leftover commands: ${stderrOf(r)}`);
569
+ },
570
+ async killTagged(h, tags, signal) {
571
+ signal.throwIfAborted();
572
+ for (const t2 of tags) {
573
+ if (!SANDBOX_TAG_RE.test(t2)) throw new Error("invalid sandbox tag");
574
+ }
575
+ if (tags.length === 0) return;
576
+ const t = await attach(h.id, signal);
577
+ const r = await fileOp(
578
+ t,
579
+ {
580
+ user: SANDBOX_USER,
581
+ args: ["-c", KILL_TAGGED_SCRIPT, "capybara-killtagged", ...tags],
582
+ maxOutputBytes: STDERR_ROOM
583
+ },
584
+ signal
585
+ );
586
+ if (r.exitCode !== 0)
587
+ throw new Error(`cannot stop crashed holders' commands: ${stderrOf(r)}`);
588
+ },
589
+ async exec(h, args) {
590
+ args.signal.throwIfAborted();
591
+ const ctl = execControlPath(args.tag);
592
+ const env = { HOME: "/workspace", LANG: "C.UTF-8" };
593
+ for (const [k, v] of Object.entries(args.env ?? {})) {
594
+ if (!ENV_KEY_RE.test(k)) throw new Error(`invalid environment variable name: ${k}`);
595
+ env[k] = v;
596
+ }
597
+ const t = await attach(
598
+ h.id,
599
+ args.signal,
600
+ Math.ceil(args.timeoutMs / 1e3) + TIMEOUT_MARGIN_SECONDS
601
+ );
602
+ const r = await run(t, {
603
+ user: SANDBOX_USER,
604
+ cmd: "setsid",
605
+ args: execArgs(ctl, args.command, args.timeoutMs),
606
+ env,
607
+ cwd: args.cwd ?? "/workspace",
608
+ maxOutputBytes: args.maxOutputBytes,
609
+ timeoutMs: args.timeoutMs,
610
+ signal: args.signal,
611
+ kill: killer(t, ctl),
612
+ // 重启也失败时只记日志(错误信息来自 API 正文与状态码,不含 apiKey 与访问令牌),照常返回本次结果
613
+ recover: () => reboot(h.id).catch((e) => {
614
+ deps.log.warn(
615
+ "sandbox-e2b: sandbox reboot failed; the stuck command may still be running",
616
+ {
617
+ sandboxId: h.id,
618
+ err: (e instanceof Error ? e.message : String(e)).slice(0, 500)
619
+ }
620
+ );
621
+ })
622
+ });
623
+ return {
624
+ exitCode: r.exitCode,
625
+ stdout: decodeOutput(r.stdout, r.truncated),
626
+ stderr: decodeOutput(r.stderr, r.truncated),
627
+ truncated: r.truncated,
628
+ timedOut: r.timedOut
629
+ };
630
+ },
631
+ async readFile(h, path, opts) {
632
+ opts.signal.throwIfAborted();
633
+ checkPath(path);
634
+ const t = await attach(h.id, opts.signal);
635
+ const want = opts.maxBytes + 1;
636
+ const r = await fileOp(
637
+ t,
638
+ {
639
+ user: SANDBOX_USER,
640
+ args: ["-c", READ_SCRIPT, "capybara-read", path, String(want)],
641
+ maxOutputBytes: want + STDERR_ROOM
642
+ },
643
+ opts.signal
644
+ );
645
+ if (r.exitCode === 3 || r.exitCode === 4) return null;
646
+ if (r.exitCode !== 0) throw new Error(`cannot read ${path}: ${stderrOf(r)}`);
647
+ const truncated = r.stdout.byteLength > opts.maxBytes;
648
+ const data = truncated ? r.stdout.subarray(0, opts.maxBytes) : r.stdout;
649
+ return { data: new Uint8Array(data), truncated };
650
+ },
651
+ async writeFile(h, path, data, signal) {
652
+ signal.throwIfAborted();
653
+ checkPath(path);
654
+ const t = await attach(h.id, signal);
655
+ const staging = `${UPLOAD_DIR}/${randomUUID()}`;
656
+ await envdUpload(t, staging, ROOT_USER, data, signal);
657
+ const skills = path === "/skills" || path.startsWith("/skills/");
658
+ const r = await fileOp(
659
+ t,
660
+ {
661
+ user: ROOT_USER,
662
+ args: [
663
+ "-c",
664
+ skills ? SKILLS_WRITE_SCRIPT : USER_WRITE_SCRIPT,
665
+ "capybara-write",
666
+ path,
667
+ staging
668
+ ],
669
+ maxOutputBytes: STDERR_ROOM
670
+ },
671
+ signal
672
+ );
673
+ if (r.exitCode !== 0) throw new Error(`cannot write ${path}: ${stderrOf(r)}`);
674
+ },
675
+ async stat(h, path, signal) {
676
+ signal.throwIfAborted();
677
+ checkPath(path);
678
+ const t = await attach(h.id, signal);
679
+ const r = await fileOp(
680
+ t,
681
+ {
682
+ user: SANDBOX_USER,
683
+ args: ["-c", STAT_SCRIPT, "capybara-stat", path],
684
+ maxOutputBytes: STDERR_ROOM
685
+ },
686
+ signal
687
+ );
688
+ if (r.exitCode === 3) return null;
689
+ const m = r.stdout.toString("utf8").match(/^(\d+) (file|directory)\n?$/);
690
+ if (r.exitCode !== 0 || !m) throw new Error(`cannot stat ${path}: ${stderrOf(r)}`);
691
+ return { size: Number(m[1]), isDir: m[2] === "directory" };
692
+ }
693
+ };
694
+ }
695
+ var SIGNALS = {
696
+ hangup: 1,
697
+ interrupt: 2,
698
+ quit: 3,
699
+ "illegal instruction": 4,
700
+ "trace/breakpoint trap": 5,
701
+ aborted: 6,
702
+ "bus error": 7,
703
+ "floating point exception": 8,
704
+ killed: 9,
705
+ "user defined signal 1": 10,
706
+ "segmentation fault": 11,
707
+ "user defined signal 2": 12,
708
+ "broken pipe": 13,
709
+ "alarm clock": 14,
710
+ terminated: 15
711
+ };
712
+ function exitCodeOf(end) {
713
+ const status = end.status ?? "";
714
+ if (end.exited === true || status.startsWith("exit status ")) return end.exitCode ?? 0;
715
+ const m = /^signal: (.+?)(?: \(core dumped\))?$/.exec(status);
716
+ const n = m ? SIGNALS[m[1]] : void 0;
717
+ return n !== void 0 ? 128 + n : null;
718
+ }
719
+ function decodeOutput(buf, truncated) {
720
+ let end = buf.byteLength;
721
+ if (truncated) {
722
+ for (let i = end - 1; i >= 0 && i >= end - 4; i--) {
723
+ const b = buf[i];
724
+ if ((b & 192) === 128) continue;
725
+ const need = b >= 240 ? 4 : b >= 224 ? 3 : b >= 192 ? 2 : 1;
726
+ if (end - i < need) end = i;
727
+ break;
728
+ }
729
+ }
730
+ return buf.subarray(0, end).toString("utf8");
731
+ }
732
+ function checkLimits(l) {
733
+ if (!(l.cpu > 0) || !(l.memoryMb > 0) || !(l.diskMb > 0)) {
734
+ throw new Error("sandbox limits cpu, memoryMb and diskMb must be positive");
735
+ }
736
+ if (l.egress === "allowlist") {
737
+ for (const host of l.allowHosts) {
738
+ const errors = validateAllowHost(host);
739
+ if (errors.length > 0)
740
+ throw new Error(`invalid allowHosts entry ${host}: ${errors.join("; ")}`);
741
+ }
742
+ }
743
+ }
744
+
745
+ // src/index.ts
746
+ var MAX_TIMEOUT_SECONDS2 = 86400;
747
+ var DeploymentConfig = z.object({
748
+ /** E2B API key(`e2b_…`)。 */
749
+ apiKey: ui(secret(z.string().min(1)), { label: { zh: "API 密钥", en: "API key" } }),
750
+ /** 控制面 API 地址;不填为 `https://api.<domain>`。自托管 E2B 填自己的地址。 */
751
+ apiUrl: ui(z.url({ protocol: /^https?$/ }).optional(), {
752
+ label: { zh: "接口地址", en: "API URL" }
753
+ }),
754
+ /** 沙箱域名(envd 地址为 `https://49983-<沙箱 id>.<domain>`);API 返回了沙箱自己的域名时以返回的为准。 */
755
+ domain: ui(
756
+ z.string().regex(HOSTNAME_RE, {
757
+ message: "domain must be a lowercase hostname such as e2b.app"
758
+ }).default("e2b.app"),
759
+ { label: { zh: "沙箱域名", en: "Sandbox domain" } }
760
+ ),
761
+ /** 模板名或 id:由 plugins/sandbox-docker/image/Dockerfile 构建(见 README)。 */
762
+ template: ui(z.string().min(1), { label: { zh: "模板", en: "Template" } }),
763
+ /**
764
+ * 沙箱的超时(秒,默认 3600):到点 E2B 自动暂停(不是删除)沙箱,文件保留。内核每次用沙箱时把它往后推,
765
+ * 长命令会按命令超时再加余量推后;空闲休眠与过期销毁由内核负责。
766
+ */
767
+ timeoutSeconds: ui(z.number().int().min(60).max(MAX_TIMEOUT_SECONDS2).default(3600), {
768
+ label: { zh: "沙箱超时", en: "Sandbox timeout" }
769
+ }),
770
+ /**
771
+ * E2B 是否支持按域名的出站白名单(E2B 云:支持)。为 false 时(不支持的自托管版本)`allowlist` 退化为
772
+ * `none` 并打告警。
773
+ */
774
+ domainAllowlist: ui(z.boolean().default(true), {
775
+ label: { zh: "按域名出站白名单", en: "Domain egress allowlist" }
776
+ }),
777
+ /** 允许 `sandbox.limits.egress = "open"`(默认 false)。为 false 时 `create` 收到 open 直接报错。 */
778
+ allowOpenEgress: ui(z.boolean().default(false), {
779
+ label: { zh: "允许开放出站", en: "Allow open egress" }
780
+ })
781
+ }).strict();
782
+ var index_default = defineExtension({
783
+ name: "sandbox-e2b",
784
+ version: "0.1.0-rc.1",
785
+ sdk: "^0.1.0-rc.0",
786
+ provides: [SANDBOX_EXTENSION_POINT],
787
+ // 只用部署配置与 E2B 的 API,不用任何需要声明的平台能力
788
+ permissions: {},
789
+ config: { deployment: DeploymentConfig },
790
+ setup(ctx) {
791
+ ctx.provide(
792
+ SANDBOX_EXTENSION_POINT,
793
+ createE2BSandbox(ctx.config, { log: ctx.log })
794
+ );
795
+ }
796
+ });
797
+ export {
798
+ DeploymentConfig,
799
+ IN_SANDBOX_GRACE_SECONDS,
800
+ METADATA_NAME,
801
+ createE2BSandbox,
802
+ decodeOutput,
803
+ index_default as default,
804
+ execArgs,
805
+ exitCodeOf
806
+ };