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/specschemaof 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 servicebothy-host" points 1, 3, 5 and 6 (assignment,osu,reportbeforesession.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 ofclose_detailand 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 theterminalcapability"; 3 "Principal context"; 4.1 "Decisions and reasons" (rowsterminal_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 withquick_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-upfresh, Audityesanddenials); 4.14 "Hosts and terminal"; 6 (rowterminal_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(purposeterminal),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 withkind: "host").docs/spec/02-architecture.md: 4.3 (rowterminal: portsSessionsandRelay,Relayonly in the adapterwsrelay), 4.4 (rowterminal), 5 "Layers within a module" (rules 1, 2, 4 and 5), 6 "Code layout" (test/**is the componenttestsupport), the "Event path" ("Log tail per api process"), 12.3 "Job catalogue" (rowterminal.sweep), 15.1 "Start", 15.3 "Shutdown".docs/spec/06-api.md: 2 "Basic rules" (rows "Unknown values", "No effect", "Nonull"), 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: operationsopenTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSession,closeTerminalSession(withx-authz-action,x-rate-limit-class,x-idempotency-exempt); schemasTerminalSessionOpen,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); parametersTerminalSessionIdPath,HostIdPath,HostIdQuery,TerminalSessionStateQuery,Cursor,Limit,IdempotencyKey.docs/api/asyncapi.yaml: channelsterminalStream,hostControl,hostDatawith theirx-close-codes; operationsterminal.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; messageevent.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 rowsD-M2-39-<n>toD-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.md4.3a "Terminal" (this ticket registers and records onlybothy.terminal.relay.unpaired, D-W-105, Design note 15) and 5.3 "Attributes in spans and logs" (its attributebothy.terminal.side).docs/operations/configuration.md: theterminal.*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(portHosts,app.EndSessionswithSessionSelector,authz.HostSourceandHostFacts, the app portsStore,PeoplewithIsActiveOwner,Presence, the test fixturesterminaltest, the purposeterminal),M2-40-terminal-authz.md(authorize.ResourcewithHostIDandTerminal *TerminalFacts,Decision.QuickReattach,identity.StepUp.ConsumeFresh,TerminalGuardwith its extension point after SR12, the mapping rows ofhost_not_activeandterminal_profile_unknown,HostSource.SessionUsersOfwithSessionUserFact),M2-41-host-pairing.md,M2-42-host-install-doctor.md,M2-43-host-control.md(ControlLinkswithOpenSession,CloseSession,CloseHost,Connected; the use casesSessionStarted,SessionEnded,ConnectionLost; the shared login function ofhelloandidentify; the clienthosttest;api/specschema.ValidateChannelFrame; the stop step inPhaseConnections),M2-38-noise-core.md; andM1-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.EventwithAudiencePrincipalID,eventlog.Appender,Tail),M1-17-api-server-stubs.md(api/operations.yaml),M1-18-http-chain.md(strict JSON decoder,httpservertestlog capture),M1-20-idempotency-keys.md,M1-24-audit-chain.md(auditing.RecorderwithRecordandRecordDenial),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(DPoPwithCurrentNonce),M1-34-identity-sessions.md(actor.PrincipalfieldsSessionID,DeviceID,ClientComponent),M1-64-gateway-connection.md(Origin check,hellowith DPoP nonce,AuthenticateCredentials),M1-65-gateway-delivery.md(the tail goroutineTail->Observe->Dispatchininternal/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 indocs/briefs/(index indocs/briefs/README.md). - Code on the base branch:
internal/terminal(M2-39 to M2-43),internal/authzandinternal/kernel/authorize(M2-40),internal/identity(M2-40ConsumeFresh),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):
- 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. 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. - 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:
openTerminalSessionrunsauthorize(person, terminal.open, host)with the rightterminal, SR10, SR11, SR12, the fresh and unconsumed step-up, hostactiveand connected, the profile known and, for a profile of kindclaude, a working directory of the own session user; onallowit consumes the step-up, inserts theterminal_sessionsrow inopeningwith the person's session user, writes the audit entry andterminal_session.changedin one transaction and answers201withTerminalSessionGrant(24 6.1 steps 1 to 3, 05 4.14, 08 6.3).- The session token is a compact JWS after ADR 0067 with the header
alg = EdDSA,kid,typ = terminal-session+jwt, signed with theactiveserver key of purposeterminal, validterminal.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 kindclaudeandatt = open),att. Every grant has a newjti, stored interminal_sessions.token_jti; older tokens of the session are worthless (24 6.2, D-T33). osuishost_session_users.os_userofterminal_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).- A valid assignment whose name the host does not report gives
403 terminal_deniedwithreason: no_session_userat opening, not a token that the host refuses with4104(SR12, D-W-63, R-TM-23, TM-T28 (e) server part). workdir_indexcounts only in theworkdirslist of the caller's own entry inhosts.reported_session_users; any other index gives403 terminal_deniedwithreason: profile_unknown, and a valid index stands aswdiin the token (24 11.1, D-W-62, D-W-69, TM-T29 (g) server part).attachTerminalSessionneeds a fresh step-up, except from the same device in the same login session withinterminal.quick_reattach_windowafter the disconnect, never in the console; it issues a new token, and the connection of an older device is closed with4103(24 6.2, 08 6.3 point 5, R-TM-07, TM-T12).- TM-T07 without the console part:
terminal.openwithout step-up, with a step-up older thanterminal.step_up_max_ageand with an already consumed step-up gives401 step_up_required; of two concurrent openings with one step-up exactly one succeeds. - The server sends
session.open(HostSessionOpen) only over a control connection that has delivered itsreport(24 2.2 point 5, M2-43ControlLinks.OpenSession); the host service answers eachsession.openwith one data connection (24 6.1 step 4). - 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).
- A text frame after
identifycloses that connection with4002; a full relay buffer (terminal.relay_buffer_max_bytesper direction) closes the slow end with4014and the session becomesdetached; a counterpart missing afterterminal.open_timeoutends an opening session withopen_failed(24 5.4, 6.1 step 6, 6.3). - The device stream sends
hellowith the server DPoP nonce and accepts onlyidentifywith an access token and a DPoP proof (htm = GET,htuthe HTTPS form of the stream URL,noncefromhello,ath) of the device of the current session token (terminal.stream, Auditdenials) (asyncapiterminalStream, 05 7). listTerminalSessionsandgetTerminalSessionshow the person's own sessions, owners all, metadata only;closeTerminalSessioncloses an own session (client_closed) or, for an owner, any session (closed_by_owner) (05terminal.read_own,terminal.close, 24 10.3).- 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 (
4100with theclose_reason, or the code of 24 5.3 for a refused handshake) and tells the host service withsession.close(24 8.3, 13). terminal_session.changedgoes only to the person of the session, at every state change, with the payloadTerminalSession, never content (05 6, 24 10.2).- Audit after 24 10.1:
terminal.openandterminal.attachwithhost_id,session_id,profile,device_id,pin_changed,foreign_assignee;terminal.closewithsession_idand who closed;terminal.streamdenials withsession_id,device_idand the reason code;terminal.terminatefor every end by the server or the host withsession_id,close_reason,close_detail,bytes_in,bytes_outand the duration. Never terminal content, commands, environment, paths or tokens (R-TM-10, SEC-LOG-2). No JSONnullanywhere (ADR 0053).
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 as decisions ofauthorize(AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). What this ticket enforces: every operation and both stream endpoints callauthorizewith their action and descriptor, and on any denial no row, no token and nosession.openexist (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 withno_session_userbefore a token exists (TestOpen_NoSessionUserIsDeniedNot4104);osucomes only from the assignment row (TestToken_OsuFromAssignment); the claimownlets 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). - 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.ConsumeFreshin 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 checkfresh, the quick reattach rule,ConsumeFreshand ID-T28 without the console part; M6-18 owns the console parts of TM-T07, TM-T12 and ID-T28 (passkey withuserVerification = required, cookie session from M6-01); M4-36 and M5-12 own the step-up with the device key in the apps. - 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 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 aftersession.open(Semgrep rulebothy-go-egress-onlyof M1-22;TestHostData_OnlyAfterSessionOpen); a foreignOriginis refused before both upgrades (SEC-CORS-3,TestStream_ForeignOriginRejected). Ifdocs/OPEN-QUESTIONS.mdlists OQ-24 as decided "yes", stop (Boundaries). - 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. - 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_timeoutplusterminal.idle_grace,terminal.max_session_duration, TM-T11) and the session limits. This ticket provides what they build on:opened_atis 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 writeslast_input_at,bytes_inandbytes_out(TestAttach_KeepsOpenedAtAndOsu,TestRelay_ByteCountersAndLastInput). - 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 starts every PTY only in the helper ofosu. 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 tosub. - 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 newjtiper grant,sid,cekfrom the request and the forwarding of the host's refusal codes4101and4104(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 andTestStream_IdentifyDecisions(AZ-M54, AZ-M55, AZ-M58 by M2-40); T-TM-08 (foreign LLM login) byosuandwdionly 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) byTestTerminalOps_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) byforeign_assigneein 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 constantsTokenAlg = "EdDSA"andTokenTyp = "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 ofterminalStreamas constants andStreamCodeForEnd(reason, detail string) int(Design note 8).internal/terminal/domain/relay.go: the pure pairing rulesDecide(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 portsRelayControl,TimersandMetrics(Design notes 2, 7, 14 and 15).internal/terminal/adapters/postgres/queries/sessions.sql: insert, lock one sessionFOR 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 instrumentbothy.terminal.relay.unpaired(or extend the package if M2-43 created it), declared as aSpecininternal/platform/otel/metricsif that registry exists (M1-68 merged), else with the meter ofinternal/platform/otel; the adapter package inarchitecture/modules.yaml(with the OpenTelemetry vendor group of M1-10 inadapter_vendorsofterminalif the registry is missing), then regenerate.go-arch-lint.yml(Design note 15).test/terminal/fakehost/: the Fake-Host of the plan row (packagefakehost, componenttestsupport, built on M2-43'shosttest):Host(control connection,report, data connections with scripted behaviour) andDevice(device stream client with DPoP); Design note 13.test/integration/terminalsessions/: the cross-module integration tests of this ticket.
Change:
internal/terminalport:SessionswithOpen(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 ofterminalStream(hello,identify,error, the binary frames, actionterminal.stream) and ofhostData(hello,identify,error, the binary frames, actionhost.connect) for AZ-P2.internal/terminal/app: M2-39'sEndSessionsas the one end path (Design note 11): selection of one session,closed_byand the actionterminal.closefor an end by a person, the eventterminal_session.changedif M2-43 did not add it, the live byte counters throughRelayControl.Counters.internal/kernel/authorize:TerminalFactsgainsHostConnected boolandProfileKnown bool; the markerRecheck(ctx context.Context) context.Context(Design notes 3 and 12).internal/authz: in M2-40'sTerminalGuardextension point after SR12 the guardshost_not_activeandterminal_profile_unknown; a guard ofterminal.streamfor the device; the handling ofRecheck(ctx)in step 8; each with its tests (Design note 3).internal/kernel/actorandinternal/identity:AuthenticateCredentialsand 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 rolesapiandall, theSessionshandlers, the relay with its callbacks intoapp, 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 amongterminal.session_token_ttl,terminal.open_timeoutandterminal.relay_buffer_max_bytesinconfigkeys_terminal.go(defaults from 24 17), the metric adapter.internal/platform/otel/attr: the constant forbothy.terminal.side(client,host), the attribute of the counter and of the relay log records (Design note 15); repository rootinfra/otel/collector.yaml:bothy.terminal.sideinredaction/allowlist.allowed_keys("Bothy attributes");docs/observability/signals.md5.3: rowbothy.terminal.side|client,host| side of a connection in the relay (24 section 9).api/operations.yaml: moveopenTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSession,closeTerminalSessionfromlatertoenabled; rerun the route generator (M1-17).docs/api/openapi.yaml:pattern: '^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$'onclient_ephemeral_key_hashofTerminalSessionOpenandTerminalSessionAttach(43 base64url characters that decode strictly to 32 bytes, as the examples already are); the description ofattachTerminalSession(an attach to anopeningsession restarts the opening, Design note 6); the description ofworkdir_index(a missing index with aclaudeprofile and an index with another profile both give403 terminal_deniedwithreason: profile_unknown). Thenmake api-lint,make api-checks,make api-tables,make api-clientsand the server code generation.docs/api/asyncapi.yaml: channelhostDatadescription (one data connection persession.open; the host service closes it with4101or4104and theclose_detailcode as close reason when it refuses a handshake) and the client actions of its codes1000and4014(keep the PTY, wait for the nextsession.open); channelterminalStreamcodes4102(an opening session ends withopen_failed, a started one isdetached) and4004(also whenidentifydoes not arrive withinterminal.open_timeout); messagehost.session.endedsummary (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 reportsrequire_local_confirm, Design note 7) and 7, 6.2 (data connection persession.open, attach to anopeningsession), 13 (column "Channel" of4101and4104adds "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 forcluster.mode = single), 15.3 step 3 (relay connections close with4013).docs/security/threat-model.md6.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, theSessionsport, the relay rules and close codes, actions, events),services/server/CHANGELOG.mdunder "Unreleased",docs/DECISIONS.mdsection 10 rowsD-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,osuandwdichecks, 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.DeviceRevokedandidentity.SessionEnded, idle and maximum duration on the server,terminal.max_sessions_per_personandterminal.max_sessions_per_host(terminal_session_limit), the rate limitsrl.terminalandrl.hoston the streams, the metricsbothy.terminal.sessions.active,bothy.terminal.sessions.closedandbothy.terminal.hosts.connected, the retention class, the jobterminal.sweep, TM-T06, TM-T10, TM-T11 (M2-46). If the merged chain already appliesrl.terminalto the REST operations through theirx-rate-limit-class, leave it as it is. cluster.mode = multi:session.openoverNOTIFY ephemeral, hash routing, TM-T15 (M2-47).- What M2-39 to M2-43 built: host records and transitions, pairing,
report,live_sessions,session.startedandsession.endedand their state changes,ConnectionLost, the control connection and its stop step, SR10 to SR12, I6, the checkfresh, the quick reattach rule andConsumeFresh. - 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 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. - 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.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 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.yamllacks one of theterminal.*actions or the purposeterminalis missing fromSigningKeyPurpose, the spec state is missing on the base branch: stop withVerdict: BLOCKED <reason>. - Time only through
kernel/clockand theTimersport (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
jtiif needed).
Design notes¶
-
Placement and layers (02 4.3, 4.4, 5). The port
Sessionsis the inbound interface of the module; the relay is not a port for other modules but lives inadapters/wsrelaynext to M2-43's control connection (02 4.3: "Relay... only in the adapterwsrelay").appowns every decision and every write;wsrelayowns sockets, queues and timers and calls back intoappfor every state change.domainholds the pure rules (token claims, profile verdict, close codes, pairing decisions) and imports onlykernel-core.terminaluses the ports ofinstance(signer of purposeterminal),identity(owners through M2-39'sPeople.IsActiveOwner,StepUp.ConsumeFresh, authentication of the stream) andauthz; nothing else. -
What the dependencies left. M2-39: the tables,
EndSessions(ctx, sel SessionSelector, reason domain.CloseReason),authz.HostSource, the host view withnoise_public_key,key_fingerprint,key_generation,terminaltest, the purposeterminalininstance.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 gateHumanSessionbefore resolution (agents, apps and personal tokens get403 forbiddenwithreason: subject_kind_not_allowedfor any ID), the visibility ofterminal_session(its person or an owner), the guards SR10 to SR12 with the extension point "M2-44 addshost_not_activeandterminal_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(OpenSessionwithErrHostNotConnectedandErrHostNotReady,CloseSession,CloseHost,Connectedtrue from the first appliedreport), the use casesSessionStarted,SessionEndedandConnectionLost, the one function forhello, the timeout and the proof check with a parameter for the channel (meant forhostData),hosttest,api/specschema.ValidateChannelFrame, the JWKS refresh inheartbeat_ack, the stop step inPhaseConnections.- If
actor.Authenticatoraccepts only an*http.Request, addAuthenticateCredentials(ctx, actor.Credentials{Scheme, Token, DPoPProof, Method, URL string; ExpectNonce bool}) (actor.Actor, error)exactly as the M1-64 brief names it (actor.Actoris the type M1-64 writes for the request principal) and implement the HTTP path on top of it, with tests in identity; exposeCurrentNonce()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.
- If
-
Authorization facts and guards.
terminalknows the request and the live connections,authzdecides, asrunsdoes with token facts in M2-09. a.TerminalFactsgainsHostConnected bool(fromControlLinks.Connected(hostID)) andProfileKnown bool. Forterminal.openthe resource is{Kind: host, ID}withTerminal: &TerminalFacts{HostConnected, ProfileKnown}; forterminal.attach,terminal.stream,terminal.read_ownandterminal.closeit is{Kind: terminal_session, ID, HostID, OwnerID: user_id, Terminal: &TerminalFacts{SessionState, SessionDeviceID, SessionAuthSessionID, DetachedAt, HostConnected, ProfileKnown: true}}. b.ProfileKnowncomes fromdomain.ProfileKnownwithhosts.reported_profilesand theworkdirsof the caller's own entry inhosts.reported_session_users(the entry whoseos_useris the caller's valid assignment; none gives an empty list). It is true exactly when the profile name is inreported_profilesand either its kind is notclaudeandworkdir_indexis absent, or its kind isclaudeand0 <= workdir_index < len(own workdirs). Ruling for docs/DECISIONS.md: aclaudeprofile 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.1terminal_profile_unknown), one rule in one place. c. In the extension point ofTerminalGuardafter SR12, in this order (24 6.1 step 2):host_not_activewhenHostFacts.Stateis notactiveorHostConnectedis false (409 host_unavailable); then, forterminal.openonly,terminal_profile_unknownwhenProfileKnownis false (403 terminal_deniedwithreason: profile_unknown). M2-46 later insertsterminal_session_limitbeforehost_not_active. d. A guard ofterminal.streamafterSelf: a principal whoseDeviceIDis notSessionDeviceIDisforbiddenwithnot_self(05: "only the device of the current token"; 07 4.1not_self: "the resource belongs to someone else (device, ...)"). e.Recheck(ctx)(Design note 12) impliesFilter(ctx)and makes step 8 pass forfreshactions; nothing else changes. -
openTerminalSessionin 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: schema400 validation_failed(body;cols,rows, unknown members and theclient_ephemeral_key_hashpattern), thenauthorizein its order (403 forbiddenwithreason: subject_kind_not_allowedfrom the gate,404,403 forbiddenfor a missingterminal,401 step_up_required,403 terminal_deniedwithserver_host_owner_only,not_host_assignee,i6_holder_only,no_session_user, then409 host_unavailable, then403 terminal_deniedwithprofile_unknown), then201. c.ConsumeFreshas the first row lock. Its failure (a concurrent opening consumed the confirmation) rolls the unit back; record the denial withauditing.Recorder.RecordDenial(actionterminal.open, reasonterminal_step_up_not_fresh) and answer401 step_up_required. d. Insertterminal_sessions: new UUIDv7id;host_id;user_id;host_session_user_id= the valid assignment of note 3b;opened_by_device_idanddevice_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 unavailableas 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 allowterminal.openwithhost_id,session_id,profile,device_id,pin_changed(from the body, default false) andforeign_assignee(true when the caller is an active owner andhosts.assigned_user_idis set and is another person, 24 8.4);terminal_session.changed(note 16). g. After the commit: the recheck of note 12; thenRelayControl.Expect(note 7) andControlLinks.OpenSessionwithHostSessionOpen{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 withopen_failedandopen_timeout. h. Answer201TerminalSessionGrant{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 ofauthorizewrite no row and send nothing; the authz layer records them (Audityes, aggregated peraudit.deny_aggregate_window). -
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 throughjose.SignCompactwith the adapter frominstance.Signertojose.Signerthat 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,iattruncated to seconds,exp = iat + terminal.session_token_ttl),dev= the device of the grant,prf= profile name,cek=client_ephemeral_key_hashas sent,own= the caller is an active owner at issue time (always present,falseincluded),osu,wdionly for a profile of kindclaudewithatt = openand then always present, also with the value0,att=openorattach. IDs canonical lower-case UUID strings; absent claims omitted, nevernull. Ruling for docs/DECISIONS.md: the encoding rules (member order, seconds,ownalways present,wdipresent with0). SessionTokenwraps the compact string like M2-06'sInvocationToken:String,GoString,Format,LogValue,MarshalText,MarshalJSONrender[redacted]; onlyReveal()returns it, called once by the HTTP adapter when it writessession_token.- The server never verifies these tokens; the host service does (M2-45) with the JWKS it gets in
helloandheartbeat_ack(M2-43). Keys of purposeterminalandinvocationnever verify each other's tokens (D-T33).
- Header JSON exactly
-
attachTerminalSessionin 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.ConsumeFreshunlessDecision.QuickReattach(failure as note 4c). Then lock the session rowFOR UPDATE:closedgives409 invalid_state_transition; for a quick reattach the locked row must still bedetachedwith the same device and login session, else record the denial and answer401 step_up_required. c.osustays the session'shost_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), answer403 terminal_deniedwithreason: no_session_user. d. Stateopening(the PTY never started): the token getsatt = openand, for aclaudeprofile, thewdithe 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 allowswdionly withatt = open; without a PTY the host service must open, so the token saysopen, 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). Stateactiveordetached:att = attach, nowdi. The state itself does not change here; the relay moves it (note 7). e. Updatedevice_id,auth_session_id,token_jti,version + 1. Audit allowterminal.attachwith the details of note 4f;terminal_session.changed. f. After the commit: recheck (note 12),RelayControl.Expect(it closes the older device side with4103and the older host side with1000),ControlLinks.OpenSession. Answer200TerminalSessionGrant.opened_at,host_session_user_idandkey_generationnever change. -
Relay model (
domain/relay.godecides,wsrelay/relay.goexecutes). 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 onesession.open; the host service opens exactly one data connection persession.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 inframes.go, so the code is4002, not1009). Until the other side is there, frames wait in the queue of their direction. - Queues: one per direction, bounded by
terminal.relay_buffer_max_bytesin bytes of queued messages; a message that does not fit closes the side that does not read (the receiver) with4014. - 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) orbytes_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_outadded,last_input_at), one update without event orversion. 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
Timersport (AfterFunc(d time.Duration, f func()) (stop func() bool), real inwsrelay, 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 bysession.started(M2-43's use case, after whichwsrelaytells 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) meanssession.startedbefore 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 lastreportsaysrequire_local_confirm = true(hosts.reported_require_local_confirm), the server deadline of an opening (grant time plusterminal.open_timeout) is met by the pairing of both sides, not bysession.started, because the consent windowhost_service.local_confirm_timeout(24 6.4) may be longer thanterminal.open_timeout; the host service bounds the rest itself:startuntilHostSessionOpen.expires_at, the consent withinhost_service.local_confirm_timeout, and it ends a refused or unanswered opening withsession.endedopen_failed(open_timeoutorlocal_confirm_denied). When such a pairing ends beforesession.started, the slot gets a new deadline ofterminal.open_timeoutfrom that moment. -
Reactions (the pure table of
Decide; state changes throughapp, using M2-43's transitionsopening -> activeandactive -> detachedand 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_atcleared)device side closes 1000started: detached; opening: unchanged until the deadlinehost side closes with 4101or4104(reason =close_detail)same code and reason not started: ends open_failedwith that detail; started and4101:detached; started and4104: endsopen_failedwith that detailhost side closes otherwise 4102started: detached; opening: unchanged until the deadlinedeadline, session not started 4102if the host side is missing, else41004100ends open_failedwithopen_timeout; a present side that found no counterpart countsbothy.terminal.relay.unpairedonce with its ownbothy.terminal.sidedeadline, started, not paired 41021000stays detached; the present side is counted as abovequeue toward the device full 40141000detachedqueue toward the host full 41024014detachedtext frame after identify, or a message above the Noise limitoffender 4002, else4102offender 4002, else1000started: detached; opening: unchangednew grant older 4103older 1000a started session whose pairing ends here becomes detacheduntil the new pairing;openingstayssession.startedwithout a paired device sidedetachedat oncesession ends (note 11) StreamCodeForEndwith theclose_reasonas close reason4100closed control link of the host ends without 4110,4111,411241021000M2-43's ConnectionLostsetsdetachedcontrol link ends with 4110,4111or4112nothing here: the sessions end through EndSessions(note 11)shutdown 40134013M2-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).
- 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
-
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
Originthat differs from the origin ofserver.public_urlgives403with problem codeforbidden(SEC-CORS-3); never turn the library's origin verification off. Compression off. hello {protocol_versions: [1], dpop_nonce, server_time}(TerminalStreamHello); then exactly oneidentify(TerminalStreamIdentify, strict decoder of M1-18) withinterminal.open_timeout; authenticate throughAuthenticateCredentials(an access token, the proof,GET,htu=https://<host and port of server.public_url>/api/v1/terminal/sessions/<id>/stream, nonce required);device_idmust equal the token's device; thenauthorize(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_versionother than 14002no identifywithinterminal.open_timeout; token, proof, nonce,htuordevice_idinvalid;identifywithoutaccess_token(the console path is M6-18's); anyforbiddenother thannot_self(subject_kind_not_allowedfrom the gate)4004decision unauthenticatedwithprincipal_inactive,session_revokedordevice_inactive4012decision not_found(unknown or foreign session), or an own closed session (close reason = itsclose_reason)4100decision forbiddenwithnot_self(not the device of the current token)4103 -
StreamCodeForEnd(reason, detail)for a session that ends while a device is connected:4101fortoken_expiredandtoken_replayed;4104fortoken_invalid,handshake_failed,profile_unknown,session_user_unknown,session_user_mismatch,server_host_owner_only; else4100. - Denials are audited by the authz layer (Audit
denials) withsession_id,device_idand the reason code. After an allow the connection becomes the device side of the slot; text frames from then on close with4002.
- Before the upgrade: a present
-
Host data connection
GET /api/v1/hosts/sessions/{session_id}/stream(host.connect): M2-43's shared function sendshello(HostChannelHello) and checksidentify(HostIdentifywithsession_idinstead oflive_sessions) with its close codes andterminal.host_auth_timeout. Then: a session that is unknown, belongs to another host or is closed gives4100; a session without an expectation on this node gives1000(the host service waits forsession.open); while a paired host side exists the newcomer gets1000, otherwise the newcomer replaces an unpaired older host side (which gets1000). Ruling for docs/DECISIONS.md: the host service refuses a handshake by closing its data connection with4101(token_expired,token_replayed) or4104(every other detail of 24 5.3) and theclose_detailcode as close reason; on4104for a started session it also ends the PTY. 24 5.3 ("It reports the reason asclose_detail") gets this sentence; a refusal reported throughsession.endedinstead (M2-43 acceptsopen_failedwith every detail) reaches the device throughStreamCodeForEndwith the same code, so the device gets exactly one code either way (R-TM-16, D-T18). -
Control connection hooks (in
wsrelay, after M2-43's use cases return): afterSessionStarted,RelayControlmarks the slot started (note 7); afterSessionEndednothing 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. -
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_byand the audit actionterminal.close(withsession_id, who closed and thereasonof note 17c) for an end by a person, elseterminal.terminate;close_detailandexit_code;terminal_session.changed;bytes_inandbytes_out= row value plus the live pairing's counts fromRelayControl.Counters(sessionID)(zero without a slot on this node).txnhas no after-commit hook, and ends happen in transactions ofterminal,authzandidentityalike (M2-39 host transitions, M2-43 reports andlive_sessions, M2-46 revocations). Ruling for docs/DECISIONS.md: the effects after the commit come from the event log tail.internal/wirehands every event of M1-65's tail goroutine, afterObserveandDispatch, to the relay's observer (Relay.ObserveLogEvent(ctx, typ string, resourceID ids.ID, payload []byte), non-blocking); for aterminal_session.changedwhose payload hasstate = closedit closes the session's sides (deviceStreamCodeForEnd, host4100), drops the slot without writing counters, and callsControlLinks.CloseSessionwith theclose_reason(a no-op without a link on this node; a duplicate after M2-43's ownCloseSessionis harmless, M2-43 note 10). 02 "Log tail per api process" names this third consumer. The tail delivers inseqorder 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 withcluster.mode = single, 02 16 level 1; for exampleterminal.sweepor a grant expiry of M2-46) and for an end committed on another node (cluster.mode = multi), because the tail reads the committed rows ofevent_logfrom the database, woken byNOTIFY event_log, which is on in every mode (03 6.6), and polls everysync.log_poll_interval(02 11.2); M2-46 and M2-47 add no other path for ends. -
Recheck after the commit (open and attach). 03 6.3 allows the lock of
permission_versiononly 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 callsauthorizeagain with the same action and fresh facts underauthorize.Recheck(ctx): it impliesFilter(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.HostConnectedkeeps the value of the first decision, because a lost connection is the deadline's business (note 7). If the recheck is no longerallow, the session ends through note 11 withaccess_revoked, ori6_violationfori6_holder_only, or forhost_not_activethe host state (disabledgiveshost_disabled,retiredgiveshost_retired,pairinggiveshost_rekeyed); nosession.openis sent (thesession.closeof 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. -
Fake-Host (
test/terminal/fakehost, testsupport; it may importgithub.com/coder/websocket,internal/kernel/jose,hosttestand the identity test helpers):Host: the host ID and Ed25519 key pair of an active host (built withterminaltestand M2-41's pairing as M2-43's tests build H1); the control connection throughhosttestwithreportof configurable profiles, session users andrequire_local_confirmand the heartbeat loop; onsession.openit opens one data connection per message unless scripted otherwise; records every op and frame; every text frame passesapi/specschema.ValidateChannelFrame.- Scripts per session:
Accept(read message 1, verify the token against the JWKS fromhello: signature,typ,aud,sid,expwithauth.clock_leeway,jtiunseen; answer one frame as message 2; on the next device frame sendsession.started; then echo),Refuse(code, detail)(close the data connection with that code and reason),End(reason, detail)(sendsession.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 fromidentitytest, sendsidentify, 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 offersSend,Recv,CloseCode.- Neither fake replaces the Go test device of M2-48 or the real host service; the README of the package says so.
-
Start and stop. Ruling for docs/DECISIONS.md: with
cluster.mode = single, a start of roleapiorall, before the listener opens, ends everyopeningsession withopen_failedandopen_timeoutthroughEndSessionsand sets everyactivesessiondetached(detached_at= now), because slots and connections lived only in the memory of the previous process; one unit of work per session in ascendingid; withcluster.mode = multinothing happens here (M2-47). Stop: aStopStepinPhaseConnectionscloses every relay connection with4013within the rest ofshutdown.total_timeout; M2-43's step detaches the sessions throughConnectionLost. -
Metrics (D-W-105). This ticket registers and records only
bothy.terminal.relay.unpaired({connection}, attributebothy.terminal.side=clientorhost), 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 recordsbothy.terminal.sessions.active,bothy.terminal.sessions.closedandbothy.terminal.hosts.connected. The attributebothy.terminal.sideis this ticket's as well (theotel/attrconstant, 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 inapp/metrics.gowith a no-op implementation, OpenTelemetry inadapters/otelmetrics(02 5 rule 5). No span per frame (24 10.4). -
Events.
terminal_session.changedthrougheventlog.Appender:AudiencePrincipalID = user_id(personal,audience = self), no scope members (the session is outside the tree),resource {kind: terminal_session, id, version}, payloadTerminalSessionof 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. -
Reads and close. a.
getTerminalSession:authorize(person, terminal.read_own, terminal_session);404for 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 onlyuser_id = self(M2-40 note 1 leaves this restriction to this query); filtershost_idandstate; each row passesauthorizein filter mode; orderiddescending with a keyset cursor onidandLimit. 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(Audityes,Idempotency-Keythrough M1-20):authorize(person, terminal.close, terminal_session);ReasonBody.reasonpassestextcheck.DisplayTextand has at mostaudit.reason_max_charscharacters, else400 validation_failedwitherrors[].codetoo_longand pointer/reason; it is the mandatory reason of the action and goes into the audit details asreason(03audit_events.details), nowhere else. Ruling for docs/DECISIONS.md. An own session endsclient_closed, another person's (owner)closed_by_owner, through note 11. An already closed session answers200with the current session, no event, audit withno_change: true(06 2 "No effect"). -
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.3terminal.sweepcovers noopeningsession (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: readsdocs/spec/24-terminal.md5.3, locating the repository root by walking up to the directory that containsdocs/spec, extracts the backticked names of the first column of the claim table, including the row withjti,iat,exp, and asserts the set equals the JSON members ofSessionClaims; asserts the header constants equalEdDSAandterminal-session+jwtas the chapter states);TestSessionClaims_Shape(openshell, openclaudewithwdi0 and 1, attach of a started session, attach of an openingclaudesession: members exactly as note 5,wdipresent with 0, absent otherwise,ownpresent asfalse, nonull,exp - iatequal toterminal.session_token_ttlin seconds);TestProfileKnown(profile missing,shellwithout index,shellwith index,claudewithout 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 ofTerminalCloseDetailand a reason without detail);TestRelayDecide(one row per line of the table of note 7);FuzzReadFrame(any input: a forwarded frame,4002or end of stream, never a panic, never a frame above 65535 bytes).internal/terminal/appwith 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_missingandnot_foundtoaccess_revoked,i6_holder_onlytoi6_violation,host_not_activewithdisabled,retiredandpairingtohost_disabled,host_retiredandhost_rekeyed; a lost connection alone ends nothing);TestSessionToken_Redacted(%v,%+v,%#v,slogJSON,json.Marshalshow[redacted]).internal/authz:TestTerminalGuard_HostNotActive(statedisabled,retired,pairing;activewithHostConnectedfalse; the guard after SR12),TestTerminalGuard_ProfileUnknown(open only),TestTerminalStream_OtherDeviceNotSelf,TestRecheck_PassesStepUpOnly(underRecheck(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 opensshell:201TerminalSessionGrantwith stateopening,stream_path,noise_protocol,prologue_version,key_generation; the row hashost_session_user_idofbothy-s-alex,opened_by_device_id = device_id = D1,auth_session_id = S1,token_jtiequal to the token'sjti;auth_sessions.step_up_consumed_atset; one allow audit entry and oneterminal_session.changedto Alex only; H1 receives exactly onesession.openwithexpires_at= grant time plusterminal.open_timeout; with H1 onAcceptand the fake device, after thestartframe the session isactivewithstarted_at.TestToken_ClaimsMatchSpec: the token of the happy path verifies against the JWKS H1 got inhello; header and claims as note 5 (audR1,subAlex,sid,devD1,prf,cekequal to the request,osubothy-s-alex,attopen, nowdi); itskidis theactiverow of purposeterminalinserver_signing_keys, andinstance.Keys.VerificationKeywith purposeinvocationdoes not find it.TestToken_NewJtiPerGrant: open and two attaches give three differentjti;token_jtiequals the last.TestToken_OsuFromAssignment: a request body with an extra memberosuis400 validation_failed(unknown member); Robin's token saysbothy-s-robin.TestToken_OwnClaim: Robin's token hasown: true, Alex'sown: false.TestOpen_ClaudeWdi: Alex opensclaudewithworkdir_index0 and 1:wdi0 and 1 in the tokens.TestOpen_TMT29g_WorkdirIndexOutsideOwnList(TM-T29 (g) server part): Alex with index 2 (her list has two entries); after H1 reportsbothy-s-alexwith an empty list whilebothy-s-robinkeepsProject C, Alex with index 0 (valid only in Robin's list);claudewithout index;shellwith index 0; the unreported profilemissing-profile: each403 terminal_deniedwithreason: profile_unknown, one aggregated deny entry withterminal_profile_unknown, no row, nosession.open, the step-up not consumed.TestOpen_NoSessionUserIsDeniedNot4104(TM-T28 (e) server part): H1 sends areportwithoutbothy-s-alex; Alex's opening gives403 terminal_deniedwithreason: no_session_user; no row, no token, nosession.open, so the host service never sees a token with an unreportedosuand no4104occurs.TestOpen_TMT07_StepUp(TM-T07 without the console part): without any step-up on S1, with a step-up older thanterminal.step_up_max_age, and a second opening after a successful one with the same step-up: each401 step_up_required, deny entry withterminal_step_up_not_fresh, no row; two parallel openings with one fresh step-up: exactly one201and one401 step_up_requiredwith a deny entry, exactly one row and onesession.open.TestOpen_DeniedOpensNothing: Sam on R1 (403 terminal_deniedwithreason: not_host_assignee), R1disabledthrough M2-39 (409 host_unavailable), H1 disconnected and H1 connected before its firstreport(409 host_unavailable), Lotse's agent token (403 forbiddenwithreason: subject_kind_not_allowed), Sam on R2 with only the instance grant (AZ-M60:404, identical to an unknown host ID): no row, nosession.open, the step-up not consumed.TestOpen_RevocationRaceRecheck: a test seam in the use case runsrevokeGrantof Alex'sterminalgrant on R1 betweenauthorizeand the commit; the answer is403 forbidden, the row exists and isclosedwithaccess_revoked, aterminal.terminateaudit entry exists, H1 got nosession.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_inandbytes_outequal the sums without the two handshake messages andlast_input_atequals the fake time of the last device frame; after a reattach and a close, theterminal.closeaudit 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 ofhttpservertest),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 afteridentifyand from H1 after itsidentify: the offender gets4002, the other side4102or1000as note 7.TestRelay_FrameOverNoiseLimit4002: a 65536-byte binary message from either side gives4002, not1009.TestRelay_SlowDevice4014andTestRelay_SlowHost4014: with a smallterminal.relay_buffer_max_bytesin the test configuration, the side that does not read gets4014, the other side1000or4102, the session isdetached, and other sessions keep flowing.TestRelay_OpenTimeoutHostMissing: H1 onIgnore; afterterminal.open_timeoutthe device gets4102, the session isclosedwithopen_failedandopen_timeout,bothy.terminal.relay.unpairedwith sideclient(the waiting device) rises by one,terminal.terminateis audited.TestRelay_OpenTimeoutDeviceMissing: the device never connects; H1's data connection gets4100, the counter with sidehostrises by one; with neither side connected nothing is counted.TestRelay_OpenTimeoutNoStart: both sides pair, nostart, so nosession.started; at the deadline both get4100and the session endsopen_failedwithopen_timeout(the server view of TM-T05).TestRelay_LocalConfirmHostDeadline: H1 reportsrequire_local_confirmtrue; both sides pair, H1 sendssession.startedafterterminal.open_timeout(a consent that took longer): the session isactive, not ended; with H1 onEnd(open_failed, local_confirm_denied)instead the device gets4100withopen_failed; a pairing that ends beforesession.startedends the opening withopen_failedandopen_timeoutterminal.open_timeoutlater; withrequire_local_confirmfalseTestRelay_OpenTimeoutNoStartholds unchanged.TestRelay_HostRefusalCodes: at opening H1 refuses with4101andtoken_replayed: device4101with that reason, sessionclosedopen_failedtoken_replayed; with4104andprofile_unknown: device4104; at an attach of a started session4101andtoken_expired: device4101, sessiondetached, a following attach succeeds; at an attach4104andtoken_invalid: device4104, sessionclosedopen_failedtoken_invalid, H1 gotsession.close; with H1 onEnd(open_failed, token_replayed)instead: device4101as well.TestRelay_EndsFromOtherPaths: with a device connected,session.endedwithprocess_exited(device4100with reasonprocess_exited,exit_codestored),setHostStatedisable of R1 by Robin (device4100withhost_disabled), areportof H1 withoutbothy-s-alex(device4100withaccess_revoked), each within the tail's wake-up; H1 getssession.closewhere it holds the link; the slot is gone.TestHostData_OnlyAfterSessionOpen: a data connection of H1 for a session without expectation gets1000; for another host's session and for a closed session4100; a second host side while one is paired gets1000; a second unpaired one replaces the first (1000for the first); a bad proof gives M2-43's4113.TestControlLoss_ClosesStreams: H1's control link drops while a session isactive: the device gets4102, the session isdetached(M2-43); a later attach after H1 reconnects pairs again.TestAttach_TMT12(TM-T12 without the console part): after D1's stream drops (sessiondetached), Alex on D2 without step-up:401 step_up_required; D1 in S1 withinterminal.quick_reattach_window:200, no step-up consumed, tokenattattach; D1 after the window:401 step_up_required; D1 in a new login session within the window:401; D1 while the session is stillactive: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 gets4103after the commit, H1's old data connection1000, H1 gets a newsession.open, D2 pairs and the session isactivewithdevice_id = D2; a later streamidentifyfrom D1 gets4103.TestAttach_OpeningSession: D1 drops before message 1 of aclaudeopening with index 1; the attach with a fresh step-up gives a token withattopenandwdi1 and a new deadline; after a restart of the server instance (note 14) the same session isclosedopen_failedopen_timeoutand an attach gives409 invalid_state_transition.TestAttach_KeepsOpenedAtAndOsu: after two attachesopened_at,host_session_user_idandkey_generationare unchanged.TestStream_IdentifyDecisions: every row of the table of note 8 (bad proof, wrong nonce, wronghtu, missing token,device_idof 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 itsclose_reason, a session of S1 after logout of S1 through M1-34 (4012), noidentifybeforeterminal.open_timeout,protocol_version2, a binary frame beforeidentify, an unknown member); denials have deny entries withsession_id,device_idand the reason code, aggregated (AZ-P13).TestStream_ForeignOriginRejected(SEC-CORS-3, part of SEC-T04): a foreignOrigingets403before the upgrade on both paths; the instance origin and noOriginare accepted.TestClose_EndsConnectionsAndTellsHost: Alex closes heractivesession with a reason:200withclosedandclient_closed,closed_byAlex, device4100with reasonclient_closed, H1 data4100, H1 gotsession.closewithclient_closed; auditterminal.closewithreason; Robin closes another session of Alex:closed_by_owner,closed_byRobin, the event goes only to Alex.TestClose_RightsAndRepeats: Sam on Alex's session:404like an unknown ID; Lotse:403 forbidden; an already closed session:200without new version or event, auditno_change: true; a replay with the sameIdempotency-Keygives the stored answer; a reason aboveaudit.reason_max_chars:400 validation_failedwitherrors[].codetoo_long.TestListAndGet: Alex lists only her sessions, Robin all; filtershost_idandstate; cursor pages without gap or duplicate;getTerminalSessionof Alex's session by Sam and of an unknown ID answer identically (AZ-P4); responses validate againstTerminalSessionPageandTerminalSession.TestEvents_TerminalSessionChanged: one event per state change of the happy path, an attach and a close; each validates againstTerminalSessionChangedEvent, hasaudienceselffor Alex, no scope, no JSONnull, no token oros_usertext.TestAudit_TerminalEntries:pin_changed: truefrom the body lands in theterminal.openandterminal.attachentries; Robin opening on R1 (assigned to Alex) hasforeign_assignee: true, Alexfalse;terminal.terminateentries carryclose_reason,close_detail,bytes_in,bytes_outand the duration; no entry contains a token, a path,os_useror frame content.TestTerminalOps_NonHumanTokens(TM-T17 over HTTP and the stream): Lotse's agent token on the five operations gives403 forbiddenwithreason: subject_kind_not_allowedand a deny entry, for an existing and for a random ID alike; on the stream4004with a deny entry.TestReconcileAtStartup: withcluster.mode = singlea new server instance on the same database endsopeningsessions withopen_failedopen_timeoutand setsactiveonesdetached; withcluster.mode = multiit changes nothing.TestShutdown_Relay4013: shutdown closes device streams and data connections with4013withinshutdown.total_timeout, and theactivesessions aredetached.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 ofterminalStreamandhostDataare 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 semgrepmake config-referencethenmake config-doc-checkmake docsmake check
Acceptance criteria¶
-
openTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSessionandcloseTerminalSessionare 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 theactivekey of purposeterminaland carries exactly the claims of 24 "Terminal session token" (schema test green); every grant has a newjtistored astoken_jti; the token appears in no log, event, audit entry or error. -
osucomes only from the person's assignment (terminal_sessions.host_session_user_id); an assignment with a name the host does not report gives403 terminal_deniedwithreason: no_session_userand never a4104. - TM-T29 (g) server part: a
workdir_indexoutside the caller's own reported list gives403 terminal_deniedwithreason: profile_unknown; a valid index stands aswdiin 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 withinterminal.quick_reattach_windowallowed without consuming, after it401. -
pin_changedandforeign_assigneeare in the audit entries ofterminal.openandterminal.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 afterterminal.open_timeoutends the opening withopen_failed(on a host that reportsrequire_local_confirm, the pairing meets that deadline); a second device closes the older one with4103; text frames afteridentifygive4002. - The host's refusal codes
4101and4104reach the device exactly once, and an opening without a PTY ends with the host'sclose_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.changedreaches only the person of the session at every state change and validates against its schema; no JSONnull. - 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 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.
services/server/internal/terminal/README.mdupdated (status, theSessionsport, 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/fakehosthas a README that says what the fakes are not.- Rulings of Design note 18 recorded in
docs/DECISIONS.mdsection 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.mdentry 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) with attention to the terminal items of PLAN 8 "Red team" named under Tests; onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - 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 (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).
- 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.sweepwithout 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>.