Skip to content

Commit d24c951

Browse files
author
Roy Lin
committed
docs(e2b): explain self-hosted SDK setup
1 parent 2f682c3 commit d24c951

1 file changed

Lines changed: 341 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,343 @@ process-global Sandbox URL override. Native A3S SDK applications use only
397397
`A3S_BOX_*` connection settings and do not read `E2B_API_URL`; that variable is
398398
reserved for the optional unchanged-official-SDK migration path.
399399

400+
#### Self-hosted usage
401+
402+
`a3s-box-e2b` is a network service. The Python and TypeScript packages connect
403+
to that service; they do not start a local Box runtime. The production-tested
404+
preview requires a Linux Sandbox host with the certified `crun` runtime, a
405+
public control-plane address, and a TLS Sandbox gateway with wildcard DNS.
406+
See [Host Sandbox Backend Design](docs/host-sandbox-backend-design.md) for the
407+
host boundary and [E2B-Compatible SDK Design](docs/e2b-compatible-sdk-design.md)
408+
for the complete protocol and release gates.
409+
410+
The simplest single-host production topology uses two public listeners:
411+
412+
| Public address | Purpose | Service destination |
413+
| --- | --- | --- |
414+
| `https://api.box.example.com` | Control API used by `A3S_BOX_ENDPOINT` | TLS reverse proxy to `api_listen`, for example `127.0.0.1:3000` |
415+
| `https://<port>-<sandbox-id>.box.example.com:8443` | Direct Sandbox data plane | `gateway.listen`, for example `0.0.0.0:8443` |
416+
| `https://sandbox.box.example.com:8443` | Shared Sandbox route form | The same `gateway.listen` |
417+
418+
Create explicit DNS for `api.box.example.com` and wildcard DNS for
419+
`*.box.example.com`. The Sandbox gateway terminates TLS itself and its
420+
certificate must cover `*.box.example.com`. Port `8443` avoids competing with
421+
the control-plane TLS proxy on a single IP. A deployment with separate IP
422+
addresses or an SNI-aware load balancer can use port `443` for both.
423+
424+
The client and server settings map as follows:
425+
426+
| Client setting | What to enter | Matching server setting |
427+
| --- | --- | --- |
428+
| `A3S_BOX_ENDPOINT` | The externally reachable control API origin, including `http://` or `https://` and any non-default port | `e2b_compat.api_public_url` |
429+
| `A3S_BOX_API_KEY` | The **raw** API key issued to this client | The raw key whose PBKDF2-SHA256 encoding is stored in `account.hash` |
430+
| `A3S_BOX_DOMAIN` | Optional Sandbox wildcard suffix, with no scheme or port | `e2b_compat.sandbox_domain` |
431+
| `A3S_BOX_SANDBOX_URL` | Single-Sandbox fixture override only; normally unset | Not a production multi-Sandbox setting |
432+
433+
Use the bare control origin for `A3S_BOX_ENDPOINT`. Do not append `/api`,
434+
`/v1`, `/sandboxes`, a query string, or a fragment, and omit the trailing
435+
slash. For example:
436+
437+
| Deployment | `A3S_BOX_ENDPOINT` | Additional setting |
438+
| --- | --- | --- |
439+
| Standard HTTPS | `https://api.box.example.com` | None; the SDK derives `box.example.com` |
440+
| Non-default control port | `https://api.box.example.com:8444` | None; the SDK still derives `box.example.com` |
441+
| Custom/LAN hostname | `https://box-api.lab.example:8444` | `A3S_BOX_DOMAIN=sandboxes.lab.example` |
442+
| Loopback development | `http://127.0.0.1:3000` | Set `A3S_BOX_DOMAIN` to the configured local Sandbox DNS suffix |
443+
444+
Plain HTTP is appropriate only on loopback. A loopback control endpoint does
445+
not remove the data-plane requirements: Filesystem, Process, PTY, health, and
446+
Code Interpreter calls still need wildcard DNS, a trusted TLS certificate, and
447+
reachability to the configured Sandbox gateway.
448+
449+
##### 1. Generate the client API key and server hash
450+
451+
The current compatibility service requires API keys in the form
452+
`e2b_[0-9a-f]+`. The native A3S packages disable the upstream client-side
453+
validator, but the self-hosted server still enforces this compatibility form.
454+
Generate a high-entropy key and its server-side hash on a trusted
455+
administrative machine:
456+
457+
```bash
458+
python3 - <<'PY'
459+
import hashlib
460+
import secrets
461+
462+
iterations = 210_000
463+
salt = secrets.token_bytes(16)
464+
api_key = f"e2b_{secrets.token_hex(32)}"
465+
digest = hashlib.pbkdf2_hmac(
466+
"sha256",
467+
api_key.encode("utf-8"),
468+
salt,
469+
iterations,
470+
dklen=32,
471+
)
472+
encoded = (
473+
f"pbkdf2-sha256${iterations}${salt.hex()}${digest.hex()}"
474+
)
475+
476+
print("Store this raw value in the client secret manager:")
477+
print(f"A3S_BOX_API_KEY={api_key}")
478+
print()
479+
print("Paste only this encoded value into the server account.hash:")
480+
print(encoded)
481+
PY
482+
```
483+
484+
The command prints the raw key once. Store the `A3S_BOX_API_KEY=...` value in
485+
the client secret manager. Paste only the `pbkdf2-sha256$...` value into the
486+
server ACL. Do not put the raw key in the ACL, and do not put the encoded hash
487+
in `A3S_BOX_API_KEY`.
488+
489+
Sandbox tokens use two different 32-byte service keys. Generate and persist
490+
them separately; they are not account API keys:
491+
492+
```bash
493+
export A3S_BOX_E2B_TOKEN_ENCRYPTION_KEY_V1="$(openssl rand -hex 32)"
494+
export A3S_BOX_E2B_TOKEN_DIGEST_KEY_V1="$(openssl rand -hex 32)"
495+
```
496+
497+
Keep these values stable across service restarts. Losing or changing them
498+
without a versioned rotation makes existing Sandbox tokens fail closed.
499+
500+
##### 2. Configure the self-hosted service
501+
502+
A3S Box accepts only ACL configuration parsed by `a3s-acl`. The following
503+
single-host example keeps the plaintext control listener on loopback, exposes
504+
the Sandbox TLS gateway on port `8443`, and defines a broker-mode base
505+
template. Replace the account hash, certificate paths, runtime paths, and image
506+
with values for the deployment. Pin the image by digest for production.
507+
508+
```acl
509+
e2b_compat {
510+
api_listen = "127.0.0.1:3000"
511+
api_public_url = "https://api.box.example.com"
512+
sandbox_domain = "box.example.com"
513+
sandbox_public_domain = "box.example.com:8443"
514+
database_path = "/var/lib/a3s-box/e2b/lifecycle.sqlite3"
515+
runtime_home = "/var/lib/a3s"
516+
runtime_state_path = "/var/lib/a3s-box/e2b/managed-executions.json"
517+
518+
gateway {
519+
listen = "0.0.0.0:8443"
520+
tls_certificate_path = "/etc/a3s-box/tls/sandbox-chain.pem"
521+
tls_private_key_path = "/etc/a3s-box/tls/sandbox-key.pem"
522+
max_connections = 4096
523+
handshake_timeout_ms = 5000
524+
connect_timeout_ms = 2000
525+
drain_timeout_seconds = 30
526+
}
527+
528+
supervisor {
529+
interval_seconds = 5
530+
batch_size = 100
531+
reconciliation_page_size = 100
532+
}
533+
534+
account "primary" {
535+
scheme = "api_key"
536+
owner_id = "production-team"
537+
client_id = "production-client"
538+
hash = "pbkdf2-sha256$210000$<salt-hex>$<digest-hex>"
539+
}
540+
541+
token_key "2026-07" {
542+
version = 1
543+
active = true
544+
encryption_key = env("A3S_BOX_E2B_TOKEN_ENCRYPTION_KEY_V1")
545+
digest_key = env("A3S_BOX_E2B_TOKEN_DIGEST_KEY_V1")
546+
}
547+
548+
template_policy "a3s-base" {
549+
image = "docker.io/library/alpine:3.20"
550+
envd_version = "0.1.3"
551+
envd_mode = "broker"
552+
isolation = "sandbox"
553+
network = "none"
554+
command = ["/bin/sh", "-c", "while :; do sleep 3600; done"]
555+
556+
resources {
557+
vcpus = 2
558+
memory_mb = 512
559+
disk_mb = 1024
560+
}
561+
562+
route {
563+
port = 49983
564+
token_scope = "envd"
565+
}
566+
}
567+
}
568+
```
569+
570+
`api_public_url` is the value clients use as `A3S_BOX_ENDPOINT`;
571+
`api_listen` is an internal bind address and normally must not be given to
572+
remote clients. The TLS reverse proxy in front of `api_listen` must forward
573+
paths unchanged and preserve `X-API-Key`. The separate Sandbox gateway must be
574+
reachable on the port advertised by `sandbox_public_domain`.
575+
576+
For the in-Sandbox envd and Code Interpreter template, use the immutable
577+
runtime image and template policy described in
578+
[`deploy/e2b/README.md`](deploy/e2b/README.md).
579+
580+
##### 3. Start and verify the service
581+
582+
Release archives install `a3s-box-e2b`. To build the same binary from this
583+
repository:
584+
585+
```bash
586+
cd src
587+
cargo build --locked --release -p a3s-box-compat --bin a3s-box-e2b
588+
589+
RUST_LOG=a3s_box_compat=info \
590+
./target/release/a3s-box-e2b --config /etc/a3s-box/e2b.acl
591+
```
592+
593+
The two token-key environment variables referenced by the ACL must be present
594+
in the service process. Run the service under a supervisor and ensure its user
595+
can read the TLS private key and write the database, runtime home, and runtime
596+
state paths.
597+
598+
Configure a client with the **raw** API key:
599+
600+
```bash
601+
export A3S_BOX_ENDPOINT="https://api.box.example.com"
602+
export A3S_BOX_API_KEY="e2b_<raw-lowercase-hex-key>"
603+
604+
# Only for a non-conventional control hostname:
605+
# export A3S_BOX_DOMAIN="sandboxes.lab.example"
606+
607+
unset A3S_BOX_SANDBOX_URL
608+
```
609+
610+
First verify the control API independently of Sandbox routing:
611+
612+
```bash
613+
curl --fail --show-error --silent \
614+
--header "X-API-Key: ${A3S_BOX_API_KEY}" \
615+
"${A3S_BOX_ENDPOINT}/v2/sandboxes"
616+
```
617+
618+
A successful request returns HTTP `200`. This proves the control endpoint,
619+
TLS trust, reverse-proxy path, and account key. It does not prove wildcard
620+
Sandbox DNS or the data-plane gateway; create a Sandbox and run a command for
621+
that end-to-end check.
622+
623+
##### 4. Use the native A3S SDKs
624+
625+
The native packages are currently source-tree previews. Build or install them
626+
as described in the
627+
[`a3s-box` Python package](sdk/python/README.md) and
628+
[`@a3s-lab/box` TypeScript package](sdk/typescript/README.md).
629+
630+
Python synchronous client:
631+
632+
```python
633+
from a3s_box import A3SConnectionConfig, Sandbox
634+
635+
connection = A3SConnectionConfig.from_environment()
636+
sandbox = Sandbox.create(
637+
"a3s-base",
638+
**connection.python_options(),
639+
)
640+
641+
try:
642+
result = sandbox.commands.run("printf 'hello from A3S Box\\n'")
643+
print(result.stdout)
644+
finally:
645+
sandbox.kill()
646+
```
647+
648+
Python asynchronous client:
649+
650+
```python
651+
import asyncio
652+
653+
from a3s_box import A3SConnectionConfig, AsyncSandbox
654+
655+
656+
async def main() -> None:
657+
connection = A3SConnectionConfig.from_environment()
658+
sandbox = await AsyncSandbox.create(
659+
"a3s-base",
660+
**connection.python_options(),
661+
)
662+
async with sandbox:
663+
result = await sandbox.commands.run(
664+
"printf 'hello from A3S Box\\n'"
665+
)
666+
print(result.stdout)
667+
668+
669+
asyncio.run(main())
670+
```
671+
672+
TypeScript client:
673+
674+
```typescript
675+
import { A3SConnectionConfig, Sandbox } from '@a3s-lab/box'
676+
677+
const connection = A3SConnectionConfig.fromEnvironment(process.env)
678+
const sandbox = await Sandbox.create('a3s-base', {
679+
...connection.typescriptOptions(),
680+
timeoutMs: 60_000,
681+
})
682+
683+
try {
684+
const result = await sandbox.commands.run(
685+
"printf 'hello from A3S Box\\n'"
686+
)
687+
console.log(result.stdout)
688+
} finally {
689+
await sandbox.kill()
690+
}
691+
```
692+
693+
`A3SConnectionConfig` passes the API key to the pinned E2B client, which sends
694+
it as `X-API-Key`. It derives `A3S_BOX_DOMAIN` only when the endpoint hostname
695+
starts with `api.`; otherwise set the domain explicitly.
696+
697+
##### 5. Use unchanged official E2B SDKs
698+
699+
The unchanged official SDKs use their own environment names. Point them at the
700+
same A3S Box service without setting a process-global Sandbox URL:
701+
702+
```bash
703+
export E2B_API_URL="${A3S_BOX_ENDPOINT}"
704+
export E2B_API_KEY="${A3S_BOX_API_KEY}"
705+
export E2B_DOMAIN="box.example.com"
706+
unset E2B_SANDBOX_URL
707+
```
708+
709+
The generated `e2b_` key passes the official clients' default API-key
710+
validation. Do not set `E2B_SANDBOX_URL` to the shared
711+
`sandbox.<domain>` endpoint in a multi-Sandbox deployment: it is a fixed URL,
712+
and file-transfer URLs would lose the Sandbox identity. The native A3S
713+
packages intentionally ignore these `E2B_*` connection variables.
714+
715+
##### Troubleshooting
716+
717+
| Symptom | Check |
718+
| --- | --- |
719+
| `A3S_BOX_ENDPOINT is required` | Export it in the same process that starts the application; the native package does not read `E2B_API_URL`. |
720+
| HTTP `401` | `A3S_BOX_API_KEY` must be the raw `e2b_...` value, not `account.hash`; also confirm the reverse proxy preserves `X-API-Key`. |
721+
| Official SDK rejects the key before sending a request | Regenerate it in the required `e2b_[0-9a-f]+` form. Uppercase hex and `a3s_` prefixes are rejected by the current compatibility service. |
722+
| HTTP `404` from every SDK operation | Remove `/api`, `/v1`, `/sandboxes`, and the trailing slash from `A3S_BOX_ENDPOINT`; ensure the reverse proxy does not add or strip a path prefix. |
723+
| Control listing works but commands, files, PTY, or health fail | Check wildcard DNS, gateway firewall/port, wildcard certificate trust, and `sandbox_public_domain`. |
724+
| TLS hostname mismatch on a Sandbox URL | The certificate must cover `*.sandbox_domain`; `A3S_BOX_DOMAIN` contains only the DNS suffix, without a scheme or port. |
725+
| A custom control hostname routes Sandbox calls to the wrong domain | Set `A3S_BOX_DOMAIN` exactly to `e2b_compat.sandbox_domain`. |
726+
| Sandbox creation reports an unknown template | Pass a template ID declared by `template_policy`, such as `a3s-base`. |
727+
| Existing Sandbox tokens fail after a restart | Restore the token encryption/digest key version used to issue them; rotate with a new retained version instead of replacing key material in place. |
728+
729+
Use HTTPS everywhere outside loopback, never commit raw account or token keys,
730+
and keep credentials out of logs. To rotate an account key without an outage,
731+
add a second `account` block with the same `owner_id`, a new label and
732+
`client_id`, and the new hash; restart the service, update clients, then remove
733+
the old account in a later restart.
734+
735+
#### Current implementation status
736+
400737
The Phase 2 preview includes an owner-scoped Rust lifecycle router for create,
401738
connect, get, memory-preserving pause, connect/resume, v1/v2 running/paused
402739
list, timeout, monotonic refresh, current single/batch metrics,
@@ -1057,6 +1394,10 @@ matrix, warm-pool Dockerfile `RUN` smoke, CRI smoke, and host soak procedures.
10571394
| Variable | Description |
10581395
| --- | --- |
10591396
| `A3S_HOME` | Data directory. Default: `~/.a3s`. |
1397+
| `A3S_BOX_ENDPOINT` | Native Python/TypeScript SDK control-plane origin for the E2B-compatible service, for example `https://api.box.example.com`. Do not append an API path or trailing slash. |
1398+
| `A3S_BOX_API_KEY` | Raw self-hosted compatibility API key sent as `X-API-Key`. It must match `e2b_[0-9a-f]+`; do not use the PBKDF2 hash stored in the server ACL. |
1399+
| `A3S_BOX_DOMAIN` | Optional Sandbox wildcard DNS suffix when it cannot be derived from a conventional `https://api.<domain>` endpoint. Do not include a scheme or port. |
1400+
| `A3S_BOX_SANDBOX_URL` | Fixed single-Sandbox fixture override. Leave unset for normal self-hosted multi-Sandbox deployments. |
10601401
| `A3S_IMAGE_CACHE_SIZE` | Image cache size. Default: `10g`. |
10611402
| `A3S_TEE_SIMULATE` | Enables simulated TEE report behavior. |
10621403
| `A3S_REGISTRY_PROTOCOL` | Legacy registry protocol override for local/insecure registry tests. Prefer `a3s-box push --plain-http` for push. |

0 commit comments

Comments
 (0)