ADR-0034: A gRPC control plane over the Unix socket and an opt-in TLS listener
Context and Problem Statement
Today every client reaches the daemon through one Unix socket
($XDG_RUNTIME_DIR/harness.sock, mode 0600) speaking a custom framed protocol
(SPEC-0002): uint32 big-endian length, a one-byte type, and a payload. There are
11 frame types; control payloads are JSON, attach payloads are raw bytes behind a
4-byte session id, and the protocol version is a hand-maintained
ProtoMajor/ProtoMinor pair whose history lives in a comment block. The only
network doors are the Wish SSH server ([server] listen), which hosts the TUI
inside the daemon, and the Prometheus listener ([server] metrics_listen).
Harness is converging with Switchboard and is expected to run as a persistent
service on production servers. That brings callers the socket cannot serve: a
laptop CLI driving a server's daemon, Switchboard or CI calling trigger, and
scripts that want structured answers without screen-scraping an SSH TUI. The
custom framing has no off-box story: no TLS, no authentication beyond file
permissions, no codegen for another language, and no tooling (grpcurl, load
balancers, health checks) that understands it.
Which wire protocol should the control plane speak, how is it exposed off-box without turning "can reach the port" into "can run code as the agents", and what of 📝 ADR-0004 survives?
Decision Drivers
- Local stays zero-config. The Unix socket and its
0600permission remain the default and the common path. Network exposure is an explicit operator choice, off by default. - Attach is code execution (📝 ADR-0008). Anyone who can write to an attach stream drives an agent CLI that often runs with permission prompts off. Every network path to attach must be authenticated, encrypted and authorized.
- Backpressure semantics survive intact (📝 ADR-0007, SPEC-0002 "Backpressure Isolation"): the PTY reader never blocks on a client, each attach session has a bounded queue, a slow client is coalesced to a fresh snapshot, and a wedged client is reaped without evicting healthy ones (#183).
- Standard tooling. A schema other languages can generate clients from, a
compatibility checker instead of a comment log, and interoperability with
grpcurl, gRPC health checks and reverse proxies. - Small dependency and operational surface. One Go binary (📝 ADR-0001), no sidecar, no second TLS stack to configure.
- No big-bang cutover. An upgraded daemon must still serve the previous release's clients for one release, and an upgraded client must still reach an older daemon.
Considered Options
Decision 1 — Control-plane protocol
- Option 1 — Keep the custom framing and add a TCP listener
- Option 2 — gRPC everywhere with grpc-go
- Option 3 — gRPC only for the network listener, framing on the Unix socket
- Option 4 — ConnectRPC (connect-go) everywhere, serving the gRPC protocol
Decision 2 — Protobuf code generation
- Option 1 —
bufwith locally pinned plugins - Option 2 —
protocdriven bygo generate
Decision 3 — HTTP surfaces (control, metrics, webhooks)
- Option 1 — Separate listeners, each a plain
net/httpserver - Option 2 — One port for everything, split by
cmux - Option 3 — One port for everything, one
net/httpmux
Decision Outcome
Decision 1: chosen option: Option 4 — ConnectRPC (connect-go) everywhere,
serving the gRPC protocol, because it gives the owner's requirement, a gRPC
service, at the wire level, while building on net/http instead of a second
HTTP/2 stack. A connect-go handler answers the gRPC, gRPC-Web and Connect
protocols on the same path, so any gRPC client (grpcurl, grpc-go, Python
grpcio) can call it, and a unary call is also reachable as a plain HTTP POST
with a JSON body, which suits Switchboard, CI and shell scripts. Harness's own
CLI and TUI always use the gRPC protocol (connect.WithGRPC()), so the wire the
project tests on every run is gRPC. Option 2 would have worked. It was not chosen
because grpc-go brings its own HTTP/2 transport and a larger dependency tree, and
it cannot share net/http TLS configuration, middleware or test helpers with the
metrics and webhook listeners.
Decision 2: chosen option: Option 1 — buf with locally pinned plugins,
because buf lint and buf breaking replace the hand-kept ProtoMinor log with
a machine check, and the plugins it runs (protoc-gen-go,
protoc-gen-connect-go) are pinned as tool directives in go.mod, so code
generation needs no network, no Buf Schema Registry account and no protoc
install.
Decision 3: chosen option: Option 1 — Separate listeners, each a plain
net/http server, because the three surfaces want opposite exposure: metrics
stay on loopback for a scraper, webhooks are published to forges on the internet,
and the control plane reaches a small set of authenticated operators. 📝 ADR-0021
already rejected sharing the metrics and webhook listeners for this reason.
Because a Connect service is an http.Handler, merging surfaces later is a mux
change, not a transport change, and cmux is never needed.
Shape
Services
One protobuf package, harness.v1, under proto/harness/v1/. Generated Go code
is committed under internal/gen/, so go install and go build never run
codegen. Three services replace the 11 frame types:
syntax = "proto3";
package harness.v1;
// Unary verbs that mirror the CLI and TUI 1:1 (ADR-0002): List, Describe, Start,
// Stop, Restart, Enable, Disable, Remove, Logs, Profiles, UseProfile, Reload,
// DaemonInfo, ProjectUp, ProjectDown, ScratchRun, Jobs, Trigger, Runs.
service ControlService {
rpc List(ListRequest) returns (ListResponse);
rpc Start(StartRequest) returns (StartResponse);
rpc Trigger(TriggerRequest) returns (TriggerResponse);
// ... one rpc per verb above
}
service EventService {
rpc Subscribe(SubscribeRequest) returns (stream Event);
}
service AttachService {
rpc Attach(stream AttachClientMsg) returns (stream AttachServerMsg);
}
message AttachClientMsg {
oneof msg {
AttachOpen open = 1; // first message, exactly once: name, cols, rows, mode
bytes input = 2; // keystrokes; discarded for a read-only session
Resize resize = 3; // smallest attached client wins (ADR-0003)
Pong pong = 4;
}
}
message AttachServerMsg {
oneof msg {
bytes snapshot = 1; // full repaint: first, and after every coalesce
bytes output = 2; // scrollback tail, then live PTY bytes
Ping ping = 3;
}
}
- One attach is one bidirectional stream. The HTTP/2 stream is the session:
the
session_idprefix,ATTACH_OPENandATTACH_CLOSEdisappear. Closing or cancelling the stream detaches exactly that session; the harness and other sessions are untouched. - Events are a server stream. Each subscriber keeps today's bounded queue of
128. Overflow still drops for that subscriber only, but the next event it
receives carries
gap = true, so the client re-lists instead of silently missing a transition. - Errors are status codes with a detail. Each SPEC-0002 error code maps to a
Connect code (
unknown_harnesstoNOT_FOUND,bad_requesttoINVALID_ARGUMENT, and so on) and travels as aharness.v1.ErrorDetailwith the machine code, so the TUI and CLI still surface a stable code and a human message.unknown_opbecomesUNIMPLEMENTED. - Versioning is the package plus
buf breaking.harness.v1is the major version. Additive changes are ordinary protobuf field and method additions, and CI runsbuf breaking --against '.git#branch=main'so a wire break cannot merge unnoticed. Client and daemon build versions ride theharness-client-versionandharness-daemon-versionheaders and are returned byDaemonInfo. A client calling a method an older daemon lacks getsUNIMPLEMENTEDand says the daemon is too old.
Backpressure is unchanged, and the head-of-line block goes away
The seam already exists: attach.Mux.Attach takes a write func([]byte) error,
and each session's pump goroutine drains its bounded queue (queueCap = 256)
through it. The gRPC handler passes a write that calls stream.Send. The queue
and the coalesce logic in internal/attach stay as they are; the one edit is
tagging each queued chunk as snapshot or output, which the mux already knows
when it enqueues:
- The PTY reader writes to the emulator, the ring and the durable log, then fans out with non-blocking sends. It never touches a network stream.
- A session whose queue overflows is coalesced to a fresh snapshot, exactly as
TestBackpressureCoalescepins today. - Liveness stays at the application layer. HTTP/2 PING frames are answered
by the peer's transport goroutine even when its application has stopped
reading, so they cannot detect the wedged client of #183. The attach stream
carries its own
Ping/Pong, and the reaper keeps its current rules, now per stream rather than per connection: any inbound message counts as alive, only a client that has answered at least one ping is eligible for eviction, and eviction cancels that stream, whose detach recomputes smallest-attached-wins for the survivors. HTTP/2 keepalive (HTTP2Config.SendPingTimeout) is also enabled, to drop dead TCP peers. - One wedged session no longer blocks a connection. Today every session on a
connection shares one write mutex, which is how a stuck attach once blocked the
heartbeat that should have reaped it (#183). With HTTP/2, each stream has its
own flow-control window: a stalled
Sendblocks only that session's pump goroutine.
The attach path end to end:
The Unix socket
The socket path, its 0600 mode and the socket-lifecycle rules of SPEC-0002
("Socket Lifecycle") are unchanged. The daemon serves gRPC on it over HTTP/2
without TLS (http.Protocols.SetUnencryptedHTTP2). As defence in depth it also
checks the peer's uid (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS) and closes a
connection from any other user. A socket peer is the owning user, so it holds the
attach tier described below.
The TCP listener: opt-in, TLS always, authenticated, tiered
[server]
env_file = "~/.config/harness/server.env" # token values (ADR-0038)
grpc_listen = "0.0.0.0:7443" # absent = no TCP listener
grpc_tls_cert_file = "/etc/harness/tls/server.crt"
grpc_tls_key_file = "/etc/harness/tls/server.key"
grpc_client_ca_file = "/etc/harness/tls/clients-ca.crt" # enables mTLS
[[server.grpc_client]]
name = "switchboard"
token = "${HARNESS_SWITCHBOARD_TOKEN}"
role = "control"
[[server.grpc_client]]
name = "laptop"
cert_san = "spiffe://harness/laptop" # must chain to grpc_client_ca_file
role = "attach"
- TLS always, TLS 1.3 only.
grpc_listenwithout a certificate and key is a load error, on loopback too: every local account can reach127.0.0.1, and the Unix socket is already the plaintext local path. There is no plaintext TCP mode. - Authentication. A request is authenticated when it matches one
[[server.grpc_client]]entry: by a client certificate that chains togrpc_client_ca_fileand carries the entry'scert_san, by the entry's bearer token (compared in constant time), or by both when the entry sets both. A certificate that chains to the CA but matches no entry is refused, because a CA may issue certificates for other purposes. Tokens resolve from the[server]table'senv_filethrough${NAME}references (📝 ADR-0038); a literal token is a load error, and a token shorter than 32 bytes is refused.grpc_listenwith no client entries is a load error, mirroring the SSH rule that an empty allowlist admits nobody. - Authorization tiers. Each entry has one role, checked by an interceptor before any handler runs.
| Tier | Grants | Why the line is here |
|---|---|---|
read | List, Describe, Logs, Jobs, Runs, Profiles, DaemonInfo, Subscribe, read-only Attach | Observes; injects nothing. Logs are masked (📝 ADR-0008) but may still reveal what agents did |
control | read, plus Start, Stop, Restart, Enable, Disable, Reload, UseProfile, and Trigger without an event | Acts only on harnesses the operator already defined in harness.toml |
attach | control, plus read-write Attach, Trigger with an event, ProjectUp, ProjectDown, Remove, ScratchRun | Each one delivers input to an agent or defines the command a harness runs. Each is code execution |
ProjectUp and ScratchRun carry a command line, and an event envelope is read
by an agent that may have permission prompts off. That is why they sit in the
attach tier and not in control. A request above the caller's tier gets
PERMISSION_DENIED before the handler, the attach.Mux, or the supervisor sees
it.
- Audit. Every authenticated TCP request logs the client
name, method and outcome to the daemon log. Token values arecore.Secretand never reach the log, the store or telemetry.
Remote clients: harness connect and --remote
A client names a remote in its own config, with the same env_file and ${NAME}
rules:
[remote.prod]
address = "prod.example.net:7443"
ca_file = "~/.config/harness/tls/prod-ca.crt"
cert_file = "~/.config/harness/tls/laptop.crt"
key_file = "~/.config/harness/tls/laptop.key"
env_file = "~/.config/harness/remote-prod.env"
token = "${HARNESS_PROD_TOKEN}"
harness connect prod (or harness connect host:port with --ca, --cert,
--key and --token-file) opens the TUI against that daemon. harness --remote prod <verb>, or HARNESS_REMOTE=prod, runs any CLI verb there. A remote TUI
cannot use anything that reads or writes the client's own filesystem: editing
harness.toml, offering to start a daemon, and the transcript watcher behind the
chatroom and the dashboard's live action field. Those views are disabled on a
remote connection until the daemon serves them over an RPC.
What survives of ADR-0004
This ADR supersedes 📝 ADR-0004's control plane only: the custom framed protocol as the single wire contract, and its rejection of gRPC over TCP. The following stay in force and are restated here so nothing depends on the superseded text:
- Wish SSH is the remote UI.
ssh harness.hoststill lands in the same TUI, hosted inside the daemon, authenticated by SSH public keys with per-key read-only scoping, and never a shell. It remains the full-fidelity remote view, because that TUI runs on the server and can read its files. Its sessions become gRPC clients of the Unix socket, like any local client. wishlistas the multi-host SSH directory andpromwishfor SSH-session metrics remain the sketched fleet path.- The daemon owns every PTY and emulator, and remote attach renders the same
x/vtscreen (📝 ADR-0002, 📝 ADR-0003).
Staging: one release side by side
- Release N. The daemon sniffs the first byte of each socket connection: a
legacy client's first frame is
HELLO, whose length's high byte is0x00; an HTTP/2 client starts with the connection prefacePRI * HTTP/2.0. The old handler serves the former and the gRPC server the latter, on the same socket path, so no client configuration changes. The CLI, TUI and in-daemon Wish TUI speak gRPC first. If the daemon closes the connection without an HTTP/2 SETTINGS frame, as a release N-1 daemon does when it reads the preface as an oversized frame length, the client retries once with the legacy framing. The TCP listener is gRPC-only from the start. - Release N+1. The sniffer, the legacy handler, the client fallback and the
framing and codec code in
internal/protocolare deleted. A release N-1 client then gets a connection close with no HELLO. The release notes of release N announce this.
When this ADR is accepted, 📝 ADR-0004 moves to superseded and SPEC-0002 is
rewritten: "Message Framing" and "Handshake And Versioning" become the services
and versioning rules above, the "no TLS/TCP listeners" non-goal is removed, and
"Transport Bindings" gains the TCP listener. Neither is edited in this proposal.
Consequences
- Good, because production servers get an authenticated, encrypted, tiered API
without SSH-scraping, and Switchboard or CI can call
Triggerwith a scopedcontroltoken that cannot attach. - Good, because the schema is the contract: clients in any language are
generated, and
buf breakingenforces compatibility the comment log only described. - Good, because per-stream flow control removes the shared write mutex that let one wedged attach block a connection's heartbeat (#183).
- Good, because the TCP listener, metrics and webhooks share
net/http, TLS helpers and test tooling. - Bad, because attach, which was the protocol's hot path, now pays protobuf framing plus HTTP/2 framing per chunk. Terminal output is kilobytes per second, so this is expected to be noise, but it must be measured, not assumed.
- Bad, because the project now owns a certificate and token story: issuing client certificates, rotating tokens, and documenting both. SSH needed none of this.
- Bad, because a remote TUI is less capable than the local or Wish TUI until the file-backed views move behind RPCs.
- Bad, because one release carries two protocols, a byte sniffer and a client fallback, all of which must be tested and then removed.
- Neutral, because bidirectional streaming needs HTTP/2 end to end. That holds on
the socket (h2c) and over TLS (ALPN
h2), but an HTTP/1.1-only proxy in front of the TCP listener breaks attach while unary calls still work.
Confirmation
Each item checks the property, not a proxy:
- No plaintext off-box. A test dials
grpc_listenwith plaintext HTTP/2 (prior knowledge) and asserts that the connection fails the TLS handshake without any HTTP response bytes.harness doctorand the config tests rejectgrpc_listenwithoutgrpc_tls_cert_file/grpc_tls_key_file, with no[[server.grpc_client]], or with a literaltoken. - Unauthenticated attach is rejected before the PTY. An
Attachover TLS with no certificate and no token getsUNAUTHENTICATED; the test asserts thatMux.SessionCount()stayed 0 and that nosnapshotoroutputmessage was received. The same test with areadtoken andmode = rwgetsPERMISSION_DENIED, and withmode = rogets a snapshot. - Tiers. A table test calls every
ControlServicemethod with aread,controlandattachcredential and asserts the grant matrix above, includingProjectUp,ScratchRunandTriggerwith an event refused tocontrol. - Backpressure ports over.
TestBackpressureCoalesce,TestWedgedAttachSessionIsEvicted,TestEvictionRestoresSurvivingClientViewport,TestClientThatNeverPongedIsNotEvicted,TestHealthyClientIsNeverEvictedandTestWedgedClientReapedWhileOutputBacksUprun against the gRPC transport (and, during release N, table-driven over both). They run on Linux under load, where the PTY output-loss and socket-buffer races reproduce. A new test opens two attach streams on one HTTP/2 connection, stops reading one, and asserts that the other keeps receiving live output. - Legacy window. During release N, the release N-1 client package passes
TestHandshakeVersionMismatchandTestMixedTrafficOneConnectionagainst the new daemon, and a new client falls back against a release N-1 daemon. In release N+1, the shipped binary no longer carries the framing:go tool nm harness | grep -c 'protocol.DecodeAttach'is 0. - Schema discipline. CI runs
buf lint,buf breaking --against '.git#branch=main', andbuf generatefollowed bygit diff --exit-code. - No secret in the log. A request authenticated by token produces a log line
naming the client, and a test asserts that the token's bytes appear in neither
the daemon log nor
state.json.
Pros and Cons of the Options
Decision 1 — Control-plane protocol
Option 1 — Keep the custom framing and add a TCP listener
Wrap the existing protocol in TLS on a TCP port and add a token to HELLO.
- Good, because it is the smallest change and nothing moves for local clients.
- Good, because the attach hot path keeps its minimal framing.
- Bad, because authentication, authorization, TLS and versioning are all ours to design, which is what 📝 ADR-0004 rejected option D for.
- Bad, because no other language, tool or proxy can speak it; Switchboard would need a hand-written client.
- Bad, because the connection-wide write mutex, and the head-of-line blocking of #183, come with it.
Option 2 — gRPC everywhere with grpc-go
- Good, because it is the reference gRPC implementation, with the widest middleware ecosystem and the best raw throughput.
- Good, because it gives the same wire contract, per-stream flow control and codegen as Option 4.
- Bad, because grpc-go runs its own HTTP/2 transport. Its
ServeHTTPbridge is documented as experimental, so sharing a port or TLS configuration withnet/httpneedscmuxor a second listener. - Bad, because it adds a large dependency tree (
google.golang.org/grpc,genproto) to a binary that otherwise needs little. - Bad, because a plain HTTP/JSON call needs a gateway (grpc-gateway) with its own codegen and annotations.
Option 3 — gRPC only for the network listener, framing on the Unix socket
- Good, because local clients do not change, and only the new surface pays the migration.
- Bad, because the daemon then implements every verb twice, over two protocols that must stay equivalent, with no end date.
- Bad, because the local path keeps the shared write mutex and the hand-kept version log.
- Bad, because the in-daemon Wish TUI and the local TUI would speak a protocol the remote CLI does not, so a bug can show on one path and not the other.
Option 4 — ConnectRPC (connect-go) everywhere, serving the gRPC protocol
- Good, because the same handler serves gRPC, gRPC-Web and Connect, so gRPC
clients and a plain
curl -H 'Content-Type: application/json'POST reach the same method, with the same auth interceptor. - Good, because handlers are
http.Handlers onnet/http: standardtls.Configfor mTLS, the Go 1.24+ HTTP/2 configuration for keepalive and h2c,httptestfor tests. - Good, because it has a small dependency footprint (
connectrpc.com/connect, plusgrpchealthandgrpcreflect) and a stable v1 API, and it is a CNCF project. - Neutral, because a Connect client can speak gRPC too, so choosing Connect does not lock any caller out of the gRPC ecosystem.
- Bad, because its interceptor API differs from grpc-go's, so grpc-go middleware cannot be reused.
- Bad, because it has lower peak throughput than grpc-go. This does not matter at terminal data rates, but it is a real ceiling.
- Bad, because the Connect protocol's JSON form of unary calls is a second
encoding to test. The JSON mapping is the protobuf canonical one, and
buf breakingchecks it with theWIRE_JSONcategory.
Decision 2 — Protobuf code generation
Option 1 — buf with locally pinned plugins
- Good, because
buf lintandbuf breakingare the compatibility gate the version handshake needs. - Good, because
buf.gen.yamlwithlocal:plugins runsprotoc-gen-goandprotoc-gen-connect-gofromgo tool, with versions pinned ingo.modand no network access. - Bad, because it adds one more tool (
buf, also run throughgo tool) to themake linttoolchain.
Option 2 — protoc driven by go generate
- Good, because it is the lowest common denominator, and every protobuf user knows it.
- Bad, because
protocis a C++ binary thatgo installcannot provide, so it is one more system dependency for contributors and CI runners. - Bad, because it has no breaking-change detection, so the project would keep maintaining a compatibility log by hand.
Decision 3 — HTTP surfaces (control, metrics, webhooks)
Option 1 — Separate listeners, each a plain net/http server
- Good, because each surface keeps the bind address, TLS and authentication its exposure needs, and turning one on never widens another.
- Good, because it matches 📝 ADR-0020 and 📝 ADR-0021 as they stand.
- Bad, because a fully exposed server has up to four ports to firewall and document: SSH, control, metrics and webhooks.
Option 2 — One port for everything, split by cmux
- Good, because it gives one port and one certificate.
- Bad, because
cmuxsniffs protocols on a raw listener and fights HTTP/2 and TLS. Its own documentation warns about both, and it is unnecessary once the control plane is anhttp.Handler. - Bad, because the port that forges must reach from the internet would also expose attach.
Option 3 — One port for everything, one net/http mux
- Good, because it gives one port, one certificate and one server, with no sniffing.
- Bad, because it has the same exposure coupling as Option 2: the webhook receiver's reachability decides the control plane's.
- Neutral, because Option 1 keeps this reachable later as a configuration change, if an operator ever wants metrics behind the control plane's TLS.
More Information
- Supersedes 📝 ADR-0004 — for the control plane only. Wish SSH as the remote UI,
SSH key authentication with per-key read-only scoping, the persistent host key,
wishlistandpromwishcarry forward unchanged, as listed under "What survives of 📝 ADR-0004". - Extends 📝 ADR-0002 — clients remain thin views of daemon-owned state; the control verbs still mirror the CLI and TUI 1:1.
- Extends 📝 ADR-0008 — attach equals terminal access equals code execution; the tier table is the per-credential scoping 📝 ADR-0008 deferred.
- Governs SPEC-0002 — rewritten on acceptance, as described under "Staging".
- Related 📝 ADR-0003, 📝 ADR-0007 — smallest-attached-wins resize and coalesce-to-snapshot backpressure, preserved as is.
- Related 📝 ADR-0020, 📝 ADR-0021 — the metrics and webhook listeners stay
separate
net/httpservers. - Related 📝 ADR-0038 —
[server] env_fileand${NAME}references supply the bearer tokens on both the daemon and the client side. - Source: the transport-agnostic attach seam is
Mux.Attachininternal/attach/mux.go; the reaper and the #183 write-mutex analysis are ininternal/daemon/conn.go. - No first-contact pinning.
DaemonInfodoes not advertise the listener's certificate fingerprint: a fingerprint learned over the connection it is meant to authenticate proves nothing. The client trusts the server throughca_fileor the system roots, and nothing else. - Deferred: a per-harness allowlist on
[[server.grpc_client]](for example,harnesses = ["reduit/*"]). A credential is scoped by tier only; per-harness scoping is a later, additive field.
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain ADR-0034 for the transitive view.