Cube ships a cubemastercli binary that covers the
operator’s daily surface area: create a sandbox, list what’s
running, kill the one that ate all the RAM, build a template from
a directory, warm a pool. We didn’t have that. We had
curl | jq and coppice-pool-ctl. This page
is about closing that gap with a single Rust binary that ships
from the same Cargo workspace as the gateway and speaks the
gateway’s JSON wire format directly.
The shape
The binary is called coppice. It lives at
e2b-compat/src/bin/coppice.rs in the same crate as the
gateway, so it compiles against whatever the gateway’s Cargo.lock
says — no protocol drift between client and server. Install with
mise run coppice:install, which shells out to
cargo install —path e2b-compat —bin coppice —locked
and drops the binary in ~/.cargo/bin.
The subcommand surface mirrors Cube’s CLI deliberately, so an operator who’s been doing Cube for a year finds what they expect:
| command | gateway route | note |
|---|---|---|
coppice login —token <t> | GET /auth/whoami | Verifies the token when gateway auth is enabled, then stores the token and URL in the OS keyring when available (secret-tool or macOS security) with a 0600 file fallback for headless CI/honor. |
coppice whoami [—json] | GET /auth/whoami | Reads the stored token if no env token is present and prints the tenant/scopes accepted by the gateway. |
coppice logout | local credential store | Clears the stored token from the keyring or fallback credentials file. |
coppice sandbox create [—template] [—ttl] [—cpu] [—mem] [—json] | POST /sandboxes | Prints the id on stdout (or full JSON with —json). —ttl follows up with POST /sandboxes/:id/timeout. |
coppice sandbox list [—json] | GET /sandboxes | Aligned columns by default: id, template, state, started, end. |
coppice sandbox kill <id> | DELETE /sandboxes/:id | Exit 2 on 404, so shell pipelines can branch on it. |
coppice sandbox exec <id> — <argv…> | POST /sandboxes/:id/exec | Relays stdout verbatim; the — separator is mandatory when argv has flags. |
coppice sandbox logs <id> | GET /sandboxes/:id/logs | Snapshot only; —follow is advertised-but-unimplemented and exits 3. |
coppice tpl list [—json] | GET /templates | Aligned columns by default (NAME / PATH / DESCRIPTION). Registry is discovered once at gateway startup from <templates_root>/*-template. |
coppice tpl show <name> [—json] | GET /templates/:name | Key=value by default. Unknown template → exit 2 (same as sandbox-not-found). |
coppice tpl reload [—json] | POST /templates/reload | Re-runs the startup discovery pass against —templates-root and swaps the entry list atomically. Prints ok: registry reloaded; count=N. Picks up a newly zfs clone’d <name>-template dataset without a gateway restart. |
coppice tpl create-from-dir <path> —name <n> | POST /templates → 501 | Endpoint is shaped but build-from-dir is future work; gateway returns 501 and the CLI relays as unimpl (exit 3) so scripts keep their existing branch semantics. |
coppice pool status [—json] | GET /pool | TEMPLATE / WARM / ALLOCATED columns plus a total-entries footer. Gateway shells out to coppice-pool-ctl.sh list —json. |
coppice pool warm <t> —count N [—json] | POST /pool/:template/warm | Loops coppice-pool-ctl.sh checkout N times. Prints ok: <t> warmed; new_warm_count=N. |
coppice pool drain <t> [—json] | POST /pool/:template/drain | Releases every entry whose pool_entry matches the template. Prints ok: <t> drained; released=N. On dev hosts without coppice-pool-ctl.sh the gateway returns 503 and the CLI surfaces that as a generic HTTP error (exit 1) rather than unimpl. |
Gateway URL and token resolution
URL sources, in priority order: —url <u>,
$COPPICE_URL, $E2B_API_URL, the URL saved by
coppice login, then http://localhost:3000.
The $E2B_API_URL rung
matters — it’s what the official Python SDK honours, and picking it up
means an operator can point their shell at a remote gateway once and
have both coppice and any SDK scripts agree without
re-exporting. The flag strips trailing slashes so
—url http://localhost:3000/ and
—url http://localhost:3000 are the same thing.
Token sources are —token <t>,
$COPPICE_TOKEN, $E2B_API_KEY, then the saved
credential. coppice login accepts —token,
—token-stdin, or either env token, verifies the token
against /auth/whoami unless —no-verify is
set, and stores it for later commands. In tests or headless setups set
COPPICE_CREDENTIALS_FILE=/path/to/credentials.json to
force the file store; otherwise the CLI tries the OS keyring first and
falls back to ~/.config/coppice/credentials.json.
A worked session
From a laptop, tunnelled into honor via
ssh -L 3000:localhost:3000 -L 49999:localhost:49999 -L 49983:localhost:49983 honor,
talking to the live gateway rc.d service (2026-04-22, verbatim):
$ coppice sandbox create --template python --json | jq -r .sandboxID
609a21703c314940b66532095ac74ad9
$ coppice sandbox list
SANDBOX_ID TEMPLATE STATE STARTED END
609a21703c314940b66532095ac74ad9 python running 2026-04-22T23:57:06.487003909Z 2026-04-23T00:12:06.487004230Z
$ coppice sandbox exec 609a21703c314940b66532095ac74ad9 -- python3 -c 'print("hello from CLI")'
hello from CLI
$ coppice sandbox kill 609a21703c314940b66532095ac74ad9
$ echo $?
0
Note the interpreter is python3, not python:
the FreeBSD python template pkg-installs
python3.11 as python3 with no
python alias. The E2B Python SDK’s
run_code path is unaffected (it reaches the in-jail
ipykernel via /execute); this only bites on ad-hoc
coppice sandbox exec invocations.
That’s the critical path. Creating with resource limits is one flag
each: —cpu 50 —mem 256 forwards cpuCount=50
(rctl pcpu in percent) and memoryMB=256 to the
same handler path the SDK uses, so the limits bind before
jexec’s first syscall — same story as the
feature-audit row.
Against cubemastercli
Cube’s CLI is bigger than ours: cluster-admin subcommands and log
streaming with cursor state. We don’t yet have clusters or streaming
logs. The CLI surface reflects that honestly —
the endpoints we have are fully wired, the ones we don’t exit 3 with
a named reason. An operator running coppice pool warm python
—count 20 is told explicitly that the route isn’t in the
gateway yet, not given a silent failure or a fake success.
The rows we cover outright, though, cover the day-to-day:
sandbox create / list / kill / exec is 90% of what hits a CLI in
anger. examples/10-cli-roundtrip.sh in this repo runs
that full cycle as a single shell script and is what we point at in
CI and in demo scripts.
Template + pool operations
After #68 (2026-04-22), the six previously-stubbed subcommands all
reach the gateway. A worked session on honor, talking to the rc.d
service:
$ coppice tpl list
NAME PATH DESCRIPTION
node /zroot/jails/node-template Node 22 LTS
python /zroot/jails/python-template Python 3.11 + numpy + pandas
$ coppice tpl show python --json | jq .
{
"name": "python",
"path": "/zroot/jails/python-template",
"description": "Python 3.11 + numpy + pandas"
}
$ coppice pool status
TEMPLATE WARM ALLOCATED
python 3 3
(total_entries=3)
$ coppice pool warm python --count 2
ok: python warmed; new_warm_count=5
$ coppice pool drain python
ok: python drained; released=5
The template registry is now re-scannable without a gateway
restart: coppice tpl reload hits POST /templates/reload, which
re-walks --templates-root and swaps the entry list atomically.
The common workflow — zfs clone zroot/jails/_template@base-dns-… zroot/jails/foo-template, then coppice tpl reload — surfaces
the new template in the very next coppice tpl list. coppice tpl create-from-dir still exits 3: the endpoint shape is in place
(POST /templates), but the handler returns 501 today. The lone
surviving exit-3 case.
Gaps the CLI surfaced
Writing the CLI flushed out two small gateway rough edges that weren’t visible from the SDK’s happy path:
DELETE /sandboxes/:idon a nonexistent id returns HTTP 500, not 404. The backend error wrapper treats “jail not found” as a generic backend error rather than translating it to the shape the SDK already handles for other 404 paths. The CLI handles either (exit 1 on 500, exit 2 on 404) so it’s not a blocker; just worth fixing the next time someone’s inroutes.rs./templatesand/poolendpoint families landed under#68.coppice tpl list / showandcoppice pool status / warm / drainall hit the gateway directly now. The only exit-3 path left on the CLI istpl create-from-dir, whose endpoint returns 501 (build-from-dir is future work; seee2b-compat/src/templates.rs). Dev hosts withoutcoppice-pool-ctl.shinstalled get 503 from the pool handlers — the gateway stays up, only the /pool routes degrade.
Receipts
- Binary:
e2b-compat/src/bin/coppice.rs, compiles against the workspace Cargo.lock;cargo build —release —bin coppiceon honor produces a ~8 MiB statically-linked binary (rustls, not OpenSSL, so no libssl drift). - Tests:
e2b-compat/tests/cli.rsstands up a wire-compatible mock gateway in-process (Axum on a random 127.0.0.1 port), drives the compiled CLI viastd::process::Command, and asserts exit-code and stdout shape for create / list / exec / kill,—ttldouble-hop, env-var URL fallback, and the exit-3 unimpl paths. Five tests, all green locally. - Example:
examples/10-cli-roundtrip.sh— the end-to-end transcript above, minus the shell prompt. - Install:
mise run coppice:install.
One more row closed in the feature audit, one more binary that new operators can run on day one instead of learning the gateway’s JSON by hand.