@capybara-hq/plugin-sandbox-e2b 0.0.0-stage → 0.1.0-rc.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.
@@ -0,0 +1,62 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "sandbox-e2b",
4
+ "version": "0.1.0-rc.0",
5
+ "publisher": "Capybara",
6
+ "description": "Conversation sandboxes (sandbox@1) as E2B sandboxes (E2B cloud or self-hosted), with pause / resume and E2B egress controls",
7
+ "homepage": "https://github.com/capybara-hq/plugins/tree/main/plugins/sandbox-e2b#readme",
8
+ "extension": {
9
+ "entry": "./dist/index.mjs",
10
+ "manifest": {
11
+ "name": "sandbox-e2b",
12
+ "version": "0.1.0-rc.0",
13
+ "sdk": "^0.1.0-rc.0",
14
+ "provides": [
15
+ "sandbox@1"
16
+ ],
17
+ "requires": [],
18
+ "optional": [],
19
+ "permissions": {},
20
+ "config": {
21
+ "deployment": [
22
+ {
23
+ "path": "allowOpenEgress",
24
+ "secret": false,
25
+ "hot": false
26
+ },
27
+ {
28
+ "path": "apiKey",
29
+ "secret": true,
30
+ "hot": false
31
+ },
32
+ {
33
+ "path": "apiUrl",
34
+ "secret": false,
35
+ "hot": false
36
+ },
37
+ {
38
+ "path": "domain",
39
+ "secret": false,
40
+ "hot": false
41
+ },
42
+ {
43
+ "path": "domainAllowlist",
44
+ "secret": false,
45
+ "hot": false
46
+ },
47
+ {
48
+ "path": "template",
49
+ "secret": false,
50
+ "hot": false
51
+ },
52
+ {
53
+ "path": "timeoutSeconds",
54
+ "secret": false,
55
+ "hot": false
56
+ }
57
+ ],
58
+ "org": []
59
+ }
60
+ }
61
+ }
62
+ }
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,2 @@
1
+ Capybara
2
+ Copyright 2026 Meta-1 (Tianjin Yuanyilian Technology Co., Ltd.)
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # @capybara-hq/plugin-sandbox-e2b
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The E2B implementation of the conversation sandbox (`sandbox@1`): one E2B sandbox per conversation (E2B cloud or self-hosted E2B), using E2B's pause for hibernation
4
+ and connect for resume. It is an either-or choice with `@capybara-hq/plugin-sandbox-docker` (`sandbox@1` is a single-select capability), and uses the same image.
5
+
6
+ ## Deployment config
7
+
8
+ | Key | Default | Description |
9
+ |---|---|---|
10
+ | `apiKey` | (required, secret) | E2B API key (`e2b_…`) |
11
+ | `template` | (required) | Template name or id, built from `plugins/sandbox-docker/image/Dockerfile` (see below) |
12
+ | `apiUrl` | `https://api.<domain>` | Control plane API address; for self-hosted E2B, use your own address |
13
+ | `domain` | `e2b.app` | Sandbox domain: the envd address is `https://49983-<sandbox id>.<domain>`; when the API returns the sandbox's own `domain`, the returned one wins |
14
+ | `timeoutSeconds` | `3600` | Sandbox timeout (60–86400 seconds); when it expires E2B **auto-pauses** (does not delete), see "Lifecycle" |
15
+ | `domainAllowlist` | `true` | Whether E2B supports a per-domain egress allowlist; set `false` for self-hosted versions that do not, and `allowlist` degrades to `none` with a warning |
16
+ | `allowOpenEgress` | `false` | Allow `sandbox.limits.egress = "open"`; when false, `create` fails immediately on open (an English error) |
17
+
18
+ ```bash
19
+ export CAPYBARA_EXTENSIONS='["@capybara-hq/plugin-sandbox-e2b"]'
20
+ export CAPYBARA_EXTENSION_CONFIG='{"sandbox-e2b":{"apiKey":"e2b_…","template":"capybara-sandbox"}}'
21
+ ```
22
+
23
+ The deployment config schema is strict: extra keys are errors. The kernel config `sandbox.image` has no effect on this plugin (the image is decided by the template).
24
+
25
+ ## Template (the same image as the Docker plugin)
26
+
27
+ The template is built from `plugins/sandbox-docker/image/Dockerfile` (E2B's Template SDK parses the Dockerfile's
28
+ FROM / RUN / COPY / ADD / WORKDIR / USER / ENV / ARG / CMD / ENTRYPOINT). From the repository root:
29
+
30
+ ```bash
31
+ export E2B_API_KEY=e2b_… # for self-hosted, also set E2B_DOMAIN / E2B_API_URL
32
+ mkdir -p /tmp/e2b-build && cd /tmp/e2b-build && npm init -y >/dev/null && npm i e2b@2.58.0
33
+ cat > build.mjs <<'EOF'
34
+ import { readFileSync } from "node:fs";
35
+ import { defaultBuildLogger, Template } from "e2b";
36
+ const dockerfile = readFileSync(process.argv[2], "utf8");
37
+ await Template.build(Template().fromDockerfile(dockerfile), "capybara-sandbox", {
38
+ cpuCount: 1, // matches the kernel's sandbox.cpu default
39
+ memoryMB: 2048, // matches the kernel's sandbox.memoryMb default
40
+ onBuildLogs: defaultBuildLogger(),
41
+ });
42
+ EOF
43
+ node build.mjs <repo path>/plugins/sandbox-docker/image/Dockerfile
44
+ ```
45
+
46
+ Then set `template` to `capybara-sandbox`. Image conventions (the plugin depends on them): the user `sandbox` (uid 1000), with HOME and the working directory at
47
+ `/workspace`; `/skills` owned by root (0755); `/opt/capybara` owned by root and 0700; `runuser` / `setsid` (util-linux),
48
+ `pkill` / `pgrep` (procps) and GNU coreutils installed. Rebuild the template after changing the Dockerfile.
49
+
50
+ ## Lifecycle
51
+
52
+ | Operation | E2B call |
53
+ |---|---|
54
+ | `create` | First lists by the metadata `capybara-sandbox-name=<name>` (`GET /v2/sandboxes?metadata=…&state=running,paused`, paginated) and deletes any old instance of the same name (the kernel may have exited before recording the handle), then `POST /v2/sandboxes` (`templateID`, `metadata`, `timeout`, `autoPause: true`, `secure: true`, egress settings); the handle id is E2B's sandbox id |
55
+ | `resume` | `POST /v2/sandboxes/{id}/connect` (a paused one is resumed, and a running one gets its timeout pushed back); 404 → `gone` |
56
+ | `suspend` | `POST /sandboxes/{id}/pause` (files and memory are both kept: background processes and `/tmp` are still there after resume; the Docker plugin's hibernation instead ends processes and clears `/tmp`); a missing one (404) or an already paused one (409) also counts as success |
57
+ | `killAll` | After connect (a paused one is resumed first, and the processes in memory come back with it), runs a script as the sandbox user that kills every command started through `exec` that is still running (SIGKILL by process group and session); called when the kernel takes over from a crashed previous lock holder |
58
+ | Concurrent use (`concurrentExec`, `killTagged`) | Declares `concurrentExec: true`: envd runs each `process.Start` independently, so parallel sub-agents of one conversation run commands and file operations in its sandbox at the same time. Each exec's control file carries the holder's tag (`/tmp/.capybara-exec-<tag>-<uuid>`, tags checked against `SANDBOX_TAG_RE`); `killTagged` runs a script as the sandbox user that kills only the commands of the given tags. Concurrent `connect` calls are idempotent (a running sandbox only gets its timeout extended); a 409 answered while another call's resume is in flight is retried briefly (5 × 500 ms; to be confirmed on real E2B, see `test/live`). Uploads use UUID names in the staging directory. When a kill cannot run, the reboot fallback (pause + cold `connect`) also ends live siblings' commands (a last resort) |
59
+ | `destroy` / `destroyByName` | `DELETE /sandboxes/{id}` (a missing one also counts as success); by name it first lists by metadata |
60
+ | `exec` / file operations | First connect (a paused one is resumed, which is the contract's "auto-start"; the envd access token is fetched. **If no token can be obtained it does not connect and sends no request without a token; it reports `SandboxGoneError` (`resume` returns `gone`), and the kernel rebuilds a sandbox with a token on that basis; a sandbox created before `secure: true` was added never has a token**; the timeout is pushed to at least "command timeout + 120 seconds", and not beyond 24 hours), then calls envd; a connect that returns an E2B-format 404 (`{code: 404, …}`) → `SandboxGoneError`; a bare 404 (a proxy, a misconfigured `apiUrl`), 401 / 5xx / network errors → ordinary errors. pause / delete also treat only an E2B-format 404 as "does not exist" |
61
+
62
+ - **Timeout**: an E2B sandbox is handled when its timeout (`timeoutSeconds`) expires; this plugin sets `autoPause: true` when creating a sandbox, so on expiry it is **paused** instead of deleted,
63
+ and files are not lost even when the kernel has no chance to `suspend` (for example the whole kernel is down). Every use pushes it back through connect (it only extends, never shortens).
64
+ Idle hibernation and expiry destruction are handled by the kernel's maintenance tasks. E2B's continuous run limit (24 hours on Pro, 1 hour on Hobby) is recounted after pause / resume.
65
+ - **Hobby plan**: the `timeout` of connect / create is at most 1 hour, so the deployment config `timeoutSeconds` must be ≤ 3600, and a single command can actually run at most
66
+ about 1 hour minus 2 minutes; however large the `sandbox_bash` `timeoutSeconds` is (capped by `sandbox.limits.maxCommandSeconds`, default 7200), it will be interrupted by the pause when the sandbox expires.
67
+ On Hobby, set `sandbox.limits.maxCommandSeconds` to below 3480.
68
+ - E2B keeps a paused sandbox indefinitely until it is deleted (the kernel deletes by `destroyAfterInactiveDays`).
69
+
70
+ ## exec and files
71
+
72
+ - **exec**: envd's `process.Process/Start` (a server stream of the Connect protocol), run as the `sandbox` user:
73
+ `setsid -w /bin/sh -c <wrapper> … timeout -s KILL <timeout + 5 seconds> /bin/bash -c <command>`; the wrapper first writes the session id to
74
+ `/tmp/.capybara-exec-<uuid>.pid`. The `timeout` inside the sandbox is the backstop: when a kernel replica crashes the timer on the plugin side is gone, disconnecting envd's stream does not end
75
+ the process, and pause then resume would bring the process back; on expiry it kills the process group the command is in.
76
+ On timeout or abort: a kill-processes script runs as the sandbox user, SIGKILLing all processes the command spawned by process group and session (the same as the Docker plugin),
77
+ then envd's `SendSignal` by pid serves as a backstop (it only ends the session's first process); it returns only after the kill finishes, and disconnects after a 3-second grace period (disconnecting itself does not
78
+ end the processes in the sandbox).
79
+ **Last resort: reboot the sandbox.** When the kill-processes script did not finish (could not start, for example process count exhausted, timeout, or unreachable), or the output stream still has not ended 3 seconds after the kill
80
+ (a process escaped the session and still holds the output): first pause, then `POST /v2/sandboxes/{id}/connect` `{timeout, memory: false}`
81
+ (that is, the SDK `e2b@2.58.0`'s `Sandbox.connect(id, { onResume: "reboot" })`) to cold-start from the disk state: all processes in memory end,
82
+ files such as `/workspace` are kept, writes not flushed before the pause may be lost, and whether things in `/tmp` survive depends on whether they are on disk. A warn log is written;
83
+ the call returns as usual with `timedOut: true` or the abort result. The SDK notes: this requires the control plane to support connect's `memory` field (E2B cloud; self-hosted must be a
84
+ `e2b-dev/runtime` after 2026-08-20), and an older control plane will **ignore the field** and restore the memory snapshot (processes still there), and an
85
+ environment without "restore only the filesystem" enabled will reject the request; in both cases the backstop is ineffective (only logged).
86
+ When a command is ended by a signal we did not send (envd's end event `exited: false`, such as OOM): by shell convention it returns `128 + signal number`
87
+ (when Go's signal name is recognized), and when it is not recognized it returns `exitCode: null, timedOut: false` (the kernel hints "the command did not finish, the result may be incomplete").
88
+ A timeout resolves `timedOut: true, exitCode: null`; an abort after start resolves `exitCode: null, timedOut: false` (with the output collected so far);
89
+ an already aborted call rejects immediately. stdout + stderr together are truncated by `maxOutputBytes` and set `truncated`; when truncating it backs up to a full
90
+ UTF-8 character boundary. envd sends a keepalive event every 50 seconds, so a command with no output for a long time is not disconnected by an intermediate proxy.
91
+ - **readFile / stat**: small `head -c` / `stat` scripts run in the sandbox as the sandbox user (permissions and symlinks are resolved as the sandbox user). A missing file → null,
92
+ and readFile on a directory also returns null.
93
+ - **writeFile**: envd's upload endpoint opens the target as root and follows symlinks, so writing to the target directly could bypass permissions with something like `/workspace/l -> /etc`.
94
+ So the plugin only transfers the bytes to a root-only staging directory `/opt/capybara/upload/<uuid>` (`/opt/capybara` owned by root, 0700), then runs as root
95
+ `runuser -u sandbox -- sh -c 'mkdir -p …; cat > target' < staging file`: root only opens the staging file, while creating the directory and writing the target are both resolved as the
96
+ sandbox user; the staging file is deleted whether it succeeds or not (when aborted it may remain in the root-only directory and is removed with the sandbox).
97
+ - **/skills is read-only**: `/skills` is owned by root (0755) and the sandbox user can only read it. `writeFile("/skills/…")` writes directly as root (`umask 022`,
98
+ file mode 0644); the sandbox user cannot place symlinks in `/skills`, so root's writes cannot be redirected elsewhere.
99
+
100
+ ## Egress (`limits.egress`)
101
+
102
+ E2B's egress control ([docs](https://docs.e2b.dev/sandbox/internet-access), verified 2026-10-21):
103
+
104
+ | `egress` | Setting when creating the sandbox |
105
+ |---|---|
106
+ | `none` | `allow_internet_access: false` (equivalent to `denyOut: ["0.0.0.0/0"]`) |
107
+ | `allowlist` | `network: { allowOut: allowHosts, denyOut: ["0.0.0.0/0"] }`; when `allowHosts` is empty it is the same as `none` |
108
+ | `open` | `allow_internet_access: true`; `allowOpenEgress` must be turned on explicitly |
109
+
110
+ Limitations of `allowlist` (decided by E2B's implementation, written here for assessment):
111
+
112
+ - Per-domain filtering applies only to **HTTP on port 80 (by the Host header) and TLS on port 443 (by SNI)**; other ports are filtered only by IP / CIDR,
113
+ and with only domains in the allowlist they are always blocked. UDP (QUIC / HTTP3) does not support per-domain filtering.
114
+ - When a domain allowlist is used, E2B automatically allows DNS to `8.8.8.8` (port 53), and the DNS queries themselves can be used to exfiltrate small amounts of data.
115
+ - **E2B looks only at the Host header / SNI, and the docs do not say it checks the destination IP**: a sandbox can connect to ports 80 / 443 of any IP and write the Host / SNI as a domain on the allowlist
116
+ (such as `curl --resolve pypi.org:443:<any IP> https://pypi.org`), and so **may reach any address outside the allowlist** (including the intranet and cloud
117
+ metadata addresses, depending on whether E2B's own network allows them). The Docker plugin's egress proxy resolves the domain and checks every destination IP; the E2B version cannot do this.
118
+ - Other sites on the same CDN / object storage may also be allowed along with it (the E2B docs say this too).
119
+ - **For self-hosted E2B, network isolation is the operator's responsibility**: intranet services and cloud metadata addresses (169.254.169.254 and so on) that the sandbox node can reach must be blocked at E2B's node /
120
+ network layer; this plugin cannot do it for you.
121
+ - If egress that "checks the destination IP after resolving" is wanted later, E2B's `network.egressProxy` can be used (SOCKS5, see SDK 2.58 `buildEgressProxyBody`):
122
+ it sends all sandbox egress to our own proxy (for example the same checks as the Docker plugin's egress proxy); this plugin does not use it yet.
123
+ - A rejected TCP connection may look "connected" inside the sandbox; you only know it was rejected from the application layer (HTTP status, TLS handshake).
124
+ - `*.example.com` matches subdomains (same as the Docker plugin, not including `example.com` itself).
125
+ - For self-hosted E2B that does not support per-domain filtering, set `domainAllowlist: false`: `allowlist` degrades to `none` with a warning (it never silently opens up).
126
+ - **The allowlist cannot stop data sent to allowlisted hosts** (the same as the Docker version): `git push` to github.com and publishing to npm / PyPI are within the allowlist,
127
+ and attachments in a conversation and data the agent has read can leak this way. Orgs that handle sensitive data should set `sandboxToolPolicy` to
128
+ `ask` in the org settings, and / or set the sandbox network `sandboxEgress` to `none` (org-level egress, Ruling M19: an org can replace the allowlist or cut the network, and cannot set `open`).
129
+
130
+ **Limits take effect only at creation**: `sandbox.limits.egress` / `allowHosts` are written into the create-sandbox request, and changing the config affects only sandboxes created afterwards; existing
131
+ sandboxes (including ones resumed after a pause) keep the egress settings from creation until they are destroyed and rebuilt.
132
+
133
+ **Command time limit**: a single command runs at most `sandbox.limits.maxCommandSeconds`, and no more than the time left in the run it belongs to (`runtime.maxRunSeconds`,
134
+ default 900 seconds, Ruling M20); to run long commands, raise `runtime.maxRunSeconds` to at least `maxCommandSeconds`.
135
+
136
+ ## Resources (`limits.cpu` / `memoryMb` / `diskMb`)
137
+
138
+ E2B's CPU and memory are fixed **when the template is built** (`cpuCount` / `memoryMB` of `Template.build`) and cannot be specified per sandbox at creation; disk size is decided by
139
+ E2B's plan / template. So for `limits` cpu / memoryMb / diskMb this plugin only validates them and **does not apply them per sandbox**: give the spec you want when
140
+ building the template (the example above matches the kernel defaults of 1 core / 2048 MB), and use different templates for different specs. The sandbox CPU / memory overrides an enterprise admin sets for an org in the admin console
141
+ (deployment setting `sandboxLimitOverrides`) likewise **have no effect on this plugin**: to give some orgs a bigger spec, the only way is to switch to a different deployment
142
+ (or template).
143
+
144
+ ## Client
145
+
146
+ It does not use the official `e2b` SDK and instead has a minimal hand-written client (`src/e2b-api.ts`, the global `fetch`, no third-party dependencies):
147
+
148
+ - Every request carries the caller's AbortSignal directly, so abort semantics are controllable (the command stream is disconnected by the plugin itself: kill the process first, then disconnect);
149
+ - The official SDK reads process environment variables such as `E2B_API_KEY` / `E2B_DOMAIN` / `E2B_SANDBOX_URL` / `E2B_DEBUG` and caches a connection pool at module level
150
+ (the multi-replica discipline does not allow module-level mutable state in a plugin), and it also brings undici, Connect, protobuf and a string of other dependencies;
151
+ - It uses only 6 control plane endpoints and 3 envd endpoints, with the protocol following the SDK `e2b@2.58.0` implementation (listed in the header comment of `src/e2b-api.ts`).
152
+
153
+ Protocol sources (verified 2026-10-21): `dist/index.mjs` of `e2b@2.58.0`; the E2B docs
154
+ [internet-access](https://docs.e2b.dev/sandbox/internet-access), [persistence](https://docs.e2b.dev/sandbox/persistence),
155
+ [sandbox lifecycle](https://docs.e2b.dev/sandbox), [template quickstart](https://docs.e2b.dev/template/quickstart),
156
+ [template migration (fromDockerfile)](https://docs.e2b.dev/template/migration-v2); the envd source
157
+ [upload.go](https://github.com/e2b-dev/infra/blob/main/packages/envd/internal/api/upload.go).
158
+
159
+ ## Test-only options
160
+
161
+ The third parameter of `createE2BSandbox(config, { log }, testOptions)` can be passed only by code that calls it directly; the plugin config cannot set it:
162
+
163
+ - `envdUrl`: the envd address is changed to this (routed by the `E2b-Sandbox-Id` request header), instead of being assembled from the sandbox id and domain.
164
+
165
+ ## Tests
166
+
167
+ - `test/sandbox-e2b.test.ts`: against `startFakeE2B` from `@capybara-hq/testing` (control plane API + envd, Connect stream protocol,
168
+ commands really run in a local temp directory), covering each method, error mapping (404 → gone, 401 / 5xx → ordinary errors), abort, kill on timeout,
169
+ file reads and writes and the way `/skills` is written, egress mapping, and the reboot backstop when killing fails / the output stream does not end (the fake server's `memory: false` connect ends all processes).
170
+ - `test/live`: real E2B, run only when `CAPYBARA_TEST_E2B_API_KEY` and `CAPYBARA_TEST_E2B_TEMPLATE` are set (optionally
171
+ `CAPYBARA_TEST_E2B_API_URL` / `CAPYBARA_TEST_E2B_DOMAIN`), otherwise skipped: the sandbox user, files, `/skills` read-only (including a write through a symlink being
172
+ rejected), the egress allowlist, output truncation, kill on timeout (including nohup child processes), abort, long periods with no output, a child process that escaped the session triggering the reboot backstop, same-name replacement,
173
+ pause / resume, gone after destroy. The file header lists the "assumptions that can only be confirmed on real E2B"; check them one by one on the first run.
package/dist/index.mjs ADDED
@@ -0,0 +1,795 @@
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 } 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: secret(z.string().min(1)),
750
+ /** 控制面 API 地址;不填为 `https://api.<domain>`。自托管 E2B 填自己的地址。 */
751
+ apiUrl: z.url({ protocol: /^https?$/ }).optional(),
752
+ /** 沙箱域名(envd 地址为 `https://49983-<沙箱 id>.<domain>`);API 返回了沙箱自己的域名时以返回的为准。 */
753
+ domain: z.string().regex(HOSTNAME_RE, {
754
+ message: "domain must be a lowercase hostname such as e2b.app"
755
+ }).default("e2b.app"),
756
+ /** 模板名或 id:由 plugins/sandbox-docker/image/Dockerfile 构建(见 README)。 */
757
+ template: z.string().min(1),
758
+ /**
759
+ * 沙箱的超时(秒,默认 3600):到点 E2B 自动暂停(不是删除)沙箱,文件保留。内核每次用沙箱时把它往后推,
760
+ * 长命令会按命令超时再加余量推后;空闲休眠与过期销毁由内核负责。
761
+ */
762
+ timeoutSeconds: z.number().int().min(60).max(MAX_TIMEOUT_SECONDS2).default(3600),
763
+ /**
764
+ * E2B 是否支持按域名的出站白名单(E2B 云:支持)。为 false 时(不支持的自托管版本)`allowlist` 退化为
765
+ * `none` 并打告警。
766
+ */
767
+ domainAllowlist: z.boolean().default(true),
768
+ /** 允许 `sandbox.limits.egress = "open"`(默认 false)。为 false 时 `create` 收到 open 直接报错。 */
769
+ allowOpenEgress: z.boolean().default(false)
770
+ }).strict();
771
+ var index_default = defineExtension({
772
+ name: "sandbox-e2b",
773
+ version: "0.1.0-rc.0",
774
+ sdk: "^0.1.0-rc.0",
775
+ provides: [SANDBOX_EXTENSION_POINT],
776
+ // 只用部署配置与 E2B 的 API,不用任何需要声明的平台能力
777
+ permissions: {},
778
+ config: { deployment: DeploymentConfig },
779
+ setup(ctx) {
780
+ ctx.provide(
781
+ SANDBOX_EXTENSION_POINT,
782
+ createE2BSandbox(ctx.config, { log: ctx.log })
783
+ );
784
+ }
785
+ });
786
+ export {
787
+ DeploymentConfig,
788
+ IN_SANDBOX_GRACE_SECONDS,
789
+ METADATA_NAME,
790
+ createE2BSandbox,
791
+ decodeOutput,
792
+ index_default as default,
793
+ execArgs,
794
+ exitCodeOf
795
+ };
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-sandbox-e2b",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-rc.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "description": "Conversation sandboxes (sandbox@1) as E2B sandboxes (E2B cloud or self-hosted), with pause / resume and E2B egress controls",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/capybara-hq/plugins.git",
10
+ "directory": "plugins/sandbox-e2b"
11
+ },
12
+ "homepage": "https://github.com/capybara-hq/plugins/tree/main/plugins/sandbox-e2b#readme",
13
+ "main": "./dist/index.mjs",
14
+ "exports": {
15
+ ".": "./dist/index.mjs"
16
+ },
17
+ "peerDependencies": {
18
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
19
+ "zod": "^4.6.7"
20
+ }
21
+ }