boxlite-cli 0.10.1

Command-line interface for BoxLite container runtime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# boxlite serve — REST API Server

`boxlite serve` starts a long-running HTTP server that exposes the full BoxLite runtime over a REST
API. SDK clients connect to it via `Boxlite.rest()` — the server holds a single `BoxliteRuntime` and
translates HTTP requests into runtime calls, WebSocket connections into bidirectional attach
sessions.

## Quick Start

```bash
boxlite serve                      # listen on 0.0.0.0:8100 (permissive)
boxlite serve --port 9090          # custom port
boxlite serve --host 127.0.0.1    # bind localhost only
boxlite serve --api-key dev-key    # require Bearer dev-key (else 401)
```

Ctrl-C triggers graceful shutdown (`runtime.shutdown` with a 10 s timeout).

Connect from an SDK:

```rust
let rt = BoxliteRuntime::rest(BoxliteRestOptions::new("http://localhost:8100"))?;
```

## Startup Call Graph

```
execute(ServeArgs, GlobalFlags)
  │
  ├─ GlobalFlags::resolve_runtime_options() — resolve local runtime settings
  ├─ GlobalFlags::create_runtime_with_options()
  │                                          — build local BoxliteRuntime
  │
  ├─ Arc::new(AppState { runtime, boxes, executions,
  │                      api_key, lifecycle, last_activity })
  │                                          — shared state for all handlers
  ├─ tokio::spawn(reaper_loop(state))       — orphan reaper + lifecycle sweep
  │                                            (30 s tick)
  │
  ├─ build_router(state)                    — register 26 routes on axum::Router
  │
  ├─ TcpListener::bind(host:port)           — bind TCP socket
  │
  └─ axum::serve(listener, app)             — start serving requests
       └─ .with_graceful_shutdown(ctrl_c)
            └─ runtime.shutdown(Some(10))   — stop all boxes on exit
```

## Architecture

```
┌───────────────────────────────────────────────────────┐
│                    SDK Clients                         │
│   (Boxlite.rest() → ApiClient → reqwest / WebSocket)  │
└────────────────────────┬──────────────────────────────┘
                         │ HTTP / WebSocket
                         ▼
┌───────────────────────────────────────────────────────┐
│                boxlite serve  (axum)                   │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │                AppState (Arc)                     │  │
│  │                                                   │  │
│  │  ┌─────────────────┐                              │  │
│  │  │ BoxliteRuntime  │    core runtime              │  │
│  │  └─────────────────┘                              │  │
│  │  ┌──────────────────────────────────────────┐     │  │
│  │  │ boxes: RwLock<HashMap<String, Arc<LiteBox>>>   │  │
│  │  └──────────────────────────────────────────┘     │  │
│  │  ┌──────────────────────────────────────────────┐ │  │
│  │  │ executions: RwLock<HashMap<String,           │ │  │
│  │  │                     Arc<ActiveExecution>>>    │ │  │
│  │  └──────────────────────────────────────────────┘ │  │
│  └──────────────────────────────────────────────────┘  │
│                                                        │
│  Handlers:  config · boxes · executions                │
│             files · metrics · snapshots · advanced     │
│                                                        │
│  Background:  reaper_loop  (orphans + lifecycle sweep)  │
└───────────────────────────────────────────────────────┘
                         │
                         ▼
┌───────────────────────────────────────────────────────┐
│               BoxliteRuntime  (core)                   │
│     LiteBox → ShimController → Guest VM                │
└───────────────────────────────────────────────────────┘
```

`AppState.boxes` is a lazy cache — `get_or_fetch_box()` populates it from `runtime.get()` on first
access. `AppState.executions` maps execution IDs to `Arc<ActiveExecution>` for the active-session
registry. `AppState.lifecycle` holds the AutoStop/AutoDelete deadlines the sweep acts on — the
engine accepts neither on a local runtime — and `AppState.last_activity` is the idle clock, stamped
by the request middleware and by client data frames on an attach.

## Handler Reference

