Wire protocol
The parts of crucible's API that OpenAPI cannot express, specified for SDK
authors in any language. Everything JSON-shaped (request/response bodies,
error envelopes) lives in openapi.json and is generated
from the Go wire types. This document covers only the binary frame
protocol and the streaming transports that carry it.
Reference implementation: the sdk/wire Go package (codec)
and scripts/wsexec (a minimal WebSocket client that
speaks exactly what a non-Go SDK would). Conformance fixtures:
sdks/fixtures; see Fixtures below.
Frames
Exec output (and interactive input) travels as a sequence of length-prefixed frames. Every frame is one fixed 8-byte header followed by its payload:
| offset | size | field | encoding |
|---|---|---|---|
| 0 | 1 | frame type | see table below |
| 1 | 3 | reserved | zeroed on write, ignored on read |
| 4 | 4 | payload length | uint32, big-endian |
| 8 | length | payload | raw bytes |
| type | value | direction | payload |
|---|---|---|---|
stdout |
1 | guest → client | raw output chunk |
stderr |
2 | guest → client | raw output chunk |
exit |
3 | guest → client | JSON ExecResult (see the WireExecResult schema in openapi.json) |
stdin |
4 | client → guest | raw input chunk (interactive only) |
stdin_close |
5 | client → guest | empty; signals stdin EOF without dropping the connection |
Rules (all pinned by the fixtures):
- Max payload is 65536 bytes (
max_payload_sizein the fixture manifest). Writers must chunk larger logical writes into consecutive frames of the same type; readers must treat consecutive same-type frames as one continuous stream; chunk boundaries carry no meaning. - A response stream ends with exactly one
exitframe, then EOF. A stream that ends without one is an error (the command was lost). - Readers must reject a header whose declared length exceeds the max payload size, and report truncation (EOF mid-header or mid-payload) as an error, never as a clean end of stream.
- Frame type values and the header layout are frozen: they travel on the wire and non-Go clients hard-code them.
- Keep numeric values and JSON tags exactly as specified; unknown frame types should be treated as a protocol error.
The framing is deliberately the same shape Docker uses for its container attach/logs API.
One-shot exec: POST /sandboxes/{id}/exec
Request: JSON ExecRequest body (see openapi.json). Response on success:
200 with Content-Type: application/octet-stream, the body being a frame
stream (stdout/stderr frames as the command runs, one terminal exit
frame). Validation failures respond before streaming with a normal JSON
error and 4xx status.
Because the 200 is committed before the command finishes, post-commit
failures (agent unreachable, VM died) are reported in-band: the daemon
synthesizes an exit frame with exit_code: -1 and an error string. The
framing contract always holds, so a client never has to parse a half-JSON,
half-frame body.
Interactive exec
A full-duplex session (persistent cwd/env, live stdin) with the same
frame protocol in both directions. Two transports carry it; the frame
bytes are identical on both, so one codec serves everything.
Transport A, hijacked connection: POST /sandboxes/{id}/exec?stdin=1
For clients that own a raw TCP/TLS socket (the Go SDK, the CLI's
shell/exec -i). The client writes an ordinary HTTP/1.1 request with the
JSON ExecRequest body; after the daemon answers with a bare
HTTP/1.1 200 OK header block, the connection stops being HTTP: the client
sends stdin/stdin_close frames and reads stdout/stderr/exit
frames until EOF. Closing the connection kills the guest command.
This is the lowest-overhead path, but it is invisible to fetch()-style
HTTP APIs and will not traverse an L7 proxy; hence transport B.
Transport B, WebSocket: GET /sandboxes/{id}/exec + upgrade
The cross-language transport (browser-style HTTP stacks, anything behind a gateway). Contract:
- Standard WebSocket upgrade handshake on
GET /sandboxes/{id}/exec. Auth is the usualAuthorization: Bearer <key>header on the handshake request. Pre-upgrade failures are plain HTTP errors on the handshake response (400bad id,404unknown sandbox); a plain GET without an upgrade handshake answers426. - The client's first message is the JSON
ExecRequest(text or binary; the daemon parses the payload either way). It must arrive within 30 seconds. A request that fails validation closes the socket with status1008and the reason in the close frame. - Everything after is the frame protocol: the concatenation of binary message payloads in each direction is exactly the frame stream from transport A. Frames may split across WebSocket messages and messages may pack multiple frames: decode the concatenated byte stream, not individual messages.
- A failure to reach the guest closes with status
1011+ reason. After theexitframe is delivered the daemon closes with1000.
File transfer: POST /sandboxes/{id}/files (push), GET (pull)
Not framed. Push streams a tar archive as the raw request body
(?path= names the guest destination directory); the guest agent extracts
entries beneath it and rejects any entry whose resolved path escapes
(absolute paths, .., symlinks pointing outside), then answers with a JSON
WireFilesPutResult. Pull (GET …/files?path=) returns a single file's
raw bytes. Both are plain HTTP streaming; no special client machinery.
Fixtures
sdks/fixtures contains recorded frame streams
(*.bin) plus manifest.json describing
every frame in them: type byte, payload length, payload SHA-256, the
payload text when short/printable, and the parsed ExecResult for exit
frames. Invalid streams (truncated header/payload, oversize length) are
included with the required failure mode.
An SDK's codec test suite should, with no daemon and no KVM:
- Parse
manifest.json, assert itsheaderconstants match the codec's. - For every valid fixture: decode the
.bin, compare each frame against the manifest entries, and require clean EOF after the last one. - For every
"invalid": truefixture: require a decode error, never a clean EOF. - For
stdin_session.bin(directionhost_to_guest): encode the frames listed in the manifest and require byte-identical output; this checks the encoder half.
The fixtures are generated by sdks/fixtures/gen
using the real Go codec, and the manifest is derived by re-decoding the
generated bytes, so they cannot disagree with the implementation. CI
regenerates them (make gen-fixtures) and fails on any diff.