The E2B SDK exposes a sandbox.commands.* surface for
running shell commands inside a sandbox with streaming stdout/stderr,
a PID handle, and kill() / send_stdin() on
live processes. Unlike the code-interpretation surface
(sandbox.run_code(…) → POST /execute
with NDJSON lines), commands speak Connect-RPC — a
flavour of protobuf JSON over HTTP with a small envelope header on
server-streaming calls. This appendix lays out what Coppice puts on
the wire and why.
Endpoints
The SDK’s e2b.sandbox_sync.commands.Commands points a
ProcessClient at the envd URL with json=True, which produces seven
routes under /process.Process/:
| route | kind | purpose |
|---|---|---|
POST /process.Process/Start | server-stream | spawn a command, stream its events |
POST /process.Process/Connect | server-stream | reattach to an already-started PID |
POST /process.Process/List | unary | active-process catalogue |
POST /process.Process/SendSignal | unary | SIGTERM/SIGKILL a PID |
POST /process.Process/SendInput | unary | feed stdin bytes to a PID |
POST /process.Process/CloseStdin | unary | close a PID’s stdin |
POST /process.Process/Update | unary | resize a PTY (ignored on commands) |
Unary calls use content-type: application/json; the body is the
protobuf-JSON encoding of the request message (see
process.proto in the SDK). Responses are the same shape.
The envelope, and why NDJSON wasn’t enough
For Start and Connect the client sends one JSON body and the
server emits a sequence of JSON messages. Connect-RPC’s server
streaming wraps each message in a 5-byte framing header:
┌──── flags (1 byte) ────┬─ payload length (4 bytes, BE) ─┬── payload (length bytes) ──┐
│ 0x00 = normal │ │ JSON-encoded ProcessEvent │
│ 0x01 = compressed │ │ or {} for end-of-stream │
│ 0x02 = end-of-stream │ │ │
└────────────────────────┴────────────────────────────────┴────────────────────────────┘
The terminal envelope sets flag 0x02 and carries {} on success or
{"error":{"code":"...","message":"..."}} on failure. Coppice’s
implementation lives in e2b-compat/src/commands.rs
(encode_connect_envelope + encode_connect_end_stream).
NDJSON (one JSON object per line, no header) was tempting — that’s
what /execute does — but the Python SDK’s Connect client buffers by
envelope length, not newlines, and synthesises a {} terminator it
needs to see before declaring the call done. So we serve real
Connect-RPC here, and the matching /execute NDJSON stays as-is.
Events
Each Start/Connect payload is a StartResponse / ConnectResponse
whose .event field is one of:
{ "event": { "start": { "pid": 12345 } } }
{ "event": { "data": { "stdout": "aGVsbG8K" } } }
{ "event": { "data": { "stderr": "b29wcwo=" } } }
{ "event": { "end": { "exitCode": 0, "exited": true, "status": "", "error": "" } } }
{ "event": { "keepalive": {} } }
Bytes fields (stdout, stderr) are base64 — protobuf-JSON’s
convention for raw bytes. The gateway line-reads each pipe and emits
one data event per line, with the trailing newline preserved.
Lifecycle on the gateway
POST /process.Process/Start
│
├─► commands::spawn_command() — jexec -l -U root e2b-<id> <cmd>
│ ├─ state.commands.insert(pid, info)
│ └─ metrics.inc_command_started()
│
├─► emit start envelope { pid }
├─► spawn stdout/stderr line-readers ─► emit data envelopes
│ └─ tee_log(sandbox, stream, line) (sibling #73's LogBuffer)
│
├─► wait-task:
│ child.wait() → exit_code
│ emit end envelope { exit_code }
│ emit end-stream envelope (flag 0x02, {})
│ finalize_command(state, pid, exit_code, killed=false)
│ └─ state.commands.remove(pid)
│ └─ metrics.inc_command_finished_{ok,error,killed}
│
└─► response closes
A POST /process.Process/SendSignal { pid, signal } while the
command is running bypasses the wait-task’s counter bump: it removes
the entry from state.commands first and increments
commands_finished_killed_total. The wait-task’s finalize_command
call is idempotent — it sees the entry already gone and exits
without bumping anything. No double-counting.
NDJSON alias: POST /commands
We also ship a raw NDJSON endpoint that’s not spoken by the SDK:
POST /commands { "cmd": "seq 1 5" }
Streamed response:
{"type":"start","pid":87654}
{"type":"stdout","data":"1\n"}
{"type":"stdout","data":"2\n"}
{"type":"stdout","data":"3\n"}
{"type":"stdout","data":"4\n"}
{"type":"stdout","data":"5\n"}
{"type":"end","exit_code":0}
This exists so curl + shell rigs can exercise the whole lifecycle
without reimplementing envelope framing. GET /commands,
GET /commands/:pid, and POST /commands/:pid/kill round out the
REST surface. The example at examples/11-commands-stream.py uses
the real SDK, not this alias.
Timeouts
The SDK’s commands.run(cmd, timeout=60) passes that value to httpcore
as the per-stream read timeout. The gateway additionally enforces it
on the NDJSON alias via tokio::time::timeout(child.wait()) —
pass timeout_ms=0 for no timeout, omit for 60 s, or an integer for
the ms count. When the deadline fires the gateway issues SIGKILL
and emits {"type":"end","exit_code":-1} with killed=true in the
bookkeeping.
Metrics
Three Prometheus series land on /metrics:
coppice_commands_started_total
coppice_commands_finished_total{status="ok|error|killed"}
coppice_commands_active
commands_active is a gauge that goes up on Start and down on every
terminal transition, so a scrape-time snapshot of live commands is
cheap. The _finished_ partition matches exactly: every started
command eventually bumps exactly one of the three statuses.
jexec presence
The gateway dev-host path (Linux, macOS, CI) skips the jexec
wrapper when /usr/sbin/jexec is absent and runs the command
directly. This keeps the HTTP surface testable on any box —
the in-tree tests in tests/commands.rs and the inline unit tests
in commands.rs hit the real router without needing a FreeBSD jail.
On honor, the wrapper is always present, so the production path runs
jexec -l -U root e2b-<id> <cmd> <args...>.