| Module       | File                     | Purpose                                                       |
|--------------|--------------------------|---------------------------------------------------------------|
| `config`     | `handlers/config.rs`     | Capability discovery (snapshots, clone, export, import)       |
| `me`         | `handlers/me.rs`         | Identity of the calling credential (`GET /v1/me`)             |
| `boxes`      | `handlers/boxes.rs`      | Box CRUD and lifecycle                                        |
| `executions` | `handlers/executions.rs` | Lifecycle: start, status, signal, kill, resize, attach        |
| `files`      | `handlers/files.rs`      | Tar-based file upload / download into / from boxes            |
| `metrics`    | `handlers/metrics.rs`    | Runtime-level and per-box metrics                             |
| `snapshots`  | `handlers/snapshots.rs`  | Snapshot CRUD + restore                                       |
| `advanced`   | `handlers/advanced.rs`   | Clone, export, import                                         |

## Route Reference

All paths are relative to the server root (e.g. `http://localhost:8100`).

### Auth & Config

| Method | Path                 | Handler               | Description                        |
|--------|----------------------|-----------------------|------------------------------------|
| GET    | `/v1/config`         | `config::get_config`  | Capability discovery (always public) |
| GET    | `/v1/me`             | `me::get_me`          | Identity of the calling credential |

**Auth.** With `--api-key <KEY>` (or `$BOXLITE_SERVE_API_KEY`) set, every
route except `GET /v1/config` requires `Authorization: Bearer <KEY>`
(constant-time match) and returns `401` otherwise. Without it the server is
permissive (accepts any/no bearer) — the zero-config local-dev default.

### Box CRUD & Lifecycle

| Method | Path                                  | Handler             | Description                    |
|--------|---------------------------------------|----------------------|-------------------------------|
| POST   | `/v1/boxes`                   | `boxes::create_box`  | Create a new box              |
| GET    | `/v1/boxes`                   | `boxes::list_boxes`  | List all boxes                |
| GET    | `/v1/boxes/{box_id}`          | `boxes::get_box`     | Get box info                  |
| HEAD   | `/v1/boxes/{box_id}`          | `boxes::head_box`    | Check box exists (204 / 404)  |
| DELETE | `/v1/boxes/{box_id}`          | `boxes::remove_box`  | Remove box (`?force=true`)    |
| POST   | `/v1/boxes/{box_id}/start`    | `boxes::start_box`   | Start a stopped box           |
| POST   | `/v1/boxes/{box_id}/stop`     | `boxes::stop_box`    | Stop a running box            |

### Command Execution

| Method | Path                                                            | Handler                       | Description                 |
|--------|-----------------------------------------------------------------|-------------------------------|-----------------------------|
| GET    | `/v1/boxes/{box_id}/attach`                             | `executions::attach_box`      | WebSocket attach to init    |
| POST   | `/v1/boxes/{box_id}/exec`                               | `executions::start_execution` | Start a new command         |
| GET    | `/v1/boxes/{box_id}/executions/{id}`                    | `executions::get_execution`   | Get status + exit code      |
| DELETE | `/v1/boxes/{box_id}/executions/{id}`                    | `executions::kill_execution`  | SIGKILL + evict             |
| GET    | `/v1/boxes/{box_id}/executions/{id}/attach`             | `executions::attach_execution`| WebSocket attach (bidi)     |
| POST   | `/v1/boxes/{box_id}/executions/{id}/signal`             | `executions::send_signal`     | Send cooperative signal     |
| POST   | `/v1/boxes/{box_id}/executions/{id}/resize`             | `executions::resize_tty`      | Resize PTY                  |

`/attach` is the box's **main command session** — the container's init, which
`run IMAGE COMMAND` replaces with COMMAND (docker semantics). It mirrors
docker's `POST /containers/{id}/attach`, distinct from exec-attach: the client
has no id for init, because init's execution id is the container id. The first
attach opens that session and registers it alongside tenant execs; the upgrade
response returns its id in `X-Boxlite-Execution-Id`, so signal/resize/kill and
reconnect then go through the `/executions/{id}/…` routes above. A second
client on an attached main session gets 409, same as for an exec.

### Files

| Method | Path                                 | Handler                | Description                       |
|--------|--------------------------------------|------------------------|-----------------------------------|
| PUT    | `/v1/boxes/{box_id}/files`   | `files::upload_files`  | Upload tar, extract into box      |
| GET    | `/v1/boxes/{box_id}/files`   | `files::download_files`| Download path as tar              |

### Metrics

| Method | Path                                        | Handler                  | Description               |
|--------|---------------------------------------------|--------------------------|---------------------------|
| GET    | `/v1/metrics`                       | `metrics::runtime_metrics`| Runtime-wide counters     |
| GET    | `/v1/boxes/{box_id}/metrics`        | `metrics::box_metrics`   | Per-box metrics + boot timing |

