Skip to content

M2-44: Terminal sessions, session token and relay

Status: ready | Depends on: M2-43 | Milestone: M2

Goal

After this ticket a person opens, attaches to, lists, reads and closes terminal sessions on a paired, connected host, and the server relays the end-to-end encrypted stream between the device and the host service without reading it. openTerminalSession and attachTerminalSession run authorize with SR10, SR11 (I6), SR12, the fresh step-up and the host and profile checks, consume the step-up in the same transaction, write the terminal_sessions row, the audit entry and terminal_session.changed, and return a one-time session token: a compact JWS after ADR 0067 with typ = terminal-session+jwt, signed with the server key of purpose terminal, carrying exactly the claims of 24 "Terminal session token", with osu taken from the person's own session user assignment (terminal_sessions.host_session_user_id) and wdi counted only in the person's own reported working directory list. The server then sends session.open over the control connection of M2-43, the host service opens its data connection /api/v1/hosts/sessions/{session_id}/stream, the device opens /api/v1/terminal/sessions/{session_id}/stream, and the relay of one node pairs both in memory and forwards every binary frame unchanged, recording only length, time and session ID. Full buffers close the slow end with 4014, a missing counterpart after terminal.open_timeout ends the opening with open_failed, a newer device closes the older one with 4103, text frames after identify close with 4002, and every end of a session, whoever causes it, closes both connections. This is the server half of R-TM-02, R-TM-07 and R-TM-09 (24 "Decisions of this chapter") and of the scenarios TM-T07 and TM-T12 (each without the console part: PLAN row (D-W-105); the console part is M6-18's) and TM-T29 (g) (server part); the host side of the handshake is M2-45, revocation, limits and the remaining metrics M2-46, several nodes M2-47.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Test doubles": Fake-Host in the server test, "Security"); section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria item 8, Test strategy "fake host in the server test", Red team terminal paragraph, Risks), block "terminal" (normative sources and order), rows M2-38 to M2-43 (what exists), M2-44 (this ticket), M2-45, M2-46, M2-47 and M2-48 (who builds what next), M4-35, M4-36, M5-12 and M6-18 (clients, console part of TM-T07 and TM-T12); section 19: P-09, P-10, P-11, P-20, P-24, P-25.
  • Dependencies: the header names M2-43. Through it this ticket builds on M2-42, M2-41, M2-40, M2-39 and M2-38, on the M1 tickets M1-01 to M1-03, M1-05 to M1-18, M1-20, M1-22 to M1-29, M1-31 to M1-35, M1-44, M1-48 and M1-49, and, because M2-43 depends on M1-67 (D-W-105), on M1-64, M1-65, M1-66 and M1-67 with their own closure (all implied, so not in the header). D-W-102 struck M2-40 from this ticket's plan column (it named "M2-40, M2-43"); M2-40 stays in this read list, because the terminal rights, SR10 to SR12, the fresh step-up and its consumption come from it. Therefore the gateway WebSocket pattern of M1-64, the log tail wiring of M1-65 and api/specschema of M1-67 are on the base branch when this brief starts.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host service bothy-host" points 1, 3, 5 and 6 (assignment, osu, report before session.open, working directory lists after HD7 and the index); 2.3 "Server host" (own); 3.1 "States"; 3.5 "Control connection" (added by M2-43); 4.1; 5.1 "Protection goal"; 5.2 "Protocol" (one WebSocket binary message carries exactly one Noise message, at most 65535 bytes); 5.3 "Terminal session token" (claims, check order of the host, the table of close_detail and close codes, the JWKS refresh of M2-43); 5.4 "Frames" (text frames forbidden, 4002); 5.5 "Key pinning on the device" (pin_changed); 6.1 "Opening" (steps 1 to 7); 6.2 "Detach and reattach"; 6.3 "Limits"; 6.4 "Local confirmation" (the consent window, Design note 7); 6.6 "State of a session"; 8.2 "Protection rules"; 8.3 "Revocation"; 8.4 "Owner on another person's host" (foreign_assignee); 9 "Multiple nodes" (M2-47); 10.1 "Audit"; 10.2 "Events"; 10.3 "Reasons for ending"; 10.4 "Privacy"; 11.1 "Definition" (workdir_index, wdi); 13 "Errors"; 14 "Tests" (TM-T07, TM-T12, TM-T29 (g); TM-T03 to TM-T05 for the host side); 16 (R-TM-01, R-TM-02, R-TM-07, R-TM-09, R-TM-10, R-TM-16, R-TM-21, R-TM-22, R-TM-23); 17 "Configuration values of this chapter".
  • docs/spec/07-permissions.md: 2.12 "Hosts and the terminal capability"; 3 "Principal context"; 4.1 "Decisions and reasons" (rows terminal_no_session_user, terminal_profile_unknown, terminal_step_up_not_fresh, host_not_active, not_self, subject_kind_not_allowed); 4.2 "Flow" (step 8 with quick_reattach, step 9 guards); 4.3 "Visibility"; SR10, SR11, SR12; 8.2 point 7; test matrix rows AZ-M54 to AZ-M61; 11.2 "Required tests" AZ-P1, AZ-P2, AZ-P4, AZ-P13.
  • docs/spec/08-identity.md: 6.2 "Step-up flow"; 6.3 "Fresh step-up for the terminal" (points 1 to 5, point 4 as M2-40 updated it); 7.2 "Access token"; 7.3 "DPoP"; 7.5 (only for what M6-18 adds later); test ID-T28.
  • docs/spec/05-actions.md (core, read only): rule 1.11; 2.4 (Step-up fresh, Audit yes and denials); 4.14 "Hosts and terminal"; 6 (row terminal_session.changed); 7 "WebSocket ops and action" (terminal channels).
  • docs/spec/03-data-model.md (core, read only): terminal_sessions, host_session_users, hosts, server_signing_keys (purpose terminal), auth_sessions (step_up_at, step_up_method, step_up_consumed_at, client_component), audit_events.details (the only free text is the mandatory reason of an administrative action); 6.3 "Lock order"; 6.6; R-DM-36, R-DM-39, R-DM-40.
  • docs/spec/04-sync-protocol.md (core, read only): the terminal paragraph of section 2, 6.4 "Close codes" (the paragraph on the terminal channels), 8 (entries with kind: "host").
  • docs/spec/02-architecture.md: 4.3 (row terminal: ports Sessions and Relay, Relay only in the adapter wsrelay), 4.4 (row terminal), 5 "Layers within a module" (rules 1, 2, 4 and 5), 6 "Code layout" (test/** is the component testsupport), the "Event path" ("Log tail per api process"), 12.3 "Job catalogue" (row terminal.sweep), 15.1 "Start", 15.3 "Shutdown".
  • docs/spec/06-api.md: 2 "Basic rules" (rows "Unknown values", "No effect", "No null"), 5 "Errors" (points 3 and 7), 7 "Idempotency" (x-idempotency-exempt: auth_flow).
  • docs/spec/19-security.md: SEC-CORS-3, SEC-LOG-2, SEC-TERM-1, SEC-TERM-2, SEC-TERM-3, SEC-TERM-5, SEC-TERM-9, SEC-TERM-10, SEC-TERM-12, SEC-T04, SEC-T10, SEC-T18.
  • docs/spec/15-client-architecture.md: 23.2 "Opening, streaming and reattaching" (what the device does on each close code), 23.3 "Key pinning".
  • docs/api/openapi.yaml: operations openTerminalSession, attachTerminalSession, listTerminalSessions, getTerminalSession, closeTerminalSession (with x-authz-action, x-rate-limit-class, x-idempotency-exempt); schemas TerminalSessionOpen, TerminalSessionAttach, TerminalSessionGrant, TerminalSession, TerminalSessionPage, TerminalSessionState, TerminalCloseReason, TerminalCloseDetail, TerminalSessionChangedEvent, TerminalStreamHello, TerminalStreamIdentify, NoiseFrame, HostChannelHello, HostIdentify, HostSessionOpen, HostSessionClose, HostReportedSessionUser, HostProfile, ReasonBody, GatewayError, Jwks, SigningKeyPurpose, Problem (x-problem-codes); parameters TerminalSessionIdPath, HostIdPath, HostIdQuery, TerminalSessionStateQuery, Cursor, Limit, IdempotencyKey.
  • docs/api/asyncapi.yaml: channels terminalStream, hostControl, hostData with their x-close-codes; operations terminal.send.hello, terminal.receive.identify, terminal.send.error, terminal.receive.frame, terminal.send.frame, hostControl.send.session.open, hostControl.send.session.close, hostData.send.hello, hostData.receive.identify, hostData.send.error, hostData.receive.frame, hostData.send.frame; message event.terminal_session.changed.
  • ADRs: 0068 (terminal over the host service, Noise, I6), 0067 (JOSE profile), 0066 (signing keys), 0070 (terminal in the console: what this ticket must not claim), 0052 (one envelope), 0053 (no JSON null), 0008 and 0009 (module rules, transactions).
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57 and T13 as D-W-62 in section 9), D-T17, D-T18, D-T23, D-T26, D-T28, D-T29, D-T33, D-T35, D-T36, D-T37, D-T41, D-W-63, D-W-66, D-W-69, D-W-90, D-W-100, D-W-101, D-W-102, and the rows D-M2-39-<n> to D-M2-43-<n> of section 10. D-W-62, D-W-63 and D-W-102 name this ticket.
  • docs/security/threat-model.md: 5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14). docs/RISKS.md: RK-24, RK-27, RK-29. docs/OPEN-QUESTIONS.md: OQ-24 (terminal only over a private network; default no; an answer "yes" changes M2-43 and this ticket), OQ-03 (M2-45) and OQ-04 (M2-46).
  • docs/observability/signals.md 4.3a "Terminal" (this ticket registers and records only bothy.terminal.relay.unpaired, D-W-105, Design note 15) and 5.3 "Attributes in spans and logs" (its attribute bothy.terminal.side). docs/operations/configuration.md: the terminal.* keys of 24 17, host_service.require_local_confirm, host_service.local_confirm_timeout, auth.dpop_nonce_ttl, auth.dpop_proof_max_age, auth.clock_leeway, server.public_url, cluster.mode, shutdown.total_timeout, database.tx_retry_max, audit.reason_max_chars, audit.deny_aggregate_window, sync.log_poll_interval.
  • Briefs of the dependencies (names as they use them): M2-39-terminal-hosts.md (port Hosts, app.EndSessions with SessionSelector, authz.HostSource and HostFacts, the app ports Store, People with IsActiveOwner, Presence, the test fixtures terminaltest, the purpose terminal), M2-40-terminal-authz.md (authorize.Resource with HostID and Terminal *TerminalFacts, Decision.QuickReattach, identity.StepUp.ConsumeFresh, TerminalGuard with its extension point after SR12, the mapping rows of host_not_active and terminal_profile_unknown, HostSource.SessionUsersOf with SessionUserFact), M2-41-host-pairing.md, M2-42-host-install-doctor.md, M2-43-host-control.md (ControlLinks with OpenSession, CloseSession, CloseHost, Connected; the use cases SessionStarted, SessionEnded, ConnectionLost; the shared login function of hello and identify; the client hosttest; api/specschema.ValidateChannelFrame; the stop step in PhaseConnections), M2-38-noise-core.md; and M1-11-lifecycle-health.md (StopStep, PhaseConnections), M1-13-unit-of-work.md (txn.UnitOfWork.Do, no after-commit hook), M1-16-event-log.md (eventlog.Event with AudiencePrincipalID, eventlog.Appender, Tail), M1-17-api-server-stubs.md (api/operations.yaml), M1-18-http-chain.md (strict JSON decoder, httpservertest log capture), M1-20-idempotency-keys.md, M1-24-audit-chain.md (auditing.Recorder with Record and RecordDenial), M1-25-authz-catalogue.md (channel ops in the registry), M1-28-authz-core.md (Filter(ctx), Decision.Err()), M1-32-signing-keys.md (instance.Keys), M1-33-identity-devices-dpop.md (DPoP with CurrentNonce), M1-34-identity-sessions.md (actor.Principal fields SessionID, DeviceID, ClientComponent), M1-64-gateway-connection.md (Origin check, hello with DPoP nonce, AuthenticateCredentials), M1-65-gateway-delivery.md (the tail goroutine Tail -> Observe -> Dispatch in internal/wire); outside this ticket's closure and read only as a pattern, M2-06-invocation-token.md (a token issuer, a redacting token type and a schema test against the chapter). These briefs are in docs/briefs/ (index in docs/briefs/README.md).
  • Code on the base branch: internal/terminal (M2-39 to M2-43), internal/authz and internal/kernel/authorize (M2-40), internal/identity (M2-40 ConsumeFresh), internal/instance, internal/kernel/*, internal/wire (configkeys_terminal.go, configchapters.go, the tail goroutine), api/specschema, architecture/modules.yaml, api/operations.yaml, services/server/internal/terminal/README.md.

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. The Noise limit of 65535 bytes per message and the protocol labels are protocol constants of 24 5.2 with a comment naming their source, not configuration keys.
  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. openTerminalSession runs authorize(person, terminal.open, host) with the right terminal, SR10, SR11, SR12, the fresh and unconsumed step-up, host active and connected, the profile known and, for a profile of kind claude, a working directory of the own session user; on allow it consumes the step-up, inserts the terminal_sessions row in opening with the person's session user, writes the audit entry and terminal_session.changed in one transaction and answers 201 with TerminalSessionGrant (24 6.1 steps 1 to 3, 05 4.14, 08 6.3).
  2. The session token is a compact JWS after ADR 0067 with the header alg = EdDSA, kid, typ = terminal-session+jwt, signed with the active server key of purpose terminal, valid terminal.session_token_ttl, with exactly the claims of 24 5.3: iss, aud, sub, sid, jti, iat, exp, dev, prf, cek, own, osu, wdi (only for a profile of kind claude and att = open), att. Every grant has a new jti, stored in terminal_sessions.token_jti; older tokens of the session are worthless (24 6.2, D-T33).
  3. osu is host_session_users.os_user of terminal_sessions.host_session_user_id, the person's own valid assignment at opening, for every token of the session; it never comes from the request (24 2.2 point 3, R-DM-40, D-W-66).
  4. A valid assignment whose name the host does not report gives 403 terminal_denied with reason: no_session_user at opening, not a token that the host refuses with 4104 (SR12, D-W-63, R-TM-23, TM-T28 (e) server part).
  5. workdir_index counts only in the workdirs list of the caller's own entry in hosts.reported_session_users; any other index gives 403 terminal_denied with reason: profile_unknown, and a valid index stands as wdi in the token (24 11.1, D-W-62, D-W-69, TM-T29 (g) server part).
  6. attachTerminalSession needs a fresh step-up, except from the same device in the same login session within terminal.quick_reattach_window after the disconnect, never in the console; it issues a new token, and the connection of an older device is closed with 4103 (24 6.2, 08 6.3 point 5, R-TM-07, TM-T12).
  7. TM-T07 without the console part: terminal.open without step-up, with a step-up older than terminal.step_up_max_age and with an already consumed step-up gives 401 step_up_required; of two concurrent openings with one step-up exactly one succeeds.
  8. The server sends session.open (HostSessionOpen) only over a control connection that has delivered its report (24 2.2 point 5, M2-43 ControlLinks.OpenSession); the host service answers each session.open with one data connection (24 6.1 step 4).
  9. The relay pairs the device stream and the host data connection of a session in the memory of one node and forwards every binary frame unchanged, one WebSocket binary message carrying exactly one Noise message of at most 65535 bytes; it records only length, time and session ID (SEC-TERM-1, 24 5.2, 5.4, 9 point 3).
  10. A text frame after identify closes that connection with 4002; a full relay buffer (terminal.relay_buffer_max_bytes per direction) closes the slow end with 4014 and the session becomes detached; a counterpart missing after terminal.open_timeout ends an opening session with open_failed (24 5.4, 6.1 step 6, 6.3).
  11. The device stream sends hello with the server DPoP nonce and accepts only identify with an access token and a DPoP proof (htm = GET, htu the HTTPS form of the stream URL, nonce from hello, ath) of the device of the current session token (terminal.stream, Audit denials) (asyncapi terminalStream, 05 7).
  12. listTerminalSessions and getTerminalSession show the person's own sessions, owners all, metadata only; closeTerminalSession closes an own session (client_closed) or, for an owner, any session (closed_by_owner) (05 terminal.read_own, terminal.close, 24 10.3).
  13. Every end of a session, by the person, an owner, the host service, the server or another module's transaction, closes the device stream and the data connection (4100 with the close_reason, or the code of 24 5.3 for a refused handshake) and tells the host service with session.close (24 8.3, 13).
  14. terminal_session.changed goes only to the person of the session, at every state change, with the payload TerminalSession, never content (05 6, 24 10.2).
  15. Audit after 24 10.1: terminal.open and terminal.attach with host_id, session_id, profile, device_id, pin_changed, foreign_assignee; terminal.close with session_id and who closed; terminal.stream denials with session_id, device_id and the reason code; terminal.terminate for every end by the server or the host with session_id, close_reason, close_detail, bytes_in, bytes_out and the duration. Never terminal content, commands, environment, paths or tokens (R-TM-10, SEC-LOG-2). No JSON null anywhere (ADR 0053).

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 as decisions of authorize (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). What this ticket enforces: every operation and both stream endpoints call authorize with their action and descriptor, and on any denial no row, no token and no session.open exist (TestOpen_DeniedOpensNothing, with AZ-M60 over HTTP); a decision is checked again after the commit so that a revocation between decision and commit ends the new session at once (TestOpen_RevocationRaceRecheck); SR12 is answered with no_session_user before a token exists (TestOpen_NoSessionUserIsDeniedNot4104); osu comes only from the assignment row (TestToken_OsuFromAssignment); the claim own lets the host service repeat the server-host rule (TestToken_OwnClaim); the stream admits only the person's current device (TestStream_IdentifyDecisions); every end reaches the connections (TestRelay_EndsFromOtherPaths).
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). This ticket calls M2-40's identity.StepUp.ConsumeFresh in the transaction of every opening and of every attach that is not a quick reattach, and proves TM-T07 and TM-T12 without the console part over HTTP (TestOpen_TMT07_StepUp, TestAttach_TMT12). M2-40 owns the check fresh, the quick reattach rule, ConsumeFresh and ID-T28 without the console part; M6-18 owns the console parts of TM-T07, TM-T12 and ID-T28 (passkey with userVerification = required, cookie session from M6-01); M4-36 and M5-12 own the step-up with the device key in the apps.
  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 adds two upgrade routes on the existing public listener and no listener or port; the server never dials a host service, the host service opens its data connection itself after session.open (Semgrep rule bothy-go-egress-only of M1-22; TestHostData_OnlyAfterSessionOpen); a foreign Origin is refused before both upgrades (SEC-CORS-3, TestStream_ForeignOriginRejected). If docs/OPEN-QUESTIONS.md lists OQ-24 as decided "yes", stop (Boundaries).
  4. Audit without content: this ticket writes the audit entries of item 25 with codes only and the relay logs only length, time, side, close code and session ID (TestAudit_TerminalEntries, TestRelay_CanaryNeverLeaks). M2-46 owns TM-T06 over the database dump and the telemetry and the remaining metrics without content.
  5. Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host from the input frames, M2-46 builds the server side (terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration, TM-T11) and the session limits. This ticket provides what they build on: opened_at is set once at opening and never changed by an attach (maximum duration across reattaching), and the relay keeps per session the time of the last device frame and the byte counters and writes last_input_at, bytes_in and bytes_out (TestAttach_KeepsOpenedAtAndOsu, TestRelay_ByteCountersAndLastInput).
  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 starts every PTY only in the helper of osu. This ticket puts into every token the session user of the person's own assignment and nothing the client chose (TestToken_OsuFromAssignment), so the host service can bind it to sub.
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-01 (content readable in the server) by the opaque relay and codes-only logs and audit (TestRelay_ForwardsBytesUnchanged, TestRelay_CanaryNeverLeaks; TM-T06 by M2-46); T-TM-02 (replay and token swap at the relay) by a new jti per grant, sid, cek from the request and the forwarding of the host's refusal codes 4101 and 4104 (TestToken_ClaimsMatchSpec, TestToken_NewJtiPerGrant, TestRelay_HostRefusalCodes; TM-T03 to TM-T05 by M2-45); T-TM-03 (unauthorised opening) by TM-T07 and TM-T12 here and TestStream_IdentifyDecisions (AZ-M54, AZ-M55, AZ-M58 by M2-40); T-TM-08 (foreign LLM login) by osu and wdi only from the own assignment and list (TestOpen_TMT29g_WorkdirIndexOutsideOwnList; the rest by M2-40, M2-42 and M2-45); T-TM-09 (prompt injection opens a terminal) by TestTerminalOps_NonHumanTokens (TM-T17 over the five operations and the stream; AZ-M59 by M2-40); T-TM-11 (session stays) by the end path that closes both connections and tells the host (TestClose_EndsConnectionsAndTellsHost, TestRelay_EndsFromOtherPaths; every revocation kind by M2-46); T-TM-12 (owner on another person's host) by foreign_assignee in the audit (TestAudit_TerminalEntries; the warning by M4-36, M5-12 and M6-18). Not touched here: T-TM-04, T-TM-05 and T-TM-14 (M2-42, M2-43, M2-45), T-TM-06 (M2-41), T-TM-07 (M6-18; the Origin check of both upgrades is here), T-TM-10 (M4-35), T-TM-13 (M2-42, M2-45).

Scope

All paths under services/server/ unless noted. Names of M2-38 to M2-43 below are the ones their briefs use; where the merged code names them differently, follow the code (Boundaries).

Create:

  • internal/terminal/domain/session.go: the session state rules this ticket adds (opening to closed at the deadline, the attach rules of Design note 6) next to the transitions M2-43 built, as pure functions.
  • internal/terminal/domain/token.go: SessionClaims, NewSessionClaims(g TokenGrant, jti ids.ID, now time.Time, ttl time.Duration) (SessionClaims, error), (SessionClaims) Payload() ([]byte, error), the header constants TokenAlg = "EdDSA" and TokenTyp = "terminal-session+jwt" (Design note 5).
  • internal/terminal/domain/profile.go: ProfileKnown(reported []ReportedProfile, own []string, profile string, workdirIndex *int) bool (Design note 3).
  • internal/terminal/domain/closecodes.go: the close codes of terminalStream as constants and StreamCodeForEnd(reason, detail string) int (Design note 8).
  • internal/terminal/domain/relay.go: the pure pairing rules Decide(slot SlotState, ev RelayEvent) []RelayAction (Design note 7).
  • internal/terminal/app/sessions_open.go, sessions_attach.go, sessions_read.go, sessions_close.go, sessions_relay.go (callbacks of the relay: paired, detached, deadline, refusal, counters), token.go (the issuer), reconcile.go (start), and the outbound ports RelayControl, Timers and Metrics (Design notes 2, 7, 14 and 15).
  • internal/terminal/adapters/postgres/queries/sessions.sql: insert, lock one session FOR UPDATE, the attach update, the counter update, list with keyset cursor, the reconciliation updates; regenerate sqlc (Design notes 4, 6, 7, 14, 17).
  • internal/terminal/adapters/httpapi: strict-server methods of the five operations (in the file layout M2-39 chose).
  • internal/terminal/adapters/wsrelay/stream.go (device stream), hostdata.go (host data connection on M2-43's shared login function), relay.go (in-memory slots, queues, deadlines, the log tail observer), frames.go (frame reading with the Noise limit), each with tests.
  • internal/terminal/adapters/otelmetrics: the instrument bothy.terminal.relay.unpaired (or extend the package if M2-43 created it), declared as a Spec in internal/platform/otel/metrics if that registry exists (M1-68 merged), else with the meter of internal/platform/otel; the adapter package in architecture/modules.yaml (with the OpenTelemetry vendor group of M1-10 in adapter_vendors of terminal if the registry is missing), then regenerate .go-arch-lint.yml (Design note 15).
  • test/terminal/fakehost/: the Fake-Host of the plan row (package fakehost, component testsupport, built on M2-43's hosttest): Host (control connection, report, data connections with scripted behaviour) and Device (device stream client with DPoP); Design note 13.
  • test/integration/terminalsessions/: the cross-module integration tests of this ticket.

Change:

  • internal/terminal port: Sessions with Open(ctx context.Context, p actor.Principal, cmd OpenSession) (Grant, error), Attach(ctx context.Context, p actor.Principal, cmd AttachSession) (Grant, error), List(ctx context.Context, p actor.Principal, q ListSessions) (SessionPage, error), Get(ctx context.Context, p actor.Principal, id ids.ID) (SessionView, error), Close(ctx context.Context, p actor.Principal, cmd CloseSession) (SessionView, error) with own DTOs (02 5 rule 1); Register(r *authorize.Registry) of M2-43 gains the channel ops of terminalStream (hello, identify, error, the binary frames, action terminal.stream) and of hostData (hello, identify, error, the binary frames, action host.connect) for AZ-P2.
  • internal/terminal/app: M2-39's EndSessions as the one end path (Design note 11): selection of one session, closed_by and the action terminal.close for an end by a person, the event terminal_session.changed if M2-43 did not add it, the live byte counters through RelayControl.Counters.
  • internal/kernel/authorize: TerminalFacts gains HostConnected bool and ProfileKnown bool; the marker Recheck(ctx context.Context) context.Context (Design notes 3 and 12).
  • internal/authz: in M2-40's TerminalGuard extension point after SR12 the guards host_not_active and terminal_profile_unknown; a guard of terminal.stream for the device; the handling of Recheck(ctx) in step 8; each with its tests (Design note 3).
  • internal/kernel/actor and internal/identity: AuthenticateCredentials and the exposed DPoP nonce only if M1-64 left them out (Design note 2).
  • internal/wire: the two upgrade routes on the public listener of the roles api and all, the Sessions handlers, the relay with its callbacks into app, the observer in the tail goroutine of M1-65 (Design note 11), the start step and the stop step of Design note 14, config keys this ticket is the first consumer of among terminal.session_token_ttl, terminal.open_timeout and terminal.relay_buffer_max_bytes in configkeys_terminal.go (defaults from 24 17), the metric adapter.
  • internal/platform/otel/attr: the constant for bothy.terminal.side (client, host), the attribute of the counter and of the relay log records (Design note 15); repository root infra/otel/collector.yaml: bothy.terminal.side in redaction/allowlist.allowed_keys ("Bothy attributes"); docs/observability/signals.md 5.3: row bothy.terminal.side | client, host | side of a connection in the relay (24 section 9).
  • api/operations.yaml: move openTerminalSession, attachTerminalSession, listTerminalSessions, getTerminalSession, closeTerminalSession from later to enabled; rerun the route generator (M1-17).
  • docs/api/openapi.yaml: pattern: '^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$' on client_ephemeral_key_hash of TerminalSessionOpen and TerminalSessionAttach (43 base64url characters that decode strictly to 32 bytes, as the examples already are); the description of attachTerminalSession (an attach to an opening session restarts the opening, Design note 6); the description of workdir_index (a missing index with a claude profile and an index with another profile both give 403 terminal_denied with reason: profile_unknown). Then make api-lint, make api-checks, make api-tables, make api-clients and the server code generation.
  • docs/api/asyncapi.yaml: channel hostData description (one data connection per session.open; the host service closes it with 4101 or 4104 and the close_detail code as close reason when it refuses a handshake) and the client actions of its codes 1000 and 4014 (keep the PTY, wait for the next session.open); channel terminalStream codes 4102 (an opening session ends with open_failed, a started one is detached) and 4004 (also when identify does not arrive within terminal.open_timeout); message host.session.ended summary (exit, idle, refused PTY start, policy).
  • docs/spec/24-terminal.md (English, like the chapter): 5.3 (how the host service reports a refused handshake), 6.1 steps 4, 6 (with the deadline on a host that reports require_local_confirm, Design note 7) and 7, 6.2 (data connection per session.open, attach to an opening session), 13 (column "Channel" of 4101 and 4104 adds "data connection, from the host service"), each with the ruling of Design notes 6, 7 and 9.
  • docs/spec/02-architecture.md: "Log tail per api process" (the terminal observer, Design note 11), 15.1 (the start step of Design note 14 for cluster.mode = single), 15.3 step 3 (relay connections close with 4013).
  • docs/security/threat-model.md 6.7: add this ticket's test names to the test column of T-TM-01, T-TM-02, T-TM-03 and T-TM-09.
  • services/server/internal/terminal/README.md (English, status, the Sessions port, the relay rules and close codes, actions, events), services/server/CHANGELOG.md under "Unreleased", docs/DECISIONS.md section 10 rows D-M2-44-<n> for every ruling of Design note 18.

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

Out of scope

  • Host service side: the check order of the token, the memory of jti, cek, osu and wdi checks, the handshake, start, the PTY, replay of the scrollback, TM-T03 to TM-T05, TM-T21, TM-T28 (c), TM-T29 (g) with the token (M2-45). The Fake-Host serves only this ticket's tests and those of M2-46 and M2-47.
  • Revocation of every kind of 24 8.3 and its subscribers of authorize.AccessChanged, identity.DeviceRevoked and identity.SessionEnded, idle and maximum duration on the server, terminal.max_sessions_per_person and terminal.max_sessions_per_host (terminal_session_limit), the rate limits rl.terminal and rl.host on the streams, the metrics bothy.terminal.sessions.active, bothy.terminal.sessions.closed and bothy.terminal.hosts.connected, the retention class, the job terminal.sweep, TM-T06, TM-T10, TM-T11 (M2-46). If the merged chain already applies rl.terminal to the REST operations through their x-rate-limit-class, leave it as it is.
  • cluster.mode = multi: session.open over NOTIFY ephemeral, hash routing, TM-T15 (M2-47).
  • What M2-39 to M2-43 built: host records and transitions, pairing, report, live_sessions, session.started and session.ended and their state changes, ConnectionLost, the control connection and its stop step, SR10 to SR12, I6, the check fresh, the quick reattach rule and ConsumeFresh.
  • The console: identify with the cookie session, passkey with userVerification = required, TM-T07, TM-T12 and ID-T28 for the console (M6-01, M6-18). Client views and pinning (M4-35, M4-36, M5-12). Maintenance mode exceptions (M7-08). Conformance and chaos suite with the real host service, TM-T16 (M2-48).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-44-terminal-sessions-relay; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(terminal): ..., 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.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d (the key store of the tests with mode 0700), removed at the end.
  • Never read or print secrets; test keys are generated by the tests and never leave the temporary directory. No network calls except package downloads for pinned tools and Go modules.
  • No real host service, no PTY and no Noise library in this ticket: the Fake-Host and the fake device exchange opaque bytes, which is exactly what the relay must not be able to tell apart. No new third-party Go module.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • 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 this ticket 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-43: their merged names win over the names here; list each difference. If the base branch's docs/spec/actions.yaml lacks one of the terminal.* actions or the purpose terminal is missing from SigningKeyPurpose, the spec state is missing on the base branch: stop with Verdict: BLOCKED <reason>.
  • Time only through kernel/clock and the Timers port (Design note 7); tests move a fake clock and fire deadlines through the fake, they never sleep for a window.
  • Session tokens, access tokens, DPoP proofs and nonces never appear in logs, audit details, events, problem bodies, close reasons or test failure messages (print a hash prefix of the jti if needed).

Design notes

  1. Placement and layers (02 4.3, 4.4, 5). The port Sessions is the inbound interface of the module; the relay is not a port for other modules but lives in adapters/wsrelay next to M2-43's control connection (02 4.3: "Relay ... only in the adapter wsrelay"). app owns every decision and every write; wsrelay owns sockets, queues and timers and calls back into app for every state change. domain holds the pure rules (token claims, profile verdict, close codes, pairing decisions) and imports only kernel-core. terminal uses the ports of instance (signer of purpose terminal), identity (owners through M2-39's People.IsActiveOwner, StepUp.ConsumeFresh, authentication of the stream) and authz; nothing else.

  2. What the dependencies left. M2-39: the tables, EndSessions(ctx, sel SessionSelector, reason domain.CloseReason), authz.HostSource, the host view with noise_public_key, key_fingerprint, key_generation, terminaltest, the purpose terminal in instance.Keys.EnsureActive (rotation only on owner request). M2-40: Resource.HostID, Resource.Terminal *TerminalFacts{SessionState string; SessionDeviceID, SessionAuthSessionID ids.ID; DetachedAt time.Time}, Decision.QuickReattach, the gate HumanSession before resolution (agents, apps and personal tokens get 403 forbidden with reason: subject_kind_not_allowed for any ID), the visibility of terminal_session (its person or an owner), the guards SR10 to SR12 with the extension point "M2-44 adds host_not_active and terminal_profile_unknown", the mapping rows of both, identity.StepUp.ConsumeFresh(ctx, p) as the first domain row lock of the action's transaction. M2-43: ControlLinks (OpenSession with ErrHostNotConnected and ErrHostNotReady, CloseSession, CloseHost, Connected true from the first applied report), the use cases SessionStarted, SessionEnded and ConnectionLost, the one function for hello, the timeout and the proof check with a parameter for the channel (meant for hostData), hosttest, api/specschema.ValidateChannelFrame, the JWKS refresh in heartbeat_ack, the stop step in PhaseConnections.

    • If actor.Authenticator accepts only an *http.Request, add AuthenticateCredentials(ctx, actor.Credentials{Scheme, Token, DPoPProof, Method, URL string; ExpectNonce bool}) (actor.Actor, error) exactly as the M1-64 brief names it (actor.Actor is the type M1-64 writes for the request principal) and implement the HTTP path on top of it, with tests in identity; expose CurrentNonce() of the DPoP verifier (M1-33) through the identity port if it is not exposed.
    • If a named piece of M2-39 to M2-43 is missing, add it where its brief puts it and list it in the report; never build a second version beside it.
  3. Authorization facts and guards. terminal knows the request and the live connections, authz decides, as runs does with token facts in M2-09. a. TerminalFacts gains HostConnected bool (from ControlLinks.Connected(hostID)) and ProfileKnown bool. For terminal.open the resource is {Kind: host, ID} with Terminal: &TerminalFacts{HostConnected, ProfileKnown}; for terminal.attach, terminal.stream, terminal.read_own and terminal.close it is {Kind: terminal_session, ID, HostID, OwnerID: user_id, Terminal: &TerminalFacts{SessionState, SessionDeviceID, SessionAuthSessionID, DetachedAt, HostConnected, ProfileKnown: true}}. b. ProfileKnown comes from domain.ProfileKnown with hosts.reported_profiles and the workdirs of the caller's own entry in hosts.reported_session_users (the entry whose os_user is the caller's valid assignment; none gives an empty list). It is true exactly when the profile name is in reported_profiles and either its kind is not claude and workdir_index is absent, or its kind is claude and 0 <= workdir_index < len(own workdirs). Ruling for docs/DECISIONS.md: a claude profile without an index and an index with another profile both count as "an index of no working directory of the own session user" (07 4.1 terminal_profile_unknown), one rule in one place. c. In the extension point of TerminalGuard after SR12, in this order (24 6.1 step 2): host_not_active when HostFacts.State is not active or HostConnected is false (409 host_unavailable); then, for terminal.open only, terminal_profile_unknown when ProfileKnown is false (403 terminal_denied with reason: profile_unknown). M2-46 later inserts terminal_session_limit before host_not_active. d. A guard of terminal.stream after Self: a principal whose DeviceID is not SessionDeviceID is forbidden with not_self (05: "only the device of the current token"; 07 4.1 not_self: "the resource belongs to someone else (device, ...)"). e. Recheck(ctx) (Design note 12) implies Filter(ctx) and makes step 8 pass for fresh actions; nothing else changes.

  4. openTerminalSession in one unit of work: a. Read the host and the caller's valid assignment through the module's store; build the descriptor of note 3a. b. authorize(person, terminal.open, host). Resulting order of answers: schema 400 validation_failed (body; cols, rows, unknown members and the client_ephemeral_key_hash pattern), then authorize in its order (403 forbidden with reason: subject_kind_not_allowed from the gate, 404, 403 forbidden for a missing terminal, 401 step_up_required, 403 terminal_denied with server_host_owner_only, not_host_assignee, i6_holder_only, no_session_user, then 409 host_unavailable, then 403 terminal_denied with profile_unknown), then 201. c. ConsumeFresh as the first row lock. Its failure (a concurrent opening consumed the confirmation) rolls the unit back; record the denial with auditing.Recorder.RecordDenial (action terminal.open, reason terminal_step_up_not_fresh) and answer 401 step_up_required. d. Insert terminal_sessions: new UUIDv7 id; host_id; user_id; host_session_user_id = the valid assignment of note 3b; opened_by_device_id and device_id = the principal's device; auth_session_id = its session; profile; state = opening; token_jti = new UUIDv7; key_generation = hosts.key_generation; opened_at = now. e. Build and sign the token inside the unit (note 5), so a key store failure rolls everything back (503 unavailable as M1-32 maps it) and no session without a token exists. Ruling for docs/DECISIONS.md: signing happens before the commit, not after it as the sequence diagram seq-8 draws it; the diagram shows order of meaning, not of transactions. f. Audit allow terminal.open with host_id, session_id, profile, device_id, pin_changed (from the body, default false) and foreign_assignee (true when the caller is an active owner and hosts.assigned_user_id is set and is another person, 24 8.4); terminal_session.changed (note 16). g. After the commit: the recheck of note 12; then RelayControl.Expect (note 7) and ControlLinks.OpenSession with HostSessionOpen{session_id, data_path: /api/v1/hosts/sessions/{id}/stream, profile, expires_at: grant time plus terminal.open_timeout}. An error of that send after the commit (the link dropped in between) does not fail the request: the deadline of note 7 ends the opening with open_failed and open_timeout. h. Answer 201 TerminalSessionGrant{session, session_token, session_token_expires_at, stream_path: /api/v1/terminal/sessions/{id}/stream, noise_public_key, key_fingerprint, key_generation, noise_protocol: Noise_NK_25519_ChaChaPoly_SHA256, prologue_version: bothy-terminal/1} with the key values as M2-39's host view renders them. Denials of authorize write no row and send nothing; the authz layer records them (Audit yes, aggregated per audit.deny_aggregate_window).

  5. Session token (24 5.3, ADR 0067, the encoding rules of M2-06 Design note 3 applied to this token):

    • Header JSON exactly {"alg":"EdDSA","kid":<KeyID() of the signer>,"typ":"terminal-session+jwt"}; a signer that reports another algorithm is an internal error. Sign through jose.SignCompact with the adapter from instance.Signer to jose.Signer that M1-34 or M2-06 built (reuse it; otherwise a small unexported one).
    • Payload members in the order of the table of 24 5.3: iss = instance.instance_id, aud = host ID (a string, never an array), sub = the person, sid = session ID, jti, iat, exp (NumericDate seconds, iat truncated to seconds, exp = iat + terminal.session_token_ttl), dev = the device of the grant, prf = profile name, cek = client_ephemeral_key_hash as sent, own = the caller is an active owner at issue time (always present, false included), osu, wdi only for a profile of kind claude with att = open and then always present, also with the value 0, att = open or attach. IDs canonical lower-case UUID strings; absent claims omitted, never null. Ruling for docs/DECISIONS.md: the encoding rules (member order, seconds, own always present, wdi present with 0).
    • SessionToken wraps the compact string like M2-06's InvocationToken: String, GoString, Format, LogValue, MarshalText, MarshalJSON render [redacted]; only Reveal() returns it, called once by the HTTP adapter when it writes session_token.
    • The server never verifies these tokens; the host service does (M2-45) with the JWKS it gets in hello and heartbeat_ack (M2-43). Keys of purpose terminal and invocation never verify each other's tokens (D-T33).
  6. attachTerminalSession in one unit of work: a. Read the session (unknown or invisible: 404); authorize(person, terminal.attach, terminal_session) with the facts of note 3a (SR10 to SR12, host_not_active, step-up or quick reattach of M2-40). b. ConsumeFresh unless Decision.QuickReattach (failure as note 4c). Then lock the session row FOR UPDATE: closed gives 409 invalid_state_transition; for a quick reattach the locked row must still be detached with the same device and login session, else record the denial and answer 401 step_up_required. c. osu stays the session's host_session_user_id; if that row is no longer the person's valid assignment (cannot happen while M2-39 and M2-43 end such sessions in the same transaction), answer 403 terminal_denied with reason: no_session_user. d. State opening (the PTY never started): the token gets att = open and, for a claude profile, the wdi the relay slot of the opening remembered (note 7); the grant restarts the opening with a new deadline. Ruling for docs/DECISIONS.md: 05 allows an attach to every session that is not closed and 24 5.3 allows wdi only with att = open; without a PTY the host service must open, so the token says open, and the working directory, which 03 does not store, is kept in the relay slot of this node for the life of the opening (24 6.2 changed accordingly). State active or detached: att = attach, no wdi. The state itself does not change here; the relay moves it (note 7). e. Update device_id, auth_session_id, token_jti, version + 1. Audit allow terminal.attach with the details of note 4f; terminal_session.changed. f. After the commit: recheck (note 12), RelayControl.Expect (it closes the older device side with 4103 and the older host side with 1000), ControlLinks.OpenSession. Answer 200 TerminalSessionGrant. opened_at, host_session_user_id and key_generation never change.

  7. Relay model (domain/relay.go decides, wsrelay/relay.go executes). Ruling for docs/DECISIONS.md: one data connection per grant. Every grant creates or replaces the slot of its session with an expectation {session, host, device, deadline = grant time plus terminal.open_timeout, started, workdir index of an opening of a claude profile} and one session.open; the host service opens exactly one data connection per session.open; the relay pairs the device side of the expected device with that host side. A new handshake therefore always runs on a fresh pair of connections, and the host service never has to tell a new handshake message 1 from transport data. 24 6.2 ("rebuilds the data connection after reconnecting") changes to "reopens the data connection as soon as the device attaches (session.open)".

    • Frames: each WebSocket binary message is read whole and forwarded as one binary message, never merged, split or altered; a message above 65535 bytes closes its side with 4002 (set the library read limit above it and enforce the limit in frames.go, so the code is 4002, not 1009). Until the other side is there, frames wait in the queue of their direction.
    • Queues: one per direction, bounded by terminal.relay_buffer_max_bytes in bytes of queued messages; a message that does not fit closes the side that does not read (the receiver) with 4014.
    • Counting: per pairing the first message of each direction is the handshake (message 1 from the device, message 2 from the host); every later message adds its length to bytes_in (from the device) or bytes_out (to the device), and every later device message sets the in-memory time of the last device frame. When a pairing ends for any other reason than the end of its session, the relay writes its counts into the row (bytes_in, bytes_out added, last_input_at), one update without event or version. Ruling for docs/DECISIONS.md: handshake messages are not transport bytes (03 "bytes of the transport messages"), and counters are metadata written without an event.
    • Deadlines through the Timers port (AfterFunc(d time.Duration, f func()) (stop func() bool), real in wsrelay, a fake driven by the fake clock in the tests; reuse M2-43's if it has one). The deadline of a slot is met by session.started (M2-43's use case, after which wsrelay tells the relay) for a session that never started and by the pairing for a started session. Ruling for docs/DECISIONS.md: the server cannot see the handshake, so "the handshake is complete" (24 17) means session.started before the PTY runs and the pairing afterwards. Ruling for docs/DECISIONS.md, stated identically in M2-45 Design note 15 d: on a host whose last report says require_local_confirm = true (hosts.reported_require_local_confirm), the server deadline of an opening (grant time plus terminal.open_timeout) is met by the pairing of both sides, not by session.started, because the consent window host_service.local_confirm_timeout (24 6.4) may be longer than terminal.open_timeout; the host service bounds the rest itself: start until HostSessionOpen.expires_at, the consent within host_service.local_confirm_timeout, and it ends a refused or unanswered opening with session.ended open_failed (open_timeout or local_confirm_denied). When such a pairing ends before session.started, the slot gets a new deadline of terminal.open_timeout from that moment.
    • Reactions (the pure table of Decide; state changes through app, using M2-43's transitions opening -> active and active -> detached and their reverse for a re-paired started session):

      Event Device side Host side Session
      both sides present forward forward a started session becomes active (detached_at cleared)
      device side closes 1000 started: detached; opening: unchanged until the deadline
      host side closes with 4101 or 4104 (reason = close_detail) same code and reason not started: ends open_failed with that detail; started and 4101: detached; started and 4104: ends open_failed with that detail
      host side closes otherwise 4102 started: detached; opening: unchanged until the deadline
      deadline, session not started 4102 if the host side is missing, else 4100 4100 ends open_failed with open_timeout; a present side that found no counterpart counts bothy.terminal.relay.unpaired once with its own bothy.terminal.side
      deadline, started, not paired 4102 1000 stays detached; the present side is counted as above
      queue toward the device full 4014 1000 detached
      queue toward the host full 4102 4014 detached
      text frame after identify, or a message above the Noise limit offender 4002, else 4102 offender 4002, else 1000 started: detached; opening: unchanged
      new grant older 4103 older 1000 a started session whose pairing ends here becomes detached until the new pairing; opening stays
      session.started without a paired device side detached at once
      session ends (note 11) StreamCodeForEnd with the close_reason as close reason 4100 closed
      control link of the host ends without 4110, 4111, 4112 4102 1000 M2-43's ConnectionLost sets detached
      control link ends with 4110, 4111 or 4112 nothing here: the sessions end through EndSessions (note 11)
      shutdown 4013 4013 M2-43's stop step detaches
    • Logs: one record per pairing at its end (session ID, side, frame and byte counts, duration, close codes) and one per refused connection (session ID if known, close code); never frame content, tokens, IP addresses, principal or host names. Ruling for docs/DECISIONS.md: the relay log carries only length, time, close code and session ID (SEC-TERM-1).

  8. Device stream GET /api/v1/terminal/sessions/{session_id}/stream (terminal.stream), registered like M2-43 registers /api/v1/hosts/connect:

    • Before the upgrade: a present Origin that differs from the origin of server.public_url gives 403 with problem code forbidden (SEC-CORS-3); never turn the library's origin verification off. Compression off.
    • hello {protocol_versions: [1], dpop_nonce, server_time} (TerminalStreamHello); then exactly one identify (TerminalStreamIdentify, strict decoder of M1-18) within terminal.open_timeout; authenticate through AuthenticateCredentials (an access token, the proof, GET, htu = https://<host and port of server.public_url>/api/v1/terminal/sessions/<id>/stream, nonce required); device_id must equal the token's device; then authorize(person, terminal.stream, terminal_session) with notes 3a and 3d.
    • Close codes (Ruling for docs/DECISIONS.md, each code from terminalStream):

      Case Code
      invalid JSON, unknown member, a binary frame before identify, protocol_version other than 1 4002
      no identify within terminal.open_timeout; token, proof, nonce, htu or device_id invalid; identify without access_token (the console path is M6-18's); any forbidden other than not_self (subject_kind_not_allowed from the gate) 4004
      decision unauthenticated with principal_inactive, session_revoked or device_inactive 4012
      decision not_found (unknown or foreign session), or an own closed session (close reason = its close_reason) 4100
      decision forbidden with not_self (not the device of the current token) 4103
    • StreamCodeForEnd(reason, detail) for a session that ends while a device is connected: 4101 for token_expired and token_replayed; 4104 for token_invalid, handshake_failed, profile_unknown, session_user_unknown, session_user_mismatch, server_host_owner_only; else 4100.

    • Denials are audited by the authz layer (Audit denials) with session_id, device_id and the reason code. After an allow the connection becomes the device side of the slot; text frames from then on close with 4002.
  9. Host data connection GET /api/v1/hosts/sessions/{session_id}/stream (host.connect): M2-43's shared function sends hello (HostChannelHello) and checks identify (HostIdentify with session_id instead of live_sessions) with its close codes and terminal.host_auth_timeout. Then: a session that is unknown, belongs to another host or is closed gives 4100; a session without an expectation on this node gives 1000 (the host service waits for session.open); while a paired host side exists the newcomer gets 1000, otherwise the newcomer replaces an unpaired older host side (which gets 1000). Ruling for docs/DECISIONS.md: the host service refuses a handshake by closing its data connection with 4101 (token_expired, token_replayed) or 4104 (every other detail of 24 5.3) and the close_detail code as close reason; on 4104 for a started session it also ends the PTY. 24 5.3 ("It reports the reason as close_detail") gets this sentence; a refusal reported through session.ended instead (M2-43 accepts open_failed with every detail) reaches the device through StreamCodeForEnd with the same code, so the device gets exactly one code either way (R-TM-16, D-T18).

  10. Control connection hooks (in wsrelay, after M2-43's use cases return): after SessionStarted, RelayControl marks the slot started (note 7); after SessionEnded nothing extra (the end reaches the relay through note 11); when a control link ends, the relay applies the two "control link" rows of note 7. M2-43's state changes stay as they are.

  11. One end path and its effects after the commit. Every end goes through M2-39's EndSessions, extended here where M2-43 did not: a selector for one session; closed_by and the audit action terminal.close (with session_id, who closed and the reason of note 17c) for an end by a person, else terminal.terminate; close_detail and exit_code; terminal_session.changed; bytes_in and bytes_out = row value plus the live pairing's counts from RelayControl.Counters(sessionID) (zero without a slot on this node). txn has no after-commit hook, and ends happen in transactions of terminal, authz and identity alike (M2-39 host transitions, M2-43 reports and live_sessions, M2-46 revocations). Ruling for docs/DECISIONS.md: the effects after the commit come from the event log tail. internal/wire hands every event of M1-65's tail goroutine, after Observe and Dispatch, to the relay's observer (Relay.ObserveLogEvent(ctx, typ string, resourceID ids.ID, payload []byte), non-blocking); for a terminal_session.changed whose payload has state = closed it closes the session's sides (device StreamCodeForEnd, host 4100), drops the slot without writing counters, and calls ControlLinks.CloseSession with the close_reason (a no-op without a link on this node; a duplicate after M2-43's own CloseSession is harmless, M2-43 note 10). 02 "Log tail per api process" names this third consumer. The tail delivers in seq order after the commit on every api node, so every end, from any module, reaches the connections and the host service without a new mechanism. This holds for an end committed by a worker process (api and worker as separate processes with cluster.mode = single, 02 16 level 1; for example terminal.sweep or a grant expiry of M2-46) and for an end committed on another node (cluster.mode = multi), because the tail reads the committed rows of event_log from the database, woken by NOTIFY event_log, which is on in every mode (03 6.6), and polls every sync.log_poll_interval (02 11.2); M2-46 and M2-47 add no other path for ends.

  12. Recheck after the commit (open and attach). 03 6.3 allows the lock of permission_version only for rights changes, so a revocation can commit between the decision and the commit of the opening, and its subscriber does not see the uncommitted row. Ruling for docs/DECISIONS.md: after the commit the use case calls authorize again with the same action and fresh facts under authorize.Recheck(ctx): it implies Filter(ctx) (no second audit, not counted) and passes step 8, because the step-up was consumed by the decision being rechecked; its result can only end what that decision allowed, never grant anything, and no other caller uses it. HostConnected keeps the value of the first decision, because a lost connection is the deadline's business (note 7). If the recheck is no longer allow, the session ends through note 11 with access_revoked, or i6_violation for i6_holder_only, or for host_not_active the host state (disabled gives host_disabled, retired gives host_retired, pairing gives host_rekeyed); no session.open is sent (the session.close of note 11 is harmless for a session the host never saw), and the answer is the recheck's error. With M2-46's subscribers, which see every committed row, no session outlives a revocation.

  13. Fake-Host (test/terminal/fakehost, testsupport; it may import github.com/coder/websocket, internal/kernel/jose, hosttest and the identity test helpers):

    • Host: the host ID and Ed25519 key pair of an active host (built with terminaltest and M2-41's pairing as M2-43's tests build H1); the control connection through hosttest with report of configurable profiles, session users and require_local_confirm and the heartbeat loop; on session.open it opens one data connection per message unless scripted otherwise; records every op and frame; every text frame passes api/specschema.ValidateChannelFrame.
    • Scripts per session: Accept (read message 1, verify the token against the JWKS from hello: signature, typ, aud, sid, exp with auth.clock_leeway, jti unseen; answer one frame as message 2; on the next device frame send session.started; then echo), Refuse(code, detail) (close the data connection with that code and reason), End(reason, detail) (send session.ended), Ignore (never open the data connection), Stall (accept but never read).
    • Device: opens the stream path with an access token and a DPoP proof from identitytest, sends identify, then message 1 as the compact token bytes (a test framing only: the relay forwards opaque bytes, so it cannot tell this from Noise), and offers Send, Recv, CloseCode.
    • Neither fake replaces the Go test device of M2-48 or the real host service; the README of the package says so.
  14. Start and stop. Ruling for docs/DECISIONS.md: with cluster.mode = single, a start of role api or all, before the listener opens, ends every opening session with open_failed and open_timeout through EndSessions and sets every active session detached (detached_at = now), because slots and connections lived only in the memory of the previous process; one unit of work per session in ascending id; with cluster.mode = multi nothing happens here (M2-47). Stop: a StopStep in PhaseConnections closes every relay connection with 4013 within the rest of shutdown.total_timeout; M2-43's step detaches the sessions through ConnectionLost.

  15. Metrics (D-W-105). This ticket registers and records only bothy.terminal.relay.unpaired ({connection}, attribute bothy.terminal.side = client or host), because the open deadline is built here and M2-47 asserts the counter with two nodes while depending only on this ticket; M2-46 registers and records bothy.terminal.sessions.active, bothy.terminal.sessions.closed and bothy.terminal.hosts.connected. The attribute bothy.terminal.side is this ticket's as well (the otel/attr constant, the collector allowlist and the row in signals.md 5.3 of the Scope), because the counter and the relay log records carry it; M2-47 uses it. Port in app/metrics.go with a no-op implementation, OpenTelemetry in adapters/otelmetrics (02 5 rule 5). No span per frame (24 10.4).

  16. Events. terminal_session.changed through eventlog.Appender: AudiencePrincipalID = user_id (personal, audience = self), no scope members (the session is outside the tree), resource {kind: terminal_session, id, version}, payload TerminalSession of the row after the change, actor the person for open, attach and close, none for host and server changes. One event per state change and per attach. Never the token, os_user, working directory names or content.

  17. Reads and close. a. getTerminalSession: authorize(person, terminal.read_own, terminal_session); 404 for unknown and invisible alike (AZ-P4). b. listTerminalSessions: authorize(person, terminal.read_own, host or instance) as M2-40's entry defines it; an active owner sees all sessions, anyone else only user_id = self (M2-40 note 1 leaves this restriction to this query); filters host_id and state; each row passes authorize in filter mode; order id descending with a keyset cursor on id and Limit. Ruling for docs/DECISIONS.md: the order follows the UUIDv7 key, because 03 has no index for all sessions of a person and the core contract is not edited here. c. closeTerminalSession (Audit yes, Idempotency-Key through M1-20): authorize(person, terminal.close, terminal_session); ReasonBody.reason passes textcheck.DisplayText and has at most audit.reason_max_chars characters, else 400 validation_failed with errors[].code too_long and pointer /reason; it is the mandatory reason of the action and goes into the audit details as reason (03 audit_events.details), nowhere else. Ruling for docs/DECISIONS.md. An own session ends client_closed, another person's (owner) closed_by_owner, through note 11. An already closed session answers 200 with the current session, no event, audit with no_change: true (06 2 "No effect").

  18. Rulings for docs/DECISIONS.md, one row each with reason: notes 3b, 4e, 5, 6d, 7 (one data connection per grant; handshake bytes and counters; meaning of the deadline, also on a host that reports require_local_confirm; relay log), 8 (close code table), 9 (host refusal by close code; data connection rules), 11 (effects after the commit from the log tail), 12, 14, 17b, 17c. Spec gaps go to the report: 24 does not say how a refused attach is reported or what the data connection does between grants (settled by notes 7 and 9); 02 12.3 terminal.sweep covers no opening session (settled for one node by note 14; the job has no owner in the plan).

If a change to 03, 04 or 05 (core contract) turns out to be necessary, stop with Verdict: BLOCKED (P-11). The changes to 24, 02, openapi.yaml, asyncapi.yaml, signals.md 5.3 and the collector allowlist named in Scope are allowed.

Tests (write them first)

Unit:

  • internal/terminal/domain: TestSessionClaims_MatchSpec (the schema test of the plan row: reads docs/spec/24-terminal.md 5.3, locating the repository root by walking up to the directory that contains docs/spec, extracts the backticked names of the first column of the claim table, including the row with jti, iat, exp, and asserts the set equals the JSON members of SessionClaims; asserts the header constants equal EdDSA and terminal-session+jwt as the chapter states); TestSessionClaims_Shape (open shell, open claude with wdi 0 and 1, attach of a started session, attach of an opening claude session: members exactly as note 5, wdi present with 0, absent otherwise, own present as false, no null, exp - iat equal to terminal.session_token_ttl in seconds); TestProfileKnown (profile missing, shell without index, shell with index, claude without index, index 0, last index, index equal to the length, negative index, an index valid only in another person's list, empty own list); TestStreamCodeForEnd (every value of TerminalCloseDetail and a reason without detail); TestRelayDecide (one row per line of the table of note 7); FuzzReadFrame (any input: a forwarded frame, 4002 or end of stream, never a panic, never a frame above 65535 bytes).
  • internal/terminal/app with fakes for the store, RelayControl, ControlLinks, Timers, the authorizer and the signer: TestOpen_OrderOfAnswers (note 4b); TestOpen_ConsumeFailsRollsBackAndAudits; TestOpen_SignFailureRollsBack; TestAttach_OpeningKeepsWdiAndSaysOpen; TestAttach_QuickReattachRaceNeedsStepUp; TestEndSessions_EventCountersAndPersonAudit; TestRecheck_EndsSessionWithMappedReason (capability_missing and not_found to access_revoked, i6_holder_only to i6_violation, host_not_active with disabled, retired and pairing to host_disabled, host_retired and host_rekeyed; a lost connection alone ends nothing); TestSessionToken_Redacted (%v, %+v, %#v, slog JSON, json.Marshal show [redacted]).
  • internal/authz: TestTerminalGuard_HostNotActive (state disabled, retired, pairing; active with HostConnected false; the guard after SR12), TestTerminalGuard_ProfileUnknown (open only), TestTerminalStream_OtherDeviceNotSelf, TestRecheck_PassesStepUpOnly (under Recheck(ctx) a consumed step-up still allows, every other denial of the rows AZ-M54 to AZ-M61 still denies, no audit entry is written).

Integration (test/integration/terminalsessions; PostgreSQL 18 through pgtest; real modules through internal/wire; HTTP and WebSockets through the real chain on 127.0.0.1 port 0; fake clock; people, devices, access tokens, DPoP proofs and device-key step-ups from the identity test helpers; world: Robin is the owner with the assignment bothy-s-robin; host R1 active, assigned to Alex, llm_credentials = organization, profiles shell (kind shell) and claude (kind claude), reported session users bothy-s-alex with workdirs Project A and Project B, bothy-s-robin with Project C, bothy-s-sam with none; host R2 active without assignee; Alex has a grant terminal on R1, the assignment bothy-s-alex and the devices D1 (login session S1) and D2 (login session S2); Sam has a grant terminal on R1, a grant on the instance with trigger and every additional right an instance grant can carry, and the assignment bothy-s-sam; the agent Lotse has an agent token; the Fake-Host H1 holds R1's keys and is connected with its report):

  • TestOpen_HappyPathShell: Alex on D1 with a fresh step-up opens shell: 201 TerminalSessionGrant with state opening, stream_path, noise_protocol, prologue_version, key_generation; the row has host_session_user_id of bothy-s-alex, opened_by_device_id = device_id = D1, auth_session_id = S1, token_jti equal to the token's jti; auth_sessions.step_up_consumed_at set; one allow audit entry and one terminal_session.changed to Alex only; H1 receives exactly one session.open with expires_at = grant time plus terminal.open_timeout; with H1 on Accept and the fake device, after the start frame the session is active with started_at.
  • TestToken_ClaimsMatchSpec: the token of the happy path verifies against the JWKS H1 got in hello; header and claims as note 5 (aud R1, sub Alex, sid, dev D1, prf, cek equal to the request, osu bothy-s-alex, att open, no wdi); its kid is the active row of purpose terminal in server_signing_keys, and instance.Keys.VerificationKey with purpose invocation does not find it.
  • TestToken_NewJtiPerGrant: open and two attaches give three different jti; token_jti equals the last.
  • TestToken_OsuFromAssignment: a request body with an extra member osu is 400 validation_failed (unknown member); Robin's token says bothy-s-robin.
  • TestToken_OwnClaim: Robin's token has own: true, Alex's own: false.
  • TestOpen_ClaudeWdi: Alex opens claude with workdir_index 0 and 1: wdi 0 and 1 in the tokens.
  • TestOpen_TMT29g_WorkdirIndexOutsideOwnList (TM-T29 (g) server part): Alex with index 2 (her list has two entries); after H1 reports bothy-s-alex with an empty list while bothy-s-robin keeps Project C, Alex with index 0 (valid only in Robin's list); claude without index; shell with index 0; the unreported profile missing-profile: each 403 terminal_denied with reason: profile_unknown, one aggregated deny entry with terminal_profile_unknown, no row, no session.open, the step-up not consumed.
  • TestOpen_NoSessionUserIsDeniedNot4104 (TM-T28 (e) server part): H1 sends a report without bothy-s-alex; Alex's opening gives 403 terminal_denied with reason: no_session_user; no row, no token, no session.open, so the host service never sees a token with an unreported osu and no 4104 occurs.
  • TestOpen_TMT07_StepUp (TM-T07 without the console part): without any step-up on S1, with a step-up older than terminal.step_up_max_age, and a second opening after a successful one with the same step-up: each 401 step_up_required, deny entry with terminal_step_up_not_fresh, no row; two parallel openings with one fresh step-up: exactly one 201 and one 401 step_up_required with a deny entry, exactly one row and one session.open.
  • TestOpen_DeniedOpensNothing: Sam on R1 (403 terminal_denied with reason: not_host_assignee), R1 disabled through M2-39 (409 host_unavailable), H1 disconnected and H1 connected before its first report (409 host_unavailable), Lotse's agent token (403 forbidden with reason: subject_kind_not_allowed), Sam on R2 with only the instance grant (AZ-M60: 404, identical to an unknown host ID): no row, no session.open, the step-up not consumed.
  • TestOpen_RevocationRaceRecheck: a test seam in the use case runs revokeGrant of Alex's terminal grant on R1 between authorize and the commit; the answer is 403 forbidden, the row exists and is closed with access_revoked, a terminal.terminate audit entry exists, H1 got no session.open.
  • TestRelay_ForwardsBytesUnchanged: in both directions frames of 1, 2, 1000, 65534 and 65535 random bytes arrive byte-identical, one message per message, in order.
  • TestRelay_ByteCountersAndLastInput: after a session with known frame sizes and a detach, bytes_in and bytes_out equal the sums without the two handshake messages and last_input_at equals the fake time of the last device frame; after a reattach and a close, the terminal.close audit entry and the row carry the sums of both pairings.
  • TestRelay_CanaryNeverLeaks: frames carrying a canary string and the token pass the relay; neither appears in captured logs (log capture of httpservertest), audit_events, event_log, close reasons or problem bodies; every relay log record has only the attributes of note 7.
  • TestRelay_TextFrameAfterIdentify4002: a text frame from the device after identify and from H1 after its identify: the offender gets 4002, the other side 4102 or 1000 as note 7.
  • TestRelay_FrameOverNoiseLimit4002: a 65536-byte binary message from either side gives 4002, not 1009.
  • TestRelay_SlowDevice4014 and TestRelay_SlowHost4014: with a small terminal.relay_buffer_max_bytes in the test configuration, the side that does not read gets 4014, the other side 1000 or 4102, the session is detached, and other sessions keep flowing.
  • TestRelay_OpenTimeoutHostMissing: H1 on Ignore; after terminal.open_timeout the device gets 4102, the session is closed with open_failed and open_timeout, bothy.terminal.relay.unpaired with side client (the waiting device) rises by one, terminal.terminate is audited.
  • TestRelay_OpenTimeoutDeviceMissing: the device never connects; H1's data connection gets 4100, the counter with side host rises by one; with neither side connected nothing is counted.
  • TestRelay_OpenTimeoutNoStart: both sides pair, no start, so no session.started; at the deadline both get 4100 and the session ends open_failed with open_timeout (the server view of TM-T05).
  • TestRelay_LocalConfirmHostDeadline: H1 reports require_local_confirm true; both sides pair, H1 sends session.started after terminal.open_timeout (a consent that took longer): the session is active, not ended; with H1 on End(open_failed, local_confirm_denied) instead the device gets 4100 with open_failed; a pairing that ends before session.started ends the opening with open_failed and open_timeout terminal.open_timeout later; with require_local_confirm false TestRelay_OpenTimeoutNoStart holds unchanged.
  • TestRelay_HostRefusalCodes: at opening H1 refuses with 4101 and token_replayed: device 4101 with that reason, session closed open_failed token_replayed; with 4104 and profile_unknown: device 4104; at an attach of a started session 4101 and token_expired: device 4101, session detached, a following attach succeeds; at an attach 4104 and token_invalid: device 4104, session closed open_failed token_invalid, H1 got session.close; with H1 on End(open_failed, token_replayed) instead: device 4101 as well.
  • TestRelay_EndsFromOtherPaths: with a device connected, session.ended with process_exited (device 4100 with reason process_exited, exit_code stored), setHostState disable of R1 by Robin (device 4100 with host_disabled), a report of H1 without bothy-s-alex (device 4100 with access_revoked), each within the tail's wake-up; H1 gets session.close where it holds the link; the slot is gone.
  • TestHostData_OnlyAfterSessionOpen: a data connection of H1 for a session without expectation gets 1000; for another host's session and for a closed session 4100; a second host side while one is paired gets 1000; a second unpaired one replaces the first (1000 for the first); a bad proof gives M2-43's 4113.
  • TestControlLoss_ClosesStreams: H1's control link drops while a session is active: the device gets 4102, the session is detached (M2-43); a later attach after H1 reconnects pairs again.
  • TestAttach_TMT12 (TM-T12 without the console part): after D1's stream drops (session detached), Alex on D2 without step-up: 401 step_up_required; D1 in S1 within terminal.quick_reattach_window: 200, no step-up consumed, token att attach; D1 after the window: 401 step_up_required; D1 in a new login session within the window: 401; D1 while the session is still active: 401; Alex on D2 with a fresh step-up: 200.
  • TestAttach_SecondDeviceCloses4103: D1 streams, Alex attaches from D2 with a fresh step-up: D1's stream gets 4103 after the commit, H1's old data connection 1000, H1 gets a new session.open, D2 pairs and the session is active with device_id = D2; a later stream identify from D1 gets 4103.
  • TestAttach_OpeningSession: D1 drops before message 1 of a claude opening with index 1; the attach with a fresh step-up gives a token with att open and wdi 1 and a new deadline; after a restart of the server instance (note 14) the same session is closed open_failed open_timeout and an attach gives 409 invalid_state_transition.
  • TestAttach_KeepsOpenedAtAndOsu: after two attaches opened_at, host_session_user_id and key_generation are unchanged.
  • TestStream_IdentifyDecisions: every row of the table of note 8 (bad proof, wrong nonce, wrong htu, missing token, device_id of D2 with D1's token, agent token, the other device after an attach, unknown and foreign session with identical close frames, own closed session with its close_reason, a session of S1 after logout of S1 through M1-34 (4012), no identify before terminal.open_timeout, protocol_version 2, a binary frame before identify, an unknown member); denials have deny entries with session_id, device_id and the reason code, aggregated (AZ-P13).
  • TestStream_ForeignOriginRejected (SEC-CORS-3, part of SEC-T04): a foreign Origin gets 403 before the upgrade on both paths; the instance origin and no Origin are accepted.
  • TestClose_EndsConnectionsAndTellsHost: Alex closes her active session with a reason: 200 with closed and client_closed, closed_by Alex, device 4100 with reason client_closed, H1 data 4100, H1 got session.close with client_closed; audit terminal.close with reason; Robin closes another session of Alex: closed_by_owner, closed_by Robin, the event goes only to Alex.
  • TestClose_RightsAndRepeats: Sam on Alex's session: 404 like an unknown ID; Lotse: 403 forbidden; an already closed session: 200 without new version or event, audit no_change: true; a replay with the same Idempotency-Key gives the stored answer; a reason above audit.reason_max_chars: 400 validation_failed with errors[].code too_long.
  • TestListAndGet: Alex lists only her sessions, Robin all; filters host_id and state; cursor pages without gap or duplicate; getTerminalSession of Alex's session by Sam and of an unknown ID answer identically (AZ-P4); responses validate against TerminalSessionPage and TerminalSession.
  • TestEvents_TerminalSessionChanged: one event per state change of the happy path, an attach and a close; each validates against TerminalSessionChangedEvent, has audience self for Alex, no scope, no JSON null, no token or os_user text.
  • TestAudit_TerminalEntries: pin_changed: true from the body lands in the terminal.open and terminal.attach entries; Robin opening on R1 (assigned to Alex) has foreign_assignee: true, Alex false; terminal.terminate entries carry close_reason, close_detail, bytes_in, bytes_out and the duration; no entry contains a token, a path, os_user or frame content.
  • TestTerminalOps_NonHumanTokens (TM-T17 over HTTP and the stream): Lotse's agent token on the five operations gives 403 forbidden with reason: subject_kind_not_allowed and a deny entry, for an existing and for a random ID alike; on the stream 4004 with a deny entry.
  • TestReconcileAtStartup: with cluster.mode = single a new server instance on the same database ends opening sessions with open_failed open_timeout and sets active ones detached; with cluster.mode = multi it changes nothing.
  • TestShutdown_Relay4013: shutdown closes device streams and data connections with 4013 within shutdown.total_timeout, and the active sessions are detached.
  • TestAZP1P2_TerminalRoutesAndOps: the five operations and both upgrade routes are registered with their actions (terminal.open, terminal.attach, terminal.read_own, terminal.close, terminal.stream, host.connect), the channel ops of terminalStream and hostData are in the registry, and the server starts.

Contract: responses of the five operations validate against their schemas and Problem; problems carry code and, where named above, reason; every text frame of the device stream and the data connection validates with api/specschema.ValidateChannelFrame (G9).

Red team focus of the milestone (PLAN 8 "Red team", terminal paragraph: replay of the first message and token swap at the relay, opening without or with a consumed step-up, without an own session user or with another person's, by agent, app and personal tokens, profile or command line injected by the client, session after every kind of revocation) is covered by TestRelay_HostRefusalCodes, TestToken_NewJtiPerGrant, TestOpen_TMT07_StepUp, TestOpen_NoSessionUserIsDeniedNot4104, TestToken_OsuFromAssignment, TestOpen_TMT29g_WorkdirIndexOutsideOwnList, TestTerminalOps_NonHumanTokens, TestOpen_RevocationRaceRecheck and TestRelay_EndsFromOtherPaths; name them in the red team request.

Run FuzzReadFrame once locally with go test -run=^$ -fuzz=FuzzReadFrame -fuzztime=<duration of your choice> and report duration and result; commit any crashing input as a seed after the fix.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/authz/... ./internal/identity/... ./internal/kernel/... ./internal/wire/... ./test/terminal/... ./test/integration/terminalsessions/...
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make api-clients, make register-check, make semgrep
  • make config-reference then make config-doc-check
  • make docs
  • make check

Acceptance criteria

  • openTerminalSession, attachTerminalSession, listTerminalSessions, getTerminalSession and closeTerminalSession are enabled; both upgrade routes and their channel ops are registered; AZ-P1 and AZ-P2 pass.
  • The session token has the header alg = EdDSA, kid, typ = terminal-session+jwt, is signed with the active key of purpose terminal and carries exactly the claims of 24 "Terminal session token" (schema test green); every grant has a new jti stored as token_jti; the token appears in no log, event, audit entry or error.
  • osu comes only from the person's assignment (terminal_sessions.host_session_user_id); an assignment with a name the host does not report gives 403 terminal_denied with reason: no_session_user and never a 4104.
  • TM-T29 (g) server part: a workdir_index outside the caller's own reported list gives 403 terminal_denied with reason: profile_unknown; a valid index stands as wdi in the token.
  • TM-T07 without the console part: no step-up, a too old one and a consumed one give 401 step_up_required; of two concurrent openings with one step-up exactly one succeeds.
  • TM-T12 without the console part: another device without step-up 401, the same device within terminal.quick_reattach_window allowed without consuming, after it 401.
  • pin_changed and foreign_assignee are in the audit entries of terminal.open and terminal.attach.
  • The relay forwards binary frames unchanged and records only length, time and session ID; full buffers close the slow end with 4014; a missing counterpart after terminal.open_timeout ends the opening with open_failed (on a host that reports require_local_confirm, the pairing meets that deadline); a second device closes the older one with 4103; text frames after identify give 4002.
  • The host's refusal codes 4101 and 4104 reach the device exactly once, and an opening without a PTY ends with the host's close_detail.
  • Every end of a session, also from M2-39's and M2-43's paths, closes the device stream and the data connection and sends session.close; a revocation between decision and commit ends the new session at once.
  • terminal_session.changed reaches only the person of the session at every state change and validates against its schema; no JSON null.
  • The changes to 24, 02, openapi.yaml, asyncapi.yaml, signals.md 5.3 and the collector allowlist of the Scope are made in the same change, and the rulings are recorded.
  • 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.
  • services/server/internal/terminal/README.md updated (status, the Sessions port, the relay rules and close codes, the log tail observer, actions, events, the start and stop steps, which ticket adds revocation, limits and several nodes); test/terminal/fakehost has a README that says what the fakes are not.
  • Rulings of Design note 18 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 24, 02, openapi.yaml, asyncapi.yaml, signals.md 5.3 and the collector allowlist of the Scope made in the same change; the threat model rows T-TM-01, T-TM-02, T-TM-03 and T-TM-09 name this ticket's tests.
  • services/server/CHANGELOG.md entry 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) with attention to the terminal items of PLAN 8 "Red team" named under Tests; on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • 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 (at least: the handshake with the real host service and real Noise, which M2-45 and M2-48 prove; several nodes, which M2-47 proves; the console parts of TM-T07 and TM-T12, which M6-18 proves).
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least the gaps of Design note 18 and the job terminal.sweep without an owner in the plan, each with a concrete proposal for the owner. List each name of M2-38 to M2-43 and of M1 that differed from this brief.

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