Wire protocol
attachwire implements interactive-attach-v1 - the binary frame format, control messages, sanitization allowlist, and backpressure shared by every leg of the wire.
attachwire implements the framing and codec layer of interactive-attach-v1:
the binary frame format, two independent sequence namespaces, typed event
payloads, the JSON control-plane message set, a screen-snapshot serialization,
degraded-lane batch envelopes, and a token-bucket backpressure helper. It is
pure Go, standard-library only, and carries no transport (WebSocket, HTTP,
SSE) and no relay logic of its own - it is the shared codec that a host, a
viewer, and a relay all encode and decode against, so the two ends of a
connection cannot drift apart from each other.
The protocol is derived from asciinema's ALiS live-stream protocol in shape
only (binary, length-prefixed, varint-framed, with a server-side snapshot for
late joiners and resume-from-sequence on reconnect) - it is not
byte-compatible with ALiS. The event types, the Snapshot/Control/Input
additions, the sanitization allowlist, and the auth model are this protocol's
own.
Out of scope by design
Multi-writer arbitration policy - who may take the pen, grab eligibility,
cooldowns, auto-release, presence derivation - is deliberately not
implemented here. This library carries the grab/release/pen-state messages
as an opaque, parse-only registry; arbitration policy belongs to whatever
sits in front of the wire (a relay) and can evolve without touching this
codec. attachwire implements only the wire encoding and the wire-visible
invariants: framing errors, the role enum, and the error-code registry.
Binary frame format
Every message, in both directions and on both the host and viewer legs, is one frame:
+----------+--------------+------------------+-------------------+
| type:u8 | seq:varint | rel_time:varint | payload:bytes... |
+----------+--------------+------------------+-------------------+seq and rel_time are unsigned LEB128 varints. Out-of-namespace frames
carry zeroed headers: every Control frame in either direction, every
non-host-produced Resize, and a post-Exit Snapshot sets seq = 0 and
rel_time = 0; receivers ignore both fields on those frames. Host-produced
stream frames (Output, applied-geometry Resize, Marker, Exit, and
pre-Exit Snapshot) carry the host output sequence. Input is the exception
on the viewer side: its header seq is meaningful and anchors the highest host
output sequence that viewer had applied, while the separate payload
inputSeq orders the viewer's keystrokes.
f := attachwire.Frame{Type: attachwire.TypeOutput, Seq: 42, Payload: data}
wire := attachwire.EncodeFrame(f)
decoded, err := attachwire.DecodeFrame(wire)Event types
type | Name | Producer | Payload |
|---|---|---|---|
0x01 | Output | host | raw terminal output bytes |
0x02 | Input | viewer | keystroke bytes + inputSeq + penGeneration + stamped userId |
0x03 | Resize | viewer (advertise) / host (applied echo) | terminal geometry |
0x04 | Marker | host | an annotation label (display-only, never executed) |
0x05 | Exit | host | process exit code + optional signal name |
0x06 | Snapshot | host | the current screen at a given sequence |
0x07 | Control | any | one JSON control-plane message |
An unknown type byte or an overflowing or truncated header varint is an
attachwire.FramingError from DecodeFrame, reported with
Code() == CodeFraming; the structured payload decoders use the same error
type for payload syntax violations. DecodeFrame does not know the
authenticated role and therefore does not enforce the protocol section 6.3
producer-admission matrix. The relay or other caller must apply that matrix and
close a disallowed producer with error.code = framing.
Two sequence namespaces
There are two independent monotonic counters, and conflating them is the defect the split exists to prevent:
- Host output sequence - assigned by the host to every host-produced frame, starting at 1, strictly increasing within one host stream epoch (the lifetime of one PTY-owning process). This is the space a resume position addresses.
- Viewer input sequence (
inputSeq) - assigned per viewer connection to its ownInputframes, starting at 1, and preserved across a carrier switch (WSS to/from the degraded lane).
An Input frame's frame-level seq is not an input sequence - it is the
host output sequence the viewer had applied when it produced the keystroke
(used for predictive-echo reconciliation). The viewer's own ordering lives in
the inputSeq payload field.
Typed payloads
out := attachwire.EncodeOutput(data)
in, err := attachwire.DecodeInput(payload) // InputPayload{InputSeq, PenGeneration, UserID, Data}
resize := attachwire.ResizePayload{Cols: 120, Rows: 40}
if err := resize.Validate(); err != nil {
// cols == 0 || rows == 0 is a framing error, not a valid 0xN geometry
}v0.52.1 geometry bound
The current codec rejects zero columns or rows but does not cap the four
unsigned geometry fields. The host ultimately applies Unix 16-bit winsize
fields, so compatible callers and relays must reject cols or rows above
65535 and pixel dimensions above 65535 before forwarding. 65536 currently
decodes successfully, then narrows to zero at the kernel PTY while the
headless VT and applied-Resize echo retain the larger value. Treat this
explicit 16-bit bound as a v0.52.1 safety requirement while the proposed wire
specification is corrected.
InputPayload.UserID is relay-stamped, never client-supplied: a viewer
sends Input with an empty user ID; whatever sits in front of the host
verifies the sender's token and stamps the verified identity before
forwarding. The host rejects any Input that arrives unstamped - a
presence check, not an independent verification, because the host trusts that
only its own authenticated outbound connection can deliver frames to it.
Control messages
Control frames (type 0x07) carry one JSON object with a type
discriminator. attachwire exposes each as a typed Go struct implementing the
ControlMessage interface (Subscribe, ResumeFrom, SnapshotRequest,
Kill, Grab, Release, Presence, InputAck, PenGranted, PenRevoked,
PenState, RoomState, ControlError):
frame, err := attachwire.BuildControlFrame(attachwire.SnapshotRequest{
Reason: attachwire.ReasonResync,
})
msg, err := attachwire.DecodeControl(jsonPayload) // dispatches on "type"Unknown JSON fields on a known message are ignored for forward
compatibility; an unknown message type is rejected. error.code values are
framing, auth, room-mismatch, pen-denied, ring-miss, backpressure,
rate-limited, epoch-stale, and internal (attachwire.ErrorCode
constants) - the code registry is draft and extendable, the error message
shape itself is frozen.
Sanitization allowlist
Terminal output originates in an untrusted process - an agent's PTY running
arbitrary commands over content it doesn't control. Direction matters:
host→relay Output carries raw PTY master bytes in v0.52.1; the sanitization
boundary is relay→viewer/display-bound output. The proposed protocol requires
a relay to filter before forwarding and requires every viewer to filter again,
but attachwire is only a codec plus sanitizer implementation and does not
enforce relay behavior. A viewer must never assume an upstream relay sanitized.
san := sanitize.New()
clean := san.Write(rawPtyBytes) // stateful across calls - a split escape
// sequence is classified the same as a
// contiguous oneThe governing rule: a viewer is a display-only mirror and never emits
input in response to output bytes. Every escape sequence whose real-terminal
effect is "reply on stdin" - cursor-position reports, device-attribute
queries, color/title queries - is stripped at the viewer-bound boundary. The
host-side VT answers those queries locally, but v0.52.1 still publishes the
original query bytes in raw host→relay Output; see PTY session
host. A representative sample of the allowlist:
| Escape class | Disposition | Why |
|---|---|---|
| Printable text, SGR color, cursor addressing | pass | normal, cosmetic, VT-bounded content |
| OSC 52 (clipboard write) | strip | paste-jacking / clipboard theft |
| OSC 8 (hyperlink) | display-only | render link text; no auto-navigation |
| Cursor-position / device-attribute query replies | strip at viewer | the host VT answers these; a viewer that replied would inject input |
| Window manipulation / geometry reports | strip | can move/resize the viewer; geometry is owned by the Resize frame only |
| Sixel graphics | pass, size-capped | display-only image, bounded by backpressure |
A dangling, incomplete escape introducer at a frame boundary is held pending
- never passed through - until its terminator arrives or a named cap is hit, at which point it is dropped rather than flushed through unsanitized.
Backpressure
Buffering toward a slow consumer is never unbounded. attachwire.TokenBucket
is the shared rate-limiting primitive a relay uses per viewer: when a slow
viewer's bucket is exhausted, intermediate Output frames for that viewer are
coalesced or dropped and it is brought current with a fresh Snapshot rather
than queued history; a viewer whose queue overflows entirely is disconnected
with error.code = backpressure rather than left to grow memory without
bound.
Degraded lane
When the primary transport is unavailable (a WebSocket upgrade blocked by a proxy, or repeated failed reconnects), the wire falls back to a first-class HTTP transport built on the same framing: Server-Sent Events downstream and batched POST upstream, with the same auth, the same two sequence namespaces, and the same idempotency guarantees (batches are deduplicated by a stable batch id, and de-duplicated content is never re-applied). Carrier fallback and upgrade-back are meant to be invisible above this library - see the attach client for how the host leg implements both carriers behind one interface.