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.updatedand 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 inhello; 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" (rowhost.connect), 10.2 "Events", 10.3, 10.4; 13 "Errors" (close codes4110to4114); 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.3grants(the paragraph onpermission_version, which namesreported_session_users); 4.14hosts(allreported_*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 (righthost-self); 2.4 (column Auditdenials); 3 (rl.host); 4.14 (rowhost.connect: host,host-self, Audit "denials; yes on a change of server host, check result or reported session users", Permissionsno); 6 (host.updated,terminal_session.changed, ephemeralhost.presence_changedwithhost.read); 7 "Terminal channels" (ops of/hosts/connect, actionhost.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" (kindhost), 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 (rowterminal: portRelayonly in the adapterwsrelay), 4.4 (terminalmay useinstance,identity,authz,agents), 4.5 (kernel/eventlog.EphemeralPublisher,kernel/actor,kernel/jose), 4.6, 4.7 (rowterminal), 5 rule 4 (libraries per adapter kind,github.com/coder/websocket), 6 "Code layout" (blockservices/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", "Nonull"), 5 "Errors", 11 "Real-time gateway" (terminal channels are their own channels), 15 "Contract tests and gate" (G2, G9).docs/spec/18-release.md9.1 "Host service and server";docs/spec/16-operations.md4.4 (edge forwards/api/v1/hosts/connect, idle time overterminal.host_heartbeat_interval; points 2, 3 and 6);docs/spec/19-security.mdSEC-AUD-5, SEC-LOG-2, SEC-LOG-4, 11a (SEC-TERM-*). For the terminal security group only:docs/spec/07-permissions.md2.12 "Hosts and theterminalcapability";docs/spec/08-identity.md6.3 "Fresh step-up for the terminal" and ID-T28.docs/api/openapi.yaml: schemasHostChannelHello,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); operationsgetHost,listHosts,setHostState,issueHostPairingCode,rotateSigningKey.docs/api/asyncapi.yaml: channelshostControl(description,x-protocol-versions,x-close-codes) andhostData(x-close-codesonly); operationshostControl.*; messageshost.*andevent.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.connectcovers 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.md5.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_charsandterminal.report_max_entries(added by M2-41),shutdown.total_timeout,cluster.mode,ratelimit.rl.host.per_minute; thehost_service.*rows.docs/operations/tailscale.md3 (security group only).docs/observability/signals.md4.3a:bothy.terminal.hosts.connectedexists; M2-46 records it (Out of scope).- Briefs:
M1-64-gateway-connection.md(frame codec, writer queue,4002rules,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,VerifyRegistrationsrule 3a),M1-28-authz-core.md(atoms, mapping table, deferred entries, liveness),M1-24-audit-chain.md(auditing.Recorder:Record,RecordDenial;Attrsvalue 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.ymlmaintained by hand,cmd/bothy-hostdispatcher); and, where they exist indocs/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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- 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 mostsync.ws_max_frame_bytes; the first frame ishellowithprotocol_versions,challenge,server_time,heartbeat_interval_ms(terminal.host_heartbeat_interval) and the JWKS of purposeterminal(24 2.2 last bullet, 5.3; asyncapihostControl;HostChannelHello). - 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 withinterminal.host_auth_timeout; otherwise4113; unknown or replaced keys4110; hostdisabledorretired4111; a protocol version the server does not support4114(24 13, D-W-24, 18 9.1, D-T38). host.connectishost-self(05 2.1, 4.14; 07 3; D-T25): only the host itself, authenticated with its key, passes; Auditdenials, plus one allow entry on a change ofreported_is_server_host,reported_doctororreported_session_usersand one entry per second control connection (24 10.1); Permissionsno; classrl.host.heartbeatis answered withheartbeat_ack; afterterminal.host_heartbeat_miss_limitmissed heartbeats the server closes with4009(asyncapihostControl.receive.heartbeat).identifyon the control connection always carrieslive_sessions; the server ends every other session of the host inactiveordetachedwithclose_reason = host_restarted(24 6.2, 10.3, D-W-90, R-TM-27, TM-T16).- After
readythe host service sendsreportbefore it accepts anysession.open, and the server sendssession.openonly after thatreport(24 2.2 point 5, asyncapihostControldescription).reportwriteshosts.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_confirmandhosts.reported_os_version(03hosts, D-W-63, D-W-64, D-W-69, D-W-109). - When
doctorrefused the start, the paired host service logs in, sends exactly onereport, closes the connection without acceptingsession.openand 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)). - 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_timeoutcloses the older with4009and accepts the new one; an answer closes both with4112, disables the host withdisabled_reason = duplicate_connection, ends all sessions (host_disabled), auditshost.connectwith codeduplicate_connectionand sendshost.updated(24 3.4, D-T13, R-TM-13, TM-T23). - 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). - Presence: the connection state lives only in the memory of the server (03
hosts) and travels as the ephemeralhost.presence_changed {host_id, connected}to everyone withhost.read(05 6, 24 10.2). - A report that no longer names the session user of a person ends that person's sessions on the host with
access_revokedin the same transaction (07 SR12, 24 8.3); a report that turnsreported_is_server_hoston ends the sessions of people who are not owners (07 SR10, 24 8.3). - Every frame of both sides conforms to
docs/api/asyncapi.yamland its body schema indocs/api/openapi.yaml(G9); no JSONnull(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):
- Rights per machine: the additional right
terminalon the targethost, SR10, SR11 with I6 and SR12 (07 "Hosts and theterminalcapability"). M2-39 built the targethostandhost.read(AZ-M60), M2-40 builtterminal.*, 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.connectpasses only for the host principal of its own row (atomHostSelf; people, agents and apps getsubject_kind_not_allowed), proven byTestHostConnectRequirement; 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). - 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
freshand 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. - 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
tailscalereaches 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 onlywss://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). - Audit without content:
host.connectentries 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 metricsbothy.terminal.*; M2-44 owns the relay that logs only length, time and session ID. - Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host, M2-46 adds the server side (
terminal.idle_timeoutplusterminal.idle_grace,terminal.max_session_duration,terminal.sweep, TM-T11). This ticket carries the host's end reports: asession.endedwithidle_timeoutordetach_expiredends the session with that reason (TestSessionEnded_HostReasons), and a dead control connection is closed after the heartbeat deadline so that its sessions turndetachedandterminal.detach_gracestarts (TestHeartbeatMissCloses4009). - Service user and session users without administrator rights (T12, HD rules): M2-42 builds
install,doctorwith 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 insidebothy-host runas 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); whendoctorrefused the start it reports and stops instead of looping (TestRefusedStart_ReportsOnceAndStops). - 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 andhost.updated(TestReport_AuditOnRelevantChange,TestRefusedStartReport_DWW91, host sideTestRefusedStart_ReportsOnceAndStops); T-TM-12 (replaced helper) by carrying the HD10 entry with the name of that channel's session user unchanged intoreported_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) byhost_restartedfor every session a restarted host no longer knows andsession.closefor 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),ValidateReportandDiffReport(note 8),SessionsEndedByReport(note 8),ValidateHostSessionEnd(note 10),HeartbeatDeadline(note 9), the constantSupportedProtocolVersions(note 3).internal/terminal/app/control.go: use casesLogin,Heartbeat,ApplyReport,SessionStarted,SessionEnded,ConnectionLost,ResolveDuplicate; outgoing app portControlLinks(note 7).internal/terminal/adapters/wsrelay/control.go,links.go,codec.go: the upgrade handler ofGET /api/v1/hosts/connect, reader and writer goroutines, the frame codec on the generatedHost*types ofapi/rest, the per-process registry of control links that implementsControlLinksand 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 withapi/specschema; M2-44'stest/terminal/fakehostbuilds 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 withversion + 1,last_seen_at,reported_host_version, the non-closed sessions of a hostFOR UPDATEin ascendingid, the transitionsopening -> activeandactive -> detachedif M2-39 has none.- Fuzz targets
FuzzCheckHostProofandFuzzDecodeControlFramenext to their code.
Create, host:
internal/control/:doc.go,client.go(Client,Run, the implementation of M2-42'sstartup.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 fromhttptest, scripted replies and close codes).testdata/control/host_frames.jsonandtestdata/control/server_frames.json(note 13).
Change:
internal/kernel/actor: kindhost(07 3), if M2-39 to M2-41 did not add it.internal/kernel/authorize:Resource.HostAuth *HostAuthFactswithHostAuthFacts{Verdict HostAuthVerdict}and the constantsHostAuthValid,HostAuthUnknownKey,HostAuthInvalid(note 4).internal/authz: requirement entry ofhost.connectinstead ofDeferred; atomHostSelf; mapping rowhost-self; liveness and visibility for kindhost; no principal lookup for kindhost; the golden file of deferred actions withouthost.connect; the reasonhost_duplicate_connection(note 5) in the reason list.internal/kernel/auditingandinternal/audit/domain: the value kind for session user names (note 8), if M2-39 has none; the reason constanthost_duplicate_connection.internal/terminalport:Register(r *authorize.Registry)registers the channel opsidentify,heartbeat,report,session.started,session.endedofhost_controland the ephemeral typehost.presence_changed.internal/terminal/app: the use cases of M2-39 (setHostStatedisable and retire) and M2-41 (re-pairing throughissueHostPairingCode, duplicate fallback) callControlLinks.CloseHostafter their commit; the use cases ofsetHostLlmCredentials(M2-40) andconfirmHost(M2-41) callControlLinks.SendReadyafter their commit; host reads fillHost.connectedfrom the presence of this ticket (note 7); M2-40'sApplySessionUserReportreturns 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)onKeys, only if the port has no read per purpose.internal/gateway(only if needed): a scope-less ephemeral event (resourcehost) reaches every live connection as candidate, admitted with its receive action (note 7).api/specschema(M1-67):ValidateChannelFrame(channel string, fromServer bool, raw []byte) errorfor the terminal channels; ifTestHarness_OnlyEntryPointrejects a second harness, allowinternal/terminal/adapters/hosttest.internal/wire: the route on the public listener for the rolesapiandall; the stop step (note 11); the configuration keys this ticket is the first consumer of, amongterminal.host_heartbeat_interval,terminal.host_heartbeat_miss_limit,terminal.host_auth_timeout,terminal.host_duplicate_probe_timeout(inconfigkeys_terminal.go, created if M2-39 to M2-41 did not, chapter constant for24-terminal.md); bindkernel/eventlog.EphemeralPublisherintoterminal.architecture/modules.yaml:github.com/coder/websocketforterminal/adapters; the ephemeral eventhost.presence_changedforterminalif the register lists ephemeral events; regenerate.go-arch-lint.yml.- Host:
cmd/bothy-host(runwirescontrolas M2-42'sControlin place ofNoControl),go.modandgo.sumwithgithub.com/coder/websocketpinned to the version ofservices/server/go.mod,.go-arch-lint.yml(componentscontrolandcontroltest, vendorgithub.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_baseandhost_service.reconnect_backoff_cap(24 17), the deny reasonhost_duplicate_connection(07 4.1), the audit value form for session user names (19 SEC-AUD-5), the membersHostChannelHello.heartbeat_miss_limitandHostHeartbeatAck.jwks(docs/api/openapi.yaml), the close code4000onhostControlandhostData(docs/api/asyncapi.yaml), the new section 24 3.5 and the shutdown step indocs/spec/02-architecture.md15.3; thenmake api-lint,make api-checks,make api-tablesandmake codegen-check. docs/operations/configuration.md: regenerate withmake config-reference(it carries the two new host keys from 24 17).- READMEs of
internal/terminalandservices/host(English),services/server/CHANGELOG.mdandservices/host/CHANGELOG.mdunder "Unreleased", ADR 0068 "Confirmation" (nameTestDuplicate_TMT23),docs/DECISIONS.mdsection 10 rowsD-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,openTerminalSessionand the other session operations, the session token and its issuer, the producers ofsession.openandsession.close(M2-44); the client side of the data connection, the token checks, PTYs and profiles, the session manager that replacesRefuseSessions, the periodic HD6 and HD11 runs (M2-45). - Rate limiting of the host connections with
rl.hostand4008, and counting failed proofs (M2-46 row "rate limitsrl.terminalandrl.host"); revocation by people and devices, limits,terminal.sweep, the metricsbothy.terminal.*includingbothy.terminal.hosts.connectedon 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 behindControlLinksso that M2-47 adds the cross-node path without changing callers. Fencing andcluster.mode = multiin general: M7-17. install,doctorand 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 andhost.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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. Host tests usehttptest.NewTLSServeron loopback and inject its certificate pool; run Go inservices/hostwithGOTOOLCHAIN=local. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d(Got.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/clockon 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/websocketwith the gateway adapter and its test client (M1-64),kernel/eventlog.EphemeralPublisherwith the ephemeral path of the hub (M1-66) andapi/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 withVerdict: BLOCKED dependency <ticket> missing.- If
docs/OPEN-QUESTIONS.mdon the base branch lists OQ-24 as decided "yes", stop withVerdict: 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 withVerdict: BLOCKED <reason>.
Design notes¶
-
Placement. The connection, its op handlers and the registry live in
terminal/adapters/wsrelay(02 4.3:Relayonly in that adapter); every decision is a pure function interminal/domain; every database effect is a use case interminal/appwithauthorize, unit of work, audit and events. Onlyterminal/adaptersimportsgithub.com/coder/websocket(02 5 rule 4).terminalusesinstance(JWKS),identity(owner status),authzand the kernel; it never importsgateway: presence goes throughkernel/eventlog.EphemeralPublisher, bound ininternal/wire. -
Endpoint and frames. Route
GET /api/v1/hosts/connecton the public listener for the rolesapiandall, registered like M1-64 registers/api/v1/gateway(same chain steps, bound tohost.connectfor AZ-P1). Accept with the library's default origin check (a host service sends noOrigin; neverInsecureSkipVerify). Text frames only, read through a reader limited tosync.ws_max_frame_bytesplus one byte; strict decoding as in M1-64 (duplicate keys, invalid UTF-8, unknown members, missingop, adthat does not match its schema). One reader goroutine handles ops in order; one writer goroutine drains a queue bounded bysync.outbound_queue_maxwith the deadlinegateway.write_timeoutper write. Close codes outside the login: a binary frame, invalid JSON, a frame over the limit (reasonframe_too_large), an unknown op, a server-to-host op from the host or a secondidentifyclose with4002; queue overflow or a missed write deadline with4014; a panic or an internal error with4000(added tohostControlandhostDatain note 14). Ruling for docs/DECISIONS.md:4002covers unknown ops on this channel becausehostControlnames no4001. -
Login.
helloright after the upgrade:protocol_versions=SupportedProtocolVersions(today[1]; a test holds it equal tox-protocol-versionsofhostControl),challenge= 32 bytes fromcrypto/randin base64url without padding,server_time,heartbeat_interval_msfromterminal.host_heartbeat_interval,heartbeat_miss_limitfromterminal.host_heartbeat_miss_limit(new member, note 14),jwks= thenext,activeandretiringkeys of purposeterminalonly.IdentifyOutcomedecides in this order, first match wins: a. No frame withinterminal.host_auth_timeoutafterhello, or a valid host-to-server op other thanidentify:4113. A frame that fails decoding:4002. b.protocol_versionnot inSupportedProtocolVersions:4114, before the proof is looked at (18 9.1). c.live_sessionsabsent orsession_idpresent on the control connection:4002. d. Proof (CheckHostProof): compact JWS with exactly three parts; headeralgexactlyEdDSA,typexactlyhost-auth+jwt,kidpresent; payload exactly the memberschallenge,host_id,iat. No host row withidentify.host_id, a host inpairing(keys discarded) orkiddifferent fromhosts.auth_jkt: verdictHostAuthUnknownKey, close4110. Signature that does not verify withhosts.auth_public_jwkthroughkernel/jose,challengedifferent from this connection's,host_idclaim different fromidentify.host_id,iatbeforehello.server_timeminusauth.clock_leewayor after now plusauth.clock_leeway, any malformed part: verdictHostAuthInvalid, close4113. Ruling for docs/DECISIONS.md:kidis the RFC 7638 thumbprint of the host's Ed25519 key (hosts.auth_jkt), so the server tells a replaced key (4110, "runpairagain") from a forged signature (4113); the claim set is closed andiatmust lie withinauth.clock_leewayof the exchange. e.authorize(host principal, host.connect, {kind: host, id, HostAuth})(note 4):principal_inactivefordisabledorretiredcloses with4111. f. Duplicate check (note 5), then the login unit of work: reconcilelive_sessions(note 6), storeidentify.host_versioninreported_host_version(a change raiseshosts.versionand writeshost.updated), setlast_seen_at(note 9). g. Register the link (note 7), re-readstateandauth_jktonce 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 sendready {host_id, state, llm_credentials}(confirmingoractive;HostReadyafter M2-42;llm_credentialsfrom the row re-read in this step).identify.host_versionmust match SemVer 2.0.0 andos_versioninreportmust passtextcheck.DisplayText; otherwise4002foridentifyand note 8 forreport. Keephello, the timeout and the proof check in one function with a parameter for the channel, so that M2-44 reuses it forhostData(session_idinstead oflive_sessions). -
Authorization.
actor.Principal{Kind: host, ID: host_id}(07 3, D-T25); never looked up inprincipals.authzchanges: requirementhost.connect:HostSelf, mapping rowhost-self->HostSelf; the atom passes for kindhostwithr.IDequal to the principal and fails withsubject_kind_not_allowedfor every other kind (05 4.14 last paragraph, as TM-T17 for the terminal actions); visibility of resource kindhostfor a host principal: only its own row (another host givesnot_found). Liveness for kindhost:Resource.HostAuthmust be set (missing is an internal error);HostAuthUnknownKeyorHostAuthInvalidgivesunauthenticatedwith reasonhost_auth_failed; a host state fromauthz.HostSourceother thanconfirmingoractivegivesunauthenticatedwithprincipal_inactive; the snapshot of a host principal is empty (no grants, not owner). Replace the deferred entry and removehost.connectfrom the golden list in the same change (07 4.2, AZ-P3);VerifyRegistrationsthen accepts the channel ops (M1-25 rule 3a). a. Every op after the login callsauthorizeagain withHostAuthValidand closes on a denial with the code of note 3 (4110for a host now inpairing,4111fordisabledorretired): 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 peraudit.deny_aggregate_window, R-AZ-04, AZ-P13); a denial without an existing host row has no resource. The actor columns ofhost.connectentries 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 ofreported_session_usersare rights state (03 4.3, 07 SR12, M2-40 Design note 8).ApplyReportwrites them only through M2-40's in-transactionApplySessionUserReport(the functionRecordSessionUserReportwraps), inside its own unit of work declared astxn.RightsChange{Action: host.connect, Resource: host, Targets: [host], Principals: people with a valid assignment on the host}(not a second unit ofRecordSessionUserReport). WhenSameReportis false, that unit raisespermission_version, ends the sessions of vanished names withaccess_revokedand writes the onehost.updated; the otherreported_*columns are written in the same unit. Onlyreported_is_server_host, which 03 does not list, is read per decision throughauthz.HostSource(M2-40 Design note 3). Login, heartbeat, session reports and reports without a change ofreported_session_usersnever 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 ofactive. Ruling for docs/DECISIONS.md: Permissionsnoofhost.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. -
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
Pingon it with a context limited byterminal.host_duplicate_probe_timeout. a. No answer: close the older with4009, runConnectionLostfor it (note 11), continue the new login; audithost.connectallow with detailscode: stale_connection_replaced. b. Answer, hostactive: in one unit of work disable the host through M2-39's transition withdisabled_reason = duplicate_connection(it ends all sessions withhost_disabled, raisespermission_versionas every transition out ofactivedoes, writeshost.updated), audithost.connectdeny with reasonhost_duplicate_connectionand detailscode: duplicate_connection; after the commit close both connections with4112. c. Answer, hostconfirming: 03 allowsdisabledonly after confirmation. Use M2-41's fallback fromconfirmingtopairingthat discards the keys (the one afterterminal.fingerprint_max_attempts), with the same audit entry andhost.updated; close both with4112. 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 ispairingwith discarded keys. d. Ruling for docs/DECISIONS.md: the reasonhost_duplicate_connection(decisionforbidden) joins 07 4.1, because the closed list has no reason for a denial with valid credentials. -
live_sessions(24 6.2, D-W-90). In the login unit of work, the host's sessions notclosedare lockedFOR UPDATEin ascendingid.ReconcileLiveSessions(sessions, live): a. Every session inactiveordetachednot inliveends withhost_restartedthrough the session-ending function (note 10).openingsessions stay;terminal.open_timeoutends them (M2-44). b. Every listed ID that isclosedfor this host, unknown, or of another host getssession.close {session_id, close_reason}right afterready: the storedclose_reason, elseaccess_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 areopening,activeordetachedstay unchanged; their end comes withsession.ended. -
Links, readiness and presence. The registry maps host ID to one link (connection, writer queue,
reportedflag, key generation).ControlLinks(app port): a.OpenSession(ctx context.Context, hostID ids.ID, cmd HostSessionOpen) error:ErrHostNotConnectedwithout a link,ErrHostNotReadybefore the first appliedreportafterready(M2-44 maps both to409 host_unavailable); otherwise enqueuesession.open. b.CloseSession(ctx context.Context, hostID, sessionID ids.ID, reason string) error: enqueuesession.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),issueHostPairingCodefor an existing host (4110), M2-41's fallback topairingafterterminal.fingerprint_max_attempts(4110) and the duplicate paths call it. d.Connected(hostID ids.ID) bool: true from the first appliedreportafterreadyuntil the link ends.Host.connectedingetHost,listHostsandhost.updatedreads it.host.presence_changed {host_id, connected}is published on each change of that value (resource{kind: host, id}, no scope, no actor, noseq). Ruling for docs/DECISIONS.md and 24 10.2: "connected" means usable for sessions, so a client that retries after409 host_unavailableonconnected: truedoes not race the firstreport. 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 ofconfirmHost(M2-41),setHostLlmCredentials(M2-40) and any other change ofhosts.llm_credentials, the registry re-reads the row and sendsreadyagain on the open link (M2-42 Design note 15); without a link nothing happens (the next login carries it). -
report.ValidateReport(failure answershost.error {op: "report", code: "validation_failed"}, stores nothing and keeps the connection): every checkhd1_usertohd11_shared_locationsappears at least once;checkandsession_userunique together;session_useronly on entries withok = false;os_usernames unique without regard to case; profile names unique;display_name,workdirs[]andos_versionpasstextcheck.DisplayText; eachsession_users[].os_userand eachdoctor[].session_userthrough M2-39'sdomain.NewOSUser(pattern of 03, at mosthost.os_user_max_chars); profile names,display_name, eachworkdirsentry andos_versionat mostterminal.report_label_max_charscharacters;profiles,session_usersand theworkdirsof one session user each at mostterminal.report_max_entriesentries. These are M2-41 Design note 4 c to f, reused through its validator where the merged code exposes one; unlikepairHost, an emptysession_usersis allowed, because a refused start must still report HD1.ApplyReportin one unit of work, host rowFOR UPDATE: a.DiffReport(stored, new): nothing changed: no write, no event, no audit (06 2 "No effect"). Otherwise update the changedreported_*columns,hosts.version + 1, onehost.updatedthrough M2-39's writer (whenreported_session_userschanged, the one thatApplySessionUserReportwrites, note 4 c). The lists are stored exactly as reported, in their order (TM-T29 (c)). b. Audit allowhost.connectonly whenreported_is_server_host,reported_doctororreported_session_userschanged. Details:changed(list of codesis_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 ofHostSessionUserAssign.os_userand at mosthost.os_user_max_charscharacters; if M2-39 has none, addauditing.OSUser(name string) Valueand 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 newreported_is_server_hostis true, every session notclosedof a person who is not an active owner (owner status throughidentity) ends withaccess_revokedin 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'sApplySessionUserReport(SR12, note 4 c). After the commitCloseSessionfor each session ended by either path (if the mergedReportOutcomeof M2-40 carries only their number, extend it with the ended session IDs). d. The first applied report afterreadysets the link'sreportedflag and makesConnectedtrue. -
Heartbeat and liveness.
heartbeat {active_sessions}(minimum: 0, note 14) is answered withheartbeat_ack {server_time};active_sessionsis informational and changes nothing. The server closes with4009when noheartbeatarrived forterminal.host_heartbeat_intervaltimes (terminal.host_heartbeat_miss_limit+ 1) sincereadyor the lastheartbeat; the extra interval absorbs the first delay, as M1-64 rules for the gateway.last_seen_atis written at login and on a heartbeat when older thanidentity.last_active_granularity, without event and withoutversion. JWKS refresh:heartbeat_ackcarriesjwks(new optional member) whenever the set of purposeterminaldiffers from the last set sent on this link. Ruling for docs/DECISIONS.md and 24 5.3: a scheduled rotation publishes thenextkeyagent.jwks_max_agebefore use, so a long-lived connection learns it in time; after an emergency rotation the window is at most one heartbeat interval plussigning_key.cache_ttl(honest limit in 24 5.3). -
session.startedandsession.ended. Both act only on a session of this host.session.started:opening -> active,started_at,terminal_session.changed; alreadyactiveordetached: no change;closed: answersession.closewith the stored reason.session.ended:ValidateHostSessionEndaccepts from the host onlyclient_closed,process_exited,idle_timeout,detach_expired,open_failedandhost_policy_violation;close_detailis required exactly foropen_failed(any value ofTerminalCloseDetail) andhost_policy_violation(subscription_login_detected,helper_untrusted,shared_location);exit_codeonly withprocess_exited. A session that is alreadyclosedgives no change (the host confirms asession.close). An unknown session or one of another host giveshost.error {op, code: "not_found"}andsession.closewithaccess_revoked; an invalid end giveshost.errorwithvalidation_failed. Ruling for docs/DECISIONS.md: these sets. Every end goes through one session-ending function of the module (expected from M2-39): stateclosed,closed_at,close_reason,close_detail,exit_code,version + 1,terminal_session.changedto the person, auditterminal.terminatewithsession_id,close_reason,close_detail,bytes_in,bytes_outand 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. -
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),ConnectionLostsets everyactivesession of the host todetachedwithdetached_atandterminal_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 rolesapiandall: aStopStepinPhaseConnections(M1-11) runsConnectionLostfor every link and closes it with4013within the remainingshutdown.total_timeout; update 02 15.3 step 3. Ruling for docs/DECISIONS.md. -
Host side (
services/host/internal/control).Config{ServerURL, HostID, AuthKey, HostVersion, BackoffBase, BackoffCap, RootCAs, Clock}from M2-42's state and configuration.internal/controlimplements M2-42'sstartup.Control(Login(ctx) (Ready, error)returning the declaration ofready,Report(ctx, Report) error,Close() error) on top ofClient;cmd/bothy-host runreplacesNoControlwith it.Reporthands the report toReportSource(c). Every laterreadyis passed tointernal/startupas the current declaration (the path the mergedinternal/startupoffers for a secondready, M2-42TestDeclarationFromReady). The refused-start path stays M2-42'sLogin, oneReport,Close, exit78, over one connection (g).Run(ctx): a. Dialwss://<server>/api/v1/hosts/connect(scheme only from anhttpsserver URL; system roots unlessRootCAsis set; proxy from the environment); refusews://and never skip verification. b. Onhello: pick the highest version in both its own list andprotocol_versions(none: stop, log "update the host service or the server"); sign the proof (headeralg,typ,kid= RFC 7638 thumbprint; payloadchallenge,host_id,iat); sendidentifywithlive_sessionsfromSessions.Live()andhost_version; store the JWKS. c. Onready: sendreportfromReportSourceas the first frame, before handling any other frame; resend on every change of the source.is_server_hostis M2-42's HD5 result or-ed withPeerIsLocal(remote, own addresses)of this connection (24 2.3). d. Sendheartbeateveryheartbeat_interval_ms; close and reconnect when no frame arrived forheartbeat_interval_mstimes (heartbeat_miss_limit+ 1). Replace the JWKS on aheartbeat_ackwithjwks. Asession.openbefore the ownreport: close with4002and reconnect. e.session.openandsession.closego toSessions;Notifiersendssession.startedandsession.ended. Until M2-45 binds its session manager,runbindsRefuseSessions(emptyLive(); everysession.openanswered withsession.ended {open_failed, pty_failed};session.closeignored, there is no PTY); its doc comment names M2-45. f. Close codes:1000,1001,4000,4002,4009,4014,4113and any unknown code: reconnect with exponential backoff and full jitter fromhost_service.reconnect_backoff_basetohost_service.reconnect_backoff_cap(new keys, note 14), reset after aready;4013: reconnect after a uniform delay belowhost_service.reconnect_backoff_base;4008: waitretry_after=<seconds>from the close reason, else the cap;4111: end all PTYs throughSessions, wait the cap before each attempt;4110,4112,4114: end all PTYs, returnErrStopwith the code.runexits onErrStopwith 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):runcallsLoginbefore it decides, M2-42 replacesdeclaration.jsonwith the declaration ofreadyand evaluates HD6 again, and on a finding that refuses the start it sends exactly oneReportwith all checks andClose(code1000) over the same connection, then exits with the refused-start code.Loginhere logs in with emptylive_sessionsand retries with the backoff for at mosthost_service.reconnect_backoff_capin total; after that it returnsErrUnreachableand the stored declaration decides (24 2.2; the localdoctoroutput names the accounts). No separate report connection: M2-42'sControlcovers the refused-start path. h. Frame types are hand-written structs (the host module has no generator) decoded withDisallowUnknownFieldsand 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. -
Cross-implementation recordings.
services/host/testdata/control/host_frames.jsonis written by a host test (fixed key from SHA-256 ofcontrol-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.jsonis written by a server test with one frame per server-to-host op,heartbeat_ackwith and withoutjwks, anderror.UPDATE_RECORDINGS=1writes, otherwise each test compares byte for byte. The server test validates every host frame withspecschema.ValidateChannelFrame("hostControl", false, ...)and verifies the recorded proof with the server'sCheckHostProof; 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. -
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 andhost.error, the accepted end reasons of note 10, the reconciliation of note 6 and the client reactions of note 12 f; 3.1 a transitionconfirming --> pairing: second control connection (3.4); 3.4 theconfirmingcase, the probe only within one node until M2-47, the audit of both outcomes; 5.3 the JWKS refresh and its limit; 6.2session.closefor listed sessions closed on the server; 134000onhostControlandhostData; 17 the rowshost_service.reconnect_backoff_base(Default1s) andhost_service.reconnect_backoff_cap(Default5m). 07 4.1: rowhost_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(optionalJwks);HostHeartbeat.active_sessionsminimum: 0;HostIdentify.host_versionwith a SemVer pattern andproofnamingkid;Host.connecteddescription.asyncapi.yaml:4000 unknown_error("reconnect with backoff") in thex-close-codesofhostControlandhostData; thehostControldescription names the JWKS refresh and report validation. -
Rulings for docs/DECISIONS.md, one row each with reason (
D-M2-43-<n>): notes 2 (4002for unknown ops), 3 d (kid, closed claims,iatwindow), 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 ofreportandsession.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;algnone,HS256,ES256;typagent-invocation+jwtandterminal-session+jwt;kidof another key givesHostAuthUnknownKey; matchingkidwith a signature of another key givesHostAuthInvalid; wrongchallenge;host_idclaim of another host;iatexactly at both bounds valid, one second outside invalid; an extra or missing claim; two or four parts; payload not JSON);TestIdentifyOutcome(protocol version2with a broken proof gives4114; unknown host,pairing, replaced key give4110; bad signature and timeout give4113;disabledandretiredgive4111; missinglive_sessionsor asession_idgive4002;confirmingandactivepass);TestReconcileLiveSessions(sessionsopeningA,activeB,detachedC,activeD,closedE withaccess_revoked;live= B, E and an unknown X: C and D endhost_restarted, A and B stay, E getssession.closewithaccess_revoked, X withaccess_revoked);TestValidateReport(one case per rule, among them a name overhost.os_user_max_charsinsession_usersand indoctor, a label overterminal.report_label_max_chars, one entry overterminal.report_max_entriesin each list; an emptysession_userspasses);TestDiffReport(no change; profiles only: event without audit; doctor: audit; names added and removed; server-host flag;os_versiononly: event without audit);TestSessionsEndedByReport;TestValidateHostSessionEnd;TestHeartbeatDeadline(exactly at the deadline open, after it closed);TestSupportedProtocolVersionsMatchAsyncAPI.internal/authz:TestHostConnectRequirement(the host for itself inconfirmingandactive: allow; another host's ID:not_found; Robin as owner, the agent Lotse and an app:forbiddensubject_kind_not_allowed(05 4.14, as TM-T17);HostAuthUnknownKeyandHostAuthInvalid:unauthenticatedhost_auth_failed;disabled,retired,pairing:unauthenticatedprincipal_inactive; missingHostAuth: internal error);TestHostPrincipalSkipsPrincipalSource;TestDeferredActionsGoldenwithouthost.connect; the source test of M1-28 for the rowhost-self;TestReasonsMatchChapterwithhost_duplicate_connection.FuzzCheckHostProof,FuzzDecodeControlFrame: arbitrary input gives a verdict or an error, never a panic, neverHostAuthValidwithout 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,kidthe RFC 7638 thumbprint, verifies withcrypto/ed25519);TestChooseProtocolVersion;TestClosePolicy(one row per code ofhostControlx-close-codesplus an unknown code);TestBackoff(bounds, jitter, reset afterready);TestReportIsFirstFrameAfterReady;TestReportResentOnChange;TestPeerIsLocal;TestJWKSFromHelloAndHeartbeatAck;TestHeartbeatAndServerSilence;TestSessionOpenBeforeReportCloses4002;TestRefuseSessionsAnswersOpenFailed;TestStopCodesReturnErrStop(4110,4112,4114);TestRefusedStart_ReportsOnceAndStops(exactly one login, onereport, close1000; with the server down, giving up after the cap);TestControlImplementsStartupControl(the type satisfies M2-42'sstartup.Control;Loginreturns the declaration ofready, a secondreadyreachesinternal/startup,Reportsends areport,Closecloses with1000);TestControlOpensNoListenerandTestControlRefusesPlainAndUnverifiedTLS(go/parserover the non-test files: nonet.Listen,http.Serve,InsecureSkipVerify; aws://URL and an untrusted certificate fail);TestControlUsesNoHelperChannel(the package imports nothing ofinternal/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:hellomembers (versions[1], a 43-character challenge,heartbeat_interval_msandheartbeat_miss_limitfrom their keys,jwkswith purposeterminalkeys only);ready {host_id, state: active, llm_credentials}with H1's declaration; H2 getsstate: confirming;reported_host_versionandlast_seen_atstored; no audit entry.TestReadyResentOnDeclarationChange: Robin'ssetHostLlmCredentialson the connected H1 gives a secondreadywith the newllm_credentials; Robin'sconfirmHostof the connected H2 gives areadywithstate: active; on a host without a link nothing is sent.TestConnect_AuthTimeout4113,TestConnect_BadProof4113(the cases ofTestCheckHostProofthat giveHostAuthInvalid),TestConnect_UnknownOrReplacedKey4110(unknown host ID; Robin re-pairs H1 withissueHostPairingCode, then the old key logs in),TestConnect_DisabledOrRetired4111,TestConnect_VersionUnsupported4114,TestConnect_DecodeErrors4002(binary frame, invalid JSON, unknown member, frame oversync.ws_max_frame_bytes, unknown op, secondidentify, missinglive_sessions),TestConnect_OpBeforeIdentify4113; each denial stands aggregated once in the audit log withhost_auth_failedorprincipal_inactive(AZ-P13).TestCloseCodes4110To4114(one subtest per code after 24 13:4110while connected when Robin re-pairs H1;4111while connected when Robin disables, and again when Robin retires;4112from the duplicate;4113from a bad proof;4114from an old version).TestDuplicate_TMT23: (a) the older answers the ping: both closed with4112, H1disabledwithduplicate_connection, Alex'sactivesession endedhost_disabled, one deny entryhost_duplicate_connectionwithduplicate_connection,host.updated, andsetHostStateback toactivegives409 invalid_state_transition; (b) the older is paused: afterterminal.host_duplicate_probe_timeoutit closes with4009, the new getsready, one allow entrystale_connection_replaced; (c) H2 with a live duplicate: both4112, H2 back inpairingwith discarded keys.TestLiveSessions_HostRestarted_TMT16(the world ofTestReconcileLiveSessionsfor Alex on H1): the ended sessions areclosedwithhost_restarted, each withterminal_session.changedto Alex and oneterminal.terminateentry; the client receivessession.closefor E and X afterready; A and B unchanged.TestReportBeforeSessionOpen: afterreadywithoutreport,OpenSessiongivesErrHostNotReadyandConnectedis false; after thereport,host.presence_changed {connected: true}reaches Robin's and Alex's gateway connections but not Sam's (nohost.read), andOpenSessiondeliverssession.openthat validates againstHostSessionOpen.TestReport_StoresListsAfterHD7_TMT29c: Sam's list without the folder HD7 dropped is stored exactly in order;getHostshowsconnected: trueand the lists;host.updatedvalidates againstHostUpdatedEvent.TestReport_AuditOnRelevantChangeandTestReport_StoresDoctorEntriesPerAccount: HD6 failing forbothy-s-alexandbothy-s-sam, HD10 failing withbothy-s-sam, HD9 with a former name and one entry without name: stored as sent, one allow entry withhd6_users,hd9_users,hd10_users,failed_checks; the same report again writes nothing; a profile-only change writeshost.updatedwithout audit.TestReport_DroppedNameEndsSessions_SR12: Alex has anactiveand adetachedsession, Robin oneactive; a report withoutbothy-s-alexends Alex's two withaccess_revokedin the same transaction, sendssession.closefor both, leaves Robin's, andpermission_versionis raised by one; the same report again raises nothing.TestReport_ServerHostEndsNonOwnerSessions_SR10: the flag turns on; Alex's sessions endaccess_revoked, Robin's stay; one audit entry withchangedis_server_host;permission_versionunchanged.TestReport_Invalid(each rule ofValidateReport, among them one case per bound: a name overhost.os_user_max_charsinsession_usersand indoctor, a label overterminal.report_label_max_chars, one list overterminal.report_max_entries):host.errorwithvalidation_failed, nothing stored, the connection stays.TestRefusedStartReport_DWW91: identify,reportwith HD6 failing for two names, close1000:reported_doctorstored with both names, audit,host.presence_changedtrue then false, exactly one login for H1 in the test.TestSessionStartedEnded,TestSessionEnded_HostReasons: every accepted reason and detail rule;access_revokedfrom the host on an open session giveshost.errorvalidation_failedand no change; an unknown session givesnot_foundplussession.close.TestHeartbeatMissCloses4009,TestHeartbeatAckCarriesNewJWKS(Robin rotates purposeterminalwithrotateSigningKeyscheduled; the nextheartbeat_ackcarries the set with thenextkey, the one after it nojwks),TestLastSeenGranularity.TestControlLossDetachesSessions(Alex'sactivesession becomesdetached,host.presence_changedfalse),TestShutdownCloses4013.TestAudit_HostConnectDetails,TestNoSecretsInLogsAuditOrEvents(proof, challenge and a canary inos_versionnever in captured logs or audit details; the canary only inhostsandhost.updated).TestHostClientFramesValid(host_frames.jsonvalidates and its proof verifies),TestServerFramesRecordingCurrent,TestWire_RouteRolesAndRegistrations(the route only inapiandall; the five channel ops withhost.connect;host.presence_changedregistered;VerifyRegistrationspasses; AZ-P1, AZ-P2).- G9: every frame the
hosttestclient sends or receives in these tests is validated byapi/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-checkmake config-referencethenmake config-doc-checkmake licenses,make osv,make docs,make linkcheckmake check
Acceptance criteria¶
- Login only with a valid signature over the challenge of
helloand withinterminal.host_auth_timeout;host.connectis a real requirement withhost-selfand no longer in the deferred golden list (AZ-P3). - Missing heartbeats close the connection with
4009afterterminal.host_heartbeat_miss_limit. -
identifynameslive_sessions, and every other session of the host inactiveordetachedends withhost_restarted(TM-T16, D-W-90). - After a refused start exactly one
reportarrives, then the host service closes the connection and stops (D-W-91). - After
ready,reportcomes before anysession.open;reportwriteshosts.reported_session_userswith the lists after HD7 (TM-T29 (c)) andhosts.reported_doctorwith one entry per affected session user. -
host.connectis 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 after4009. - A host service below the supported protocol version gets
4114(18 "Host service and server"). - Close codes
4110to4114each have a test after 24 section 13. - Frames of both sides are checked against
asyncapi.yaml(G9, recordings in both directions). -
host.presence_changedreaches exactly the principals withhost.read; the server closes the connection on disable, retirement and re-pairing. - A report that drops a name ends the affected sessions with
access_revokedand 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 anddocs/DECISIONS.mddescribe what was built. -
make checkis 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) andservices/host(internal/control, reconnect policy, refused start throughLogin,ReportandClose) updated. - Rulings of Design note 15 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes of note 14 made in the same change. services/server/CHANGELOG.mdandservices/host/CHANGELOG.mdentries 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix 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 onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- 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). - 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>.