Skip to content

M2-43: Control connection between host service and server

Status: ready | Depends on: M1-67, M2-42 | Milestone: M2

Goal

After this ticket a paired host service holds exactly one outgoing control connection to the server at wss://<instance>/api/v1/hosts/connect (asyncapi channel hostControl), and the server knows at every moment which host is connected, what it reports and whether it may receive sessions. The server opens with hello (challenge, server time, heartbeat interval, protocol versions, JWKS of purpose terminal); the host service logs in with identify, a compact EdDSA proof over the challenge made with the Ed25519 key it registered at pairing, within terminal.host_auth_timeout; an unknown or replaced key closes with 4110, a disabled or retired host with 4111, a second live connection with the same keys with 4112 and a disabled host, a bad or missing proof with 4113, a protocol version below the supported one with 4114 (24 "Errors", 18 "Host service and server"). identify lists live_sessions, and every other session of the host in active or detached ends with host_restarted (24 "Detach and reattach", D-W-90, TM-T16). After ready the host service sends report before it accepts any session.open, and the server sends session.open only after that report; the report writes the profiles, the session users with their working directories after HD7 (TM-T29 (c)), the self-checks with one entry per affected account and the server-host flag (24 "Host service bothy-host" points 5 and 6). Heartbeats keep the connection alive; terminal.host_heartbeat_miss_limit missed heartbeats close it. Presence travels as the ephemeral host.presence_changed; the server closes the connection on disable, retirement and re-pairing; a host whose doctor refused the start logs in, sends exactly one report and stops (D-W-91). This ticket builds both ends: the server side in internal/terminal/adapters/wsrelay and the client side in services/host/internal/control, and it makes the deferred authorization entry host.connect of M1-28 a real requirement with the right host-self. Without it the server cannot reach a host, a stolen key pair stays invisible (T-TM-05), and a host that stops for all people because of one account names nobody (T-TM-14). Sessions, the session token and both stream relays follow in M2-44 and M2-45.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Test doubles", "Security": Go fuzzing of the parsers); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope", "Out of scope", "Exit criteria" item 8, "Test strategy" (Terminal), "Red team" (terminal paragraph: T-TM-05, T-TM-14, "the session after every kind of access removal", "misrouting across several nodes"), "Risks"; block "terminal" with the rows M2-38 to M2-42 (what exists), M2-43 (this ticket, the contract), M2-44 to M2-48 (who builds what next); section 13 rows M7-17 and M7-23; section 19 (P-01, P-04, P-09, P-10, P-11, P-12, P-20).
  • Dependencies: the header names M1-67 and M2-42 (D-W-105). Through M2-42 this ticket builds on M2-41, M2-40, M2-39, M2-38, M1-49, M1-48 and the M1 tickets M1-11, M1-13, M1-16, M1-17, M1-18, M1-24, M1-25, M1-28, M1-29, M1-32, M1-33 and M1-44 (all implied, so not in the header). D-W-102 struck M2-39 from this ticket's plan column (it lies in the closure of M2-42); read it all the same, because the tables, host.updated and the host state machine you use are its. Through M1-67 it also builds on M1-66, M1-65 and M1-64, whose gateway pieces it needs (D-W-105 added M1-67 to the plan column for that reason).
  • docs/spec/24-terminal.md (English, normative): 1; 2.1; 2.2 (the paragraphs on the session users, points 3 to 6, the table HD1 to HD11, "Rule for names", "Output on the host", "Message on a refused start", the last bullet: only outgoing WebSocket with TLS); 2.3 "Server host"; 3.1 "States"; 3.2 "Pairing"; 3.3 "Re-pairing"; 3.4 "Second control connection"; 4.1, 4.2; 5.3 (the JWKS in hello; the check order M2-45 builds on it); 6.1 steps 4 and 7; 6.2 "Detach and reattach"; 6.6; 7.4 ("Hit during operation"); 8.2 (SR10, SR12), 8.3 "Revocation"; 9 "Multiple nodes"; 10.1 "Audit" (row host.connect), 10.2 "Events", 10.3, 10.4; 13 "Errors" (close codes 4110 to 4114); 14 (TM-T16, TM-T21, TM-T23, TM-T29); 16 (R-TM-01, R-TM-12, R-TM-13, R-TM-14, R-TM-23, R-TM-27, R-TM-29, R-TM-30); 17.
  • docs/spec/03-data-model.md (core, read only): 4.3 grants (the paragraph on permission_version, which names reported_session_users); 4.14 hosts (all reported_* columns, reported_host_version, last_seen_at, version, the checks, "connection state ... only in memory"), host_session_users, terminal_sessions (states, close_reason, close_detail, exit_code, detached_at, started_at, the checks); audit_events; 6.3 "Lock order"; 6.6.
  • docs/spec/05-actions.md (core, read only): rule 1.7 "Permission version"; 2.1 (right host-self); 2.4 (column Audit denials); 3 (rl.host); 4.14 (row host.connect: host, host-self, Audit "denials; yes on a change of server host, check result or reported session users", Permissions no); 6 (host.updated, terminal_session.changed, ephemeral host.presence_changed with host.read); 7 "Terminal channels" (ops of /hosts/connect, action host.connect).
  • docs/spec/04-sync-protocol.md (core, read only): 2.1 "Two kinds" (terminal paragraph), 2.2, 6.4 (the codes of that table apply on the terminal channels only where the channel names them), 8.1, 9.
  • docs/spec/07-permissions.md: 3 "Principal context" (kind host), 4.1 (host_auth_failed, principal_inactive, subject_kind_not_allowed), 4.2, 5 SR10 and SR12 ("and so does a report of the host that no longer lists that name"), 7.2 and 7.3 (what the rights snapshot holds), 8.2 point 7, 11.2 AZ-P1, AZ-P2, AZ-P3, AZ-P13.
  • docs/spec/02-architecture.md: 4.3 (row terminal: port Relay only in the adapter wsrelay), 4.4 (terminal may use instance, identity, authz, agents), 4.5 (kernel/eventlog.EphemeralPublisher, kernel/actor, kernel/jose), 4.6, 4.7 (row terminal), 5 rule 4 (libraries per adapter kind, github.com/coder/websocket), 6 "Code layout" (block services/host/, internal/control), 12.3 (terminal.sweep, not built here), 14.1, 15.3 "Shutdown".
  • docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "No null"), 5 "Errors", 11 "Real-time gateway" (terminal channels are their own channels), 15 "Contract tests and gate" (G2, G9).
  • docs/spec/18-release.md 9.1 "Host service and server"; docs/spec/16-operations.md 4.4 (edge forwards /api/v1/hosts/connect, idle time over terminal.host_heartbeat_interval; points 2, 3 and 6); docs/spec/19-security.md SEC-AUD-5, SEC-LOG-2, SEC-LOG-4, 11a (SEC-TERM-*). For the terminal security group only: docs/spec/07-permissions.md 2.12 "Hosts and the terminal capability"; docs/spec/08-identity.md 6.3 "Fresh step-up for the terminal" and ID-T28.
  • docs/api/openapi.yaml: schemas HostChannelHello, HostIdentify, HostReady, LlmCredentials, HostHeartbeat, HostHeartbeatAck, HostReport, HostProfile, HostReportedSessionUser, HostDoctorCheck, HostSessionOpen, HostSessionClose, HostSessionStarted, HostSessionEnded, GatewayError, HostPresencePayload, HostPresenceChangedEvent, HostUpdatedEvent, HostUpdatedPayload, Host (connected, host_version, os_version, last_seen_at), HostState, HostPairResult, TerminalCloseReason, TerminalCloseDetail, TerminalSession, TerminalSessionChangedEvent, Jwks, SigningKeyPurpose, Problem (x-problem-codes); operations getHost, listHosts, setHostState, issueHostPairingCode, rotateSigningKey.
  • docs/api/asyncapi.yaml: channels hostControl (description, x-protocol-versions, x-close-codes) and hostData (x-close-codes only); operations hostControl.*; messages host.* and event.host.presence_changed.
  • ADRs: 0005 (Go, github.com/coder/websocket), 0013 (spec first), 0017, 0021, 0052, 0053, 0067 (JOSE with the standard library), 0068 (the terminal decision; "Confirmation" names TM-T23).
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in 7.1, T12 as D-W-57 and T13 as D-W-62), the writer rulings D-T12, D-T13 (second control connection), D-T25 (host as principal kind), D-T26 (host.connect covers control and data connection), D-T34 (rl.host), D-T35 (own close codes), D-T38 (old versions refused); D-W-24, D-W-63, D-W-64, D-W-69, D-W-90, D-W-91, D-W-109, D-W-100, D-W-101, D-W-102, D-OP-03; section 10 for the format of new rows.
  • docs/security/threat-model.md 5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14). docs/RISKS.md: RK-25 (stolen keys stay unnoticed while the real host is off), RK-89. docs/OPEN-QUESTIONS.md: OQ-24 (terminal only over a private network; "Due by: before M2-43"; default no), OQ-03 and OQ-04 (they concern M2-45 and M2-46, not this ticket).
  • docs/operations/configuration.md: terminal.host_heartbeat_interval, terminal.host_heartbeat_miss_limit, terminal.host_auth_timeout, terminal.host_duplicate_probe_timeout, terminal.detach_grace, terminal.open_timeout, sync.ws_max_frame_bytes, sync.outbound_queue_max, gateway.write_timeout, auth.clock_leeway, identity.last_active_granularity, agent.jwks_max_age, signing_key.cache_ttl, audit.deny_aggregate_window, host.os_user_max_chars, terminal.report_label_max_chars and terminal.report_max_entries (added by M2-41), shutdown.total_timeout, cluster.mode, ratelimit.rl.host.per_minute; the host_service.* rows. docs/operations/tailscale.md 3 (security group only).
  • docs/observability/signals.md 4.3a: bothy.terminal.hosts.connected exists; M2-46 records it (Out of scope).
  • Briefs: M1-64-gateway-connection.md (frame codec, writer queue, 4002 rules, wstest, route registration on the public listener), M1-66-gateway-focus-typing.md (kernel/eventlog.EphemeralEvent, EphemeralPublisher), M1-67-gateway-conformance.md (api/specschema, TestHarness_OnlyEntryPoint, the register test), M1-25-authz-catalogue.md (ChannelOp, (*Registry) ChannelOp, VerifyRegistrations rule 3a), M1-28-authz-core.md (atoms, mapping table, deferred entries, liveness), M1-24-audit-chain.md (auditing.Recorder: Record, RecordDenial; Attrs value kinds), M1-32-signing-keys.md (instance.Keys, JWKS, purposes), M1-33-identity-devices-dpop.md (kernel/jose), M1-11-lifecycle-health.md (stop steps), M1-13-unit-of-work.md, M2-38-noise-core.md (host module, .go-arch-lint.yml maintained by hand, cmd/bothy-host dispatcher); and, where they exist in docs/briefs/, the briefs of M2-39, M2-40, M2-41 and M2-42.
  • Code on the base branch: services/server/internal/terminal (M2-39 to M2-41: port, domain, app, adapters/postgres, adapters/httpapi, the state transitions, the session-ending function, host.updated, the pairing fallback), internal/authz (M2-40: host facts, authz.HostSource), internal/kernel/authorize, internal/kernel/actor, internal/kernel/eventlog, internal/kernel/auditing, internal/kernel/jose, internal/instance (Keys), internal/gateway/adapters (M1-64 to M1-67), api/specschema, internal/wire (configkeys_*.go, configchapters.go), architecture/modules.yaml; services/host (M2-38, M2-42: cmd/bothy-host, internal/state, internal/doctor, internal/localchan, .go-arch-lint.yml, go.mod).

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. One control connection per host at /api/v1/hosts/connect, opened only by the host service, outgoing, over TLS; JSON text frames {op, d} of at most sync.ws_max_frame_bytes; the first frame is hello with protocol_versions, challenge, server_time, heartbeat_interval_ms (terminal.host_heartbeat_interval) and the JWKS of purpose terminal (24 2.2 last bullet, 5.3; asyncapi hostControl; HostChannelHello).
  2. Login only with a valid signature over the challenge of hello (HostIdentify.proof: compact JWS, alg = EdDSA, typ = host-auth+jwt, over {challenge, host_id, iat}, with the key registered at pairing) and within terminal.host_auth_timeout; otherwise 4113; unknown or replaced keys 4110; host disabled or retired 4111; a protocol version the server does not support 4114 (24 13, D-W-24, 18 9.1, D-T38).
  3. host.connect is host-self (05 2.1, 4.14; 07 3; D-T25): only the host itself, authenticated with its key, passes; Audit denials, plus one allow entry on a change of reported_is_server_host, reported_doctor or reported_session_users and one entry per second control connection (24 10.1); Permissions no; class rl.host.
  4. heartbeat is answered with heartbeat_ack; after terminal.host_heartbeat_miss_limit missed heartbeats the server closes with 4009 (asyncapi hostControl.receive.heartbeat).
  5. identify on the control connection always carries live_sessions; the server ends every other session of the host in active or detached with close_reason = host_restarted (24 6.2, 10.3, D-W-90, R-TM-27, TM-T16).
  6. After ready the host service sends report before it accepts any session.open, and the server sends session.open only after that report (24 2.2 point 5, asyncapi hostControl description). report writes hosts.reported_profiles, hosts.reported_session_users (the lists after HD7, TM-T29 (c)), hosts.reported_doctor (one entry per affected session user under the rule for names), hosts.reported_is_server_host, hosts.reported_require_local_confirm and hosts.reported_os_version (03 hosts, D-W-63, D-W-64, D-W-69, D-W-109).
  7. When doctor refused the start, the paired host service logs in, sends exactly one report, closes the connection without accepting session.open and ends with the exit code after which the service manager does not restart it (24 2.2 "Message on a refused start", D-W-91, TM-T21 (g)).
  8. A second control connection with the same keys makes the server probe the older one with a WebSocket ping: no answer within terminal.host_duplicate_probe_timeout closes the older with 4009 and accepts the new one; an answer closes both with 4112, disables the host with disabled_reason = duplicate_connection, ends all sessions (host_disabled), audits host.connect with code duplicate_connection and sends host.updated (24 3.4, D-T13, R-TM-13, TM-T23).
  9. The server closes the control connection when the host is disabled or retired (4111) and when it is re-paired (4110) (24 3.1, 3.3, 13).
  10. Presence: the connection state lives only in the memory of the server (03 hosts) and travels as the ephemeral host.presence_changed {host_id, connected} to everyone with host.read (05 6, 24 10.2).
  11. A report that no longer names the session user of a person ends that person's sessions on the host with access_revoked in the same transaction (07 SR12, 24 8.3); a report that turns reported_is_server_host on ends the sessions of people who are not owners (07 SR10, 24 8.3).
  12. Every frame of both sides conforms to docs/api/asyncapi.yaml and its body schema in docs/api/openapi.yaml (G9); no JSON null (ADR 0053); no proof, challenge, key, terminal content or path in logs, audit details, events, problem bodies or test output (24 10.1, 10.4, SEC-LOG-2).

