Skip to content

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 only 127.0.0.1 and 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-claude of 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 of host_service.profiles and host_service.claude_workdirs), internal/state (bindings.json, declaration.json, the reserved key file claude_api_key), internal/hostos (users, file security, path resolution and identity, command runner), internal/doctor (HD1 to HD11 in the modes Full and Light, the resolved host_service.claude_bin and the search list, the login-shell probe of HD6), internal/localchan (codec, kinds 0x00 and 0x01, peer check), internal/helper (the pty-helper process, "M2-45 adds the PTY to it"), internal/startup (Control, Readiness, the declaration), internal/install (service definitions), internal/buildinfo, testdata/fakeclaude, test/platform and make -C services/host platform-test.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host service bothy-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, start as first transport message); 5.3 "Terminal session token" (claims, the fixed check order, the table of close_detail and 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 codes 4100 to 4104); 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.md 4.4 "Terminal: forwarding and host service" (installation points 1, 2, 5 and 6). docs/spec/09-agents.md 18.2 "Process contract" (row "Environment": the allow list) and 18.3 "Reported mode per run" (environment of organization and personal, the variables never set). docs/spec/07-permissions.md 2.12 "Hosts and the terminal capability", SR10, SR11, SR12 and AZ-M54 to AZ-M61; docs/spec/08-identity.md 6.3 "Fresh step-up for the terminal" and ID-T28 (for the terminal security group only). docs/spec/19-security.md 11a "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.md 6 "Code layout" (block services/host/, the places with the product name). docs/spec/23-quality.md 3 "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_reason CHECK, close_detail), server_signing_keys (purpose terminal, alg only EdDSA).
  • docs/api/asyncapi.yaml: channels hostControl and hostData (descriptions, x-close-codes), terminalStream (4100, 4101, 4104); messages host.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: schemas HostChannelHello (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 rows D-M2-42-<n>, D-M2-43-<n> and D-M2-44-<n> of the merged tickets; section 10 for the format of new rows.
  • docs/security/threat-model.md 5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14). docs/RISKS.md: RK-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: the host_service.* keys (with host_service.auth_status_timeout of M2-42 and host_service.reconnect_backoff_base and host_service.reconnect_backoff_cap of 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.md 4.3a: every terminal metric has the source api (the server); M2-44 records bothy.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 in docs/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 store keys.go, ReportSource), and M2-44 Design notes 6 to 9 (one data connection per grant, the refusal by close code, att of an attach to an opening session, the deadline on a host that reports require_local_confirm) and 11 (the end path that closes both sides after session.ended); of the M1 briefs M1-13-unit-of-work.md (work after the commit runs once Do has returned), M1-65-gateway-delivery.md (the log tail goroutine Tail -> Observe -> Dispatch in internal/wire, which M2-44 extends with the relay's observer), M1-17-api-server-stubs.md (code generation from openapi.yaml), M1-32-signing-keys.md (purpose terminal, 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/wsrelay of M2-43 and M2-44, the hello builder, the Fake-Host in services/server/test/terminal/fakehost), tools/ci/tests/test_host_release_build.py. Read the merged state with git log before you start.
  • External, read only: the Claude Code documentation pages https://code.claude.com/docs/en/authentication, https://code.claude.com/docs/en/cli-reference and https://code.claude.com/docs/en/legal-and-compliance (Design note 0, OQ-03); the documentation of github.com/creack/pty, of golang.org/x/sys/windows (CreatePseudoConsole, Job Objects) and of systemd Delegate=.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

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

Rules for every brief:

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

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:

  1. 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_detail from 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 is handshake_failed (24 5.3, R-TM-16, SEC-TERM-2).
  2. A token is valid once: every jti is remembered until its exp plus auth.clock_leeway; a replayed message 1 gives token_replayed and never a second PTY (TM-T03, Ruling T9, T-TM-02).
  3. The PTY starts only after message 2 and the first transport message start; message 1 and 2 without start start nothing (TM-T05, Ruling T9, 24 5.2).
  4. A refused handshake reaches the device with exactly one close code: 4101 for token_expired and token_replayed, 4104 for 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 the close_detail as close reason (M2-44 Design note 9).
  5. The PTY starts only in the helper of the session user named by osu, only when osu is in host_service.session_users, and only for the person (sub) the host service first gave that session user; the binding osu to sub lives in the state directory (bindings.json; 24 2.2 point 3, Ruling T12, TM-T28 (c)).
  6. The claude profile starts only in a working directory from the list of osu after HD7 that the host service reported, chosen by wdi; an index outside it gives profile_unknown (24 5.3 and 11.1, Ruling T13, D-W-69, TM-T29 (c) and (g)).
  7. 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 plus TERM (24 11.1, R-TM-08, SEC-TERM-9).
  8. claude with organization: exactly the resolved path of host_service.claude_bin that HD10 checked, the fixed search list of HD6 first in PATH, ANTHROPIC_API_KEY from the state directory, CLAUDE_CONFIG_DIR set to .config/bothy-host/claude in the home of the session user, none of the OAuth or provider variables of 09 18.3; with an empty host_service.claude_bin, or one that counts as empty (not resolvable, interpreter not in the search list), the profile is not offered (HD7 warning claude_profile_unavailable) (24 7.4 and 11.1, D-W-81, D-W-85, TM-T29 (l)).
  9. claude with personal: the holder's own claude through the login shell of the session user, with the CLAUDE_CONFIG_DIR that 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_failed with shared_location) and shell stays (24 7.4, D-W-82, D-W-84, TM-T29 (k)). The service user never opens, reads, copies or passes a login of personal (TM-T25, D-W-17, R-TM-19, I5). With none there is no claude profile.
  10. 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 under none or organization refuses new sessions with subscription_login_detected and ends all running sessions with host_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 with host_policy_violation and shared_location and 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.
  11. 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 with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; every end, for whatever reason, ends the whole tree (24 6.5, R-TM-15, SEC-TERM-11).
  12. 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).
  13. The scrollback buffer of host_service.scrollback_bytes per session lives only in the memory of the host service, never on disk or in the server; an attach replays it between replay_begin and replay_end (24 6.2).
  14. With host_service.require_local_confirm = true no PTY starts and no attach is replayed without the consent of the person at the machine within host_service.local_confirm_timeout; otherwise local_confirm_denied (24 6.4, Ruling T3).
  15. 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).
  16. PTY tests run on macOS, Linux and Windows (PLAN 8 exit criterion 8, RK-23).

