boxlite-cli 0.9.4

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
# 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
boxlite serve --port 9090          # custom port
boxlite serve --host 127.0.0.1    # bind localhost only
```

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::create_runtime()          — build local BoxliteRuntime
  │
  ├─ Arc::new(AppState { runtime, boxes, executions })
  │                                          — shared state for all handlers
  ├─ tokio::spawn(reaper_loop(state))       — background orphan reaper (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:  auth · config · boxes · executions         │
│             files · metrics · snapshots · advanced     │
│                                                        │
│  Background:  reaper_loop  (orphan cleanup)            │
└───────────────────────────────────────────────────────┘
                         │
                         ▼
┌───────────────────────────────────────────────────────┐
│               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.

## Handler Reference

| Module       | File                     | Purpose                                                       |
|--------------|--------------------------|---------------------------------------------------------------|
| `auth`       | `handlers/auth.rs`       | OAuth2 token endpoint (local passthrough, always succeeds)    |
| `config`     | `handlers/config.rs`     | Capability discovery (snapshots, clone, export, import)       |
| `boxes`      | `handlers/boxes.rs`      | Box CRUD: create, list, get, head, start, stop, remove        |
| `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                        |
|--------|----------------------|-----------------------|------------------------------------|
| POST   | `/v1/oauth/tokens`   | `auth::oauth_token`   | Get bearer token (always succeeds) |
| GET    | `/v1/config`         | `config::get_config`  | Capability discovery               |

### Box CRUD & Lifecycle

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

### Command Execution

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

### Files

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

### Metrics

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

### Snapshots

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

### Advanced (Clone, Export, Import)

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

## Execution Lifecycle

### Start

```
POST /v1/default/boxes/{box_id}/exec
  │
  ├─ get_or_fetch_box(state, box_id)          — resolve LiteBox from cache or runtime
  ├─ 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/default/boxes/{box_id}/executions/{id}/attach
  │
  ├─ 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)
       │
       ├─ 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  → stdin.write_all()
       │    ├─ Text {"type":"resize"}  → execution.resize_tty()
       │    ├─ Text {"type":"signal"}  → execution.signal()
       │    └─ Text {"type":"stdin_eof"}  → stdin.close() + drop
       │
       ├─ 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 from reader)
       │    │    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_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