Terminal security requirements (D-W-101):

  1. Rights per machine: the additional right terminal on the target host, SR10, SR11 with I6 and SR12 (07 "Hosts and the terminal capability"). M2-39 built the target host and host.read (AZ-M60), M2-40 built terminal.*, SR10, SR11 with I6 and SR12 (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). This ticket enforces the rights of the machine itself: host.connect passes only for the host principal of its own row (atom HostSelf; people, agents and apps get subject_kind_not_allowed), proven by TestHostConnectRequirement; and it keeps the facts SR10 and SR12 read current: a report that drops a name or turns the server-host flag on ends the sessions that SR12 or SR10 no longer allow, in the same transaction (TestReport_DroppedNameEndsSessions_SR12, TestReport_ServerHostEndsNonOwnerSessions_SR10).
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not touched here: the control connection carries no person and no step-up. M2-40 builds step-up fresh and its consumption (ID-T28 without the console part), M2-44 TM-T07 without the console part, M4-36 and M5-12 the step-up key in the apps, M6-18 the console with passkey only.
  3. Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile tailscale reaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03, docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket builds the outbound control connection: the host service dials only wss:// with certificate verification and opens no listener (TestControlOpensNoListener, TestControlRefusesPlainAndUnverifiedTLS); the server accepts the upgrade only on the public listener route behind the edge like every API path, and no state of the host is reachable without the Ed25519 proof (TestConnect_BadProof4113). If OQ-24 is answered "yes" before the build, stop (Boundaries).
  4. Audit without content: host.connect entries carry only IDs, codes, booleans, integers and the names of session users the rule for names allows (24 10.1, 2.2); never paths, values, profile command lines, proofs or challenges (TestAudit_HostConnectDetails, TestNoSecretsInLogsAuditOrEvents). M2-46 owns TM-T06 and the metrics bothy.terminal.*; M2-44 owns the relay that logs only length, time and session ID.
  5. Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host, M2-46 adds the server side (terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration, terminal.sweep, TM-T11). This ticket carries the host's end reports: a session.ended with idle_timeout or detach_expired ends the session with that reason (TestSessionEnded_HostReasons), and a dead control connection is closed after the heartbeat deadline so that its sessions turn detached and terminal.detach_grace starts (TestHeartbeatMissCloses4009).
  6. Service user and session users without administrator rights (T12, HD rules): M2-42 builds install, doctor with HD1 to HD11 and the key storage (TM-T13, TM-T22, TM-T24); M2-45 runs every PTY in the helper of its session user. This ticket's client runs inside bothy-host run as the service user, reads the keys only through M2-42's state package and never passes a key, a proof or a control frame to a PTY helper (TestControlUsesNoHelperChannel); when doctor refused the start it reports and stops instead of looping (TestRefusedStart_ReportsOnceAndStops).
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-05 (stolen keys) by the duplicate probe that disables the host on a second live connection, TM-T23 here (TestDuplicate_TMT23); T-TM-14 (standstill by one person) by the report that names every affected account, also on a refused start, with audit and host.updated (TestReport_AuditOnRelevantChange, TestRefusedStartReport_DWW91, host side TestRefusedStart_ReportsOnceAndStops); T-TM-12 (replaced helper) by carrying the HD10 entry with the name of that channel's session user unchanged into reported_doctor (TestReport_StoresDoctorEntriesPerAccount); T-TM-08 and T-TM-03 by the SR12 and SR10 session ends of point 23; T-TM-11 (sessions surviving) by host_restarted for every session a restarted host no longer knows and session.close for every listed session the server already closed (TestLiveSessions_HostRestarted_TMT16); T-TM-01 by control frames that never carry terminal content (TestNoSecretsInLogsAuditOrEvents). Not touched here: T-TM-02 (M2-44, M2-45), T-TM-04 and T-TM-13 (M2-42, M2-45), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-09 (M2-40, M2-44), T-TM-10 (M4-35).

