Skip to content

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_size in 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 exit frame, 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:

  1. Standard WebSocket upgrade handshake on GET /sandboxes/{id}/exec. Auth is the usual Authorization: Bearer <key> header on the handshake request. Pre-upgrade failures are plain HTTP errors on the handshake response (400 bad id, 404 unknown sandbox); a plain GET without an upgrade handshake answers 426.
  2. 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 status 1008 and the reason in the close frame.
  3. 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.
  4. A failure to reach the guest closes with status 1011 + reason. After the exit frame is delivered the daemon closes with 1000.

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:

  1. Parse manifest.json, assert its header constants match the codec's.
  2. For every valid fixture: decode the .bin, compare each frame against the manifest entries, and require clean EOF after the last one.
  3. For every "invalid": true fixture: require a decode error, never a clean EOF.
  4. For stdin_session.bin (direction host_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.

Was this page helpful?