M2-45: bothy-host: sessions, PTY and profiles¶
Status: ready | Depends on: M2-44 | Milestone: M2
Goal¶
After this ticket the host service bothy-host runs terminal sessions end to end on macOS, Linux and Windows. For every session.open of the server it opens one data connection /api/v1/hosts/sessions/{session_id}/stream, answers Noise message 1 as responder (M2-38), checks the session token in the fixed order of 24 "Terminal session token" and refuses at the first failing step without a PTY, with exactly that step's close_detail; only then it sends message 2, and only after the first transport message start (Ruling T9), a passed HD6 and HD11 run and, with host_service.require_local_confirm, the consent of the person at the machine it starts a PTY, and only in the PTY helper of the session user named by osu. It serves the profiles shell, claude and command of 24 "Profiles" with allow-list environments plus TERM: the claude profile starts with organization exactly the resolved path of host_service.claude_bin with the fixed search list of HD6 first in PATH, its own CLAUDE_CONFIG_DIR in the session user's home and the organization key, with personal the holder's own claude through the login shell with a CLAUDE_CONFIG_DIR that resolves inside the home, and with none it does not exist. HD6 and HD11 run before every PTY and every host_service.credential_check_interval with the consequences of 24 "Enforcement on the host" and of HD11 in operation; every session runs in its own process group, cgroup or Job Object, and every end kills the whole process tree; every helper ends its PTYs when its channel to the host service breaks; the scrollback lives only in memory and is replayed between replay_begin and replay_end on attach. Why: the server only decides and relays (24 "Protection goal"); on the machine the token is the only proof of a decision, so a replayed or swapped first message, a foreign session user, a foreign working directory or a foreign login must never reach a PTY (T-TM-02, T-TM-08, T-TM-13, Rulings T9, T12, T13, I6), and a session that ends must leave no process behind (T-TM-11, R-TM-15). This is the host side of TM-T03, TM-T04, TM-T05, TM-T21, TM-T25, TM-T28 (c) and (d), TM-T29 (a), (c), (f), (g), (k) and (l); before building, the owner's own login path of OQ-03 is checked against the current provider documentation. M2-46 adds revocation, the server-side limits, audit and metrics; M2-48 runs the whole terminal suite against the real server and a host service in a Linux container.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket" (point 5: tests bind only127.0.0.1and port 0, no real model calls); section 5 "Test strategy" (rows "Test doubles": Fake-Host and Go test device of M2-44 and M2-48, Fake-claudeof M2-42; "Security": TM-T01 to TM-T29, Go fuzzing of the session token); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part), "Out of scope", "Exit criteria" item 8 (PTY tests on all three systems), "Test strategy" (PTY tests on macOS and Windows on own machines, Fake-claude), "Red team" (terminal paragraph), "Risks" (long-lived login path checked by M2-45, ConPTY differs from a Unix PTY); block "terminal" with the rows M2-38 to M2-44 (what exists), M2-45 (this ticket, the contract), M2-46 (depends on this ticket: revocation, limits, audit, metrics), M2-47 and M2-48 (several nodes, conformance and chaos suite); section 18 rows I5, I6 and "Claude Code session in the terminal"; section 19 (P-01, P-09, P-10, P-11, P-16, P-20).- Dependencies: the header names M2-44. Through it this ticket builds on M2-43, M2-42, M2-41, M2-40, M2-39 and M2-38 and on the M1 tickets below them (M1-13, M1-16, M1-17, M1-24, M1-32, M1-33, M1-48, M1-49 and M1-65 among them; by D-W-105 M1-64 to M1-67 lie in the closure of every terminal ticket from M2-43 on). D-W-102 struck M2-42 from this ticket's plan column (it lies in the closure of M2-44); it stays in this read list because its packages are the base of this ticket:
cmd/bothy-host(install,session-user,restart,pair,doctor,run,pty-helper --session-user <name>),internal/config(the YAML file and the form ofhost_service.profilesandhost_service.claude_workdirs),internal/state(bindings.json,declaration.json, the reserved key fileclaude_api_key),internal/hostos(users, file security, path resolution and identity, command runner),internal/doctor(HD1 to HD11 in the modesFullandLight, the resolvedhost_service.claude_binand the search list, the login-shell probe of HD6),internal/localchan(codec, kinds0x00and0x01, peer check),internal/helper(thepty-helperprocess, "M2-45 adds the PTY to it"),internal/startup(Control,Readiness, the declaration),internal/install(service definitions),internal/buildinfo,testdata/fakeclaude,test/platformandmake -C services/host platform-test. docs/spec/24-terminal.md(English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host servicebothy-host" completely (session users points 1 to 6 with the four terms of point 6, the helper rules 1 to 4, the table HD1 to HD11, "Check of the entries", the rule for names, "Message on a refused start"); 2.3 "Server host"; 4.2 "Storage on the host"; 5.1 "Protection goal"; 5.2 "Protocol" (message 2 payload,startas first transport message); 5.3 "Terminal session token" (claims, the fixed check order, the table ofclose_detailand close codes); 5.4 "Frames"; 6.1 "Opening" (step 7); 6.2 "Detach and reattach"; 6.3 "Limits" (idle measured by the host service); 6.4 "Local confirmation"; 6.5 "End of a session on the host"; 7.2 to 7.4 (declaration, I6, "Enforcement on the host" completely); 10.1 "Audit" (the sentence "Never in the audit"); 10.3 "Reasons for ending"; 11 "Profiles"; 12 (PTY and ConPTY); 13 "Errors" (close codes4100to4104); 14 "Tests" (TM-T03, TM-T04, TM-T05, TM-T21, TM-T25, TM-T28, TM-T29 here; the rest for the owners named below); 16 (R-TM-02, R-TM-08, R-TM-11, R-TM-14, R-TM-15, R-TM-16, R-TM-18 to R-TM-30); 17 "Configuration values of this chapter".docs/spec/16-operations.md4.4 "Terminal: forwarding and host service" (installation points 1, 2, 5 and 6).docs/spec/09-agents.md18.2 "Process contract" (row "Environment": the allow list) and 18.3 "Reported mode per run" (environment oforganizationandpersonal, the variables never set).docs/spec/07-permissions.md2.12 "Hosts and theterminalcapability", SR10, SR11, SR12 and AZ-M54 to AZ-M61;docs/spec/08-identity.md6.3 "Fresh step-up for the terminal" and ID-T28 (for the terminal security group only).docs/spec/19-security.md11a "Controls for the terminal" (SEC-TERM-1, 2, 4, 9, 10, 11, 12, 13), 9 "Logs without secrets and without content".docs/spec/02-architecture.md6 "Code layout" (blockservices/host/, the places with the product name).docs/spec/23-quality.md3 "Component contract" (row Go module), "Documentation in code", 11.docs/spec/03-data-model.md(core contract, read only):hosts,host_session_users,terminal_sessions(close_reasonCHECK,close_detail),server_signing_keys(purposeterminal,algonlyEdDSA).docs/api/asyncapi.yaml: channelshostControlandhostData(descriptions,x-close-codes),terminalStream(4100,4101,4104); messageshost.hello,host.identify,host.ready,host.report,host.session.open,host.session.close,host.session.started,host.session.ended,noise.frame.docs/api/openapi.yaml: schemasHostChannelHello(jwks,heartbeat_interval_ms),HostIdentify(session_id,live_sessions),HostReady(llm_credentials, added by M2-42),HostHeartbeat,HostHeartbeatAck,HostReport,HostSessionOpen(expires_at),HostSessionClose,HostSessionStarted,HostSessionEnded,HostProfile,HostReportedSessionUser,HostDoctorCheck,LlmCredentials,TerminalCloseReason,TerminalCloseDetail,TerminalSessionOpen(workdir_index),TerminalSessionGrant,NoiseFrame,Jwks,Problem(x-problem-codes).- ADRs:
docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md(decision points, "Confirmation"),0067-jose-profile-with-the-standard-library.md(profile rules 1 to 3; the host verifies with the same rules, Design note 4),0070-terminal-in-the-web-console.md(local confirmation as the only defence of the console against a malicious server),0046-multi-user-dev-environment-only-with-isolation.md,0053-no-json-null-in-api-and-events.md,0005-go-as-the-server-language.md(github.com/coder/websocket). docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57, T13 as D-W-62) and D-T12, D-T15, D-T16, D-T18, D-T33, D-T39; the rulings that name this ticket: D-W-57, D-W-62, D-W-67, D-W-69, D-W-81, D-W-82, D-W-84, D-W-85, D-W-89, D-W-90, D-W-102; their neighbours D-W-17, D-W-18, D-W-45, D-W-63, D-W-64, D-W-68, D-W-70, D-W-80, D-W-83, D-W-86 to D-W-88, D-W-91, D-W-109; D-R-STACK; D-OP-03; D-W-100, D-W-101, D-W-105 (the planner's rulings for the terminal block); the rowsD-M2-42-<n>,D-M2-43-<n>andD-M2-44-<n>of the merged tickets; section 10 for the format of new rows.docs/security/threat-model.md5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14).docs/RISKS.md: RK-23, RK-24, RK-25, RK-30, RK-84, RK-88, RK-89, RK-90.docs/OPEN-QUESTIONS.md: OQ-03 (checked by this ticket before the build, Design note 0), OQ-04 (no content recording; M2-46) and OQ-24 (terminal only over a private network; default no).docs/operations/configuration.md: thehost_service.*keys (withhost_service.auth_status_timeoutof M2-42 andhost_service.reconnect_backoff_baseandhost_service.reconnect_backoff_capof M2-43),auth.clock_leeway,terminal.session_token_ttl,terminal.open_timeout,terminal.detach_grace,terminal.idle_timeout,terminal.idle_grace,terminal.max_session_duration,cluster.mode.docs/observability/signals.md4.3a: every terminal metric has the sourceapi(the server); M2-44 recordsbothy.terminal.relay.unpaired, M2-46 the other three (D-W-105); this ticket adds no metric and no telemetry to the host service.docs/quality/gates.md,docs/quality/licenses.md,quality/licenses/allowed.txt,docs/quality/red-team-gate.md,docs/quality/push-discipline.md.- Briefs:
docs/briefs/M2-38-noise-core.md(internal/noise:NewResponder,ReadMessage1,WriteMessage2,EphemeralKeyHash,ParseID,Transport,ErrBroken;internal/frame:Decode,Encode,FromDevice,IsInput,MaxLen, the body rules; the hand-maintained.go-arch-lint.yml; Design note 4 on cgo), and the briefs of M2-39 to M2-44 indocs/briefs/(file names from the index), in particular M2-42 Design notes 3, 4, 5, 7, 9 to 12, 15 and 19, M2-43 Design notes 10 and 12 (host side:Sessions,Notifier,RefuseSessions, the JWKS storekeys.go,ReportSource), and M2-44 Design notes 6 to 9 (one data connection per grant, the refusal by close code,attof an attach to anopeningsession, the deadline on a host that reportsrequire_local_confirm) and 11 (the end path that closes both sides aftersession.ended); of the M1 briefsM1-13-unit-of-work.md(work after the commit runs onceDohas returned),M1-65-gateway-delivery.md(the log tail goroutineTail->Observe->Dispatchininternal/wire, which M2-44 extends with the relay's observer),M1-17-api-server-stubs.md(code generation fromopenapi.yaml),M1-32-signing-keys.md(purposeterminal, JWKS). - Code on the base branch:
services/host(all packages,README.md,CHANGELOG.md,.go-arch-lint.yml,Makefile,testdata,test/platform),services/server/internal/terminal(sessions, token and relay of M2-44,adapters/wsrelayof M2-43 and M2-44, the hello builder, the Fake-Host inservices/server/test/terminal/fakehost),tools/ci/tests/test_host_release_build.py. Read the merged state withgit logbefore you start. - External, read only: the Claude Code documentation pages
https://code.claude.com/docs/en/authentication,https://code.claude.com/docs/en/cli-referenceandhttps://code.claude.com/docs/en/legal-and-compliance(Design note 0, OQ-03); the documentation ofgithub.com/creack/pty, ofgolang.org/x/sys/windows(CreatePseudoConsole, Job Objects) and of systemdDelegate=.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Note on rule 9: the protocol constants of 24 and M2-38 (frame types, frame.MaxLen, the labels), the fixed folder .config/bothy-host/claude of 24 7.4, the default configuration folder .claude of the coding agent, the value of TERM (Design note 8) and the fixed search list of HD6 are constants in code with a comment naming their source, not configuration keys. Every wait of this ticket is a key: auth.clock_leeway, terminal.idle_timeout and terminal.detach_grace (server values the host service receives, Design note 2), host_service.credential_check_interval, host_service.auth_status_timeout (M2-42), host_service.local_confirm_timeout and host_service.scrollback_bytes.
Product requirements implemented here:
- The host service checks the session token in the fixed order of 24 5.3 and stops at the first failing step without a PTY; the order of Design note 5 is the order of the spec with the two steps it adds; a failure carries exactly one
close_detailfrom 24 10.3 (token_invalid,token_expired,token_replayed,profile_unknown,session_user_unknown,session_user_mismatch,server_host_owner_only); a failed Noise message 1 ishandshake_failed(24 5.3, R-TM-16, SEC-TERM-2). - A token is valid once: every
jtiis remembered until itsexpplusauth.clock_leeway; a replayed message 1 givestoken_replayedand never a second PTY (TM-T03, Ruling T9, T-TM-02). - The PTY starts only after message 2 and the first transport message
start; message 1 and 2 withoutstartstart nothing (TM-T05, Ruling T9, 24 5.2). - A refused handshake reaches the device with exactly one close code:
4101fortoken_expiredandtoken_replayed,4104for every other failure of the token or the handshake (24 5.3 table, R-TM-16, D-T18; TM-T04, TM-T05); the host service signals it by closing its data connection with that code and theclose_detailas close reason (M2-44 Design note 9). - The PTY starts only in the helper of the session user named by
osu, only whenosuis inhost_service.session_users, and only for the person (sub) the host service first gave that session user; the bindingosutosublives in the state directory (bindings.json; 24 2.2 point 3, Ruling T12, TM-T28 (c)). - The
claudeprofile starts only in a working directory from the list ofosuafter HD7 that the host service reported, chosen bywdi; an index outside it givesprofile_unknown(24 5.3 and 11.1, Ruling T13, D-W-69, TM-T29 (c) and (g)). - Profiles come only from
host_service.profiles; the client sends a name and an index, never a command line or a path; the environment of every profile is an allow list plusTERM(24 11.1, R-TM-08, SEC-TERM-9). claudewithorganization: exactly the resolved path ofhost_service.claude_binthat HD10 checked, the fixed search list of HD6 first inPATH,ANTHROPIC_API_KEYfrom the state directory,CLAUDE_CONFIG_DIRset to.config/bothy-host/claudein the home of the session user, none of the OAuth or provider variables of 09 18.3; with an emptyhost_service.claude_bin, or one that counts as empty (not resolvable, interpreter not in the search list), the profile is not offered (HD7 warningclaude_profile_unavailable) (24 7.4 and 11.1, D-W-81, D-W-85, TM-T29 (l)).claudewithpersonal: the holder's ownclaudethrough the login shell of the session user, with theCLAUDE_CONFIG_DIRthat the helper read from a separate login shell and that resolves inside the home (unset: the default folder of the coding agent in the home); outside the home or not resolvable: the profile does not start (open_failedwithshared_location) andshellstays (24 7.4, D-W-82, D-W-84, TM-T29 (k)). The service user never opens, reads, copies or passes a login ofpersonal(TM-T25, D-W-17, R-TM-19, I5). Withnonethere is noclaudeprofile.- HD6 and HD11 run before every PTY and every
host_service.credential_check_interval, with the consequences of 24 7.4 and HD11: a subscription login undernoneororganizationrefuses new sessions withsubscription_login_detectedand ends all running sessions withhost_policy_violation; a violation at a location of a session user blocks only that session user (shared_location); a violation at a location for all ends all sessions withhost_policy_violationandshared_locationand admits none until HD11 passes (D-W-89, TM-T21, TM-T29 (a) and (f)); a finding of the entry scan is lifted only by a full run. - On macOS and Linux every session runs in its own process group and session (
setsid), on Linux additionally in its own cgroup; on Windows every session runs in a Job Object withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; every end, for whatever reason, ends the whole tree (24 6.5, R-TM-15, SEC-TERM-11). - Every PTY helper ends its PTYs with their trees as soon as its channel to the host service breaks (D-W-90, R-TM-27).
- The scrollback buffer of
host_service.scrollback_bytesper session lives only in the memory of the host service, never on disk or in the server; an attach replays it betweenreplay_beginandreplay_end(24 6.2). - With
host_service.require_local_confirm = trueno PTY starts and no attach is replayed without the consent of the person at the machine withinhost_service.local_confirm_timeout; otherwiselocal_confirm_denied(24 6.4, Ruling T3). - No terminal content, session token, key, environment value, path or command line in any log line,
report,session.ended, close reason, error text or test failure message of the host service (24 10.1, R-TM-10, SEC-TERM-1, SEC-LOG-2). - PTY tests run on macOS, Linux and Windows (PLAN 8 exit criterion 8, RK-23).
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"). This ticket adds no right, action or decision ofauthorize: M2-39 builds the targethostandhost.read(AZ-M60), M2-40 buildsterminal.*, SR10, SR11 with I6 and SR12 (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)), M2-44 issues the token withown,osuandwdi. What this ticket enforces on the machine, as the second line behind the server: the server-host part of SR10 by the claimown(TestVerifyOrderrowserver_host_owner_only); SR12 and T12 byosuinhost_service.session_users, the binding to the firstsuband the PTY only in the helper ofosu(TestVerifyOrderrowssession_user_unknownandsession_user_mismatch,TestBindingConcurrentFirstUse,TestPlatform_TMT28c,TestPlatform_PTYRunsAsSessionUser); I6 on the machine through the own session user of the holder (TestPlatform_TMT28d,TestPlatform_TMT25). - Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not decided here: M2-40 builds step-up
freshand its consumption (ID-T28 without the console part), M2-44 proves TM-T07 and TM-T12 without the console part (D-W-105), M4-36 and M5-12 build the step-up key in the apps, M6-18 the passkey of the console. What this ticket enforces: a token that one consumed step-up produced opens at most one PTY, because thejtiis remembered and the token is bound to the device's ephemeral key bycek(TestTMT03Replay,TestVerifyOrderrowstoken_replayedandcek). - 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). What this ticket enforces: the data connection of a session is an outbound WebSocket like the control connection of M2-43; the only endpoint this ticket adds is the local confirmation channel (a Unix socket or a named pipe that rejects remote clients, never TCP or UDP), and no package opens a network listener (TestNoInboundNetworkListener). On an answer "yes" to OQ-24, M2-43 and M2-44 change, not this ticket. - Audit without content: M2-46 owns audit and metrics of sessions without content (TM-T06), M2-44 the relay that writes only length, time and session ID. This ticket writes no audit; its logs,
report,session.ended, close reasons of the data connection and errors carry IDs, names of session users, profile names and codes only (TestNoContentInLogsAndReports,TestPlatform_TMT25). - Idle timeout and maximum duration (24 "Limits"): this ticket measures idle on the machine from the device's input frames (
dataandresize,frame.IsInput) withterminal.idle_timeoutand ends a sessionterminal.detach_graceafter its data connection was lost without an attach (TestIdleTimeout,TestDetachGrace). M2-46 adds the server side:terminal.idle_timeoutplusterminal.idle_gracein the relay,terminal.max_session_duration, the jobterminal.sweepand TM-T11 with a manipulated host service. - 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, TM-T26). What this ticket enforces: no PTY ever runs as the service user or with raised rights; the helper is already the session user and starts its children without setuid or a change of user; on Linux it manages only its own delegated cgroup subtree (TestPlatform_PTYRunsAsSessionUser,TestSessionCgroup). - Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-02 (replay and token swap at the relay) by the single-use token,
cek, the prologue and the PTY only afterstart:TestTMT03Replay,TestTMT04TokenFailures,TestTMT05NoStart,FuzzVerifySessionToken; T-TM-08 (a foreign LLM login through the terminal) by the own session user, theclaudeprofile environment and HD6 before every PTY:TestPlatform_TMT25,TestPlatform_TMT28d,TestPlatform_TMT29k,TestPlatform_TMT29l,TestPlatform_TMT21_Sessions; T-TM-11 (processes after the end) by the session tree and the helper's reaction to a broken channel:TestKillTreeEndsBackground,TestHelperChannelBreakEndsPTYs; T-TM-12 (replacing or tapping the helper) by the PTY only in the helper ofosuand the consequence of a failed peer check in operation:TestPlatform_PTYRunsAsSessionUser,TestHelperUntrustedEndsSessions; T-TM-13 (shared location) bywdionly in the reported list ofosuand HD11 before every PTY:TestPlatform_TMT29a_Session,TestPlatform_TMT29c_Open,TestPlatform_TMT29f_Operation,TestPlatform_TMT29g_Token; T-TM-14 (standstill by one person) by the consequences and reports of HD6 and HD11:TestPlatform_TMT21_Sessions; T-TM-01 and T-TM-05 (malicious server, stolen keys) only by the local confirmation as fallback:TestConfirmOpenAndAttach,TestPlatform_LocalConfirm. Not touched here: T-TM-03 and T-TM-09 (M2-40, M2-44), T-TM-04 (M2-42), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-10 (M4-35).
Scope¶
Paths under services/host/ unless noted. Package and function names are proposals; keep the responsibilities. Names of M2-42 to M2-44 below follow their briefs; their merged names win (Boundaries).
Create:
internal/session/:doc.go;manager.go(implements M2-43'scontrol.Sessions:Live, the handling ofsession.openandsession.close; usescontrol.Notifierforsession.startedandsession.ended;ActiveCountforheartbeat);dataconn.go(the outbound data connection: dial with the TLS settings of M2-43's client,hello,identifywithsession_idand the proof of M2-43'slogin.go, then binary messages only);handshake.go(responder of M2-38, token check, message 2, wait forstart);token.go(compact JWS parse and EdDSA verification, claims,Verdict);jti.go;binding.go(osutosubthroughinternal/state);gate.go(admission and enforcement from HD6, HD10 and HD11, Design note 11);scrollback.go;timers.go(idle and detach);pump.go(frames between data connection and helper); tests; fuzz targetFuzzVerifySessionToken;sessiontest/(Design note 17).internal/pty/:doc.go;pty.go(typesStartSpec,Process,Tree);pty_unix.go(Unix PTY withgithub.com/creack/pty,Setsid,Setctty);pty_windows.go(ConPTY);tree_linux.go(session cgroup in the delegated subtree),tree_darwin.go(process group plus descendant snapshot, run file),tree_windows.go(Job Object); tests per platform.internal/profiles/:doc.go;profiles.go(Offered, start specifications);env.go,env_windows.go(allow lists);claude.go(organizationandpersonal);loginshell_unix.go,loginshell_windows.go(start of a login shell, the start command ofpersonal);configdir.go(resolution inside the home); tests.internal/confirm/:doc.go;server.go(service side: endpoint, peer check, requests, timeout);agent.go;dialog_darwin.go,dialog_linux.go,dialog_windows.go;peer_unix.go,peer_windows.go; tests (Design note 15).cmd/bothy-host: the subcommandconfirm-agent;runbinds the session manager instead ofRefuseSessions(M2-43) and starts the gate and the confirmation endpoint;pty-helperserves the session ops.test/platform/(M2-42's suite, build tagplatform): the platform tests of this ticket (Tests);tools/ci/tests/test_host_cross_platform_vet.py.
Change:
go.mod,go.sum:github.com/creack/pty(MIT, newest release);golang.org/x/sysraised to its newest release. No other dependency..go-arch-lint.yml(hand-maintained since M2-38): componentssession,pty,profiles,confirm,sessiontest, theirdepsand vendors (github.com/creack/ptyonly forpty,golang.org/x/sysforpty,profilesandconfirm,github.com/coder/websocketonly for the data connection code);make archmust pass.internal/helper(M2-42): the session ops of Design note 10, running PTYs throughinternal/ptyandinternal/profiles, ending all PTYs when its channel breaks.internal/localchan(M2-42): the kind0x01body of Design note 10 (reserved there for this ticket) and the session ops of kind0x00.internal/control(M2-43): pass a repeatedreadyon the open control connection tointernal/startup(Design note 2), takelive_sessionsandactive_sessionsfrom the session manager, send pendingsession.endedafterready; build the profile list ofreportfromprofiles.Offered.internal/startupandinternal/doctor(M2-42): expose what the gate needs without a second implementation (Readiness, theLightrun per session user and for the locations for all, the resolvedhost_service.claude_binwith its search list and usable state, the HD10 peer event ofinternal/localchan); add the OQ-03 case toclaude_profile_unavailableonly if Design note 0d dropspersonal.internal/install(M2-42): the systemd unit of each PTY helper getsDelegate=yes(Design note 12); the host service gets the confirmation folder (Design note 15); golden tests updated.testdata/fakeclaude(M2-42): the interactive andloginmodes of Design note 17.- Server (
services/server) and contract, only where the merged code lacks it (Design note 2): the hello builder fills the newHostChannelHellomembers; the control hub fillsHostReady.llm_credentialsand sendsreadyagain after a committed change of the declaration (its log tail observer ininternal/wire, Design note 2b);docs/api/openapi.yaml(HostChannelHello) anddocs/api/asyncapi.yaml(hostControldescription); M2-43's recordingsservices/host/testdata/control/*.jsonregenerated; thenmake api-lint,make api-checks,make api-tablesand the server code generation;services/server/CHANGELOG.mdand the README ofterminal. services/server/test/integration/terminalsessions(M2-44): the testTestRelay_HostEndCloseOrder(Design note 6), with the Fake-Host'sEndscript keeping the data connection open if it does not already.docs/api/asyncapi.yaml, channelhostData,x-close-codes: the client action of4013on the data connection becomes "keep the PTY detached untilterminal.detach_grace; wait for the nextsession.open" (Design note 3), unless the merged M2-44 already changed it; thenmake api-lintandmake api-checks.docs/spec/24-terminal.md,docs/spec/16-operations.md4.4,docs/spec/02-architecture.md6 (English, Design note 18).docs/OPEN-QUESTIONS.mdOQ-03,docs/RISKS.mdRK-30 and RK-23: the findings of Design notes 0d and 12, facts only, the decision stays with the owner.docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md"Confirmation": the host tests of TM-T03 to TM-T05, TM-T21, TM-T25, TM-T28 and TM-T29 named; thenmake adr-index.services/host/README.md(English),services/host/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-45-<n>(Design note 19).
This ticket creates no migration, no server table, no new configuration key and no new top-level make target.
Out of scope¶
- Revocation of every kind of 24 8.3 with the subscriptions of
authorize.AccessChanged,identity.DeviceRevokedandidentity.SessionEnded, the server-side idle check withterminal.idle_grace,terminal.max_session_duration, the jobterminal.sweep,terminal.max_sessions_per_person,terminal.max_sessions_per_host,rl.terminal,rl.host, audit ofterminal.terminate, the metricsbothy.terminal.sessions.active,bothy.terminal.sessions.closedandbothy.terminal.hosts.connected(bothy.terminal.relay.unpairedis M2-44's, D-W-105), TM-T06, TM-T10, TM-T11 (M2-46). This ticket ends trees onsession.close, which M2-46 sends. - Sessions, tokens and relay in the server,
openTerminalSession,attachTerminalSession,closeTerminalSession, the mapping of the host's refusal codes and ofsession.endedto the device stream, TM-T07, TM-T12, the relay closes4014,4103,4002andopen_failedafterterminal.open_timeout(M2-44); several nodes and TM-T15 (M2-47); the conformance and chaos suite against the real server, the host service in a Linux container inmake check, TM-T16 (M2-48). install,pair,doctorand the checks HD1 to HD11 themselves,session-user addandremove, the key storage, the configuration file, TM-T13, TM-T22, TM-T24, TM-T26, TM-T27 and the parts of TM-T29 without a session (M2-42); the control connection,hello,identify, heartbeat,report,ReportOnceand the close codes4110to4114(M2-43); pairing and fingerprint (M2-41); rights, SR10 to SR12, I6 and step-up (M2-40); the Noise core (M2-38).- Terminal views and pinning in the apps and the console, TM-T14, TM-T18 (M4-35, M5, M6-18); release, signing and installation packages of the host service (M7-23); terminal in dev environments (V2); file transfer and clipboard frames (V3-16).
- Content recording of sessions (OQ-04: no).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-45-host-pty-profiles; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(host): ...,feat(terminal): ...,docs(spec): ...,test(host): ...) 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); they also name the disposable Linux, macOS and Windows machines with administrator rights for the platform runs.
- Tests of
make checkrun without administrator rights, bind only127.0.0.1and port 0 (P-10), create no operating system user, service or ACL outsidet.TempDir(); PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09). Without a Docker-compatible runtime the server integration tests fail; they never skip. - The platform suite (
make -C services/host platform-test) runs only on the disposable machines the host rules name and cleans up after itself; never create, change or delete a user, service or ACL of the machine you build on. - Stop only processes you started, by PID or by the process group, cgroup or Job Object of a session your test started. Temporary directories only via
mktemp -d(Got.TempDir()), removed at the end. - Never read or print secrets. Never start a
claudeinstalled on any machine, never read a real Claude Code configuration or login, and never make a model call: every test uses the Fake-claudeof M2-42 throughhost_service.claude_binor a testPATH. - No network calls except package downloads for pinned tools and Go modules, the read-only fetch of the three documentation pages of Design note 0, and what the gates of
make checkdo themselves. - Never lower a threshold, disable a gate or delete a test; never extend
quality/licenses/allowed.txt. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - 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-44: their merged names win over the names here; list each difference. Where their merged code already covers a part this brief marks "only where the merged code lacks it", use it and build nothing twice.
- Never build a second implementation of HD6, HD10, HD11, the fixed search list, the login-shell probe, path resolution, the peer check, the proof of the host login or the Noise handshake; call the merged ones.
Design notes¶
-
Start checks, in this order, before any code:
a.
make checkon the fresh worktree; red givesVerdict: BLOCKED base branch red.b. M2-38 to M2-44 are merged (
services/host/internal/noise,frame,config,state,hostos,doctor,localchan,helper,startup,control,test/platform;services/server/internal/terminalwith sessions and relay); otherwiseVerdict: BLOCKED dependency <ticket> missing.c. The machines for the platform runs exist under the host rules (a disposable Linux machine with systemd, a macOS machine, a Windows machine, each with administrator rights); otherwise build and test everything else and stop with
Verdict: BLOCKED platform run <system> not available(RK-23: "no terminal on a platform without proof for it").d. OQ-03 (Ruling T10, RK-30): read the three documentation pages of "Read first" and record whether one of two login paths of the holder in the account of the session user is documented: path A, a token from
claude setup-tokeninCLAUDE_CODE_OAUTH_TOKENfor the subscriber's own use, with no restriction in the authentication page or the legal and compliance page to non-interactive use; path B,/loginwith the credentials stored in a file of the home (Linux, Windows) or in the keychain with the file as fallback (macOS). Write the quotes with URL and date into 24 7.4 as [verified], and replace [assumed] there only where a quote answers it. If path A or B is documented, buildpersonalas this brief says, and update OQ-03 ("What the plan does with it") and RK-30 with the finding; the question itself stays with the owner. If neither path is documented, theclaudeprofile is not offered withpersonal:profiles.Offereddrops it, HD7 reportsclaude_profile_unavailableforpersonaltoo, 24 7.2, 7.4, 11.1 and 14 (TM-T25 and TM-T29 (k) then run with the profileshelland expect noclaudeprofile) change in the same change, and the report names it as an owner decision with the recommendation to keeporganizationfor such hosts. If the pages cannot be fetched under the host rules:Verdict: BLOCKED OQ-03 documentation not reachable.e. Read how the merged code gives the host service the declaration and the server values. M2-42 added
HostReady.llm_credentialsto the contract, stores the last value indeclaration.jsonand treats it as unknown before the firstready(declaration_unknown); check that the server fills it and sendsreadyagain after a change. No message carriesauth.clock_leeway,terminal.idle_timeoutorterminal.detach_gracetoday (HostChannelHellohasheartbeat_interval_msand, from M2-43,heartbeat_miss_limit). Build the missing parts of Design note 2. -
Process split. The service user process (
bothy-host run) holds the keys, the control and data connections, Noise, the token check, thejtimemory, the bindings, the scrollback, the timers and the gate. Each PTY helper (bothy-host pty-helper, one per session user) holds the PTYs of its person, resolves that person's locations and runs that person's checks; it never sees a key or a token. Between them runs only M2-42's local channel with decrypted frames, the session ops of Design note 10 and check results (24 2.2). Nothing of this ticket runs as the service user in a session user's home (TM-T25). -
Declaration and server values on the host (spec first, only where Design note 0e found them missing).
a.
HostChannelHellogets three required membersclock_leeway_ms,idle_timeout_msanddetach_grace_ms(integers,format: int64, likeheartbeat_interval_ms), filled by the server's hello builder fromauth.clock_leeway,terminal.idle_timeoutandterminal.detach_grace. The host service uses the values of thehelloof the data connection that carries a handshake: the leeway for steps 5 and 6 of Design note 5, idle and detach for the timers of that session (each attach refreshes them). Ruling for docs/DECISIONS.md: these are server values the host service must apply (24 5.3, 6.2, 6.3);helloalready carries server values and comes on every data connection, so no extra push is needed, because the server reads its configuration only at start.b. The server fills
HostReady.llm_credentialsfromhosts.llm_credentialsand sendsreadyagain on the open control connection after every committed change of the declaration of that host. Ruling for docs/DECISIONS.md: the trigger is the durable eventhost.updated(24 10.2, written in the transaction of every change of the declaration), read from the log tail: the control hub of M2-43 inadapters/wsrelaybecomes a further observer of M1-65's tail goroutine beside M2-44'sRelay.ObserveLogEvent(same signature, wired ininternal/wire, non-blocking); for ahost.updatedof a host with a live control link on this node it readshosts.llm_credentialsthrough the module's store and sendsreadyagain when the value differs from the one last sent on that link. Reason: the tail delivers only committed events, so a rolled back change sends nothing, and it runs on every api node, so the node that holds the control link sends thereadywithout the cross-node path that M2-47 builds only forsession.openandsession.close. M2-43's host client hands every furtherreadytointernal/startup, which stores it indeclaration.json, runs HD6 at once with the new declaration and sendsreportwhen profiles or results changed; a running session keeps its profile and environment, new opens and attaches use the new offer. These are M2-42's rulings; this ticket only completes them.c. Update 24 2.2 point 5, 5.3, 6.2, 6.3 and 7.4, the descriptions of
HostChannelHelloand of the channelhostControl. -
Data connection per grant (M2-44 Design note 7). The host service opens exactly one data connection for each
session.openand never one on its own; each carries exactly one Noise handshake. Asession.openfor a session ID without a live PTY is an opening (also a repeated opening of anopeningsession, whose token saysatt = open); one for a session ID with a live PTY is an attach (att = attach). A newersession.openfor the same session replaces any older data connection of it, which the host service closes with1000if the server has not already.session.close {session_id, close_reason}and a close of the data connection by the server with4100(hostDatax-close-codes: the session ended) end the PTY with its tree at once (Design note 12), or abandon an opening that has no PTY yet; the host service stops the timers of that session, drops its scrollback, removes it fromlive_sessionsand sends nosession.ended, because the server closed the session already (M2-43 Design note 10 changes nothing for a closed session); asession.closefor a session the host service does not know changes nothing. Every other close of the data connection by the server (1000,4002,4013,4014, a code of the login) or its loss, while the PTY lives, makes the session detached on the host (Design note 14); the host waits for the nextsession.openand never redials a data connection on its own. Ruling for docs/DECISIONS.md: these reactions, because M2-44 Design note 11 closes the host side with4100and sendssession.closefor every end, and one data connection per grant leaves no other reconnect. 24 6.2 changes as M2-44 states. -
Session token verification (24 5.3, ADR 0067 rules 1 to 3, D-T33). The host module cannot import the server's
internal/kernel/jose, sointernal/session/token.goimplements the needed subset with the standard library: compact JWS only, exactly two dots, base64url without padding; header withalgexactlyEdDSA, akid,typ; anycrit,jku,x5u,x5c,ziporjwkmember rejected; the key is the Ed25519 key (ktyOKP,crvEd25519) with thatkidin thejwksof thehelloof this data connection;crypto/ed25519verifies. Claims:iss,aud,sub,sid,jti,iat,exp,dev,prf,cek,own,osu,attare required with their JSON types (strings, integer NumericDates, booleanown),wdian integer of at least 0 when present,attopenorattach; a JSONnullanywhere, a missing or mistyped claim or trailing data is a parse failure; unknown claims are ignored.issandiatare not compared: the key from this server's JWKS binds the issuer, and 24 5.3 fixes the checks. Ruling for docs/DECISIONS.md (a second, smaller implementation of the ADR 0067 profile in the host module, tested with the RFC 8037 A.4 vector and the negative cases of ADR 0067 "Confirmation"); add a sentence on it to ADR 0068 "Confirmation". -
Check order and close details. In the service user, for every message 1 on a data connection; first failure wins, nothing after it runs:
0 ReadMessage1 with prologue (sid of session.open, own host ID) and the static Noise key -> handshake_failed 1 JWS parse and signature (Design note 4) -> token_invalid 2 typ = terminal-session+jwt -> token_invalid 3 aud = own host ID -> token_invalid 4 sid = session ID of the prologue -> token_invalid 5 now <= exp + clock leeway (Design note 2a) -> token_expired 6 jti not seen; remember it now until exp + clock leeway -> token_replayed 7 cek = EphemeralKeyHash(e of message 1) -> token_invalid 8 att: open needs a sid without live PTY; attach needs a sid with a live PTY whose sub, osu and prf equal the claims -> token_invalid 9 prf names a profile in profiles.Offered now -> profile_unknown 10 osu in host_service.session_users -> session_user_unknown 11 osu unbound (bind it to sub now) or bound to sub -> session_user_mismatch 12 wdi: present exactly for a profile of kind claude with att open, and an index into the reported list of osu after HD7 -> profile_unknown 13 own = true when this host detected that it is the server host -> server_host_owner_onlySteps 8 and 12 add what 24 5.3 leaves open: which session an
attachmay name, and whenwdimay be present (M2-44 issueswdiexactly so). Ruling for docs/DECISIONS.md (anattmismatch can only come from a server fault or manipulation, R-TM-16); update 24 5.3. Anattachfor a session whose end the host service already reported is no refusal: it closes that data connection with1000and reports nothing new (the server closed the session). Step 6 records thejtibefore later steps run, so a replay of a token that failed later is stilltoken_replayed. Thejtimemory lives only in memory: after a restart of the host service every earlier session is ended (host_restarted, D-W-90), so only a server that sendssession.openfor an ended session could replay, which 24 5.1 excludes from the protection goal. Step 13 uses M2-42's HD5 result or-ed with M2-43'sPeerIsLocal(theis_server_hostofreport). Step 11 writesbindings.jsonthroughinternal/state(atomic write, M2-42 Design note 4) under a lock before step 12, so two first opens of one session user with two people never both bind; a binding stays when a later step fails, because the server never reassigns a session user (T12). Ruling for docs/DECISIONS.md. -
Reporting a failure (M2-44 Design notes 8 and 9). A refusal at steps 0 to 13, for an opening and for an attach, closes the data connection with
4101(token_expired,token_replayed) or4104(every other detail) and theclose_detailas close reason; the host service sends nosession.endedfor it, and the server ends an opening withopen_failedand that detail, leaves a started sessiondetachedon4101and ends it on4104; on a4104refusal of an attach to a live PTY the host service also ends that PTY with its tree at once (M2-44 Design note 9), and the followingsession.closefinds nothing left to do. A failure after message 2 and before the PTY runs (Design notes 7, 11, 15, the PTY start) and every fault of the stream sendsession.endedwithclose_reasonopen_failedand theclose_detailand keep the data connection open: the server ends the session and closes both sides through M2-44's end path (host side4100, deviceStreamCodeForEnd, M2-44 Design note 11), so the device gets4104forhandshake_failedand4100foropen_timeout,subscription_login_detected,shared_location,helper_untrusted,local_confirm_deniedandpty_failed. A close by the host service right after the report would reach the relay before the end and give the device4102(M2-44 Design note 7, row "host side closes otherwise"). This holds for everysession.endedthe host service sends while it holds the data connection (Design notes 7, 11 and 14). Only when the server has not closed the data connection within the length of the opening window (HostSessionOpen.expires_atminus the arrival of thatsession.open) after the report does the host service close it with1000. Ruling for docs/DECISIONS.md: the close order aftersession.ended. M2-43 accepts from a host exactlyclient_closed,process_exited,idle_timeout,detach_expired,open_failedandhost_policy_violation(M2-43 Design note 10); the host service sends no other reason. -
Message 2,
startand the stream. After step 13 the host service sends message 2 with the payload{"v":1,"host_version":"<internal/buildinfo.Version>","profile":"<prf>"}and waits for the first transport message untilHostSessionOpen.expires_at;expires_atbounds only this wait, the gate and the local confirmation afterstartare bounded by their own values (Design note 15 d). It must bestart; anything else, a failed decryption, an unknown, malformed or wrong-direction frame (M2-38frame.Decodeerrors), a secondstartor any such fault later in the session ends the session: PTY and tree end,session.endedwithopen_failedandhandshake_failed, and the device gets4104(24 5.4) through the server's close (Design note 6). Ruling for docs/DECISIONS.md: 03terminal_sessions.close_reasonhas no value for a fault of the encrypted stream after the opening, and M2-43 accepts no other host reason;open_failed("rejection by the host service") withhandshake_failedkeeps the manipulation signal of4104without a core change (spec gap in the report). Withoutstartuntilexpires_at: no PTY,session.endedwithopen_failedandopen_timeout(the server's own deadline may have ended the session already; then the report changes nothing, TM-T05). Until the PTY runs the host service reads no further frame (backpressure through the relay).startsets the size of a new PTY or resizes the PTY of an attach;datagoes to the PTY;resizeresizes;closeends the session withclient_closed. PTY output goes out indataframes within the body limit of M2-38. When the process exits, the host service sendsexitwith the exit code (Unix: the status, or 128 plus the signal number after a signal; Windows: the process exit code), ends the tree and sendssession.endedwithprocess_exitedandexit_code. After a PTY started the host service sendssession.startedonce; an attach sends none (the server sets the state when the relay pairs, M2-44). -
Profiles (24 11.1, 7.4).
host_service.profilesin the form of M2-42 (shell,claudeor{name, display_name, kind: command, program, args}).profiles.Offered(declaration, claudeBin, workdirs, oq03)returns the offered list:shelland everycommandthat HD7 kept;claudeonly withorganizationand a usablehost_service.claude_bin(the resolved path and search list from M2-42's HD6 and HD10 result, never recomputed), or withpersonal(unless Design note 0d dropped it), and in both cases only when at least one session user has a working directory after HD7 (an emptyhost_service.claude_workdirsmeans noclaudeprofile, 24 17); never withnoneor an unknown declaration. Start specifications, all built from scratch, never by removing from an inherited environment:TERMisxterm-256colorfor every profile (the renderer isxterm, 24 12). Unix allow list:HOME,USER,LOGNAME,SHELLfromhostos.Users,PATHcleaned from the helper's own environment (absolute, existing directories, not writable by group or others, no duplicates, no empty entry or.),LANG,LC_*andTMPDIRfrom the helper's environment when present. Windows: the environment block of the helper's token fromCreateEnvironmentBlockwithout inheritance (the user and system variables, the equivalent of a login) plusTERM.shell: the login shell of the session user (Design note 9), working directory the home.command: the resolved path ofprogram(the path HD11 checked) withargs, working directory the home, the allow list.claudewithorganization: program the resolvedhost_service.claude_bin; working directory the working directory ofwdi(Design note 10);PATHthe search list of HD6 followed by the cleanedPATH;ANTHROPIC_API_KEYfromclaude_api_key(Design note 10);CLAUDE_CONFIG_DIR=<home>/.config/bothy-host/claude(Windows%USERPROFILE%\.config\bothy-host\claude), which M2-42's helper creates with mode0700(Windows: an ACL only for the session user), refused withshared_locationwhen it does not resolve to exactly that folder inside the home; neverANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_PROFILE,CLAUDE_CODE_USE_BEDROCK,CLAUDE_CODE_USE_VERTEX,CLAUDE_CODE_USE_FOUNDRY(09 18.3), because they are not on the list.claudewithpersonal: the login shell started with a command that setsCLAUDE_CONFIG_DIRto the checked value (or removes it when it was unset) and then runsclaudefrom the shell's ownPATH(Unix:export CLAUDE_CONFIG_DIR=<quoted value>; exec claudeorunset CLAUDE_CONFIG_DIR; exec claude, quoted for the shell kind; Windows:powershell.exe -NoLogo -Commandwith$env:CLAUDE_CONFIG_DIR = '<value>'orRemove-Item Env:\CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue, then& claude); working directory the working directory ofwdi; no API key.
Ruling for docs/DECISIONS.md:
TERM, the Windows environment block, the resolved program ofcommand; update 24 11.1 and 7.4. -
Login shell and the query of
CLAUDE_CONFIG_DIR(24 7.4, D-W-82, D-W-84). The login shell and the home come fromhostos.Users(M2-42, which reads the user database without cgo). Unix: a login shell starts withargv[0]=-plus the base name of the shell (login(1) convention, valid for every POSIX shell,fishandtcsh); a shell that is not an absolute existing path listed in/etc/shellsgivespty_failed. Windows:%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo(present on every Windows 10 and 11) is the login shell, with the environment block of Design note 8. Before every start ofclaudewithpersonal, the helper runs M2-42's login-shell probe of HD6 (it prints only whetherCLAUDE_CODE_OAUTH_TOKENis set and the value ofCLAUDE_CONFIG_DIR, never a token; on Windows from the user's registry hive), bounded byhost_service.auth_status_timeout; unset means the default folder<home>/.claude; the value is resolved by the helper as the session user with M2-42'sResolverand must be the home or below it; a value outside, a link leading outside, a dangling link or a missing folder giveshared_location(TM-T29 (k)); a timeout or a failed shell givespty_failed. Only that probe is ever run, so no other variable and never a token is read (24 7.4). Ruling for docs/DECISIONS.md: the login shell convention and PowerShell on Windows; update 24 7.4 and 11.1. -
Session ops on the local channel (24 2.2; M2-42 codec). Kind
0x00JSON ops{"op","id","d"}: from the servicepty.start {session_id, profile, kind, program and args for command, workdir_index, cols, rows, api_key only for claude with organization},pty.resize {session_id, cols, rows},pty.stop {session_id}; from the helperpty.started {session_id},pty.exited {session_id, exit_code},pty.failed {session_id, close_detail}. Kind0x01body: the 16-byte session ID (M2-38ParseIDorder) followed by the bytes of one M2-38dataframe, in both directions; the helper reads PTY output in chunks small enough that the body stays within the codec limit of M2-42. The service user reads the key fileclaude_api_keyinhost_service.state_dir(reserved by M2-42, placed by the operator, 16 4.4 point 5) for each such start and sends the value only in thatpty.start; the helper puts it only into that child's environment and clears its copy after the start; it never reaches a log, a file or another start. The helper mapsworkdir_indexto the entry of its own list after HD7, resolves it as the session user and requires the identity it reported at start (M2-42, D-W-84), elseshared_location. Withpersonalno key, token or login ever crosses the channel (TM-T25). Ruling for docs/DECISIONS.md: the op set, the kind0x01body and the per-start handover; update 24 7.4 and 16 4.4 point 5 with the file name. -
Gate: HD6, HD10 and HD11 in operation (24 2.2 HD10, HD11, 7.4, D-W-89).
gate.gokeeps the latest result per check and session user, builds on M2-42'sReadinessand decides admission:- Before every PTY start (after
startand before the local confirmation): theLightrun of the helper ofosu(HD6 and HD11 without the entry scan) and the service user's HD11 for the locations for all; the latest periodic results of the other helpers still apply. Ruling for docs/DECISIONS.md: other helpers are not run per opening, because the open time would grow with the number of session users and the periodic run bounds their age tohost_service.credential_check_interval. - Every
host_service.credential_check_interval: theLightrun in every helper and HD11 for the locations for all. - HD6 not passed in any session user while the declaration is
noneororganization: no new session (open_failedwithsubscription_login_detected), every running session ends withhost_policy_violationandsubscription_login_detected,reportwith one HD6 entry per affected session user; sessions again only when HD6 passes in every session user. - HD11 not passed at a location of session user X (also
entry_scan_running,entry_scan_timeout,foreign_entry,home_unresolvable): X gets no session (open_failedwithshared_location), X's running sessions end withhost_policy_violationandshared_location, the others continue; a finding of the entry scan is lifted only by aFullrun (start orbothy-host doctor), never byLight. - HD11 not passed at a location for all: no new session, all running sessions end with
host_policy_violationandshared_location,reportwithout a session user name; lifted by the next passing run. - A failed peer check of the local channel in operation (HD10,
peer_mismatch): no new session, all running sessions end withhost_policy_violationandhelper_untrusted(24 HD10 "as with HD6"). - Every change of a result sends
report(24 7.4,HostReport"on every change").
- Before every PTY start (after
-
Process trees (24 6.5, R-TM-15). Every end, whatever its reason, kills the whole tree at once with SIGKILL or
TerminateJobObject, without a grace period, because every end is either the person'scloseor an enforcement that 24 8.3 wants "immediately". Ruling for docs/DECISIONS.md.- Linux: the helper unit gets
Delegate=yes; at start the helper moves itself into the leaf<its cgroup>/helperand creates one cgroup<its cgroup>/s-<session id>per session, places the child there before it runs user code (clone into the cgroup, or write the PID before releasing the child), ends it withcgroup.kill, falling back to SIGKILL of every PID incgroup.procsuntil it is empty, then removes the cgroup. If the cgroup cannot be created the PTY does not start (pty_failed). Ruling for docs/DECISIONS.md: 24 6.5 names a systemd scope of the session user, but a session user without a login session has no user manager; a delegated subtree of the helper's system unit gives the same per-session cgroup (verify against the systemd documentation ofDelegate=and record it [verified] with URL and date in 24 6.5); systemd also kills the whole subtree when the helper unit stops or restarts. - macOS: the child starts with
Setsid; at the end the helper takes a process table snapshot (golang.org/x/sys/unixSysctlKinfoProcSlice("kern.proc.all")) and kills every process of the session user whose parent chain in the snapshot leads to the session leader, or whose POSIX session ID or process group equals the session's, repeating snapshots until one finds none. The helper keeps a run file of its live sessions (leader PID, its start time, process group, session ID) with mode0600in<home>/.config/bothy-host/run, because launchd does not kill process groups created withsetsidwhen the helper exits; at start it kills the recorded trees whose leader start time still matches. Limit, honestly, in 24 6.5 and RK-23: a process that left the session withsetsidafter its parent ended before the end of the session is not found; it runs in the person's own session user like a long-running process (6.5 "Limit", T12). - Windows: the child is created suspended with the pseudo console attribute, assigned to a new Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEand withoutJOB_OBJECT_LIMIT_BREAKAWAY_OK, then resumed; the end callsTerminateJobObjectandClosePseudoConsole; the job handle belongs to the helper, so the tree also ends when the helper dies. - Unix PTY with
github.com/creack/ptyandSetctty; ConPTY withgolang.org/x/sys/windows(CreatePseudoConsole,ResizePseudoConsole,ClosePseudoConsole,NewProcThreadAttributeList); where the pinnedx/syslacks one of them, declare it throughwindows.NewLazySystemDLL("kernel32.dll"). The release build stays without cgo (M2-38 note 4). - The helper ends every PTY with its tree when its channel to the host service breaks, then waits for the next connection (D-W-90).
- Linux: the helper unit gets
-
Scrollback and attach (24 6.2). The service user keeps per session a ring buffer of
host_service.scrollback_byteswith the newest PTY output, in memory only (no file, never logged), and keeps draining the helper's output into it while no device is attached, so the program never blocks on a full PTY. After a successful attach (steps 0 to 13,start, local confirmation) it resizes the PTY to thestartsize, sendsreplay_begin, the buffer as it was whenstartarrived indataframes,replay_end, then the live output that arrived since; when the buffer has wrapped, the replay starts after its first line feed, so no half line, half UTF-8 sequence or half escape sequence opens it. Ruling for docs/DECISIONS.md; update 24 6.2. -
Idle and detach on the machine (24 6.2, 6.3, R-TM-11). Idle is the time since the last input frame of the device (
frame.IsInput:dataandresize), also across a detach; after the idle value of Design note 2a the host service ends the session withidle_timeout. When the data connection of a session with a live PTY closes or is lost, the host service starts the detach value; an attach stops it; when it runs out, the session ends withdetach_expired. The server keeps its own checks (M2-44, M2-46). The clock is injectable for tests. -
Local confirmation (24 6.4, Ruling T3, ADR 0070). The host service cannot show anything in another user's desktop session, so the person at the machine runs a confirmation agent
bothy-host confirm-agentin their desktop session, started at their login by an autostart entry the operator creates (LaunchAgent, XDG autostart file, a value under theRunkey of that user; 16 4.4).a. Endpoint, created by the host service itself: Linux
/run/bothy-host-confirm/confirm.sock(folder fromRuntimeDirectory=bothy-host-confirmof the host service unit, mode0755, owned by the service user), macOS/Library/Application Support/bothy-host/confirm/confirm.sock(folder created byinstall, owned by the service user, mode0755), Windows the named pipe\\.\pipe\bothy-host-confirmwithPIPE_REJECT_REMOTE_CLIENTS; the socket has mode0666and the pipe a DACL for the service account and interactive users. Access is decided by the peer check, not the mode: the peer's user (LinuxSO_PEERCRED, macOSLOCAL_PEERCRED, WindowsGetNamedPipeClientProcessIdand the token user SID) must be neither the service user nor inhost_service.session_usersnor a former session user inbindings.json, and must own the active console session (Linux: the user of the active session onseat0fromloginctl; macOS: the owner of/dev/console; Windows: the peer process's session equalsWTSGetActiveConsoleSessionId); otherwise the connection is closed and logged with the codeconfirm_peer_refused. Reason: a long-running process of a session user must not approve PTYs, and the option means the person sitting at the machine (24 6.4). Ruling for docs/DECISIONS.md: the console owner instead of a configured user, because 24 6.4 names "the person logged in on the host" and a key could contradict who sits there.b. Protocol: JSON lines. Service to agent
{"op":"confirm","id":"<uuid>","kind":"open"|"attach","profile":"<name>","session_user":"<osu>","timeout_ms":<n>}and{"op":"cancel","id":"<uuid>"}; agent to service{"op":"answer","id":"<uuid>","allow":true|false}. The request goes to every accepted agent; the first answer wins, the others getcancel.c. Dialog in the agent, default "deny": macOS
/usr/bin/osascriptwithdisplay dialogandgiving up after; Linux/usr/bin/zenity --question --timeoutor else/usr/bin/kdialog --yesno; WindowsMessageBoxW(golang.org/x/sys/windows) withMB_YESNO,MB_DEFBUTTON2,MB_SYSTEMMODAL. No dialog program: the agent answersallow: falseand logsno_dialog_tool. The text names open or attach, the profile and the session user; nothing else.d. With
host_service.require_local_confirm = true, before every PTY start and before every replay of an attach (afterstartand the gate): no accepted agent, no answer withinhost_service.local_confirm_timeoutor a denial giveopen_failedwithlocal_confirm_denied; a denied attach ends the session (the person at the machine refused access to it). Ruling for docs/DECISIONS.md, stated identically in M2-44 Design note 7: 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). Ruling for docs/DECISIONS.md: the agent, the endpoint, the peer rule, and the attach, because an attach hands the scrollback and the live stream to the attaching device, exactly what the option guards against a malicious server; update 24 6.4 and 16 4.4. -
Logs and errors. Log lines of the host service are JSON with an event code, the session ID, the profile name, the session user name,
close_reason,close_detailand the exit code; never content, a token or itsjtiin clear (a SHA-256 prefix if needed), a key, an environment value, a path or a command line. Errors of these packages wrap sentinel values whose text names an operation and a code. Test failure messages print lengths and hash prefixes only. -
Tests and platforms.
- Unit and integration tests run with
go testin the current user, without administrator rights, on every platform and inmake check: the service side againstsessiontest(a fake server on127.0.0.1port 0 that extends M2-43'sinternal/control/controltestwith the data endpoint andsession.open, signs session tokens with a test Ed25519 key whose JWKS it sends inhello, and recordsreport,session.startedandsession.endedand the close codes of data connections) and the Go test device (initiator ofinternal/noise,frame); the helper as a subprocessbothy-host pty-helperof the same user over M2-42's local channel; the peer check runs unchanged, with M2-42'shostostestin the role of the service manager that reports the subprocess's PID as the helper's main PID and the current user as its session user (where the merged M2-42 tests use another seam for this, use that one; never a production flag that disables the peer check). - Platform tests (real session users, helper services,
install,session-user addandremove) extend M2-42'stest/platformsuite (build tagplatform) and run withmake -C services/host platform-teston the disposable Linux, macOS and Windows machines; each run writes M2-42's result file. Process starts are traced by M2-42's start log (canary programs append their name) and on Linux additionally bystrace -f -e trace=execve,execveatof the helper; file opens of the service user bystrace -f -e trace=open,openat,openat2on Linux. - Fake-
claude(M2-42testdata/fakeclaude) gains:auth statusprints theoauth_tokenfixture whenCLAUDE_CODE_OAUTH_TOKENis set and nofake-authfile names another; an interactive mode (any other arguments) that appends its argument list, working directory, the names of its environment variables, itsCLAUDE_CONFIG_DIRand the SHA-256 ofANTHROPIC_API_KEYto the start log, prints a line and exits onexitor end of input; aloginmode that writesfake-authwithclaude.aiinto its configuration folder and, on Linux and Windows, a.credentials.jsonthere, where the real agent keeps a subscription login (24 7.4); on macOS it writes no.credentials.json, because the real agent keeps the login in the keychain, which HD6 sees only throughclaude auth statusofhost_service.claude_bin(the keychain itself stays unused; the report names that).
- Unit and integration tests run with
-
Specification text (English, spec first; keep every [verified] and its source): 24 2.2 point 5 and 6.3 (the values from
hello, the declaration fromready); 6.2 and 13 (the host's reactions tosession.close,4100and the other close codes of the data connection, Design note 3); 5.3 (steps 8 and 12, the binding at step 11, thejtimemory only in memory); 5.4 and 10.3 (a fault of the stream after the opening isopen_failedwithhandshake_failed); 6.1 step 7 (order: message 2,start, gate, confirmation, PTY,session.started); 6.2 (replay order and cut); 6.4 (agent, endpoint, peer rule, attach, and the deadline ruling of Design note 15 d as a sentence in English, like the chapter); 6.5 (Linux delegation [verified], macOS snapshot and run file with the limit, Windows suspended start, kill without grace, exit codes); 7.4 (OQ-03 quotes, the query through the probe, the key file and handover, Windows); 11.1 (TERM, Windows shell). 16 4.4: point 2 (Delegate=yes, the confirmation folder), point 5 (claude_api_key), a new step for the autostart ofbothy-host confirm-agent. 02 6:internal/addssession,pty,profiles,confirm;cmd/bothy-hostaddsconfirm-agent; the places with the product name add the confirmation endpoints of Design note 15 and the folder.config/bothy-hostin the home of a session user. Only where Design note 0e found them missing:HostChannelHelloand thehostControldescription (Design note 2). -
Rulings for docs/DECISIONS.md, one row each with date, decision, reason and place (
D-M2-45-<n>): notes 2a, 2b (only if built here), 3 (reactions tosession.closeand the close codes of the data connection), 4, 6 (close order aftersession.ended), 5 (steps 8 and 12; binding), 7 (stream fault reason), 8, 9, 10, 11 (per-opening scope), 12 (no grace; Linux delegation; macOS run file), 13, 15 (with the deadline ruling of 15 d), and 0d (the OQ-03 result). Facts already in 24 or in the rows of M2-42 to M2-44 are not repeated there.
Tests (write them first)¶
Test names carry the specification ID where one exists, so go test -run TMT29 finds them. Every window is set through the configuration or the fake server's hello; production code never hard-codes one.
Unit (current user, every platform, make check):
internal/session:TestVerifyOrder(TM-T04 and the order of Design note 5): a valid token forshellpasses; one row per step with exactly one mutation and its expected detail: wrong signature, unknownkid,algnone,HS256andES256, acritmember, ajwkmember, three dots, anullclaim, a missingosu(token_invalid);typagent-invocation+jwt(token_invalid);audof another host (token_invalid);sidof another session (token_invalid); now equal toexpplus the leeway passes, one second latertoken_expired; a seenjti(token_replayed);cekof another key (token_invalid);attopenfor a running session andattachfor an unknown one or with anothersub,osuorprf(token_invalid); unknownprf(profile_unknown);osunot configured (session_user_unknown);osubound to anothersub(session_user_mismatch);wdimissing or out of range forclaude, and present forshell(profile_unknown);own = falseon a detected server host (server_host_owner_only). Order rows: wrongaudand expired givestoken_invalid; expired and wrongcekgivestoken_expired; replayed and unknownosugivestoken_replayed.TestVerifyRFC8037Vector(the Ed25519 JWS example of RFC 8037 A.4 verifies with the profile, withtypset by the test) andTestJTIMemory(pruned afterexpplus the leeway, safe under-race).TestBindingConcurrentFirstUse: two first opens of one session user with two people in parallel: exactly one binds, the other getssession_user_mismatch; the binding is inbindings.jsonafter a simulated crash right after step 11.TestScrollbackRing(boundhost_service.scrollback_bytes, wrap, cut after the first line feed, no line feed in the buffer, snapshot atstart),TestIdleTimeout(onlydataandresizereset the timer; detached time counts; endidle_timeout),TestDetachGrace(data connection closed with1000, attach in time stops it, elsedetach_expired).TestGateConsequences(Design note 11): each opening triggers exactly oneLightrun in the helper ofosuand one HD11 run for the locations for all beforepty.startreaches the helper (counted by the fake helper); advancing the fake clock byhost_service.credential_check_intervaltriggers oneLightrun in every helper and one HD11 run for all; every row of Design note 11, including that a passingLightrun lifts HD6 and the HD11 for all but not an entry-scan finding, and that every change sendsreport.TestNoContentInLogsAndReports: a session with the canaryCANARY-5c1etyped and printed, a token, an API key canary and an environment canary; no captured log line,report,session.ended, close reason or error text contains a canary, a token, ajti, a path or a command line.TestNoInboundNetworkListener:go/parserover the non-test files ofinternal/session,internal/pty,internal/profiles,internal/confirm,internal/helperandcmd/bothy-host: nonet.Listen,net.ListenTCP,net.ListenUDP,net.ListenPacketorhttp.ListenAndServe; onlyinternal/confirmmay listen, and only onunixor a named pipe.FuzzVerifySessionToken: arbitrary message-1 payloads give a verdict or a parse failure, never a panic, never a pass without a signature that verifies.
internal/profiles:TestOffered(each declaration and an unknown one, withhost_service.claude_binusable, empty and counting as empty, with and without working directories, and the OQ-03 branch);TestEnvShell,TestEnvCommand,TestEnvClaudeOrganization,TestEnvClaudePersonal(exact key sets per platform from a helper environment that also holdsCLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_AUTH_TOKEN,AWS_SECRET_ACCESS_KEYand an unrelated variable;TERMalwaysxterm-256color;PATHoforganizationstarts with the search list; no key withpersonal);TestCleanPath;TestPersonalStartCommand(quoting of a value with spaces and quotes forsh,zsh,fishand PowerShell; unset removes the variable);TestPersonalConfigDir(on a temporary home: outside, a link leading outside, a dangling link and a missing default folder giveshared_location; inside passes; a probe timeout ofhost_service.auth_status_timeoutgivespty_failed).internal/pty(per platform, current user):TestPTYStartEchoResizeExit(a shell prints its size,resizechanges it, the exit code arrives);TestKillTreeEndsBackground(Unix:nohup sleepand asetsidchild from the session shell; Windows: a child started withCREATE_BREAKAWAY_FROM_JOB, which fails, and a detached child; after the end no process of the tree exists);TestHelperChannelBreakEndsPTYs(D-W-90); LinuxTestSessionCgroup(one cgroup per session in a delegated test subtree, removed after the end; it fails, never skips, where the runner gives no delegated cgroup, and the report names the runner setup); macOSTestRunFileCleanup(a helper killed with SIGKILL leaves a tree that the next helper start ends); WindowsTestJobObjectKillOnClose.internal/confirm:TestConfirmOpenAndAttach(allow starts, deny and timeout givelocal_confirm_denied, no agent giveslocal_confirm_denied, a denied attach ends the session, the first of two agents wins);TestConfirmPeerRefused(the service user, a session user, a former session user and a user without the console session are refused);TestConfirmDisabled(withhost_service.require_local_confirm = falseno request is sent).
Integration (in-process service with sessiontest, helper subprocess of the same user, every platform, make check):
TestOpenShellHappyPath:session.open, data connection withidentifyandsession_id, message 1 and 2 with the payload of Design note 7,start120x40,dataecho ok\nreturns output,resize,close:session.startedonce,session.endedwithclient_closed, tree gone.TestTMT03Replay: after a successful open, a secondsession.openfor the same session and message 1 replayed on the new data connection: that data connection closes with4101andtoken_replayed, the first PTY keeps running and no second PTY exists (the helper counts one); the recorded message 1 replayed for another session ID fails the handshake (4104,handshake_failed, the prologue binds the session), and a fresh message 1 for another session ID that carries the used token gives4104withtoken_invalid(sidis checked beforejti).TestTMT04TokenFailures: for an opening, each case of the TM-T04 row in 24 14 (wrongaud,sid,cek, foreignkid,typof the invocation token, expired) closes the data connection with4104andtoken_invalid, the expired one with4101andtoken_expired; nopty.startreached the helper; nosession.endedwas sent.TestTMT05NoStart: message 1 and 2 complete, nostartuntilexpires_at: no PTY,session.endedwithopen_failedandopen_timeout; adataframe as first transport message: no PTY,session.endedwithopen_failedandhandshake_failed; in both cases the host service keeps the data connection open aftersession.endeduntil the fake server closes it.TestSessionEndedLeavesCloseToServer(Design note 6): aftersession.endedwithopen_failedandhandshake_failed, withopen_failedandlocal_confirm_denied, and withprocess_exited, the host service does not close the data connection; the fake server closes it with4100and the host service reports nothing more; when the fake server does not close it, the host service closes it with1000after the length of the opening window.TestLocalConfirmAfterOpenWindow(Design note 15 d): withhost_service.require_local_confirm = true,reportsaysrequire_local_confirmtrue;startarrives beforeHostSessionOpen.expires_atand the fake agent allows afterexpires_atbut withinhost_service.local_confirm_timeout: the PTY starts andsession.startedis sent; without an answer withinhost_service.local_confirm_timeout: no PTY,session.endedwithopen_failedandlocal_confirm_denied.TestStreamFaultEndsSession: a flipped ciphertext bit, an unknown frame type and a secondstartin a running session end it withopen_failedandhandshake_failedand kill its tree.TestAttachReplay: the server closes the data connection with1000, output continues into the buffer, attach with a new token and ephemeral key:replay_begin, the buffered bytes,replay_end, then live output, no secondsession.started; an expired attach token closes that data connection with4101and the PTY stays; an attach token with thecekof another key closes it with4104andtoken_invalidand ends the PTY with its tree.TestSessionCloseAndDataClose(Design note 3): a runningshellsession with a background process started withsetsid(Windows: a detached child) getssession.closewithaccess_revoked, and in a second run its data connection is closed by the fake server with4100: PTY and background process are gone, nosession.endedis sent, and the nextidentifydoes not list the session; closes with1000,4013and4014and a dropped TCP connection keep the PTY detached until the nextsession.open; asession.closefor an unknown session changes nothing.TestDeclarationAndValues:hellovalues set the leeway and the timers; areadywithorganizationand then a repeatedreadywithnone: the nextreporthas noclaudeprofile, HD6 ran again, a runningclaudesession continues.TestHelperUntrustedEndsSessions: the local channel reportspeer_mismatchin operation: running sessions end withhost_policy_violationandhelper_untrusted, an opening fails.
Platform (make -C services/host platform-test on Linux with systemd, macOS, Windows 10 and Windows 11; world: session users of Alex, Sam and Robin, Robin is the owner, the fake server plays the server and signs tokens):
TestPlatform_PTYRunsAsSessionUser: each opened PTY's process runs as the user ofosu, never as the service user; a token for Sam's session user opens in Sam's helper only; the PTY start, resize, exit code and tree kill of the unit tests hold under the real services.TestPlatform_TMT28c:osuoutsidehost_service.session_users: no PTY,4104withsession_user_unknown;osualready bound to Alex inbindings.jsonand a token for Sam with thatosu(assignment changed in the database bypassing the server): no PTY,4104withsession_user_mismatch.TestPlatform_TMT28d(macOS run):organization,host_service.claude_binempty, Robin and Alex with own session users; Robin starts a Fake-claudefrom herPATHin her shell and logs in with itsloginmode (on macOS it leaves no file HD6 checks, like a login in the keychain); then Alex opens a shell and runsclaude auth statusthere:reportwarnsauth_status_uncheckedandclaude_profile_unavailableand lists noclaudeprofile; Alex's session runs as Alex's session user;auth statusin Alex's session reportsnone; Alex's shell cannot read Robin's home.TestPlatform_TMT25:personalwith holder Robin, a canary token in a file of Robin's session user set asCLAUDE_CODE_OAUTH_TOKENby her login shell; open and end theclaudeprofile:auth statusin the session reportsoauth_token; the canary is nowhere in the state directory, in a captured local channel stream or in anything sent to the fake server; on Linux thestraceof the service user shows no open of a file under Robin's home (macOS and Windows: the canary parts without the trace, named in the report).TestPlatform_TMT29a_Session: a working directory of Sam that Alex can write, with.claude/settings.jsonholding a hook and anapiKeyHelperthat are canary programs; Sam opensclaudethere: HD11 fails with Sam's name inreport,session.endedwithopen_failedandshared_location, a running session of Sam ends withhost_policy_violationandshared_location, the start log shows neither program, Alex still opens.TestPlatform_TMT29c_Open: one directory listed for Alex and Sam inhost_service.claude_workdirs, before it in Sam's list, and a further directory of Sam: both reported lists lack it, and Sam's reported index of the further directory startsclaudein exactly that directory (the Fake-claudelogs its working directory).TestPlatform_TMT29f_Operation: Alex and Sam have sessions; the program of acommandprofile becomes writable for a session user: the next periodic run refuses new sessions, both sessions end withhost_policy_violationandshared_location,reportnames no session user; after the fix both open again after the next periodic run.TestPlatform_TMT29g_Token: a token withwdioutside the list ofosu(the fake server bypasses the server's check): no PTY,4104withprofile_unknown.TestPlatform_TMT29k:personal, Robin's login shell setsCLAUDE_CONFIG_DIRto a folder outside her home, to a link in the home leading outside, to a dangling link, to a folder in the home: the first three givesession.endedwithopen_failedandshared_locationandshellstill opens; in the fourth the Fake-claudelogs exactly that folder asCLAUDE_CONFIG_DIR.TestPlatform_TMT29l:organization, anotherclaudecanary in thePATHof the session user and of the service; Sam opensclaude: exactly the resolvedhost_service.claude_binstarts, the other never; thenhost_service.claude_binis a script with#!/usr/bin/env node, nonodein the search list and anodecanary only in the rest of the profilePATH, andbothy-host restart:reportlists noclaudeprofile withclaude_profile_unavailable,auth_status_uncheckedis reported, a token forclaudegives4104withprofile_unknown(the server's403 terminal_deniedwithreason: profile_unknownis M2-44's), and thenodecanary never runs.TestPlatform_TMT21_Sessions(the session part of all seven steps of the TM-T21 row in 24 14): (a) withorganizationandhost_service.claude_binset to the Fake-claude, Alex logs in by subscription in her shell session (Fake-claudelogin); the next periodic run ends all sessions withhost_policy_violationandsubscription_login_detected, Sam's opening fails withsubscription_login_detected,reporthas an HD6 entry with Alex's session user; (b) Sam logs in too: two HD6 entries; (c) the login of Sam removed and Alex's session user locked on the machine only (the owner's end of her assignment is server side): still blocked,reportstill names Alex's; (d)bothy-host session-user removeof Alex's session user, whose home is readable for all: after the restart HD9 and HD6 pass, the name is no longer reported, Sam opens again; (e)session-user addwith the removed name is refused; (f)session-user addwith a new name while Sam has a session: Sam's helper ends the PTY with its tree when the channel breaks, the nextidentifylists no session, and the nextreportlists the new name; (g) with the host service stopped, a subscription login file in Sam's account and a start of the service: onereportwith Sam's HD6 entry, nosession.openaccepted, the service stays stopped,bothy-host doctornames Sam's session user and exits non-zero; after removing the file andbothy-host restart, Sam opens again. M2-42'sTestPlatform_TMT21_SessionUserAddRemovestays green; the server-side expectations of TM-T21 (audit,host.updated,403 terminal_deniedwithreason: no_session_user, the console) are proven by M2-40, M2-43, M2-44 and M2-48.TestPlatform_HelperChannelBreak:bothy-host restartduring a session with a background process: PTY and background process are gone (D-W-90).TestPlatform_LocalConfirm(Linux and macOS with a desktop session, Windows): withhost_service.require_local_confirm = trueand the agent of the console user, an allowed and a denied opening; an agent run as a session user is refused.
Server (the first two only for the parts built per Design note 2; PostgreSQL 18 via pgtest, real modules through internal/wire, the Fake-Host of M2-44):
TestHostHelloCarriesValues:helloon the control and the data connection validates againstHostChannelHellowithclock_leeway_ms,idle_timeout_msanddetach_grace_msequal to the configuration.TestHostReadyAfterDeclarationChange:readycarriesllm_credentials;setHostLlmCredentialson a connected host sends a freshreadyafter the commit (through the log tail observer), a rolled back change sends none, and ahost.updatedthat leavesllm_credentialsunchanged (a new name of the host) sends none.TestRelay_HostEndCloseOrder(always; Design note 6): the Fake-Host in the close order of this ticket (session.endedwithopen_failedandhandshake_failed, the data connection kept open): the device gets4104withhandshake_failedand the host data connection4100; withlocal_confirm_deniedthe device gets4100; with a running session andprocess_exitedthe device gets4100withprocess_exited.
Repository tools: tools/ci/tests/test_host_cross_platform_vet.py: GOOS=darwin GOARCH=arm64 and GOOS=windows GOARCH=amd64 with CGO_ENABLED=0 GOTOOLCHAIN=local go vet ./... and the same with -tags platform in services/host pass, so the platform files and their tests compile on the Linux runner; tools/ci/tests/test_host_release_build.py of M2-38 stays green.
Red team focus of the milestone (PLAN 8 "Red team", terminal): replay of the first message and token swap at the relay, profile or command line from the client, a foreign session user, a foreign working directory, a readable CLAUDE_CONFIG_DIR, a program of another account, replacing the helper, processes after the end. Name TestVerifyOrder, TestTMT03Replay, TestPlatform_TMT28c, TestPlatform_TMT29a_Session, TestPlatform_TMT29g_Token, TestPlatform_TMT29k, TestPlatform_TMT29l, TestPlatform_TMT25, TestKillTreeEndsBackground and TestHelperUntrustedEndsSessions in the red team request.
Run FuzzVerifySessionToken once locally with go test -run=^$ -fuzz=FuzzVerifySessionToken -fuzztime=<duration of your choice> ./internal/session and report duration and result; commit any crashing input as a seed after the fix.
Platform runs: on each platform machine run go test -race -count=1 ./... in services/host and make -C services/host platform-test; the report lists each result file.
Commands that must pass, from the repository root:
cd services/host && GOTOOLCHAIN=local go vet ./... && GOTOOLCHAIN=local go test -race -count=1 ./...cd tools && uv run --locked --quiet pytest ci/tests/test_host_cross_platform_vet.py ci/tests/test_host_release_build.pymake -C services/host release-build VERSION=$(cat services/host/VERSION) OUT=<temporary directory>make -C services/host platform-teston the Linux, macOS and Windows machinesmake arch,make fmt-check,make lint,make typecheck,make coverage,make licenses,make osv- if the server changed:
cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/wire/..., thenmake codegen-check,make api-lint,make api-checks,make api-tables make docs,make linkcheckmake check
Acceptance criteria¶
- TM-T03: a replayed message 1 is refused with
token_replayed(4101), and no second PTY starts. - TM-T04: a token with a wrong
aud,sidorcek, a foreignkidor thetypof an invocation token starts no PTY and is refused withtoken_invalid(4104); an expired token withtoken_expired(4101); the checks run in the order of Design note 5. - TM-T05: message 1 and 2 without
startstart no PTY; the close codes4101and4104follow 24 "Terminal session token". - TM-T21: the session part of steps (a) to (g) holds on Linux, macOS and Windows: HD6 refusals and ends with one entry per affected session user, the standstill ends only through the operator steps, a refused start reports once and stays stopped.
- TM-T25: with
personalthe service user opens no login file (Linux trace), and no subscription login lies in the state directory, in the local channel or in a report;auth statusin the session reportsoauth_token. - TM-T28 (c) and (d): an
osuoutsidehost_service.session_usersand anosubound to another person start no PTY (4104withsession_user_unknownandsession_user_mismatch); on macOS with an emptyhost_service.claude_binthe host warnsauth_status_unchecked, offers noclaudeprofile, and another person's session sees no foreign login. - TM-T29 (a) and (g) with the token, (c) with the opening through the reported index, (k), (l) and (f) in operation hold as the platform tests state.
- The
claudeprofile starts withorganizationexactly the resolvedhost_service.claude_binand is not offered without it or without the script's interpreter in the search list; withpersonalonly with aCLAUDE_CONFIG_DIRthat resolves inside the home; withnoneit does not exist; the environment of every profile is an allow list plusTERM, tested per declaration without a model call. - Every PTY starts only in the helper of
osu,claudeonly in the working directorywdiof the reported list ofosu, and the bindingosutosubis stored in the state directory. - HD6 and HD11 run before every PTY and every
host_service.credential_check_interval; a violation of HD11 at a location for all ends all sessions withshared_location. - Every session runs in its own process group, cgroup or Job Object, and its end leaves no process of its tree; every helper ends its PTYs when its channel breaks.
-
session.closeand a close of the data connection with4100end the PTY with its tree withoutsession.ended; every other close keeps the PTY detached until the nextsession.openorterminal.detach_grace. - An attach replays the scrollback between
replay_beginandreplay_end; the buffer exists only in memory. - With
host_service.require_local_confirmno PTY starts and no attach replays without consent withinhost_service.local_confirm_timeout; a consent afterHostSessionOpen.expires_atwithin that window still starts the PTY (the pairing meets the server deadline, Design note 15 d). - After
session.endedthe host service leaves the close of the data connection to the server, so the device gets the code of the end (4104forhandshake_failed), not4102. - OQ-03 is checked against the current documentation, the result stands in 24 7.4, OQ-03 and RK-30, and the
personalbranch follows Design note 0d. - PTY tests ran on macOS, Linux and Windows; the report lists each platform run.
- No content, token, key, environment value, path or command line in logs, reports or errors; no inbound network listener.
- 24, 16 and 02 describe the built behaviour, and the rows
D-M2-45-<n>are indocs/DECISIONS.md. -
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/host/README.md(English) updated: packages, the process split, the session ops of the local channel, the profiles and their environments, the confirmation agent, the platform specifics and limits; the README ofservices/server/internal/terminalif the server changed.- Rulings of Design note 19 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 24, 16, 02, OQ-03, RK-23, RK-30, ADR 0068 and, if built,openapi.yamlandasyncapi.yamlmade in the same change;make adr-indexrun. services/host/CHANGELOG.md(andservices/server/CHANGELOG.mdif the server changed) 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - 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 parts of TM-T25 without a system call trace on macOS and Windows; the macOS limit of Design note 12; the keychain path of TM-T28 (d); real
claudelogins (never run). - The OQ-03 finding with quotes, URLs and date; the platform runs (system, version, Go version, commands, counts, result files); the fuzz run with duration and result; which parts of Design note 2 were found merged and which were built here.
- Follow-ups that could not be done in this scope, each with its reason, every difference to the M1 and M2 names of this brief, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.