Scope

Server paths under services/server/, host paths under services/host/, unless noted. Names of M2-39 to M2-42 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).

Create, server:

  • internal/terminal/domain/control.go: pure functions with the time passed in: CheckHostProof (Design note 3), IdentifyOutcome (the close-code order of note 3), ReconcileLiveSessions (note 6), ValidateReport and DiffReport (note 8), SessionsEndedByReport (note 8), ValidateHostSessionEnd (note 10), HeartbeatDeadline (note 9), the constant SupportedProtocolVersions (note 3).
  • internal/terminal/app/control.go: use cases Login, Heartbeat, ApplyReport, SessionStarted, SessionEnded, ConnectionLost, ResolveDuplicate; outgoing app port ControlLinks (note 7).
  • internal/terminal/adapters/wsrelay/control.go, links.go, codec.go: the upgrade handler of GET /api/v1/hosts/connect, reader and writer goroutines, the frame codec on the generated Host* types of api/rest, the per-process registry of control links that implements ControlLinks and the presence read (notes 2, 5, 7).
  • internal/terminal/adapters/hosttest/: a Go test client for the control connection (Dial, Hello, Identify, Send, Next, ExpectClose, Pause, optional heartbeat loop), validating every frame with api/specschema; M2-44's test/terminal/fakehost builds on it.
  • internal/terminal/adapters/postgres/queries/control.sql: the login read of a host (state, auth_public_jwk, auth_jkt, key_generation), the update of the reported columns with version + 1, last_seen_at, reported_host_version, the non-closed sessions of a host FOR UPDATE in ascending id, the transitions opening -> active and active -> detached if M2-39 has none.
  • Fuzz targets FuzzCheckHostProof and FuzzDecodeControlFrame next to their code.