Serving per-box metrics boots a box that is not running, and a scrape is not
counted as use — so on a box with an AutoStop deadline it would only be stopped
again on the next tick. `box_metrics` answers 409 rather than start that cycle.
A box with no deadline still boots on scrape, as it always has.

### Snapshots

| Method | Path                                                        | Handler                       | Description          |
|--------|-------------------------------------------------------------|-------------------------------|----------------------|
| POST   | `/v1/boxes/{box_id}/snapshots`                      | `snapshots::create_snapshot`  | Create snapshot      |
| GET    | `/v1/boxes/{box_id}/snapshots`                      | `snapshots::list_snapshots`   | List snapshots       |
| GET    | `/v1/boxes/{box_id}/snapshots/{name}`               | `snapshots::get_snapshot`     | Get snapshot info    |
| DELETE | `/v1/boxes/{box_id}/snapshots/{name}`               | `snapshots::delete_snapshot`  | Delete snapshot      |
| POST   | `/v1/boxes/{box_id}/snapshots/{name}/restore`       | `snapshots::restore_snapshot` | Restore snapshot     |

### Advanced (Clone, Export, Import)

| Method | Path                                        | Handler               | Description                    |
|--------|---------------------------------------------|-----------------------|--------------------------------|
| POST   | `/v1/boxes/{box_id}/clone`          | `advanced::clone_box` | Clone a box                    |
| POST   | `/v1/boxes/{box_id}/export`         | `advanced::export_box`| Export box as archive          |
| POST   | `/v1/boxes/import`                  | `advanced::import_box`| Import box from archive body   |

## Execution Lifecycle

### Start

```
POST /v1/boxes/{box_id}/exec
  │
  ├─ get_or_resume_box(state, box_id)         — resolve LiteBox; 409 if the box
  │                                             is down and AutoResume is off
  ├─ build_box_command(req)                    — JSON body → BoxCommand
  ├─ litebox.run(cmd)                          — start command (returns Execution)
  │
  ├─ execution.stdin()                         — take stdin handle
  │    └─ if req.stdin is Some:
  │         stdin.write_all(data) + close       — write inline stdin, discard handle
  │
  └─ ActiveExecution::new(box_id, execution, stdin)  — wrap in server-side state
       │
       ├─ execution.stdout() / .stderr()       — take single-consumer stream halves
       │
       ├─ BacklogBroadcast::new(256, 256 KiB)  — stdout_bus + stderr_bus
       │
       ├─ tokio::spawn(stdout pump)            — Stream → bus.send()
       ├─ tokio::spawn(stderr pump)            — Stream → bus.send()
       │
       └─ tokio::spawn(wait task)
            ├─ execution.wait()                — block until process exits
            ├─ stdout_handle.await             — barrier: wait for pump completion
            ├─ stderr_handle.await             — ensures all output is broadcast
            ├─ done.store(true)
            └─ done_tx.send(true)              — notify all observers
```

### BacklogBroadcast

`BacklogBroadcast` wraps `tokio::sync::broadcast` with a byte-capped `VecDeque` backlog (256 KiB).
When a new subscriber calls `subscribe()`, it receives the full backlog snapshot first, then
switches to live broadcast — no gap, no interleaving. Both the backlog append and the broadcast
`send()` happen under the same `Mutex`, preventing duplicates. This mirrors the Go runner's
`streamBus` pattern for late-attach replay.

### Attach (WebSocket)

