Skip to content

Latest commit

 

History

History
184 lines (152 loc) · 12.1 KB

File metadata and controls

184 lines (152 loc) · 12.1 KB

Node protocol

ker nodes connect to the control plane at wss://<public-host>/nodes/socket through the TLS proxy, or ws://<loopback-host>:<port>/nodes/socket in local mode. The node opens the connection; the server never dials a node. The current node protocol version is 4, the current session-store version is 9, and every frame is UTF-8 JSON sent as a WebSocket text frame.

Remote mode is enabled by --public-url https://<public-host>. ker speaks HTTP behind a TLS-terminating proxy; see the deployment guide. The socket upgrade must preserve the configured public Host. An absent Origin is accepted for native nodes; if supplied, it must match the public origin. The upgrade does not require a browser device credential: the first socket frame authenticates the node.

Publicly trusted certificates need no node configuration. For a private CA, start the node with NODE_EXTRA_CA_CERTS=/absolute/path/to/ca.pem; keep TLS certificate verification enabled.

Enrollment and authentication

POST /nodes/enrollments creates a random, single-use token that expires after 15 minutes. The response includes the exact ker node --server <url> --token <token> command, using the public HTTPS origin in remote mode. Creating an enrollment requires a paired device there. On its first connection, the node sends enroll with that token and its stable identity. The server stores only a SHA-256 hash of the token, consumes it transactionally, creates a random per-node secret, stores only that secret's hash, and returns the secret once in welcome.

The node writes the server URL and secret to ~/.ker/node.json with mode 0600. Later connections send auth with the node ID and secret. Enrolling the same node identity again rotates its secret. Revocation disconnects the active socket and prevents later authentication without deleting the node's catalog rows or session logs. The bundled daemon marks its in-process node as local and refuses to revoke it.

The first frame must be enroll or auth and must arrive within five seconds. Invalid, expired, or spent tokens, invalid credentials, revoked nodes, and incompatible versions receive refused and the socket closes.

Handshake

node                                  plane
  -- connect -------------------------->
  -- enroll | auth -------------------->
  <-- welcome { nodeId, secret? } ------
  -- hello { versions, runtimes, running } ------>
  -- records ... ---------------------->  pending spool, one session at a time
  <-- ack | reject ---------------------
  -- drained -------------------------->
  <-- ready { recover } ----------------
  -- load / records / delta ... ------->
  <-- call / chunk / end / hb ... ------

hello carries nodeProtocol, storeVersion, runtimes, and the IDs of sessions still running in the node process. Each runtime describes the choices available on that node. The native entry is { kind: "native", default: { kind: "native", provider, model, reasoningEffort? }, models }, where each model has provider, id, optional contextWindow and maxOutputTokens, and reasoningEfforts. The default comes from the node config; the models come from its registry. Headless entries have kind: "claude-code" | "codex", the installed CLI version, a default runtime, registry-filtered models, and modes: [{ id, name, description }]. Their runtime is { kind, mode, model?, reasoningEffort? }; omitted model and effort select vendor defaults. The node probes installed binaries asynchronously before advertising its runtimes. An unavailable CLI is omitted from the handshake. GET /nodes relays the connected handle's runtimes and returns [] for offline nodes.

The node drains every existing spool file before sending drained. The server then registers the connection and returns busy sessions assigned to that node but absent from hello.running; the node loads and recovers those sessions, then drains the spool again before reporting itself connected. The second drain includes records appended after drained but before ready arrived.

A second connection with the same node ID replaces the older socket.

Run only one ker node process against a given node identity and spool directory. Two processes using the defaults share ~/.ker/node.json and ~/.ker/spool, so each new connection replaces the other and both can race on spool files.

Node-to-plane frames

Frame Fields Meaning
enroll token, identity { id, name, createdAt } Exchange a one-time token for a node secret.
auth nodeId, secret Authenticate an enrolled node.
hello nodeProtocol, storeVersion, runtimes, running Declare compatibility, available runtimes, and in-memory running sessions.
records sessionId, records Send one or more chained store records from the spool.
drained — Declare that the startup spool has been sent.
delta sessionId, event Publish an ephemeral message or reasoning delta.
load id, sessionId Request the canonical history before executing a session.
credential id, provider Request a native provider credential after the handshake.
result id, value Complete a plane-initiated call.
error id, code, message Fail a call with invalid_cwd, unknown_session, turn_unavailable, context_exhausted, invalid_runtime, or internal.

Plane-to-node frames

Frame Fields Meaning
welcome nodeId, secret? Accept authentication; secret appears only after enrollment.
refused code, message Refuse with unauthorized, revoked, token_expired, token_used, or version_mismatch.
ready recover Finish the handshake and name sessions requiring recovery.
ack sessionId, recordId Confirm every record through recordId.
reject sessionId, recordId, reason: "chain" Reject a batch that forks the canonical chain.
call id, method, args Invoke createSession, admit, compact, cancel, recover, resolveProjectRoot, observeGitRemote, or folderExists.
chunk id, text Return a portion of a JSONL history load.
end id, found Finish a history load.
hb — Keep the connection live.
credential id, auth, expiresAt?, error? Answer a credential request; never carries a refresh token.

