Skip to content

Unix domain socket binding

The UDS binding speaks Tesseron over a Unix domain socket on the local filesystem. Lower per-message overhead than WebSocket and avoids the loopback TCP stack entirely. Available on Linux and macOS in 1.1; Windows tracks separately (see Windows below).

This page is the wire spec for that binding. The transport overview covers the binding-neutral contract this binding satisfies.

{
"version": 2,
"instanceId": "inst-...",
"appName": "...",
"addedAt": 1777038462692,
"transport": { "kind": "uds", "path": "/tmp/tesseron-Xy7/sock" }
}

The path is the absolute filesystem path the gateway connects to. Apps SHOULD put the socket inside a per-process directory under os.tmpdir() (the reference SDK creates a mkdtemp-style 0700 dir, then binds <dir>/sock inside it). The directory mode is what gates same-UID access.

NDJSON: one JSON-RPC envelope per \n-terminated line.

  • Compact JSON.stringify never emits a raw \n (newlines inside strings are escaped as \\n), so a line splitter recovers messages losslessly.
  • JSON.stringify(msg) + '\n' on send.
  • Buffer inbound bytes and split on \n on receive. Empty lines are ignored.
  • No batching, no fragmentation, no compression.

There is no subprotocol negotiation - a socket has no upgrade handshake to carry one. Bytes start flowing the moment connect() succeeds, and the app sends tesseron/hello (or tesseron/resume) as its first message.

Host-minted instances are the exception: there the gateway sends tesseron/bind first and the app's hello is held back until the bind succeeds.

The WebSocket binding carries a host-minted claim code in a tesseron-bind.<code> subprotocol element. A Unix socket has no upgrade to hang that on, so the same gate is a JSON-RPC request instead: the gateway sends tesseron/bind as the first NDJSON frame after connect, before any other traffic.

{ "jsonrpc": "2.0", "id": "__tesseron-bind-<uuid>", "method": "tesseron/bind", "params": { "code": "7Q4K-M2" } }

The host upper-cases the incoming code, compares it against hostMintedClaim.code in constant time, and answers:

{ "jsonrpc": "2.0", "id": "__tesseron-bind-<uuid>", "result": { "ok": true } }

Failures answer with an error and, where noted, close the socket:

ConditionCodeSocket
Host is in bind lockout-32009 UnauthorizedClosed
Code does not match-32009 UnauthorizedClosed
Already bound-32009 UnauthorizedKept
Claim already spent (boundAgent !== null)-32009 UnauthorizedKept
params.code missing or not a string-32602 InvalidParamsKept
Any non-bind frame arrives first-32600 InvalidRequestClosed

That last row is the UDS counterpart to the WebSocket binding's 426 Upgrade Required. A host that minted its own claim has already answered the app's hello with a synthesized welcome, so a gateway that starts talking without binding would produce a second, conflicting welcome. The host closes instead.

Mismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout; a successful bind resets the window.

After acking, the host replays the app's cached hello to the gateway and drops the gateway's id-matched reply, so the app never sees the second welcome. Queued non-hello frames drain afterwards.

Hosts that leave helloHandledByHost unset never take this path: the gateway auto-dials and mints the code itself, and the first frame on the wire is the app's hello.

Apps MUST restrict the socket file so only the same UID can connect(). Two complementary mechanisms, both supported on Linux and macOS:

  1. Parent directory mode 0700. Put the socket inside a private dir; the kernel's directory permission check rejects connect() from any other UID before it ever reaches the socket inode. The reference SDK does this via mkdtemp + chmod 0700.
  2. Socket file mode 0600. Apply chmod 0600 to the socket file itself after bind(). Belt-and-suspenders against directory misconfiguration; some kernels (macOS pre-10.10, Linux pre-3.9) ignore the socket-file mode and rely solely on the parent dir.

The threat model is identical to loopback WS plus the claim code: any process running as the same OS user can connect; the claim code is what gates the privilege escalation from "can talk to the socket" to "is bound to a session". Cross-UID isolation is the OS's job.

  • App creates a 0700 temp dir under os.tmpdir() (or wherever the OS lets the user write privately), bind()s a socket inside, optionally chmod 0600s the socket file.
  • App writes ~/.tesseron/instances/<instanceId>.json with the path.
  • Gateway watches ~/.tesseron/instances/, picks the manifest up, dials.
  • App accepts exactly one connection - the first peer wins; subsequent connect attempts are closed immediately.
  • On session close, the app deletes its manifest and the socket file, and removes the temp dir.
EventWhat you seeNotes
Same-host other-UID connect attemptEACCES from connect()The kernel rejects before any byte is exchanged.
Stale socket file from prior runEADDRINUSE on bindApps SHOULD unlink before bind if they pin a path.
App crashes without cleanupStale manifest + stale socket fileGateway dial hits ECONNREFUSED; manifest is harmless until manually swept.
Gateway disconnects'close' on the app side, no codeTreat as session end; rebind + re-announce to recover.

Windows ≥ 1803 has an AF_UNIX implementation, but Node's net.listen({ path }) on Windows actually creates a named pipe under the hood, not a filesystem socket. The path semantics differ (\\.\pipe\<name> instead of arbitrary filesystem paths) and the file-mode-based UID enforcement does not apply - Windows uses ACLs.

The 1.1 reference SDK skips the UDS binding on Windows. A separate pipe binding is tracked as follow-up work; until then, Windows apps should use the WebSocket binding.

Implement a UDS server that:

  1. Creates a private (mode 0700) directory under os.tmpdir() (or equivalent), binds a socket inside.
  2. chmod 0600s the socket file after bind.
  3. Writes ~/.tesseron/instances/<instanceId>.json with { kind: 'uds', path }.
  4. Accepts exactly one connection; rejects subsequent connect attempts.
  5. Serialises outgoing JSON-RPC envelopes with \n terminator; splits incoming bytes on \n.
  6. Deletes its manifest, the socket file, and the temp dir on close.

If you also mint claims host-side, implement tesseron/bind with constant-time comparison, the rate limit, and the close-on-unbound-frame rule. Skipping it while advertising helloHandledByHost: true leaves the app unreachable.

The full conformance checklist lives in Port Tesseron to your language.