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 docs/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.