```
GET /v1/boxes/{box_id}/executions/{id}/attach
  │
  ├─ AttachQuery::policy()                     — stdin=0|false → Refuse, absent|1|true → Allow,
  │                                              anything else → 400 (never falls open)
  ├─ mark_connected()                          — claim single-attach slot (409 if taken)
  ├─ WebSocketUpgrade → on_upgrade             — HTTP → WS handshake
  │    └─ on_failed_upgrade: mark_disconnected()
  │
  └─ run_attach_session(socket, active, state, stdin_policy)
       │
       ├─ stdout_bus.subscribe()               — BacklogReceiver (replay + live)
       ├─ stderr_bus.subscribe()
       ├─ done_rx = active.done_rx()
       ├─ socket.split() → (sink, stream)
       │
       ├─ tokio::spawn(reader)                 — client → server
       │    ├─ Binary frames  → Refuse: error frame + Close 1008; else stdin.write_all()
       │    ├─ Text {"type":"resize"}  → Refuse: error frame; else execution.resize_tty()
       │    ├─ Text {"type":"signal"}  → Refuse: error frame; else execution.signal()
       │    └─ Text {"type":"stdin_eof"}  → Refuse: error frame; else stdin.close() + drop
       │       (Refuse answers each kind once; the reader keeps draining so the
       │        writer can flush its close — whichever task ends first aborts
       │        the other.)
       │
       ├─ tokio::spawn(writer)                 — server → client
       │    ├─ if already done → drain backlog only (fast path)
       │    ├─ select! loop:
       │    │    stdout_rx.recv() → Binary [0x01 | data]
       │    │    stderr_rx.recv() → Binary [0x02 | data]
       │    │    ctrl_rx.recv()   → Text (error frames), or Text + Close 1008
       │    │    ping_interval    → Ping (every 15 s)
       │    │    done_rx.changed()→ drain remaining, break
       │    └─ if done: Text {"type":"exit","exit_code":N} + Close
       │
       └─ select!(reader, writer)              — first to finish aborts the other
            └─ mark_disconnected()
```

### Attach Wire Format

```
Client                       WebSocket                        Server
  │                                                              │
  │── Binary(raw bytes) ───────────────────────────────────────▶│ stdin
  │── Text({"type":"resize","rows":N,"cols":N}) ──────────────▶│ resize_tty
  │── Text({"type":"signal","sig":N}) ─────────────────────────▶│ signal
  │── Text({"type":"stdin_eof"}) ──────────────────────────────▶│ close stdin
  │                                                              │
  │◀────── Binary([0x01 | stdout data]) ─────────────────────────│ stdout
  │◀────── Binary([0x02 | stderr data]) ─────────────────────────│ stderr
  │◀────── Text({"type":"exit","exit_code":N}) ──────────────────│ completion
  │◀────── Text({"type":"error","message":"..."}) ───────────────│ error
  │◀────── Ping ─────────────────────────────────────────────────│ keepalive
  │                                                              │
```

Only one WebSocket client may be attached to an execution at a time. `mark_connected()` returns
`false` (HTTP 409) if another client is already attached or the reaper has claimed a kill. On
reconnect, escalation flags reset — a fresh disconnect starts a fresh reap clock.

## Orphan Reaper

A background task (`tokio::spawn`) that ticks every 30 s, cleaning up executions whose attach
client disconnected and never came back.

### Escalation Timeline

```
Client disconnects
──────┬────────────────────────────────────────────────────────────
      │
      ├── 5 min (BOXLITE_RECONNECT_GRACE) ───────▶ SIGHUP
      │                                              │
      ├── + 30 s (BOXLITE_SHUTDOWN_GRACE) ─────────▶ SIGTERM
      │                                              │
      ├── + 30 s (BOXLITE_SHUTDOWN_GRACE) ─────────▶ SIGKILL + evict
      │
──────┴────────────────────────────────────────────────────────────

Hard cap:  24 h (BOXLITE_MAX_SESSION_LIFETIME) → immediate SIGKILL
Completed: 5 min after exit → evict from map
```

### Reaper Call Graph

```
reaper_loop(state)
  │
  ├─ resolve_duration("BOXLITE_RECONNECT_GRACE", 300 s)
  ├─ resolve_duration("BOXLITE_SHUTDOWN_GRACE",   30 s)
  ├─ resolve_duration("BOXLITE_MAX_SESSION_LIFETIME", 86400 s)
  │
  └─ loop { ticker.tick(); run_reap_once(...); run_lifecycle_once(...) }

run_reap_once(state, now, ...)
  │
  ├─ snapshot candidates from executions map  (read lock, then drop)
  │
  └─ for each (id, active):
       │
       ├─ is_done() + !should_retain(now)  → evict from map
       │
       ├─ lifetime > max_lifetime          → mark_reaping_kill + try_kill_and_evict
       │
       ├─ already reaping_kill             → try_kill_and_evict  (retry from prior tick)
       │
       ├─ try_escalate_hup()               — SIGHUP if reconnect_grace expired
       │    ├─ signal OK  → finish_escalation()
       │    └─ signal fail → escalation_failed_mark_doomed + try_kill_and_evict
       │
       ├─ try_escalate_term()              — SIGTERM if shutdown_grace expired after HUP
       │    ├─ signal OK  → finish_escalation()
       │    └─ signal fail → escalation_failed_mark_doomed + try_kill_and_evict
       │
       └─ try_escalate_kill()              — SIGKILL if shutdown_grace expired after TERM
            └─ try_kill_and_evict

try_kill_and_evict(state, id, active)
  ├─ timeout(10 s, execution.kill())
  ├─ success → executions.write().remove(id)
  └─ failure → leave in map (reaping_kill=true), retry next tick
```