Create, host:

  • internal/control/: doc.go, client.go (Client, Run, the implementation of M2-42's startup.Control), login.go (proof, version choice), frames.go (hand-written frame types), policy.go (reaction per close code, backoff), report.go (ReportSource, server-host flag from the peer), sessions.go (Sessions, Notifier, RefuseSessions), keys.go (JWKS store for M2-45), tests, FuzzDecodeServerFrame (note 12).
  • internal/control/controltest/: a fake server for the host tests (TLS from httptest, scripted replies and close codes).
  • testdata/control/host_frames.json and testdata/control/server_frames.json (note 13).

Change:

  • internal/kernel/actor: kind host (07 3), if M2-39 to M2-41 did not add it.
  • internal/kernel/authorize: Resource.HostAuth *HostAuthFacts with HostAuthFacts{Verdict HostAuthVerdict} and the constants HostAuthValid, HostAuthUnknownKey, HostAuthInvalid (note 4).
  • internal/authz: requirement entry of host.connect instead of Deferred; atom HostSelf; mapping row host-self; liveness and visibility for kind host; no principal lookup for kind host; the golden file of deferred actions without host.connect; the reason host_duplicate_connection (note 5) in the reason list.
  • internal/kernel/auditing and internal/audit/domain: the value kind for session user names (note 8), if M2-39 has none; the reason constant host_duplicate_connection.
  • internal/terminal port: Register(r *authorize.Registry) registers the channel ops identify, heartbeat, report, session.started, session.ended of host_control and the ephemeral type host.presence_changed.
  • internal/terminal/app: the use cases of M2-39 (setHostState disable and retire) and M2-41 (re-pairing through issueHostPairingCode, duplicate fallback) call ControlLinks.CloseHost after their commit; the use cases of setHostLlmCredentials (M2-40) and confirmHost (M2-41) call ControlLinks.SendReady after their commit; host reads fill Host.connected from the presence of this ticket (note 7); M2-40's ApplySessionUserReport returns the IDs of the sessions it ended if it gives only their number (note 8 c).
  • internal/instance: JWKSFor(ctx context.Context, p Purpose) (JWKSet, error) on Keys, only if the port has no read per purpose.
  • internal/gateway (only if needed): a scope-less ephemeral event (resource host) reaches every live connection as candidate, admitted with its receive action (note 7).
  • api/specschema (M1-67): ValidateChannelFrame(channel string, fromServer bool, raw []byte) error for the terminal channels; if TestHarness_OnlyEntryPoint rejects a second harness, allow internal/terminal/adapters/hosttest.
  • internal/wire: the route on the public listener for the roles api and all; the stop step (note 11); the configuration keys this ticket is the first consumer of, among terminal.host_heartbeat_interval, terminal.host_heartbeat_miss_limit, terminal.host_auth_timeout, terminal.host_duplicate_probe_timeout (in configkeys_terminal.go, created if M2-39 to M2-41 did not, chapter constant for 24-terminal.md); bind kernel/eventlog.EphemeralPublisher into terminal.
  • architecture/modules.yaml: github.com/coder/websocket for terminal/adapters; the ephemeral event host.presence_changed for terminal if the register lists ephemeral events; regenerate .go-arch-lint.yml.
  • Host: cmd/bothy-host (run wires control as M2-42's Control in place of NoControl), go.mod and go.sum with github.com/coder/websocket pinned to the version of services/server/go.mod, .go-arch-lint.yml (components control and controltest, vendor github.com/coder/websocket), the host configuration loader of M2-42 for the two new keys (note 12).
  • Spec and API (note 14), introduced by this ticket: the host keys host_service.reconnect_backoff_base and host_service.reconnect_backoff_cap (24 17), the deny reason host_duplicate_connection (07 4.1), the audit value form for session user names (19 SEC-AUD-5), the members HostChannelHello.heartbeat_miss_limit and HostHeartbeatAck.jwks (docs/api/openapi.yaml), the close code 4000 on hostControl and hostData (docs/api/asyncapi.yaml), the new section 24 3.5 and the shutdown step in docs/spec/02-architecture.md 15.3; then make api-lint, make api-checks, make api-tables and make codegen-check.
  • docs/operations/configuration.md: regenerate with make config-reference (it carries the two new host keys from 24 17).
  • READMEs of internal/terminal and services/host (English), services/server/CHANGELOG.md and services/host/CHANGELOG.md under "Unreleased", ADR 0068 "Confirmation" (name TestDuplicate_TMT23), docs/DECISIONS.md section 10 rows D-M2-43-<n>.

This ticket creates no migration and no new top-level make target.

Out of scope

  • The data connection /hosts/sessions/{session_id}/stream (hostData) apart from the shared login function of note 3, the device stream, the relay, openTerminalSession and the other session operations, the session token and its issuer, the producers of session.open and session.close (M2-44); the client side of the data connection, the token checks, PTYs and profiles, the session manager that replaces RefuseSessions, the periodic HD6 and HD11 runs (M2-45).
  • Rate limiting of the host connections with rl.host and 4008, and counting failed proofs (M2-46 row "rate limits rl.terminal and rl.host"); revocation by people and devices, limits, terminal.sweep, the metrics bothy.terminal.* including bothy.terminal.hosts.connected on the registry of this ticket (M2-46).
  • Several api nodes: the duplicate probe and presence across nodes over NOTIFY ephemeral (24 3.4, 9) belong to M2-47, which builds the node routing of the control connection; this ticket keeps the registry behind ControlLinks so that M2-47 adds the cross-node path without changing callers. Fencing and cluster.mode = multi in general: M7-17.
  • install, doctor and its output, the state directory, pair, the exit code of a refused start and the service definitions (M2-42); pairing, confirmation and the server's re-pairing logic (M2-41); host records, state machine and host.updated (M2-39); terminal.* rights (M2-40).
  • The conformance and chaos suite, the host service in a Linux container, the full TM-T16 (M2-48); release chain of the host service (M7-23); terminal views (M4-35, M4-36, M5-12, M6-18).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-43-host-control; one writer per worktree.
  • Conventional Commits with the module or component as scope (for example feat(terminal): ..., feat(host): ..., feat(authz): ..., docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. Host tests use httptest.NewTLSServer on loopback and inject its certificate pool; run Go in services/host with GOTOOLCHAIN=local.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d (Go t.TempDir()), removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • Time only through kernel/clock on the server and an injected clock on the host; tests move a fake clock or set short durations through the configuration loader; no test depends on a default value.
  • Proofs, challenges and private keys never appear in logs, audit details, problem bodies, event payloads or test failure messages (print a SHA-256 prefix if needed).
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • github.com/coder/websocket with the gateway adapter and its test client (M1-64), kernel/eventlog.EphemeralPublisher with the ephemeral path of the hub (M1-66) and api/specschema (M1-67) are in the closure of the header through M1-67 (D-W-105); a base branch without them means a dependency is not merged: stop with Verdict: BLOCKED dependency <ticket> missing.
  • If docs/OPEN-QUESTIONS.md on the base branch lists OQ-24 as decided "yes", stop with Verdict: BLOCKED OQ-24: that answer needs an ADR and a change of 24, M2-43 and M2-44 first.
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for M2-38 to M2-42: their merged names win over the expected names here; list each difference. If their code behaves differently from 24 in a way this ticket depends on (for example M2-42 does not keep host ID, server URL and both key pairs after pair), stop with Verdict: BLOCKED <reason>.

Design notes

  1. Placement. The connection, its op handlers and the registry live in terminal/adapters/wsrelay (02 4.3: Relay only in that adapter); every decision is a pure function in terminal/domain; every database effect is a use case in terminal/app with authorize, unit of work, audit and events. Only terminal/adapters imports github.com/coder/websocket (02 5 rule 4). terminal uses instance (JWKS), identity (owner status), authz and the kernel; it never imports gateway: presence goes through kernel/eventlog.EphemeralPublisher, bound in internal/wire.

  2. Endpoint and frames. Route GET /api/v1/hosts/connect on the public listener for the roles api and all, registered like M1-64 registers /api/v1/gateway (same chain steps, bound to host.connect for AZ-P1). Accept with the library's default origin check (a host service sends no Origin; never InsecureSkipVerify). Text frames only, read through a reader limited to sync.ws_max_frame_bytes plus one byte; strict decoding as in M1-64 (duplicate keys, invalid UTF-8, unknown members, missing op, a d that does not match its schema). One reader goroutine handles ops in order; one writer goroutine drains a queue bounded by sync.outbound_queue_max with the deadline gateway.write_timeout per write. Close codes outside the login: a binary frame, invalid JSON, a frame over the limit (reason frame_too_large), an unknown op, a server-to-host op from the host or a second identify close with 4002; queue overflow or a missed write deadline with 4014; a panic or an internal error with 4000 (added to hostControl and hostData in note 14). Ruling for docs/DECISIONS.md: 4002 covers unknown ops on this channel because hostControl names no 4001.

  3. Login. hello right after the upgrade: protocol_versions = SupportedProtocolVersions (today [1]; a test holds it equal to x-protocol-versions of hostControl), challenge = 32 bytes from crypto/rand in base64url without padding, server_time, heartbeat_interval_ms from terminal.host_heartbeat_interval, heartbeat_miss_limit from terminal.host_heartbeat_miss_limit (new member, note 14), jwks = the next, active and retiring keys of purpose terminal only. IdentifyOutcome decides in this order, first match wins: a. No frame within terminal.host_auth_timeout after hello, or a valid host-to-server op other than identify: 4113. A frame that fails decoding: 4002. b. protocol_version not in SupportedProtocolVersions: 4114, before the proof is looked at (18 9.1). c. live_sessions absent or session_id present on the control connection: 4002. d. Proof (CheckHostProof): compact JWS with exactly three parts; header alg exactly EdDSA, typ exactly host-auth+jwt, kid present; payload exactly the members challenge, host_id, iat. No host row with identify.host_id, a host in pairing (keys discarded) or kid different from hosts.auth_jkt: verdict HostAuthUnknownKey, close 4110. Signature that does not verify with hosts.auth_public_jwk through kernel/jose, challenge different from this connection's, host_id claim different from identify.host_id, iat before hello.server_time minus auth.clock_leeway or after now plus auth.clock_leeway, any malformed part: verdict HostAuthInvalid, close 4113. Ruling for docs/DECISIONS.md: kid is the RFC 7638 thumbprint of the host's Ed25519 key (hosts.auth_jkt), so the server tells a replaced key (4110, "run pair again") from a forged signature (4113); the claim set is closed and iat must lie within auth.clock_leeway of the exchange. e. authorize(host principal, host.connect, {kind: host, id, HostAuth}) (note 4): principal_inactive for disabled or retired closes with 4111. f. Duplicate check (note 5), then the login unit of work: reconcile live_sessions (note 6), store identify.host_version in reported_host_version (a change raises hosts.version and writes host.updated), set last_seen_at (note 9). g. Register the link (note 7), re-read state and auth_jkt once and close yourself with the mapped code if they changed in between (a state change committed between e and the registration found no link to close), then send ready {host_id, state, llm_credentials} (confirming or active; HostReady after M2-42; llm_credentials from the row re-read in this step). identify.host_version must match SemVer 2.0.0 and os_version in report must pass textcheck.DisplayText; otherwise 4002 for identify and note 8 for report. Keep hello, the timeout and the proof check in one function with a parameter for the channel, so that M2-44 reuses it for hostData (session_id instead of live_sessions).

  4. Authorization. actor.Principal{Kind: host, ID: host_id} (07 3, D-T25); never looked up in principals. authz changes: requirement host.connect: HostSelf, mapping row host-self -> HostSelf; the atom passes for kind host with r.ID equal to the principal and fails with subject_kind_not_allowed for every other kind (05 4.14 last paragraph, as TM-T17 for the terminal actions); visibility of resource kind host for a host principal: only its own row (another host gives not_found). Liveness for kind host: Resource.HostAuth must be set (missing is an internal error); HostAuthUnknownKey or HostAuthInvalid gives unauthenticated with reason host_auth_failed; a host state from authz.HostSource other than confirming or active gives unauthenticated with principal_inactive; the snapshot of a host principal is empty (no grants, not owner). Replace the deferred entry and remove host.connect from the golden list in the same change (07 4.2, AZ-P3); VerifyRegistrations then accepts the channel ops (M1-25 rule 3a). a. Every op after the login calls authorize again with HostAuthValid and closes on a denial with the code of note 3 (4110 for a host now in pairing, 4111 for disabled or retired): a state change on another node still ends the connection at its next op, fail closed. b. Denials are recorded by the authz layer as for every action (aggregated per audit.deny_aggregate_window, R-AZ-04, AZ-P13); a denial without an existing host row has no resource. The actor columns of host.connect entries stay empty and the host is the resource. Ruling for docs/DECISIONS.md: audit actor IDs point to principals with a row; a host is named as resource. c. Changes of reported_session_users are rights state (03 4.3, 07 SR12, M2-40 Design note 8). ApplyReport writes them only through M2-40's in-transaction ApplySessionUserReport (the function RecordSessionUserReport wraps), inside its own unit of work declared as txn.RightsChange{Action: host.connect, Resource: host, Targets: [host], Principals: people with a valid assignment on the host} (not a second unit of RecordSessionUserReport). When SameReport is false, that unit raises permission_version, ends the sessions of vanished names with access_revoked and writes the one host.updated; the other reported_* columns are written in the same unit. Only reported_is_server_host, which 03 does not list, is read per decision through authz.HostSource (M2-40 Design note 3). Login, heartbeat, session reports and reports without a change of reported_session_users never raise the version; the other rights-version change of this ticket is the host transition of note 5 b, which 24 3.1 requires for every transition out of active. Ruling for docs/DECISIONS.md: Permissions no of host.connect (05 4.14) does not exempt a report from 03 4.3; a changed list of session users raises the rights version, the server-host flag does not.

  5. Second control connection (24 3.4, TM-T23). Before the login unit of work, under a per-process lock per host: if the registry holds a live link for the host, call Ping on it with a context limited by terminal.host_duplicate_probe_timeout. a. No answer: close the older with 4009, run ConnectionLost for it (note 11), continue the new login; audit host.connect allow with details code: stale_connection_replaced. b. Answer, host active: in one unit of work disable the host through M2-39's transition with disabled_reason = duplicate_connection (it ends all sessions with host_disabled, raises permission_version as every transition out of active does, writes host.updated), audit host.connect deny with reason host_duplicate_connection and details code: duplicate_connection; after the commit close both connections with 4112. c. Answer, host confirming: 03 allows disabled only after confirmation. Use M2-41's fallback from confirming to pairing that discards the keys (the one after terminal.fingerprint_max_attempts), with the same audit entry and host.updated; close both with 4112. Ruling for docs/DECISIONS.md and update 24 3.1 and 3.4: for an unconfirmed host the only state from which only a new pairing helps is pairing with discarded keys. d. Ruling for docs/DECISIONS.md: the reason host_duplicate_connection (decision forbidden) joins 07 4.1, because the closed list has no reason for a denial with valid credentials.

  6. live_sessions (24 6.2, D-W-90). In the login unit of work, the host's sessions not closed are locked FOR UPDATE in ascending id. ReconcileLiveSessions(sessions, live): a. Every session in active or detached not in live ends with host_restarted through the session-ending function (note 10). opening sessions stay; terminal.open_timeout ends them (M2-44). b. Every listed ID that is closed for this host, unknown, or of another host gets session.close {session_id, close_reason} right after ready: the stored close_reason, else access_revoked. Ruling for docs/DECISIONS.md: a PTY the server no longer allows ends at the next login (8.3 "instructs the host service"), fail closed for IDs the server does not know. c. Listed sessions that are opening, active or detached stay unchanged; their end comes with session.ended.

  7. Links, readiness and presence. The registry maps host ID to one link (connection, writer queue, reported flag, key generation). ControlLinks (app port): a. OpenSession(ctx context.Context, hostID ids.ID, cmd HostSessionOpen) error: ErrHostNotConnected without a link, ErrHostNotReady before the first applied report after ready (M2-44 maps both to 409 host_unavailable); otherwise enqueue session.open. b. CloseSession(ctx context.Context, hostID, sessionID ids.ID, reason string) error: enqueue session.close; without a link nothing happens (note 6 b catches it at the next login). c. CloseHost(hostID ids.ID, code int): close the link with the code after the caller's commit. setHostState (disable, retire: 4111), issueHostPairingCode for an existing host (4110), M2-41's fallback to pairing after terminal.fingerprint_max_attempts (4110) and the duplicate paths call it. d. Connected(hostID ids.ID) bool: true from the first applied report after ready until the link ends. Host.connected in getHost, listHosts and host.updated reads it. host.presence_changed {host_id, connected} is published on each change of that value (resource {kind: host, id}, no scope, no actor, no seq). Ruling for docs/DECISIONS.md and 24 10.2: "connected" means usable for sessions, so a client that retries after 409 host_unavailable on connected: true does not race the first report. If the merged gateway drops ephemeral events without a space or channel scope, add the rule "no scope: every live connection is a candidate, admitted with the receive action in delivery mode" to its candidate selection with a test. e. SendReady(hostID ids.ID): after the commit of confirmHost (M2-41), setHostLlmCredentials (M2-40) and any other change of hosts.llm_credentials, the registry re-reads the row and sends ready again on the open link (M2-42 Design note 15); without a link nothing happens (the next login carries it).

  8. report. ValidateReport (failure answers host.error {op: "report", code: "validation_failed"}, stores nothing and keeps the connection): every check hd1_user to hd11_shared_locations appears at least once; check and session_user unique together; session_user only on entries with ok = false; os_user names unique without regard to case; profile names unique; display_name, workdirs[] and os_version pass textcheck.DisplayText; each session_users[].os_user and each doctor[].session_user through M2-39's domain.NewOSUser (pattern of 03, at most host.os_user_max_chars); profile names, display_name, each workdirs entry and os_version at most terminal.report_label_max_chars characters; profiles, session_users and the workdirs of one session user each at most terminal.report_max_entries entries. These are M2-41 Design note 4 c to f, reused through its validator where the merged code exposes one; unlike pairHost, an empty session_users is allowed, because a refused start must still report HD1. ApplyReport in one unit of work, host row FOR UPDATE: a. DiffReport(stored, new): nothing changed: no write, no event, no audit (06 2 "No effect"). Otherwise update the changed reported_* columns, hosts.version + 1, one host.updated through M2-39's writer (when reported_session_users changed, the one that ApplySessionUserReport writes, note 4 c). The lists are stored exactly as reported, in their order (TM-T29 (c)). b. Audit allow host.connect only when reported_is_server_host, reported_doctor or reported_session_users changed. Details: changed (list of codes is_server_host, doctor, session_users), is_server_host (bool), failed_checks (codes <check> or <check>:<code>), hd6_users, hd9_users, hd10_users, hd11_users, session_users_added, session_users_removed (lists of names). Session user names enter audit details only through a value kind that accepts exactly the pattern of HostSessionUserAssign.os_user and at most host.os_user_max_chars characters; if M2-39 has none, add auditing.OSUser(name string) Value and extend SEC-AUD-5 in 19 with this form (19 allows the ticket that needs a value form to add it). Ruling for docs/DECISIONS.md. c. SessionsEndedByReport (SR10 part only): when the new reported_is_server_host is true, every session not closed of a person who is not an active owner (owner status through identity) ends with access_revoked in the same unit of work, without a rights-version change. The sessions whose session user (host_session_user_id -> os_user) is missing from the new list end in M2-40's ApplySessionUserReport (SR12, note 4 c). After the commit CloseSession for each session ended by either path (if the merged ReportOutcome of M2-40 carries only their number, extend it with the ended session IDs). d. The first applied report after ready sets the link's reported flag and makes Connected true.

  9. Heartbeat and liveness. heartbeat {active_sessions} (minimum: 0, note 14) is answered with heartbeat_ack {server_time}; active_sessions is informational and changes nothing. The server closes with 4009 when no heartbeat arrived for terminal.host_heartbeat_interval times (terminal.host_heartbeat_miss_limit + 1) since ready or the last heartbeat; the extra interval absorbs the first delay, as M1-64 rules for the gateway. last_seen_at is written at login and on a heartbeat when older than identity.last_active_granularity, without event and without version. JWKS refresh: heartbeat_ack carries jwks (new optional member) whenever the set of purpose terminal differs from the last set sent on this link. Ruling for docs/DECISIONS.md and 24 5.3: a scheduled rotation publishes the next key agent.jwks_max_age before use, so a long-lived connection learns it in time; after an emergency rotation the window is at most one heartbeat interval plus signing_key.cache_ttl (honest limit in 24 5.3).

  10. session.started and session.ended. Both act only on a session of this host. session.started: opening -> active, started_at, terminal_session.changed; already active or detached: no change; closed: answer session.close with the stored reason. session.ended: ValidateHostSessionEnd accepts from the host only client_closed, process_exited, idle_timeout, detach_expired, open_failed and host_policy_violation; close_detail is required exactly for open_failed (any value of TerminalCloseDetail) and host_policy_violation (subscription_login_detected, helper_untrusted, shared_location); exit_code only with process_exited. A session that is already closed gives no change (the host confirms a session.close). An unknown session or one of another host gives host.error {op, code: "not_found"} and session.close with access_revoked; an invalid end gives host.error with validation_failed. Ruling for docs/DECISIONS.md: these sets. Every end goes through one session-ending function of the module (expected from M2-39): state closed, closed_at, close_reason, close_detail, exit_code, version + 1, terminal_session.changed to the person, audit terminal.terminate with session_id, close_reason, close_detail, bytes_in, bytes_out and the duration (24 10.1). If the merged function lacks the event or the audit entry, add them there, so M2-44 and M2-46 reuse one end path.

  11. Connection loss and shutdown. When an authenticated link ends for any reason other than a close that comes with a host state change (4110, 4111, 4112), ConnectionLost sets every active session of the host to detached with detached_at and terminal_session.changed (24 6.2), unless a newer link of the same host is registered in this process; then the presence turns false. Shutdown of the roles api and all: a StopStep in PhaseConnections (M1-11) runs ConnectionLost for every link and closes it with 4013 within the remaining shutdown.total_timeout; update 02 15.3 step 3. Ruling for docs/DECISIONS.md.

  12. Host side (services/host/internal/control). Config{ServerURL, HostID, AuthKey, HostVersion, BackoffBase, BackoffCap, RootCAs, Clock} from M2-42's state and configuration. internal/control implements M2-42's startup.Control (Login(ctx) (Ready, error) returning the declaration of ready, Report(ctx, Report) error, Close() error) on top of Client; cmd/bothy-host run replaces NoControl with it. Report hands the report to ReportSource (c). Every later ready is passed to internal/startup as the current declaration (the path the merged internal/startup offers for a second ready, M2-42 TestDeclarationFromReady). The refused-start path stays M2-42's Login, one Report, Close, exit 78, over one connection (g). Run(ctx): a. Dial wss://<server>/api/v1/hosts/connect (scheme only from an https server URL; system roots unless RootCAs is set; proxy from the environment); refuse ws:// and never skip verification. b. On hello: pick the highest version in both its own list and protocol_versions (none: stop, log "update the host service or the server"); sign the proof (header alg, typ, kid = RFC 7638 thumbprint; payload challenge, host_id, iat); send identify with live_sessions from Sessions.Live() and host_version; store the JWKS. c. On ready: send report from ReportSource as the first frame, before handling any other frame; resend on every change of the source. is_server_host is M2-42's HD5 result or-ed with PeerIsLocal(remote, own addresses) of this connection (24 2.3). d. Send heartbeat every heartbeat_interval_ms; close and reconnect when no frame arrived for heartbeat_interval_ms times (heartbeat_miss_limit + 1). Replace the JWKS on a heartbeat_ack with jwks. A session.open before the own report: close with 4002 and reconnect. e. session.open and session.close go to Sessions; Notifier sends session.started and session.ended. Until M2-45 binds its session manager, run binds RefuseSessions (empty Live(); every session.open answered with session.ended {open_failed, pty_failed}; session.close ignored, there is no PTY); its doc comment names M2-45. f. Close codes: 1000, 1001, 4000, 4002, 4009, 4014, 4113 and any unknown code: reconnect with exponential backoff and full jitter from host_service.reconnect_backoff_base to host_service.reconnect_backoff_cap (new keys, note 14), reset after a ready; 4013: reconnect after a uniform delay below host_service.reconnect_backoff_base; 4008: wait retry_after=<seconds> from the close reason, else the cap; 4111: end all PTYs through Sessions, wait the cap before each attempt; 4110, 4112, 4114: end all PTYs, return ErrStop with the code. run exits on ErrStop with M2-42's exit code for a refused start, the one exit code the service definitions do not restart. Ruling for docs/DECISIONS.md. g. Refused start (M2-42 Design note 15): run calls Login before it decides, M2-42 replaces declaration.json with the declaration of ready and evaluates HD6 again, and on a finding that refuses the start it sends exactly one Report with all checks and Close (code 1000) over the same connection, then exits with the refused-start code. Login here logs in with empty live_sessions and retries with the backoff for at most host_service.reconnect_backoff_cap in total; after that it returns ErrUnreachable and the stored declaration decides (24 2.2; the local doctor output names the accounts). No separate report connection: M2-42's Control covers the refused-start path. h. Frame types are hand-written structs (the host module has no generator) decoded with DisallowUnknownFields and a required-member check; note 13 keeps them equal to the contract. No log line contains a proof, the challenge, a key or report content beyond codes.

  13. Cross-implementation recordings. services/host/testdata/control/host_frames.json is written by a host test (fixed key from SHA-256 of control-recording/host-auth, fixed clock, the challenge and server time of the recording), with one frame per host-to-server op and the public JWK; server_frames.json is written by a server test with one frame per server-to-host op, heartbeat_ack with and without jwks, and error. UPDATE_RECORDINGS=1 writes, otherwise each test compares byte for byte. The server test validates every host frame with specschema.ValidateChannelFrame("hostControl", false, ...) and verifies the recorded proof with the server's CheckHostProof; the host test decodes every server frame strictly. Ruling for docs/DECISIONS.md: recordings in both directions hold the hand-written host frames to the contract, as M2-38 does for Noise.

  14. Specification text (spec first; chapters English). 24: new 3.5 "Control connection" with the login order and proof rules of note 3, connected, report validation and host.error, the accepted end reasons of note 10, the reconciliation of note 6 and the client reactions of note 12 f; 3.1 a transition confirming --> pairing: second control connection (3.4); 3.4 the confirming case, the probe only within one node until M2-47, the audit of both outcomes; 5.3 the JWKS refresh and its limit; 6.2 session.close for listed sessions closed on the server; 13 4000 on hostControl and hostData; 17 the rows host_service.reconnect_backoff_base (Default 1s) and host_service.reconnect_backoff_cap (Default 5m). 07 4.1: row host_duplicate_connection. 19 SEC-AUD-5: the value form of note 8 b. 02 15.3 step 3: control connections of host services. openapi.yaml: HostChannelHello.heartbeat_miss_limit (required integer, minimum 1); HostHeartbeatAck.jwks (optional Jwks); HostHeartbeat.active_sessions minimum: 0; HostIdentify.host_version with a SemVer pattern and proof naming kid; Host.connected description. asyncapi.yaml: 4000 unknown_error ("reconnect with backoff") in the x-close-codes of hostControl and hostData; the hostControl description names the JWKS refresh and report validation.

  15. Rulings for docs/DECISIONS.md, one row each with reason (D-M2-43-<n>): notes 2 (4002 for unknown ops), 3 d (kid, closed claims, iat window), 4 b (actor of host entries), 4 c (report of session users through M2-40's rights change), 5 c and 5 d, 6 b, 7 d (connected), 8 b (session user names in audit), 9 (heartbeat deadline, JWKS refresh), 10 (accepted end reasons), 11, 12 f and 12 g, 13. Facts already in 24 (codes, order of report and session.open, host_restarted) are not repeated there.

Tests (write them first)

Unit, server (go test, fake clock, fakes for ports):

  • internal/terminal/domain: TestCheckHostProof (valid; alg none, HS256, ES256; typ agent-invocation+jwt and terminal-session+jwt; kid of another key gives HostAuthUnknownKey; matching kid with a signature of another key gives HostAuthInvalid; wrong challenge; host_id claim of another host; iat exactly at both bounds valid, one second outside invalid; an extra or missing claim; two or four parts; payload not JSON); TestIdentifyOutcome (protocol version 2 with a broken proof gives 4114; unknown host, pairing, replaced key give 4110; bad signature and timeout give 4113; disabled and retired give 4111; missing live_sessions or a session_id give 4002; confirming and active pass); TestReconcileLiveSessions (sessions opening A, active B, detached C, active D, closed E with access_revoked; live = B, E and an unknown X: C and D end host_restarted, A and B stay, E gets session.close with access_revoked, X with access_revoked); TestValidateReport (one case per rule, among them a name over host.os_user_max_chars in session_users and in doctor, a label over terminal.report_label_max_chars, one entry over terminal.report_max_entries in each list; an empty session_users passes); TestDiffReport (no change; profiles only: event without audit; doctor: audit; names added and removed; server-host flag; os_version only: event without audit); TestSessionsEndedByReport; TestValidateHostSessionEnd; TestHeartbeatDeadline (exactly at the deadline open, after it closed); TestSupportedProtocolVersionsMatchAsyncAPI.
  • internal/authz: TestHostConnectRequirement (the host for itself in confirming and active: allow; another host's ID: not_found; Robin as owner, the agent Lotse and an app: forbidden subject_kind_not_allowed (05 4.14, as TM-T17); HostAuthUnknownKey and HostAuthInvalid: unauthenticated host_auth_failed; disabled, retired, pairing: unauthenticated principal_inactive; missing HostAuth: internal error); TestHostPrincipalSkipsPrincipalSource; TestDeferredActionsGolden without host.connect; the source test of M1-28 for the row host-self; TestReasonsMatchChapter with host_duplicate_connection.
  • FuzzCheckHostProof, FuzzDecodeControlFrame: arbitrary input gives a verdict or an error, never a panic, never HostAuthValid without a verifying signature.

Unit, host (services/host, go test -race, standard library plus github.com/coder/websocket, fake server of controltest):

  • internal/control: TestProofFormat (header and claims exact, kid the RFC 7638 thumbprint, verifies with crypto/ed25519); TestChooseProtocolVersion; TestClosePolicy (one row per code of hostControl x-close-codes plus an unknown code); TestBackoff (bounds, jitter, reset after ready); TestReportIsFirstFrameAfterReady; TestReportResentOnChange; TestPeerIsLocal; TestJWKSFromHelloAndHeartbeatAck; TestHeartbeatAndServerSilence; TestSessionOpenBeforeReportCloses4002; TestRefuseSessionsAnswersOpenFailed; TestStopCodesReturnErrStop (4110, 4112, 4114); TestRefusedStart_ReportsOnceAndStops (exactly one login, one report, close 1000; with the server down, giving up after the cap); TestControlImplementsStartupControl (the type satisfies M2-42's startup.Control; Login returns the declaration of ready, a second ready reaches internal/startup, Report sends a report, Close closes with 1000); TestControlOpensNoListener and TestControlRefusesPlainAndUnverifiedTLS (go/parser over the non-test files: no net.Listen, http.Serve, InsecureSkipVerify; a ws:// URL and an untrusted certificate fail); TestControlUsesNoHelperChannel (the package imports nothing of internal/localchan); TestNoSecretsInLogs; TestHostFramesRecordingCurrent; TestServerFramesDecodeStrictly; FuzzDecodeServerFrame.

Integration, server (PostgreSQL 18 via pgtest, real modules through internal/wire, the hosttest client, fake clock where the code allows it; world: Robin is the owner, Alex and Sam are people; host H1 "Build server" active with Alex as assignee, keys from the terminal test helpers (follow M2-39 to M2-41; if none create a paired host, add internal/terminal/adapters/terminaltest), assignments Alex bothy-s-alex, Sam bothy-s-sam, Robin bothy-s-robin; host H2 confirming; sessions inserted by the helper where named):

  • TestConnect_LoginAndReady: hello members (versions [1], a 43-character challenge, heartbeat_interval_ms and heartbeat_miss_limit from their keys, jwks with purpose terminal keys only); ready {host_id, state: active, llm_credentials} with H1's declaration; H2 gets state: confirming; reported_host_version and last_seen_at stored; no audit entry.
  • TestReadyResentOnDeclarationChange: Robin's setHostLlmCredentials on the connected H1 gives a second ready with the new llm_credentials; Robin's confirmHost of the connected H2 gives a ready with state: active; on a host without a link nothing is sent.
  • TestConnect_AuthTimeout4113, TestConnect_BadProof4113 (the cases of TestCheckHostProof that give HostAuthInvalid), TestConnect_UnknownOrReplacedKey4110 (unknown host ID; Robin re-pairs H1 with issueHostPairingCode, then the old key logs in), TestConnect_DisabledOrRetired4111, TestConnect_VersionUnsupported4114, TestConnect_DecodeErrors4002 (binary frame, invalid JSON, unknown member, frame over sync.ws_max_frame_bytes, unknown op, second identify, missing live_sessions), TestConnect_OpBeforeIdentify4113; each denial stands aggregated once in the audit log with host_auth_failed or principal_inactive (AZ-P13).
  • TestCloseCodes4110To4114 (one subtest per code after 24 13: 4110 while connected when Robin re-pairs H1; 4111 while connected when Robin disables, and again when Robin retires; 4112 from the duplicate; 4113 from a bad proof; 4114 from an old version).
  • TestDuplicate_TMT23: (a) the older answers the ping: both closed with 4112, H1 disabled with duplicate_connection, Alex's active session ended host_disabled, one deny entry host_duplicate_connection with duplicate_connection, host.updated, and setHostState back to active gives 409 invalid_state_transition; (b) the older is paused: after terminal.host_duplicate_probe_timeout it closes with 4009, the new gets ready, one allow entry stale_connection_replaced; (c) H2 with a live duplicate: both 4112, H2 back in pairing with discarded keys.
  • TestLiveSessions_HostRestarted_TMT16 (the world of TestReconcileLiveSessions for Alex on H1): the ended sessions are closed with host_restarted, each with terminal_session.changed to Alex and one terminal.terminate entry; the client receives session.close for E and X after ready; A and B unchanged.
  • TestReportBeforeSessionOpen: after ready without report, OpenSession gives ErrHostNotReady and Connected is false; after the report, host.presence_changed {connected: true} reaches Robin's and Alex's gateway connections but not Sam's (no host.read), and OpenSession delivers session.open that validates against HostSessionOpen.
  • TestReport_StoresListsAfterHD7_TMT29c: Sam's list without the folder HD7 dropped is stored exactly in order; getHost shows connected: true and the lists; host.updated validates against HostUpdatedEvent.
  • TestReport_AuditOnRelevantChange and TestReport_StoresDoctorEntriesPerAccount: HD6 failing for bothy-s-alex and bothy-s-sam, HD10 failing with bothy-s-sam, HD9 with a former name and one entry without name: stored as sent, one allow entry with hd6_users, hd9_users, hd10_users, failed_checks; the same report again writes nothing; a profile-only change writes host.updated without audit.
  • TestReport_DroppedNameEndsSessions_SR12: Alex has an active and a detached session, Robin one active; a report without bothy-s-alex ends Alex's two with access_revoked in the same transaction, sends session.close for both, leaves Robin's, and permission_version is raised by one; the same report again raises nothing.
  • TestReport_ServerHostEndsNonOwnerSessions_SR10: the flag turns on; Alex's sessions end access_revoked, Robin's stay; one audit entry with changed is_server_host; permission_version unchanged.
  • TestReport_Invalid (each rule of ValidateReport, among them one case per bound: a name over host.os_user_max_chars in session_users and in doctor, a label over terminal.report_label_max_chars, one list over terminal.report_max_entries): host.error with validation_failed, nothing stored, the connection stays.
  • TestRefusedStartReport_DWW91: identify, report with HD6 failing for two names, close 1000: reported_doctor stored with both names, audit, host.presence_changed true then false, exactly one login for H1 in the test.
  • TestSessionStartedEnded, TestSessionEnded_HostReasons: every accepted reason and detail rule; access_revoked from the host on an open session gives host.error validation_failed and no change; an unknown session gives not_found plus session.close.
  • TestHeartbeatMissCloses4009, TestHeartbeatAckCarriesNewJWKS (Robin rotates purpose terminal with rotateSigningKey scheduled; the next heartbeat_ack carries the set with the next key, the one after it no jwks), TestLastSeenGranularity.
  • TestControlLossDetachesSessions (Alex's active session becomes detached, host.presence_changed false), TestShutdownCloses4013.
  • TestAudit_HostConnectDetails, TestNoSecretsInLogsAuditOrEvents (proof, challenge and a canary in os_version never in captured logs or audit details; the canary only in hosts and host.updated).
  • TestHostClientFramesValid (host_frames.json validates and its proof verifies), TestServerFramesRecordingCurrent, TestWire_RouteRolesAndRegistrations (the route only in api and all; the five channel ops with host.connect; host.presence_changed registered; VerifyRegistrations passes; AZ-P1, AZ-P2).
  • G9: every frame the hosttest client sends or receives in these tests is validated by api/specschema; a violation fails the test.

Run each fuzz target once locally with go test -run=^$ -fuzz=<target> -fuzztime=<duration of your choice> and report duration and result.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/authz/... ./internal/kernel/... ./internal/audit/... ./internal/gateway/... ./internal/instance/... ./internal/wire/... ./api/...
  • cd services/host && GOTOOLCHAIN=local go vet ./... && GOTOOLCHAIN=local go test -race -count=1 ./...
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make register-check, make catalog-check
  • make config-reference then make config-doc-check
  • make licenses, make osv, make docs, make linkcheck
  • make check

Acceptance criteria

  • Login only with a valid signature over the challenge of hello and within terminal.host_auth_timeout; host.connect is a real requirement with host-self and no longer in the deferred golden list (AZ-P3).
  • Missing heartbeats close the connection with 4009 after terminal.host_heartbeat_miss_limit.
  • identify names live_sessions, and every other session of the host in active or detached ends with host_restarted (TM-T16, D-W-90).
  • After a refused start exactly one report arrives, then the host service closes the connection and stops (D-W-91).
  • After ready, report comes before any session.open; report writes hosts.reported_session_users with the lists after HD7 (TM-T29 (c)) and hosts.reported_doctor with one entry per affected session user.
  • host.connect is audited only on denial, on a change of server host, check result or reported session users, and on a second control connection.
  • TM-T23: a second live connection disables the host with both closed by 4112; a dead older one is replaced after 4009.
  • A host service below the supported protocol version gets 4114 (18 "Host service and server").
  • Close codes 4110 to 4114 each have a test after 24 section 13.
  • Frames of both sides are checked against asyncapi.yaml (G9, recordings in both directions).
  • host.presence_changed reaches exactly the principals with host.read; the server closes the connection on disable, retirement and re-pairing.
  • A report that drops a name ends the affected sessions with access_revoked and raises the rights version (03 4.3); a report that turns the server-host flag on ends the non-owner sessions without a rights-version change.
  • 24, 07, 19, 02, openapi.yaml, asyncapi.yaml, the READMEs and docs/DECISIONS.md describe what was built.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of internal/terminal (control connection, ControlLinks, presence, close codes, configuration keys) and services/host (internal/control, reconnect policy, refused start through Login, Report and Close) updated.
  • Rulings of Design note 15 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes of note 14 made in the same change.
  • services/server/CHANGELOG.md and services/host/CHANGELOG.md entries under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts. Name in the red team request the terminal points of PLAN 8 "Red team" this ticket touches: stolen keys and a second host service (T-TM-05), standstill reporting (T-TM-14), sessions after a restart or a dropped name.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (for example timing margins of the heartbeat and probe tests, the host client against the real server only through recordings, behaviour with cluster.mode = multi).
  5. Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.