Frames are runtime-validated against @ker-ai/protocol/node. Unknown fields or frame types close the connection. The server limits incoming frames to 16 MiB and splits history responses into 1 MiB text chunks. A node may load, append to, or publish deltas for only sessions assigned to its identity. A new-session batch must begin with a session record whose session.id equals the frame's sessionId and whose session.nodeId equals the authenticated node ID.

createSession takes [cwd] to use the node default or [cwd, runtime] for an explicit choice. The runtime is validated on the node and saved in SessionDescriptor.runtime in the first store record. Native runtimes are { kind: "native", provider, model, reasoningEffort? }; malformed choices, unavailable runtime kinds, and unknown headless modes return an error frame with code invalid_runtime. HTTP session creation maps that failure to 400 invalid_session. Reopening a session uses its saved runtime, including its provider and effort, regardless of later default changes. Credentials resolve for that provider at request time.

Upgrade the plane and nodes together. A protocol-3 or older hello is refused with version_mismatch, even though it has no runtimes field. Store-v8 and older logs remain unreadable under store v9.

Headless sessions persist { type: "child", id } when the vendor session is first created, and resume that ID on subsequent process starts. Reopening does not depend on the startup advertisement; a binary removed after creation produces runtime_unavailable on the next start. Missing vendor history permits one fresh-start retry only when ker has no assistant message in its display history. Claude reuses the ker session ID; Codex creates a new thread and ker records its ID. Otherwise missing history produces child_session_missing. Ker never injects its display transcript into a new vendor conversation. Vendor history remains node-local and is not replicated to the plane. Headless sessions have no native definition or instruction records, pruning, automatic compaction, or native context admission ceiling. Their stats use vendor-reported context usage and limits.

Provider credentials

Before each native model request, the node sends { type: "credential", id, provider }. Only an authenticated, drained node may request credentials; an early request closes the socket. The plane returns { type: "credential", id, auth, expiresAt?, error? }. auth is either { kind: "apikey", key }, { kind: "oauth", accessToken, accountId }, or null. OAuth answers include their ISO expiry; refresh tokens remain on the plane.

auth: null without an error is authoritative absence: the node clears its remembered copy and emits credential_missing with the provider. auth: null with an error means temporary failure. A disconnect or temporary failure permits the last in-memory key, or an OAuth token with more than 60 seconds remaining, to be reused. An aborted caller never uses this fallback. Copies are never written to disk. Cancel stops the wait while an already-started refresh finishes and saves its rotated token. The plane serializes refresh, key replacement, login completion, and logout per provider; a logout queued behind a refresh removes its result.

The plane stores credentials in catalog v7 and serves only summaries through the five private /providers HTTP routes. A refresh 401 or 400 invalid_grant deletes the login and answers explicit absence; network, timeout, 429, and server errors keep the row. Token calls time out after 45 seconds. Headless runtimes continue using their vendor CLI's node-local credentials.

Store, spool, and recovery

The plane's store is canonical. Store records carry recordId and previousRecordId; the server writes an incoming batch only when it continues the stored tail. Already-present record IDs are idempotent, which makes reconnect replay safe. A fork receives reject, marks the catalog session unreadable, drops that session's spool, and aborts its running work.

If a startup spool names a session for which the plane has neither a catalog row nor a store file, and its first record cannot create that session, the plane sends reject without creating an unreadable session. The node drops the stale spool and reconnects. A batch targeting a known session owned by another node is an authorization failure and closes the socket instead.

The node writes each batch to ~/.ker/spool/<sessionId>.jsonl before sending it. An ack removes the acknowledged prefix and deletes the file when no records remain. If the socket disappears, an admitted turn can continue writing to the spool. Reconnection uses exponential backoff from one second to one minute, re-authenticates, drains the spool, and then resumes normal calls. Live deltas are not spooled; the durable assistant record contains the final text. Usage, successful compaction, and prune batches include a stats record so the plane's context estimate reflects the latest node-side context immediately.

When a history load reconciles a non-empty spool, the node removes only the contiguous pending prefix whose record IDs are already canonical. If the first pending record is absent from the loaded history, the whole spool remains pending.

The server sends a WebSocket ping and hb every 15 seconds and terminates a node after two missed pongs. The node closes and reconnects if it receives no frame for 45 seconds. Protocol or store version mismatches are hard failures; there is no compatibility negotiation.

Node-initiated closes use private application codes because Node's built-in WebSocket client accepts only 1000 or 3000–4999: 4003 for a required text frame, 4008 for an invalid or refused frame, 4011 for an internal node failure, and 4013 for a heartbeat timeout. Server-initiated closes use the corresponding standard codes through ws.