### AttachState Machine

```
                mark_connected()                  mark_disconnected()
┌──────────┐ ──────────────────▶ ┌────────────┐ ──────────────────────┐
│   Idle   │                     │  Connected  │                       │
│  (disc.) │ ◀──────────────────────────────────────────────────────────┘
└──────────┘
     │
     │ reconnect_grace expired
     ▼
┌──────────┐  shutdown_grace    ┌────────────┐  shutdown_grace
│  HUP'd   │ ────────────────▶ │   TERM'd   │ ────────────────┐
└──────────┘                    └────────────┘                  │
                                                                ▼
                                                         ┌────────────┐
                                                         │   KILL'd   │
                                                         │  (doomed)  │
                                                         └────────────┘
```

TOCTOU safety: the `escalating` flag blocks `mark_connected()` during signal delivery so a late
attach cannot race the kill. `escalation_failed_mark_doomed()` atomically clears `escalating` and
sets `reaping_kill` under one lock acquisition.

## Configuration Reference

### Environment Variables

| Variable                        | Default  | Format    | Description                                         |
|---------------------------------|----------|-----------|-----------------------------------------------------|
| `BOXLITE_RECONNECT_GRACE`      | `300s`   | `Ns/Nm/Nh`| Grace period before SIGHUP after disconnect          |
| `BOXLITE_SHUTDOWN_GRACE`       | `30s`    | `Ns/Nm/Nh`| Grace between SIGHUP→SIGTERM and SIGTERM→SIGKILL    |
| `BOXLITE_MAX_SESSION_LIFETIME` | `86400s` | `Ns/Nm/Nh`| Hard cap: SIGKILL after this lifetime                |

### CLI Flags

| Flag     | Default   | Description              |
|----------|-----------|--------------------------|
| `--port` | `8100`    | TCP port to listen on    |
| `--host` | `0.0.0.0` | Address to bind          |

### Internal Constants

| Constant                    | Value   | Description                                |
|-----------------------------|---------|--------------------------------------------|
| `REAPER_TICK`               | 30 s    | Reaper check interval                      |
| `REAPER_SIGNAL_TIMEOUT`     | 10 s    | Timeout for signal / kill delivery         |
| `COMPLETED_RETENTION_GRACE` | 5 min   | How long completed runs stay in map        |
| `ATTACH_BROADCAST_CAPACITY` | 256     | Broadcast channel buffer (chunks)          |
| `BACKLOG_BYTE_CAP`          | 256 KiB | Max backlog retained for late replay       |
| `ATTACH_KEEPALIVE_INTERVAL` | 15 s    | WebSocket ping interval                    |
| `ATTACH_WRITE_TIMEOUT`      | 20 s    | WebSocket write timeout                    |
| `ALLOWED_SIGNALS`           | 1, 2, 3, 6, 10, 12, 15, 28 | Cooperative signal whitelist |

## Error Handling

All handlers use `error_response(StatusCode, message, error_type)` to return a consistent JSON
shape:

```json
{
  "error": {
    "message": "box not found: abc123",
    "type": "NotFoundError",
    "code": 404
  }
}
```

`classify_boxlite_error()` maps `BoxliteError` to HTTP status:

| Error message contains | Status | Type               |
|------------------------|--------|--------------------|
| `"not found"`          | 404    | `NotFoundError`    |
| `"already"` / `"conflict"` | 409 | `ConflictError`   |
| `"unsupported"`        | 400    | `UnsupportedError` |
| anything else          | 500    | `InternalError`    |

## See Also

- [CLI Development Guide]../../../../../docs/development/cli.md — building and testing the CLI
- [Architecture]../../../../../docs/architecture/README.md — core runtime architecture
- [Rust Style Guide]../../../../../docs/development/rust-style.md — coding standards