Terminal security requirements (D-W-101):

  1. Rights per machine: the additional right terminal on the target host, SR10, SR11 with I6 and SR12 (07 "Hosts and the terminal capability"). This ticket adds no right, action or decision of authorize: M2-39 builds the target host and host.read (AZ-M60), M2-40 builds terminal.*, 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 with own, osu and wdi. What this ticket enforces on the machine, as the second line behind the server: the server-host part of SR10 by the claim own (TestVerifyOrder row server_host_owner_only); SR12 and T12 by osu in host_service.session_users, the binding to the first sub and the PTY only in the helper of osu (TestVerifyOrder rows session_user_unknown and session_user_mismatch, TestBindingConcurrentFirstUse, TestPlatform_TMT28c, TestPlatform_PTYRunsAsSessionUser); I6 on the machine through the own session user of the holder (TestPlatform_TMT28d, TestPlatform_TMT25).
  2. 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 fresh and 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 the jti is remembered and the token is bound to the device's ephemeral key by cek (TestTMT03Replay, TestVerifyOrder rows token_replayed and cek).
  3. Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile tailscale reaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03, docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). 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.
  4. 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).
  5. Idle timeout and maximum duration (24 "Limits"): this ticket measures idle on the machine from the device's input frames (data and resize, frame.IsInput) with terminal.idle_timeout and ends a session terminal.detach_grace after its data connection was lost without an attach (TestIdleTimeout, TestDetachGrace). M2-46 adds the server side: terminal.idle_timeout plus terminal.idle_grace in the relay, terminal.max_session_duration, the job terminal.sweep and TM-T11 with a manipulated host service.
  6. Service user and session users without administrator rights (T12, HD rules): M2-42 builds install, doctor with HD1 to HD11 and the key storage (TM-T13, TM-T22, TM-T24, 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).
  7. 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 after start: TestTMT03Replay, TestTMT04TokenFailures, TestTMT05NoStart, FuzzVerifySessionToken; T-TM-08 (a foreign LLM login through the terminal) by the own session user, the claude profile 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 of osu and the consequence of a failed peer check in operation: TestPlatform_PTYRunsAsSessionUser, TestHelperUntrustedEndsSessions; T-TM-13 (shared location) by wdi only in the reported list of osu and 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's control.Sessions: Live, the handling of session.open and session.close; uses control.Notifier for session.started and session.ended; ActiveCount for heartbeat); dataconn.go (the outbound data connection: dial with the TLS settings of M2-43's client, hello, identify with session_id and the proof of M2-43's login.go, then binary messages only); handshake.go (responder of M2-38, token check, message 2, wait for start); token.go (compact JWS parse and EdDSA verification, claims, Verdict); jti.go; binding.go (osu to sub through internal/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 target FuzzVerifySessionToken; sessiontest/ (Design note 17).
  • internal/pty/: doc.go; pty.go (types StartSpec, Process, Tree); pty_unix.go (Unix PTY with github.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 (organization and personal); loginshell_unix.go, loginshell_windows.go (start of a login shell, the start command of personal); 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 subcommand confirm-agent; run binds the session manager instead of RefuseSessions (M2-43) and starts the gate and the confirmation endpoint; pty-helper serves the session ops.
  • test/platform/ (M2-42's suite, build tag platform): 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/sys raised to its newest release. No other dependency.
  • .go-arch-lint.yml (hand-maintained since M2-38): components session, pty, profiles, confirm, sessiontest, their deps and vendors (github.com/creack/pty only for pty, golang.org/x/sys for pty, profiles and confirm, github.com/coder/websocket only for the data connection code); make arch must pass.
  • internal/helper (M2-42): the session ops of Design note 10, running PTYs through internal/pty and internal/profiles, ending all PTYs when its channel breaks.
  • internal/localchan (M2-42): the kind 0x01 body of Design note 10 (reserved there for this ticket) and the session ops of kind 0x00.
  • internal/control (M2-43): pass a repeated ready on the open control connection to internal/startup (Design note 2), take live_sessions and active_sessions from the session manager, send pending session.ended after ready; build the profile list of report from profiles.Offered.
  • internal/startup and internal/doctor (M2-42): expose what the gate needs without a second implementation (Readiness, the Light run per session user and for the locations for all, the resolved host_service.claude_bin with its search list and usable state, the HD10 peer event of internal/localchan); add the OQ-03 case to claude_profile_unavailable only if Design note 0d drops personal.
  • internal/install (M2-42): the systemd unit of each PTY helper gets Delegate=yes (Design note 12); the host service gets the confirmation folder (Design note 15); golden tests updated.
  • testdata/fakeclaude (M2-42): the interactive and login modes of Design note 17.
  • Server (services/server) and contract, only where the merged code lacks it (Design note 2): the hello builder fills the new HostChannelHello members; the control hub fills HostReady.llm_credentials and sends ready again after a committed change of the declaration (its log tail observer in internal/wire, Design note 2b); docs/api/openapi.yaml (HostChannelHello) and docs/api/asyncapi.yaml (hostControl description); M2-43's recordings services/host/testdata/control/*.json regenerated; then make api-lint, make api-checks, make api-tables and the server code generation; services/server/CHANGELOG.md and the README of terminal.
  • services/server/test/integration/terminalsessions (M2-44): the test TestRelay_HostEndCloseOrder (Design note 6), with the Fake-Host's End script keeping the data connection open if it does not already.
  • docs/api/asyncapi.yaml, channel hostData, x-close-codes: the client action of 4013 on the data connection becomes "keep the PTY detached until terminal.detach_grace; wait for the next session.open" (Design note 3), unless the merged M2-44 already changed it; then make api-lint and make api-checks.
  • docs/spec/24-terminal.md, docs/spec/16-operations.md 4.4, docs/spec/02-architecture.md 6 (English, Design note 18).
  • docs/OPEN-QUESTIONS.md OQ-03, docs/RISKS.md RK-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; then make adr-index.
  • services/host/README.md (English), services/host/CHANGELOG.md; docs/DECISIONS.md section 10 rows D-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.DeviceRevoked and identity.SessionEnded, the server-side idle check with terminal.idle_grace, terminal.max_session_duration, the job terminal.sweep, terminal.max_sessions_per_person, terminal.max_sessions_per_host, rl.terminal, rl.host, audit of terminal.terminate, the metrics bothy.terminal.sessions.active, bothy.terminal.sessions.closed and bothy.terminal.hosts.connected (bothy.terminal.relay.unpaired is M2-44's, D-W-105), TM-T06, TM-T10, TM-T11 (M2-46). This ticket ends trees on session.close, which M2-46 sends.
  • Sessions, tokens and relay in the server, openTerminalSession, attachTerminalSession, closeTerminalSession, the mapping of the host's refusal codes and of session.ended to the device stream, TM-T07, TM-T12, the relay closes 4014, 4103, 4002 and open_failed after terminal.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 in make check, TM-T16 (M2-48).
  • install, pair, doctor and the checks HD1 to HD11 themselves, session-user add and remove, 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, ReportOnce and the close codes 4110 to 4114 (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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules); they also name the disposable Linux, macOS and Windows machines with administrator rights for the platform runs.
  • Tests of make check run without administrator rights, bind only 127.0.0.1 and port 0 (P-10), create no operating system user, service or ACL outside t.TempDir(); PostgreSQL runs only as a throwaway container on a random port through pgtest (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 (Go t.TempDir()), removed at the end.
  • Never read or print secrets. Never start a claude installed on any machine, never read a real Claude Code configuration or login, and never make a model call: every test uses the Fake-claude of M2-42 through host_service.claude_bin or a test PATH.
  • 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 check do 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

  1. Start checks, in this order, before any code:

    a. make check on the fresh worktree; red gives Verdict: 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/terminal with sessions and relay); otherwise Verdict: 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-token in CLAUDE_CODE_OAUTH_TOKEN for the subscriber's own use, with no restriction in the authentication page or the legal and compliance page to non-interactive use; path B, /login with 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, build personal as 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, the claude profile is not offered with personal: profiles.Offered drops it, HD7 reports claude_profile_unavailable for personal too, 24 7.2, 7.4, 11.1 and 14 (TM-T25 and TM-T29 (k) then run with the profile shell and expect no claude profile) change in the same change, and the report names it as an owner decision with the recommendation to keep organization for 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_credentials to the contract, stores the last value in declaration.json and treats it as unknown before the first ready (declaration_unknown); check that the server fills it and sends ready again after a change. No message carries auth.clock_leeway, terminal.idle_timeout or terminal.detach_grace today (HostChannelHello has heartbeat_interval_ms and, from M2-43, heartbeat_miss_limit). Build the missing parts of Design note 2.

  2. Process split. The service user process (bothy-host run) holds the keys, the control and data connections, Noise, the token check, the jti memory, 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).

  3. Declaration and server values on the host (spec first, only where Design note 0e found them missing).

    a. HostChannelHello gets three required members clock_leeway_ms, idle_timeout_ms and detach_grace_ms (integers, format: int64, like heartbeat_interval_ms), filled by the server's hello builder from auth.clock_leeway, terminal.idle_timeout and terminal.detach_grace. The host service uses the values of the hello of 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); hello already 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_credentials from hosts.llm_credentials and sends ready again 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 event host.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 in adapters/wsrelay becomes a further observer of M1-65's tail goroutine beside M2-44's Relay.ObserveLogEvent (same signature, wired in internal/wire, non-blocking); for a host.updated of a host with a live control link on this node it reads hosts.llm_credentials through the module's store and sends ready again 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 the ready without the cross-node path that M2-47 builds only for session.open and session.close. M2-43's host client hands every further ready to internal/startup, which stores it in declaration.json, runs HD6 at once with the new declaration and sends report when 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 HostChannelHello and of the channel hostControl.

  4. Data connection per grant (M2-44 Design note 7). The host service opens exactly one data connection for each session.open and never one on its own; each carries exactly one Noise handshake. A session.open for a session ID without a live PTY is an opening (also a repeated opening of an opening session, whose token says att = open); one for a session ID with a live PTY is an attach (att = attach). A newer session.open for the same session replaces any older data connection of it, which the host service closes with 1000 if the server has not already. session.close {session_id, close_reason} and a close of the data connection by the server with 4100 (hostData x-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 from live_sessions and sends no session.ended, because the server closed the session already (M2-43 Design note 10 changes nothing for a closed session); a session.close for 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 next session.open and 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 with 4100 and sends session.close for every end, and one data connection per grant leaves no other reconnect. 24 6.2 changes as M2-44 states.

  5. 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, so internal/session/token.go implements the needed subset with the standard library: compact JWS only, exactly two dots, base64url without padding; header with alg exactly EdDSA, a kid, typ; any crit, jku, x5u, x5c, zip or jwk member rejected; the key is the Ed25519 key (kty OKP, crv Ed25519) with that kid in the jwks of the hello of this data connection; crypto/ed25519 verifies. Claims: iss, aud, sub, sid, jti, iat, exp, dev, prf, cek, own, osu, att are required with their JSON types (strings, integer NumericDates, boolean own), wdi an integer of at least 0 when present, att open or attach; a JSON null anywhere, a missing or mistyped claim or trailing data is a parse failure; unknown claims are ignored. iss and iat are 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".

  6. 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_only
    

    Steps 8 and 12 add what 24 5.3 leaves open: which session an attach may name, and when wdi may be present (M2-44 issues wdi exactly so). Ruling for docs/DECISIONS.md (an att mismatch can only come from a server fault or manipulation, R-TM-16); update 24 5.3. An attach for a session whose end the host service already reported is no refusal: it closes that data connection with 1000 and reports nothing new (the server closed the session). Step 6 records the jti before later steps run, so a replay of a token that failed later is still token_replayed. The jti memory 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 sends session.open for 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's PeerIsLocal (the is_server_host of report). Step 11 writes bindings.json through internal/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.

  7. 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) or 4104 (every other detail) and the close_detail as close reason; the host service sends no session.ended for it, and the server ends an opening with open_failed and that detail, leaves a started session detached on 4101 and ends it on 4104; on a 4104 refusal 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 following session.close finds 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 send session.ended with close_reason open_failed and the close_detail and keep the data connection open: the server ends the session and closes both sides through M2-44's end path (host side 4100, device StreamCodeForEnd, M2-44 Design note 11), so the device gets 4104 for handshake_failed and 4100 for open_timeout, subscription_login_detected, shared_location, helper_untrusted, local_confirm_denied and pty_failed. A close by the host service right after the report would reach the relay before the end and give the device 4102 (M2-44 Design note 7, row "host side closes otherwise"). This holds for every session.ended the 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_at minus the arrival of that session.open) after the report does the host service close it with 1000. Ruling for docs/DECISIONS.md: the close order after session.ended. M2-43 accepts from a host exactly client_closed, process_exited, idle_timeout, detach_expired, open_failed and host_policy_violation (M2-43 Design note 10); the host service sends no other reason.

  8. Message 2, start and 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 until HostSessionOpen.expires_at; expires_at bounds only this wait, the gate and the local confirmation after start are bounded by their own values (Design note 15 d). It must be start; anything else, a failed decryption, an unknown, malformed or wrong-direction frame (M2-38 frame.Decode errors), a second start or any such fault later in the session ends the session: PTY and tree end, session.ended with open_failed and handshake_failed, and the device gets 4104 (24 5.4) through the server's close (Design note 6). Ruling for docs/DECISIONS.md: 03 terminal_sessions.close_reason has 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") with handshake_failed keeps the manipulation signal of 4104 without a core change (spec gap in the report). Without start until expires_at: no PTY, session.ended with open_failed and open_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). start sets the size of a new PTY or resizes the PTY of an attach; data goes to the PTY; resize resizes; close ends the session with client_closed. PTY output goes out in data frames within the body limit of M2-38. When the process exits, the host service sends exit with the exit code (Unix: the status, or 128 plus the signal number after a signal; Windows: the process exit code), ends the tree and sends session.ended with process_exited and exit_code. After a PTY started the host service sends session.started once; an attach sends none (the server sets the state when the relay pairs, M2-44).

  9. Profiles (24 11.1, 7.4). host_service.profiles in the form of M2-42 (shell, claude or {name, display_name, kind: command, program, args}). profiles.Offered(declaration, claudeBin, workdirs, oq03) returns the offered list: shell and every command that HD7 kept; claude only with organization and a usable host_service.claude_bin (the resolved path and search list from M2-42's HD6 and HD10 result, never recomputed), or with personal (unless Design note 0d dropped it), and in both cases only when at least one session user has a working directory after HD7 (an empty host_service.claude_workdirs means no claude profile, 24 17); never with none or an unknown declaration. Start specifications, all built from scratch, never by removing from an inherited environment:

    • TERM is xterm-256color for every profile (the renderer is xterm, 24 12). Unix allow list: HOME, USER, LOGNAME, SHELL from hostos.Users, PATH cleaned from the helper's own environment (absolute, existing directories, not writable by group or others, no duplicates, no empty entry or .), LANG, LC_* and TMPDIR from the helper's environment when present. Windows: the environment block of the helper's token from CreateEnvironmentBlock without inheritance (the user and system variables, the equivalent of a login) plus TERM.
    • shell: the login shell of the session user (Design note 9), working directory the home.
    • command: the resolved path of program (the path HD11 checked) with args, working directory the home, the allow list.
    • claude with organization: program the resolved host_service.claude_bin; working directory the working directory of wdi (Design note 10); PATH the search list of HD6 followed by the cleaned PATH; ANTHROPIC_API_KEY from claude_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 mode 0700 (Windows: an ACL only for the session user), refused with shared_location when it does not resolve to exactly that folder inside the home; never ANTHROPIC_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.
    • claude with personal: the login shell started with a command that sets CLAUDE_CONFIG_DIR to the checked value (or removes it when it was unset) and then runs claude from the shell's own PATH (Unix: export CLAUDE_CONFIG_DIR=<quoted value>; exec claude or unset CLAUDE_CONFIG_DIR; exec claude, quoted for the shell kind; Windows: powershell.exe -NoLogo -Command with $env:CLAUDE_CONFIG_DIR = '<value>' or Remove-Item Env:\CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue, then & claude); working directory the working directory of wdi; no API key.

    Ruling for docs/DECISIONS.md: TERM, the Windows environment block, the resolved program of command; update 24 11.1 and 7.4.

  10. 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 from hostos.Users (M2-42, which reads the user database without cgo). Unix: a login shell starts with argv[0] = - plus the base name of the shell (login(1) convention, valid for every POSIX shell, fish and tcsh); a shell that is not an absolute existing path listed in /etc/shells gives pty_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 of claude with personal, the helper runs M2-42's login-shell probe of HD6 (it prints only whether CLAUDE_CODE_OAUTH_TOKEN is set and the value of CLAUDE_CONFIG_DIR, never a token; on Windows from the user's registry hive), bounded by host_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's Resolver and must be the home or below it; a value outside, a link leading outside, a dangling link or a missing folder give shared_location (TM-T29 (k)); a timeout or a failed shell gives pty_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.

  11. Session ops on the local channel (24 2.2; M2-42 codec). Kind 0x00 JSON ops {"op","id","d"}: from the service pty.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 helper pty.started {session_id}, pty.exited {session_id, exit_code}, pty.failed {session_id, close_detail}. Kind 0x01 body: the 16-byte session ID (M2-38 ParseID order) followed by the bytes of one M2-38 data frame, 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 file claude_api_key in host_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 that pty.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 maps workdir_index to 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), else shared_location. With personal no key, token or login ever crosses the channel (TM-T25). Ruling for docs/DECISIONS.md: the op set, the kind 0x01 body and the per-start handover; update 24 7.4 and 16 4.4 point 5 with the file name.

  12. Gate: HD6, HD10 and HD11 in operation (24 2.2 HD10, HD11, 7.4, D-W-89). gate.go keeps the latest result per check and session user, builds on M2-42's Readiness and decides admission:

    • Before every PTY start (after start and before the local confirmation): the Light run of the helper of osu (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 to host_service.credential_check_interval.
    • Every host_service.credential_check_interval: the Light run in every helper and HD11 for the locations for all.
    • HD6 not passed in any session user while the declaration is none or organization: no new session (open_failed with subscription_login_detected), every running session ends with host_policy_violation and subscription_login_detected, report with 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_failed with shared_location), X's running sessions end with host_policy_violation and shared_location, the others continue; a finding of the entry scan is lifted only by a Full run (start or bothy-host doctor), never by Light.
    • HD11 not passed at a location for all: no new session, all running sessions end with host_policy_violation and shared_location, report without 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 with host_policy_violation and helper_untrusted (24 HD10 "as with HD6").
    • Every change of a result sends report (24 7.4, HostReport "on every change").
  13. 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's close or 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>/helper and 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 with cgroup.kill, falling back to SIGKILL of every PID in cgroup.procs until 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 of Delegate= 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/unix SysctlKinfoProcSlice("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 mode 0600 in <home>/.config/bothy-host/run, because launchd does not kill process groups created with setsid when 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 with setsid after 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_CLOSE and without JOB_OBJECT_LIMIT_BREAKAWAY_OK, then resumed; the end calls TerminateJobObject and ClosePseudoConsole; the job handle belongs to the helper, so the tree also ends when the helper dies.
    • Unix PTY with github.com/creack/pty and Setctty; ConPTY with golang.org/x/sys/windows (CreatePseudoConsole, ResizePseudoConsole, ClosePseudoConsole, NewProcThreadAttributeList); where the pinned x/sys lacks one of them, declare it through windows.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).
  14. Scrollback and attach (24 6.2). The service user keeps per session a ring buffer of host_service.scrollback_bytes with 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 the start size, sends replay_begin, the buffer as it was when start arrived in data frames, 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.

  15. 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: data and resize), also across a detach; after the idle value of Design note 2a the host service ends the session with idle_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 with detach_expired. The server keeps its own checks (M2-44, M2-46). The clock is injectable for tests.

  16. 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-agent in their desktop session, started at their login by an autostart entry the operator creates (LaunchAgent, XDG autostart file, a value under the Run key of that user; 16 4.4).

    a. Endpoint, created by the host service itself: Linux /run/bothy-host-confirm/confirm.sock (folder from RuntimeDirectory=bothy-host-confirm of the host service unit, mode 0755, owned by the service user), macOS /Library/Application Support/bothy-host/confirm/confirm.sock (folder created by install, owned by the service user, mode 0755), Windows the named pipe \\.\pipe\bothy-host-confirm with PIPE_REJECT_REMOTE_CLIENTS; the socket has mode 0666 and 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 (Linux SO_PEERCRED, macOS LOCAL_PEERCRED, Windows GetNamedPipeClientProcessId and the token user SID) must be neither the service user nor in host_service.session_users nor a former session user in bindings.json, and must own the active console session (Linux: the user of the active session on seat0 from loginctl; macOS: the owner of /dev/console; Windows: the peer process's session equals WTSGetActiveConsoleSessionId); otherwise the connection is closed and logged with the code confirm_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 get cancel.

    c. Dialog in the agent, default "deny": macOS /usr/bin/osascript with display dialog and giving up after; Linux /usr/bin/zenity --question --timeout or else /usr/bin/kdialog --yesno; Windows MessageBoxW (golang.org/x/sys/windows) with MB_YESNO, MB_DEFBUTTON2, MB_SYSTEMMODAL. No dialog program: the agent answers allow: false and logs no_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 (after start and the gate): no accepted agent, no answer within host_service.local_confirm_timeout or a denial give open_failed with local_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 last report says require_local_confirm = true (hosts.reported_require_local_confirm), the server deadline of an opening (grant time plus terminal.open_timeout) is met by the pairing of both sides, not by session.started, because the consent window host_service.local_confirm_timeout (24 6.4) may be longer than terminal.open_timeout; the host service bounds the rest itself: start until HostSessionOpen.expires_at, the consent within host_service.local_confirm_timeout, and it ends a refused or unanswered opening with session.ended open_failed (open_timeout or local_confirm_denied). 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.

  17. 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_detail and the exit code; never content, a token or its jti in 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.

  18. Tests and platforms.

    • Unit and integration tests run with go test in the current user, without administrator rights, on every platform and in make check: the service side against sessiontest (a fake server on 127.0.0.1 port 0 that extends M2-43's internal/control/controltest with the data endpoint and session.open, signs session tokens with a test Ed25519 key whose JWKS it sends in hello, and records report, session.started and session.ended and the close codes of data connections) and the Go test device (initiator of internal/noise, frame); the helper as a subprocess bothy-host pty-helper of the same user over M2-42's local channel; the peer check runs unchanged, with M2-42's hostostest in 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 add and remove) extend M2-42's test/platform suite (build tag platform) and run with make -C services/host platform-test on 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 by strace -f -e trace=execve,execveat of the helper; file opens of the service user by strace -f -e trace=open,openat,openat2 on Linux.
    • Fake-claude (M2-42 testdata/fakeclaude) gains: auth status prints the oauth_token fixture when CLAUDE_CODE_OAUTH_TOKEN is set and no fake-auth file names another; an interactive mode (any other arguments) that appends its argument list, working directory, the names of its environment variables, its CLAUDE_CONFIG_DIR and the SHA-256 of ANTHROPIC_API_KEY to the start log, prints a line and exits on exit or end of input; a login mode that writes fake-auth with claude.ai into its configuration folder and, on Linux and Windows, a .credentials.json there, 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 through claude auth status of host_service.claude_bin (the keychain itself stays unused; the report names that).
  19. 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 from ready); 6.2 and 13 (the host's reactions to session.close, 4100 and the other close codes of the data connection, Design note 3); 5.3 (steps 8 and 12, the binding at step 11, the jti memory only in memory); 5.4 and 10.3 (a fault of the stream after the opening is open_failed with handshake_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 of bothy-host confirm-agent. 02 6: internal/ adds session, pty, profiles, confirm; cmd/bothy-host adds confirm-agent; the places with the product name add the confirmation endpoints of Design note 15 and the folder .config/bothy-host in the home of a session user. Only where Design note 0e found them missing: HostChannelHello and the hostControl description (Design note 2).

  20. 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 to session.close and the close codes of the data connection), 4, 6 (close order after session.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 for shell passes; one row per step with exactly one mutation and its expected detail: wrong signature, unknown kid, alg none, HS256 and ES256, a crit member, a jwk member, three dots, a null claim, a missing osu (token_invalid); typ agent-invocation+jwt (token_invalid); aud of another host (token_invalid); sid of another session (token_invalid); now equal to exp plus the leeway passes, one second later token_expired; a seen jti (token_replayed); cek of another key (token_invalid); att open for a running session and attach for an unknown one or with another sub, osu or prf (token_invalid); unknown prf (profile_unknown); osu not configured (session_user_unknown); osu bound to another sub (session_user_mismatch); wdi missing or out of range for claude, and present for shell (profile_unknown); own = false on a detected server host (server_host_owner_only). Order rows: wrong aud and expired gives token_invalid; expired and wrong cek gives token_expired; replayed and unknown osu gives token_replayed.
    • TestVerifyRFC8037Vector (the Ed25519 JWS example of RFC 8037 A.4 verifies with the profile, with typ set by the test) and TestJTIMemory (pruned after exp plus the leeway, safe under -race).
    • TestBindingConcurrentFirstUse: two first opens of one session user with two people in parallel: exactly one binds, the other gets session_user_mismatch; the binding is in bindings.json after a simulated crash right after step 11.
    • TestScrollbackRing (bound host_service.scrollback_bytes, wrap, cut after the first line feed, no line feed in the buffer, snapshot at start), TestIdleTimeout (only data and resize reset the timer; detached time counts; end idle_timeout), TestDetachGrace (data connection closed with 1000, attach in time stops it, else detach_expired).
    • TestGateConsequences (Design note 11): each opening triggers exactly one Light run in the helper of osu and one HD11 run for the locations for all before pty.start reaches the helper (counted by the fake helper); advancing the fake clock by host_service.credential_check_interval triggers one Light run in every helper and one HD11 run for all; every row of Design note 11, including that a passing Light run lifts HD6 and the HD11 for all but not an entry-scan finding, and that every change sends report.
    • TestNoContentInLogsAndReports: a session with the canary CANARY-5c1e typed 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, a jti, a path or a command line.
    • TestNoInboundNetworkListener: go/parser over the non-test files of internal/session, internal/pty, internal/profiles, internal/confirm, internal/helper and cmd/bothy-host: no net.Listen, net.ListenTCP, net.ListenUDP, net.ListenPacket or http.ListenAndServe; only internal/confirm may listen, and only on unix or 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, with host_service.claude_bin usable, 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 holds CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN, AWS_SECRET_ACCESS_KEY and an unrelated variable; TERM always xterm-256color; PATH of organization starts with the search list; no key with personal); TestCleanPath; TestPersonalStartCommand (quoting of a value with spaces and quotes for sh, zsh, fish and PowerShell; unset removes the variable); TestPersonalConfigDir (on a temporary home: outside, a link leading outside, a dangling link and a missing default folder give shared_location; inside passes; a probe timeout of host_service.auth_status_timeout gives pty_failed).
  • internal/pty (per platform, current user): TestPTYStartEchoResizeExit (a shell prints its size, resize changes it, the exit code arrives); TestKillTreeEndsBackground (Unix: nohup sleep and a setsid child from the session shell; Windows: a child started with CREATE_BREAKAWAY_FROM_JOB, which fails, and a detached child; after the end no process of the tree exists); TestHelperChannelBreakEndsPTYs (D-W-90); Linux TestSessionCgroup (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); macOS TestRunFileCleanup (a helper killed with SIGKILL leaves a tree that the next helper start ends); Windows TestJobObjectKillOnClose.
  • internal/confirm: TestConfirmOpenAndAttach (allow starts, deny and timeout give local_confirm_denied, no agent gives local_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 (with host_service.require_local_confirm = false no request is sent).

Integration (in-process service with sessiontest, helper subprocess of the same user, every platform, make check):

  • TestOpenShellHappyPath: session.open, data connection with identify and session_id, message 1 and 2 with the payload of Design note 7, start 120x40, data echo ok\n returns output, resize, close: session.started once, session.ended with client_closed, tree gone.
  • TestTMT03Replay: after a successful open, a second session.open for the same session and message 1 replayed on the new data connection: that data connection closes with 4101 and token_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 gives 4104 with token_invalid (sid is checked before jti).
  • TestTMT04TokenFailures: for an opening, each case of the TM-T04 row in 24 14 (wrong aud, sid, cek, foreign kid, typ of the invocation token, expired) closes the data connection with 4104 and token_invalid, the expired one with 4101 and token_expired; no pty.start reached the helper; no session.ended was sent.
  • TestTMT05NoStart: message 1 and 2 complete, no start until expires_at: no PTY, session.ended with open_failed and open_timeout; a data frame as first transport message: no PTY, session.ended with open_failed and handshake_failed; in both cases the host service keeps the data connection open after session.ended until the fake server closes it.
  • TestSessionEndedLeavesCloseToServer (Design note 6): after session.ended with open_failed and handshake_failed, with open_failed and local_confirm_denied, and with process_exited, the host service does not close the data connection; the fake server closes it with 4100 and the host service reports nothing more; when the fake server does not close it, the host service closes it with 1000 after the length of the opening window.
  • TestLocalConfirmAfterOpenWindow (Design note 15 d): with host_service.require_local_confirm = true, report says require_local_confirm true; start arrives before HostSessionOpen.expires_at and the fake agent allows after expires_at but within host_service.local_confirm_timeout: the PTY starts and session.started is sent; without an answer within host_service.local_confirm_timeout: no PTY, session.ended with open_failed and local_confirm_denied.
  • TestStreamFaultEndsSession: a flipped ciphertext bit, an unknown frame type and a second start in a running session end it with open_failed and handshake_failed and kill its tree.
  • TestAttachReplay: the server closes the data connection with 1000, output continues into the buffer, attach with a new token and ephemeral key: replay_begin, the buffered bytes, replay_end, then live output, no second session.started; an expired attach token closes that data connection with 4101 and the PTY stays; an attach token with the cek of another key closes it with 4104 and token_invalid and ends the PTY with its tree.
  • TestSessionCloseAndDataClose (Design note 3): a running shell session with a background process started with setsid (Windows: a detached child) gets session.close with access_revoked, and in a second run its data connection is closed by the fake server with 4100: PTY and background process are gone, no session.ended is sent, and the next identify does not list the session; closes with 1000, 4013 and 4014 and a dropped TCP connection keep the PTY detached until the next session.open; a session.close for an unknown session changes nothing.
  • TestDeclarationAndValues: hello values set the leeway and the timers; a ready with organization and then a repeated ready with none: the next report has no claude profile, HD6 ran again, a running claude session continues.
  • TestHelperUntrustedEndsSessions: the local channel reports peer_mismatch in operation: running sessions end with host_policy_violation and helper_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 of osu, 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: osu outside host_service.session_users: no PTY, 4104 with session_user_unknown; osu already bound to Alex in bindings.json and a token for Sam with that osu (assignment changed in the database bypassing the server): no PTY, 4104 with session_user_mismatch.
  • TestPlatform_TMT28d (macOS run): organization, host_service.claude_bin empty, Robin and Alex with own session users; Robin starts a Fake-claude from her PATH in her shell and logs in with its login mode (on macOS it leaves no file HD6 checks, like a login in the keychain); then Alex opens a shell and runs claude auth status there: report warns auth_status_unchecked and claude_profile_unavailable and lists no claude profile; Alex's session runs as Alex's session user; auth status in Alex's session reports none; Alex's shell cannot read Robin's home.
  • TestPlatform_TMT25: personal with holder Robin, a canary token in a file of Robin's session user set as CLAUDE_CODE_OAUTH_TOKEN by her login shell; open and end the claude profile: auth status in the session reports oauth_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 the strace of 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.json holding a hook and an apiKeyHelper that are canary programs; Sam opens claude there: HD11 fails with Sam's name in report, session.ended with open_failed and shared_location, a running session of Sam ends with host_policy_violation and shared_location, the start log shows neither program, Alex still opens.
  • TestPlatform_TMT29c_Open: one directory listed for Alex and Sam in host_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 starts claude in exactly that directory (the Fake-claude logs its working directory).
  • TestPlatform_TMT29f_Operation: Alex and Sam have sessions; the program of a command profile becomes writable for a session user: the next periodic run refuses new sessions, both sessions end with host_policy_violation and shared_location, report names no session user; after the fix both open again after the next periodic run.
  • TestPlatform_TMT29g_Token: a token with wdi outside the list of osu (the fake server bypasses the server's check): no PTY, 4104 with profile_unknown.
  • TestPlatform_TMT29k: personal, Robin's login shell sets CLAUDE_CONFIG_DIR to 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 give session.ended with open_failed and shared_location and shell still opens; in the fourth the Fake-claude logs exactly that folder as CLAUDE_CONFIG_DIR.
  • TestPlatform_TMT29l: organization, another claude canary in the PATH of the session user and of the service; Sam opens claude: exactly the resolved host_service.claude_bin starts, the other never; then host_service.claude_bin is a script with #!/usr/bin/env node, no node in the search list and a node canary only in the rest of the profile PATH, and bothy-host restart: report lists no claude profile with claude_profile_unavailable, auth_status_unchecked is reported, a token for claude gives 4104 with profile_unknown (the server's 403 terminal_denied with reason: profile_unknown is M2-44's), and the node canary never runs.
  • TestPlatform_TMT21_Sessions (the session part of all seven steps of the TM-T21 row in 24 14): (a) with organization and host_service.claude_bin set to the Fake-claude, Alex logs in by subscription in her shell session (Fake-claude login); the next periodic run ends all sessions with host_policy_violation and subscription_login_detected, Sam's opening fails with subscription_login_detected, report has 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, report still names Alex's; (d) bothy-host session-user remove of 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 add with the removed name is refused; (f) session-user add with a new name while Sam has a session: Sam's helper ends the PTY with its tree when the channel breaks, the next identify lists no session, and the next report lists the new name; (g) with the host service stopped, a subscription login file in Sam's account and a start of the service: one report with Sam's HD6 entry, no session.open accepted, the service stays stopped, bothy-host doctor names Sam's session user and exits non-zero; after removing the file and bothy-host restart, Sam opens again. M2-42's TestPlatform_TMT21_SessionUserAddRemove stays green; the server-side expectations of TM-T21 (audit, host.updated, 403 terminal_denied with reason: no_session_user, the console) are proven by M2-40, M2-43, M2-44 and M2-48.
  • TestPlatform_HelperChannelBreak: bothy-host restart during 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): with host_service.require_local_confirm = true and 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: hello on the control and the data connection validates against HostChannelHello with clock_leeway_ms, idle_timeout_ms and detach_grace_ms equal to the configuration.
  • TestHostReadyAfterDeclarationChange: ready carries llm_credentials; setHostLlmCredentials on a connected host sends a fresh ready after the commit (through the log tail observer), a rolled back change sends none, and a host.updated that leaves llm_credentials unchanged (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.ended with open_failed and handshake_failed, the data connection kept open): the device gets 4104 with handshake_failed and the host data connection 4100; with local_confirm_denied the device gets 4100; with a running session and process_exited the device gets 4100 with process_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.py
  • make -C services/host release-build VERSION=$(cat services/host/VERSION) OUT=<temporary directory>
  • make -C services/host platform-test on the Linux, macOS and Windows machines
  • make 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/..., then make codegen-check, make api-lint, make api-checks, make api-tables
  • make docs, make linkcheck
  • make 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, sid or cek, a foreign kid or the typ of an invocation token starts no PTY and is refused with token_invalid (4104); an expired token with token_expired (4101); the checks run in the order of Design note 5.
  • TM-T05: message 1 and 2 without start start no PTY; the close codes 4101 and 4104 follow 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 personal the 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 status in the session reports oauth_token.
  • TM-T28 (c) and (d): an osu outside host_service.session_users and an osu bound to another person start no PTY (4104 with session_user_unknown and session_user_mismatch); on macOS with an empty host_service.claude_bin the host warns auth_status_unchecked, offers no claude profile, 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 claude profile starts with organization exactly the resolved host_service.claude_bin and is not offered without it or without the script's interpreter in the search list; with personal only with a CLAUDE_CONFIG_DIR that resolves inside the home; with none it does not exist; the environment of every profile is an allow list plus TERM, tested per declaration without a model call.
  • Every PTY starts only in the helper of osu, claude only in the working directory wdi of the reported list of osu, and the binding osu to sub is 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 with shared_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.close and a close of the data connection with 4100 end the PTY with its tree without session.ended; every other close keeps the PTY detached until the next session.open or terminal.detach_grace.
  • An attach replays the scrollback between replay_begin and replay_end; the buffer exists only in memory.
  • With host_service.require_local_confirm no PTY starts and no attach replays without consent within host_service.local_confirm_timeout; a consent after HostSessionOpen.expires_at within that window still starts the PTY (the pairing meets the server deadline, Design note 15 d).
  • After session.ended the host service leaves the close of the data connection to the server, so the device gets the code of the end (4104 for handshake_failed), not 4102.
  • OQ-03 is checked against the current documentation, the result stands in 24 7.4, OQ-03 and RK-30, and the personal branch 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 in docs/DECISIONS.md.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • services/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 of services/server/internal/terminal if the server changed.
  • Rulings of Design note 19 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 24, 16, 02, OQ-03, RK-23, RK-30, ADR 0068 and, if built, openapi.yaml and asyncapi.yaml made in the same change; make adr-index run.
  • services/host/CHANGELOG.md (and services/server/CHANGELOG.md if 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven, at least: the 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 claude logins (never run).
  5. 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.
  6. 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>.