Skip to content

M2-42: bothy-host: installation, state, doctor and pair

Status: ready | Depends on: M2-38, M2-41 | Milestone: M2

Goal

After this ticket the host service bothy-host can be installed on macOS, Linux and Windows without leaving administrator rights behind, checks itself before every start and on command, and pairs itself with a server. bothy-host install creates the service user bothy-host with the state directory host_service.state_dir, one session user per person with a home only for that user, one PTY helper per session user as a system service of the operating system (launchd LaunchDaemon, systemd system service with socket activation, Windows service under the session user's account) and the host service itself, and on Windows, after the operator's confirmation, removes only the entry Authenticated Users:(AD) from the root of each drive with a location. bothy-host session-user add and session-user remove, restart, pair, doctor, run and pty-helper work as 24 "Host service bothy-host" and 16 "Terminal: forwarding and host service" describe. The private keys lie in the state directory as files with mode 0600 (on Windows encrypted with DPAPI in the context of the service user) and never leave it. bothy-host doctor runs HD1 to HD11 with the per-platform terms of 24 2.2 point 6 (trusted owners, "writable" including creating entries, appending and attributes, full path resolution, places of a session user resolved by its own helper, directory identity, the Windows drive root), reports codes and, by the rule for names, the names of session users, never paths, contents or values, and refuses the start of the host service with its own exit code after which no service manager restarts it. The local channel between the service user and each helper checks its peer by user and by the process ID the service manager reports. A fake claude in testdata proves HD6 without a real model or account. Why: a person with a terminal must never reach the keys of the host, another person's account, a subscription login of someone else or a place another person can write (Rulings T2, T12, T13; threats T-TM-04, T-TM-05, T-TM-08, T-TM-12, T-TM-13, T-TM-14), and a host that cannot prove this for its platform gets no terminal (RK-23). This is the host side of TM-T13, TM-T22, TM-T24, TM-T26, TM-T27 and of the parts of TM-T21 and TM-T29 that need no session; the control connection (M2-43) and the sessions with PTY (M2-45) build on it.

Read first

  • docs/PLAN.md: section 1 "Principles", 3 "Way of working per ticket", 4 "Closing a milestone", 5 "Test strategy" (rows "Test doubles": fake claude for doctor; "Security"; "End to end"); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part), "Out of scope", "Exit criteria" item 8, "Test strategy" (terminal part: PTY tests on macOS and Windows on own machines, fake claude), "Red team" (terminal paragraph), "Dependencies", "Risks" (output of claude auth status, list of chain files, RK-88); block "terminal" with the rows M2-38 and M2-41 (what exists), M2-42 (this ticket, the contract), M2-43 (depends on this ticket; it builds the control connection and the wire of report), M2-44, M2-45 (sessions, PTY, profiles, the periodic and pre-PTY runs of HD6 and HD11), M2-46, M2-47 and M2-48 (conformance and platform runs); M7-23 (release chain of the host service, signatures and installation packages); section 18.2 row "I6" and 18.3 row "Claude Code session in the terminal"; section 19: P-01, P-05, P-09, P-10, P-11, P-12, P-20.
  • Dependencies: the header names M2-38 and M2-41. Through M2-41 this ticket also builds on M2-40, M2-39, through M2-38 on M1-01, and on the M1 tickets M1-48, M1-49, M1-44, M1-35, M1-34, M1-33, M1-32, M1-31, M1-29, M1-28, M1-20, M1-17, M1-13, M1-12 and their closure (all implied, so not in the header). D-W-102 struck no dependency from this ticket's plan column (it named M2-38 and M2-41 before and after); it struck M1-44 from M2-41 (pairing code format hpc), M1-35 from M2-40 (step-up), M1-29 and M1-32 from M2-39 and M2-39 from M2-43. Those stay in this read list: docs/briefs/M1-44-identity-api-tokens.md (token format, checksum), M1-35-identity-step-up.md, M1-29-authz-invariants.md, M1-32-signing-keys.md.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits" (point 5); 2.1 "Overview"; 2.2 "Host service bothy-host" completely: the users, the local channel, points 1 to 6 of "One session user per person and host, never shared" with the four terms of point 6 ("Trusted owner", "Writable", "Path resolution", "Drive root under Windows"), "Limit, honestly" and "Consequence, honestly", the PTY helper points 1 to 4, the table HD1 to HD11, "Check of the entries (HD11)", "Reason for HD9", "Reason for HD10", the rule for names, "Output on the host", "Message on a refused start", the paragraph on the own exit code; 2.3 "Server host"; 3.1 "States"; 3.2 "Pairing"; 3.3 "Re-pairing"; 4.1 "Two key pairs per host"; 4.2 "Storage on the host"; 4.3 "Fingerprint"; 5.3 "Terminal session token" (claims osu and wdi, which M2-45 checks against what this ticket reports); 6.2 "Detach and reattach" (restart ends sessions); 6.5 "End of a session on the host" (long runners); 7.2 "Declaration"; 7.3 "Invariant I6"; 7.4 "Enforcement on the host" completely; 10.1 "Audit"; 10.3 "Reasons for ending (terminal_sessions.close_reason)"; 11.1 "Definition"; 13 "Errors"; 14 "Tests" (TM-T13, TM-T21, TM-T22, TM-T24, TM-T26, TM-T27, TM-T28, TM-T29); 16 (R-TM-01, R-TM-12, R-TM-14, R-TM-17, R-TM-18, R-TM-20 to R-TM-30); 17 "Configuration values of this chapter".
  • docs/spec/16-operations.md 4.4 "Terminal: forwarding and host service", part "Installation of the host service on a host", points 1 to 6 (you edit it, Design note 17).
  • docs/spec/02-architecture.md 6 "Code layout" (block services/host/, "Places that carry the product name").
  • docs/spec/18-release.md 2 "Components" (row host) and 9.1 "Host service and server".
  • docs/spec/19-security.md: SEC-TERM-4, SEC-TERM-9, SEC-TERM-10, SEC-TERM-12, SEC-TERM-13, SEC-T18.
  • docs/spec/23-quality.md: 1 "Principles", 3 "Component contract" with "Documentation in code", 11 (quality.coverage.go_min_percent).
  • docs/spec/03-data-model.md (read only): hosts (reported_profiles, reported_session_users, reported_doctor, reported_is_server_host, llm_credentials), host_session_users (os_user rule).
  • For the terminal security group: docs/spec/07-permissions.md 2.12 "Hosts and the terminal capability", SR10, SR11, SR12; docs/spec/08-identity.md 6.3 "Fresh step-up for the terminal" and ID-T28; docs/spec/10-backend-policy.md 3 "Invariants" (I6).
  • docs/api/openapi.yaml: operation pairHost (x-rate-limit-class, x-idempotency-exempt, examples); schemas HostPairRequest, HostPairResult, HostProfile, HostReportedSessionUser, HostDoctorCheck, HostReport, HostReady, HostIdentify (live_sessions), HostPlatform, LlmCredentials, Problem (x-problem-codes); for the integration test createHost, issueHostPairingCode, getHost. docs/api/asyncapi.yaml: channel hostControl with the messages host.ready and host.report.
  • docs/operations/configuration.md: the rows host_service.* under "24 Terminal" and host.os_user_max_chars.
  • ADRs: docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md (decision and "Confirmation"), 0061-token-format-with-a-fixed-prefix-and-checksum.md, 0005-go-as-the-server-language.md, 0046-multi-user-dev-environment-only-with-isolation.md, 0053-no-json-null-in-api-and-events.md.
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57 and T13 as D-W-62), D-T10, D-T12, D-T14, D-T15, D-T38, D-OP-03, D-W-17, D-W-18, D-W-45, D-W-63, D-W-64, D-W-67 to D-W-70, D-W-80 to D-W-91 and D-W-109 (each names this ticket or its checks), D-W-100, D-W-101, D-W-102; 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-14, RK-22, RK-23, RK-84, RK-88, RK-89, RK-90. docs/OPEN-QUESTIONS.md: OQ-03 (M2-45), OQ-04 (M2-46), OQ-24 (terminal only over a private network, default no). docs/operations/tailscale.md 3 "Tailnet policy (required)".
  • docs/quality/gates.md, docs/quality/licenses.md, docs/quality/red-team-gate.md, docs/quality/push-discipline.md.
  • Briefs: the brief of M2-38 (docs/briefs/M2-38-noise-core.md: module path, cmd/bothy-host dispatcher, internal/noise with GenerateKeyPair, internal/fingerprint with Compute, internal/frame, the hand-maintained services/host/.go-arch-lint.yml, services/host/Makefile with release-build); the brief of M2-41 (docs/briefs/M2-41-host-pairing.md: pairHost with 401 unauthenticated for every redemption failure, the report rules with their errors[].code values, issueHostPairingCode, confirmHost, the server's fingerprint check and its integration test pattern); the brief of M2-39 (docs/briefs/M2-39-terminal-hosts.md: createHost, getHost, hosts), M1-12-postgres-migrations.md (pgtest), M1-34-identity-sessions.md and M1-35-identity-step-up.md (identitytest), M1-07-config-schema-loader.md (duration grammar, YAML with go.yaml.in/yaml/v3, unknown keys abort).
  • Code on the base branch: services/host (M2-38), services/server/internal/terminal (M2-39 to M2-41), services/server/internal/wire, the root Makefile (GO_MODS, typecheck, lint, test, coverage), services/host/README.md.
  • External, read only (Boundaries): the Claude Code documentation pages that 24 cites (memory, settings, mcp, cli-reference, authentication, setup under https://code.claude.com/docs/en/), the git documentation (https://git-scm.com/docs/git), and the Microsoft pages that 24 2.2 cites.

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.

Product requirements implemented here:

  1. Users without administrator rights (24 2.2, 16 4.4 point 1, Ruling T12): the service user bothy-host owns the state directory (mode 0700, on Windows a protected DACL); each person gets an own session user from host_service.session_users with a home only for that user (mode 0700, on Windows an ACL only for the user and the trusted owners); no session user can read the state directory; on macOS no session user is in staff.
  2. Each PTY helper is a system service under its session user, never setuid; binary, service definition and the folder of the channel endpoint belong to a trusted owner and are writable by no session user; on Linux and macOS the service manager creates the endpoint (socket activation); the session user cannot stop or reconfigure the service (24 2.2 PTY helper points 1 and 2, HD10).
  3. The local channel carries only control messages, check results and (later, M2-45) decrypted frames, never keys; bothy-host checks the peer by user and by the process ID the service manager reports, sends no frame on a mismatch and reports HD10 failed with the name of that session user (24 2.2 PTY helper point 3, D-W-109).
  4. Private keys: an X25519 key pair (Noise) and an Ed25519 key pair (login) generated locally by pair, stored only in the state directory as a file with mode 0600 (Windows: DPAPI in the context of the service user); a key chain only where the service user has an unlocked session, which no installation of this ticket has (24 4.2, Ruling T10).
  5. pair --server <url> --code <code> redeems the code through pairHost with both public keys, platform, host version, the profiles after HD7 and the session users with the display names of their working directories after HD7, shows the fingerprint of 24 4.3 in groups of four characters, and stores the keys only after the server accepted them (24 3.2, D-W-63, D-W-69).
  6. doctor runs HD1 to HD11 before every start of the host service and on command (full run with the entry scan of HD11); it outputs per check result and code, with the names of the rule for names, with each listable home under HD9 and with the drive under drive_root_writable, never contents or values, and exits non-zero as soon as one check fails; warnings alone do not change the exit code (24 2.2, D-W-91).
  7. The report to the server carries codes and, by the rule for names, one entry per account a person with a terminal can cause the finding in (HD6, HD10 with a wrong peer, HD11 at a place of a session user, HD9 for each former session user with a listable home); names of other users never leave the host; never paths, contents or values (24 2.2, D-W-70, D-W-109, HostDoctorCheck).
  8. A refused start starts no PTY and accepts no session; a paired host reports first (login, one report, close) and then ends with its own exit code, after which the service definition of each platform does not restart it; only bothy-host restart starts it again (24 2.2, D-W-91, 16 4.4 points 2 and 6).
  9. HD6 asks the coding agent only through the resolved host_service.claude_bin with the fixed search list (the configured folder of host_service.claude_bin, then /usr/bin and /bin, on Windows %SystemRoot%\System32) and the environment of 7.4, in every session user, plus the known login locations; at none or organization a detected subscription login (authMethod claude.ai or oauth_token, or a login file) refuses the start, one entry per affected session user; an empty value or one that counts as empty gives the warning auth_status_unchecked; an output that cannot be read fails closed (24 7.4, RK-14, D-W-45, D-W-85).
  10. HD7, HD10 and HD11 use the per-platform terms of 24 2.2 point 6, resolve every path fully before the check (places of a session user by its helper, places for all by the service user), check each folder of the resolved chain, each configured component and each link with its folder, treat a path that cannot be resolved as absent, and compare working directories by file system identity (device and inode; on Windows volume serial number and the 128-bit file ID of FILE_ID_INFO) (D-W-80, D-W-83, D-W-84, D-W-86).
  11. HD11 checks one rule for every configured path and every place of a session user, including the chain files CLAUDE.md, CLAUDE.local.md, AGENTS.md, .mcp.json, .claude and .git in every folder on the way; the entry scan runs per session user in its helper, only that session user waits for it (entry_scan_running), host_service.entry_scan_timeout bounds it (entry_scan_timeout), and only a later full run lifts its finding (D-W-68, D-W-89).
  12. host_service.session_users and host_service.claude_workdirs are read only at start; a folder listed under two session users drops out of both lists; the CLAUDE_CONFIG_DIR of the profile claude at organization is the fixed folder .config/bothy-host/claude in the session user's home (D-W-69, 24 7.4).
  13. session-user add creates the user with a home only for that user and its helper service, refuses a name the state directory already binds to a person, and restarts the host service; session-user remove removes the name from host_service.session_users and its entry from host_service.claude_workdirs, removes the helper service, closes the home and restarts the host service, keeping the binding; restart restarts the host service so that a changed configuration takes effect (D-W-67, D-W-88, D-W-90).
  14. On Windows install (also with --drive-root <drive>) removes, after the operator's confirmation, only the entry Authenticated Users:(AD) at the root of the system drive and of each drive with a location, by rewriting exactly that folder's DACL with SetFileSecurityW, keeping every other entry in order and with its control bits; without confirmation nothing changes and HD11 reports drive_root_writable (D-W-87).
  15. The host service opens no network listener; its only network connection in this ticket is the outgoing HTTPS request of pair (R-TM-01).

Terminal security requirements (D-W-101):

  1. Rights per machine (07 "Hosts and the terminal capability": capability terminal, SR10, SR11 with I6, SR12): the rights themselves are server decisions of M2-39 (host.read, AZ-M60) and M2-40 (terminal.*, SR10, SR11 with I6, SR12; AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). What this ticket enforces on the host: SR12 rests on names the host reports, so HD1 refuses an empty list, a duplicate name (compared without case, like the server) and a session user equal to the service user (TestHD1), pair reports exactly the configured names with their lists after HD7 (TestPairRequestBody, TestHostPairAgainstServer), and session-user add refuses a name bound to a person, so a session user is never given to a second person (T12; TestSessionUserAddRefusesBoundName, TestPlatform_TMT21_SessionUserAddRemove). For SR10 on the server host, HD5 reports the server host (TestHD5); the server refuses non-owners (M2-40) and the host service refuses own = false (M2-45).
  2. Fresh step-up (device key in the apps, only passkey in the console; 08 6.3, ID-T28): not touched here. M2-40 builds step-up fresh and its consumption (ID-T28 without the console part), M2-44 TM-T07 without the console part, M4-36 and M5-12 the step-up key in the apps, M6-18 the passkey-only console. The commands of this ticket are local administrator commands on the host and grant no server right; the owner confirms the paired host with step-up through confirmHost (M2-41).
  3. Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile tailscale reaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03, docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket enforces: the admin endpoint and every local channel are Unix sockets or named pipes with PIPE_REJECT_REMOTE_CLIENTS, never a TCP or UDP listener (TestNoNetworkListener, TestPlatform_NoNetworkListener); pair accepts only https:// URLs (TestPairRequiresHTTPS). M2-43 builds the outbound control connection and M2-44 the data connections; on an answer "yes" to OQ-24 those change, not this one.
  4. Audit without content: the server's audit of host.pair is M2-41's, of host.connect M2-43's, of sessions M2-46's (TM-T06). This ticket enforces the host's share: the report and the output of doctor carry codes and names only, never paths, contents or values (TestReportHasNoPathsOrValues, TestDoctorOutputHasNoPathsOrValues); logs of the host service carry codes and names only; the pairing code and private keys appear in no output, log, report or test failure message (TestPairNeverPrintsCodeOrKeys).
  5. Idle timeout and maximum duration (24 "Limits"): not touched here, because this ticket starts no PTY. M2-45 measures idle on the host, M2-46 adds the server side (terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration, TM-T11).
  6. Service user and session users without administrator rights (T12, HD rules): the core of this ticket. install and session-user add create users outside every forbidden group and, on macOS, outside staff; doctor refuses the start on HD1 to HD3, HD4, HD8, HD9, HD10 and on HD11 at a place for all; the helper keeps the keys out of reach of the session user (HD8) and cannot be replaced (HD10). Proven by TM-T13, TM-T22, TM-T24 and TM-T26 on macOS, Linux and Windows (TestPlatform_TMT13, TestPlatform_TMT22, TestPlatform_TMT24, TestPlatform_TMT26) and by the unit tests of Design notes 6 to 10.
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-04 (escalation on the host) by HD1 to HD3 and HD9, TM-T13 and TM-T24; T-TM-05 (theft of the host keys) by the state directory, 0600 or DPAPI and HD8, TM-T22 (TM-T23 is M2-43's, TM-T18 M4-35's); T-TM-06 (pairing of a foreign host) by the fingerprint shown at pair, TestHostPairAgainstServer (TM-T19 and TM-T20 are M2-41's); T-TM-08 (a foreign LLM login) by HD6 through host_service.claude_bin only, TM-T13 (subscription case) and TM-T27; T-TM-11 (processes after revocation) by session-user remove closing the home and keeping the binding, TestPlatform_TMT21_SessionUserAddRemove (the session part TM-T10 is M2-46's); T-TM-12 (replacing the PTY helper) by socket activation, the peer check and memory protection, TM-T26 and TM-T22; T-TM-13 (shared places) by HD7 and HD11, the TM-T29 parts (a), (b), (c) with pair, (d), (e), (f), (h), (i), (j); T-TM-14 (standstill by one person) by the names in the report and the local output, TM-T24, TM-T26, TM-T29 (i) and the parts (e) to (g) of TM-T21 without sessions. Not touched here: T-TM-01 and T-TM-02 (M2-38, M2-45, M2-46), T-TM-03 and T-TM-09 (M2-40, M2-44), T-TM-07 (M6-18), T-TM-10 (M4-35).

Scope

Paths under services/host/ unless noted. M2-38 names below are the expected ones; where its merged code names them differently, follow the code (Boundaries). This ticket creates no migration, no server operation and no new top-level make target.

Create:

  • cmd/bothy-host/: install.go, sessionuser.go (session-user add <name>, session-user remove <name>), restart.go, pair.go, doctor.go, run.go, ptyhelper.go, registered in the M2-38 command table; flags with the standard library flag only (Design note 2).
  • internal/buildinfo: Version (default 0.0.0-dev, set by release-build with -ldflags -X), Platform() returning a HostPlatform value.
  • internal/clock and internal/clock/clocktest: Clock{Now() time.Time; NewTimer(d time.Duration) Timer} with a real and a fake implementation, so that no test sleeps for a window.
  • internal/config: the configuration file of Design note 3 (Load, DefaultPath, AddSessionUser, RemoveSessionUser editing the YAML node tree so that comments and other keys stay).
  • internal/state: state directory layout, key file, pairing record, bindings, last declaration (Design note 4).
  • internal/hostos with linux, darwin and windows files and internal/hostos/hostostest: the port to the operating system (users, groups, file security, path resolution with identity, service manager, command runner, probes) and its fake (Design note 5).
  • internal/doctor: the checks HD1 to HD11, modes Full and Light, the result model, the report entries by the rule for names, the local output (Design notes 6 to 10).
  • internal/localchan: endpoints, dial and listen per platform, peer verification, the message codec, FuzzDecodeMessage (Design note 11).
  • internal/helper: the pty-helper process: serves its channel, sets its memory protection, runs the helper-side checks (Design note 12). M2-45 adds the PTY to it.
  • internal/install: plans and executors of install, install --drive-root, session-user add, session-user remove, restart; templates of the service definitions in internal/install/units/ (Design notes 13 and 14).
  • internal/startup: the start sequence of run, the admin endpoint, the Control port and its implementation NoControl, per session user readiness (Design notes 15 and 16).
  • internal/pairing: pair (Design note 16).
  • testdata/fakeclaude/: main.go (a Go program built by the tests, .exe on Windows), fixtures/ with outputs of claude auth status, README.md (English: sources, CLI version, date) (Design note 9).
  • test/platform/: the platform suite behind the build tag platform (Design note 19).
  • services/server/test/terminal/hostpair_test.go (server module): TestHostPairAgainstServer (Tests).

Change:

  • services/host/.go-arch-lint.yml (hand-maintained, M2-38 Design note 2): one component per new package, vendors golang.org/x/sys and go.yaml.in/yaml/v3; cmd may use every internal package; doctor, install, helper, startup, pairing use hostos only through its interfaces.
  • services/host/go.mod, go.sum: golang.org/x/sys (already indirect through M2-38) and go.yaml.in/yaml/v3 at the version the server module pins; no other dependency.
  • services/host/Makefile: release-build sets internal/buildinfo.Version; new target platform-test (Design note 19).
  • Root Makefile: GO_CROSS_MODULES := services/host and, in typecheck and lint, go vet and golangci-lint run for that module with GOOS linux, darwin and windows, so code for the other platforms is checked on every build host. Ruling for docs/DECISIONS.md.
  • docs/spec/24-terminal.md (English): Design notes 3, 4, 6 to 11, 14, 15 and 18 name each change; section 17 gains host_service.auth_status_timeout and, after the measurement, the new default of host_service.entry_scan_timeout.
  • docs/spec/16-operations.md 4.4 (English, Design note 17); docs/spec/02-architecture.md 6 "Code layout" (the new packages, test/platform, the places of the product name of Design note 13).
  • docs/api/openapi.yaml: HostReady gains the required member llm_credentials (LlmCredentials); docs/api/asyncapi.yaml channel hostControl: the description names the repeated ready of Design note 15. Then make api-lint, make api-checks, make api-tables and the server code generation (make codegen-check clean).
  • docs/operations/configuration.md: regenerate with make config-reference.
  • docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md "Confirmation": the tests of this ticket for TM-T13, TM-T22, TM-T24, TM-T26, TM-T27 and the TM-T29 parts; then make adr-index.
  • docs/RISKS.md (facts only): RK-90 (measured durations, new default), RK-88 (date and result of the check of the chain files), RK-23 and RK-84 (which platforms passed TM-T26).
  • services/host/README.md (English), services/host/CHANGELOG.md, docs/DECISIONS.md section 10 rows D-M2-42-<n> for every Ruling below.

Out of scope

  • The control connection /hosts/connect (hello, identify with live_sessions, ready, heartbeat, the wire of report, close codes 4110 to 4114, the server's handling of report and hosts.reported_*): M2-43 implements the Control port of Design note 15 and sends llm_credentials in ready. Until then run behaves like a host that cannot reach the server (24 2.2).
  • Sessions, session token, data connections, relay: M2-44; PTY, profiles at runtime, host_service.require_local_confirm, the binding of osu to sub at the first session, HD6 and HD11 before every PTY and every host_service.credential_check_interval with their consequences in operation (ending sessions with host_policy_violation), the environment of the profile claude, the API key of organization and its Windows import, TM-T03 to TM-T05, TM-T21 (a) to (d), TM-T25, TM-T28 (c) and (d), TM-T29 (g), (k), (l) and the session parts of (a), (e) and (f): M2-45. This ticket provides doctor.RunLight and the readiness that M2-45 schedules and consults.
  • The server side of pairing, confirmHost, the fingerprint check of the server, TM-T19, TM-T20, TM-T28 (b) and (e): M2-41. Rights, SR10 to SR12, I6, step-up: M2-40. Revocation, limits, audit of sessions, metrics: M2-46. Several nodes: M2-47. The conformance and chaos suite, the host service in a Linux container in make check, TM-T16: M2-48. Client warnings and pins: M4-35, M4-36, M5-12, M6-18.
  • Developer ID signatures, notarization and Authenticode (signtool), SBOM, checksums, installation packages and their service definitions in infra/host/: M7-23; it may render its packages from the templates of Design note 14.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-42-host-install-doctor; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(host): ..., test(host): ..., docs(spec): ..., build(gates): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests of make check run without administrator rights, bind only 127.0.0.1 and port 0, create no operating system user, service or ACL outside t.TempDir(), and PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the server integration test fails; it never skips.
  • The platform suite (Design note 19) creates and deletes operating system users, services and ACLs with administrator rights. Run it only on disposable machines the launcher's host rules name (a Linux machine with systemd, a macOS machine, Windows 10 and Windows 11 with default rights), never on a workstation or a machine with real users. Without a machine for a platform, stop with Verdict: BLOCKED platform machine missing: <platform>, because RK-23 allows no terminal on a platform without proof.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d (Go t.TempDir()), removed at the end.
  • Never read or print secrets; test keys are generated in the test, pairing codes come only from the server under test, and fixed text values contain EXAMPLE. Never log in to a real Claude account and never call a model.
  • No network calls except package downloads for pinned tools and Go modules, one download of a pinned Claude Code CLI for the fixture recording of Design note 9, and read-only fetches of the public documentation pages named under "Read first" for the checks of Design notes 9 and 10.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • 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-41: their merged names win over the expected names here; list each difference. If pairHost is not enabled on the base branch, stop with Verdict: BLOCKED pairHost missing.
  • Commands this ticket runs on the host (id, sudo, useradd, systemctl, dscl, launchctl, ls and the like) are started by absolute path from the fixed list /usr/bin, /bin, /usr/sbin, /sbin (Windows: %SystemRoot%\System32), with an argument vector, never through a shell or PATH.

Design notes

  1. Who runs what. Three identities: the administrator (root, or an elevated member of Administrators) runs install, session-user, restart, and pair and doctor when the service user cannot run them itself; the service user runs run and the service-side checks; each session user runs its pty-helper and the helper-side checks. Every command that needs administrator rights and lacks them exits 2 with bothy-host <command>: administrator rights required. Ruling for docs/DECISIONS.md: pair and doctor run with the identity of the service user: directly when the caller is the owner of the state directory (the service user itself, and the tests); through the admin endpoint of the running host service when an administrator calls them; with the service stopped, on Linux and macOS in a child process with the service user's credentials (SysProcAttr.Credential), and on Windows doctor starts the service once with the start argument doctor-once (it runs the full run, hands the result to the waiting command over the admin endpoint and stops with exit code 0, connecting to no server and accepting no session), while pair on Windows requires the running service and otherwise exits 2 naming bothy-host restart. Reason: keys under DPAPI and files owned by the service user can only be written in its context, and places for all must be resolved by the service user (24 2.2 point 6).

  2. Commands and exit codes. install [--session-user <name>] [--drive-root <drive>] [--drive-root-confirm yes|no] [--config <path>]; session-user add <name>; session-user remove <name>; restart; pair --server <url> --code <code> [--ca-file <pem>]; doctor; run; pty-helper --session-user <name>. Exit codes: 0 success; 1 a check failed (doctor) or a runtime failure; 2 usage or environment error (unknown flag, missing rights, unreadable configuration in a command); 78 refused start of run (constant ExitStartRefused, the value EX_CONFIG of sysexits). Ruling for docs/DECISIONS.md. --drive-root alone makes install do only the drive root step (24 2.2 point 6). The confirmation is a prompt on the terminal; --drive-root-confirm answers it for unattended installs; without a terminal and without the flag the answer is no. Ruling for docs/DECISIONS.md, the local output of doctor (24 2.2 "Output on the host"): one line per check in the order HD1 to HD11, HD<n> ok, HD<n> WARN <code> or HD<n> FAIL <code>, followed by session_user=<name> for a named entry, user=<name> for each user whose home HD9 could list and drive=<letter>: for drive_root_writable, one line per entry; then the summary doctor: <failed> failed, <warnings> warnings; the declaration in use on its own line (declaration: none|personal|organization|unknown); never a path, a content or a value.

  3. Configuration file. 24 and 16 name the keys host_service.* but no file. Ruling for docs/DECISIONS.md (update 24 17 and 16 4.4): one YAML file nested like the names (host_service: {session_users: [...], ...}), decoded with go.yaml.in/yaml/v3 like the server (M1-07): unknown keys, duplicate keys and wrong types are errors naming the key; durations in the duration grammar of M1-07. Paths: Linux /etc/bothy-host/config.yaml, macOS /Library/Application Support/bothy-host/config.yaml, Windows %ProgramFiles%\bothy-host\config.yaml; --config overrides it for tests. The file and every folder on the way belong to a trusted owner and are writable by no session user; HD10 checks them (Design note 8), because the file chooses every program of every session user. a. host_service.profiles: a list whose items are shell, claude or a mapping {name, display_name, kind: command, program, args} with an absolute program; names match the pattern of HostProfile.name; shell and claude get the display names Shell and Claude Code. b. host_service.claude_workdirs: a mapping from a session user name to a list of {display_name, path}; display names are not empty and unique within one list, else HD7 drops that entry with a warning. c. New key host_service.auth_status_timeout (Host service, default 30s): the bound of one call of claude auth status and of the login-shell probe of HD6. Add the row to 24 17. Ruling for docs/DECISIONS.md. d. An invalid file makes run log config_invalid with the key and exit 78, so no service manager loops on it; doctor and the other commands exit 2.

  4. State directory (internal/state). Files, each written atomically (temp file in the directory, fsync, rename, fsync of the directory), mode 0600 on Linux and macOS, on Windows inheriting the protected DACL of the directory (service account, SYSTEM, Administrators): keys (both private keys; on Windows the content is a DPAPI blob from CryptProtectData without CRYPTPROTECT_LOCAL_MACHINE), pairing.json (server URL, host_id, instance_id, the fingerprint; no private member), bindings.json (session user name to person ID, written by M2-45 at the first session, read here), declaration.json (the last llm_credentials learned from ready, a code), admin.sock on Linux and macOS (Design note 15). The name claude_api_key is reserved for the API key of organization (16 4.4 point 5), which M2-45 reads. Ruling for docs/DECISIONS.md: 24 4.2 and 7.4 say the state directory holds only the keys, the bindings and the API key; the pairing record and the last declaration are added there and in 24 17 (row host_service.state_dir), both without secrets and without a subscription login.

  5. Port to the operating system (internal/hostos). All platform behaviour sits behind small interfaces so that every rule is unit-tested on any build host with hostostest: Users (look up by name and ID, home, enumeration of local users with their IDs, UID_MIN and UID_MAX of /etc/login.defs on Linux and the profiles of ProfileList on Windows for HD9, membership by id -G <name> on Linux and macOS, because the pure Go user database misses directory services, and local plus global groups on Windows), Security (owner, mode, POSIX ACL from system.posix_acl_access on Linux, the macOS ACL read with getattrlist and ATTR_CMN_EXTENDED_SECURITY where golang.org/x/sys/unix offers it, else parsed from /bin/ls -led, the Windows security descriptor), Resolver (Design note 7), Services (install, remove, start, stop, restart, main PID, exit policy per platform), Runner (absolute path, argument vector, environment, timeout), Probe (sudo -n true as a user, the Docker socket, kernel.yama.ptrace_scope). Ruling for docs/DECISIONS.md: no cgo on any platform; an ACL entry that cannot be read, parsed or mapped to a user or group counts as granting every right to every session user (fail closed), because one right too many only blocks, one too few opens a place (24 2.2 point 6).

  6. Checks HD1 to HD5 and HD8, HD9. The check names of the report are the check values of HostDoctorCheck (hd1_user to hd11_shared_locations). Codes, Ruling for docs/DECISIONS.md and a new table "Codes of doctor" in 24 2.2 (the core contract only says "code"):

    • HD1: session_users_empty, session_user_duplicate (names compared without case on every platform, like host_session_users), session_user_is_service_user, privileged_user (root, Administrator, SYSTEM, or uid 0), user_missing, session_user_name_invalid (not the pattern of HostReportedSessionUser.os_user; the length is the server's host.os_user_max_chars and fails at pairHost).
    • HD2: forbidden_group (the fixed list of 24 HD2, compared without case on Windows, plus host_service.extra_forbidden_groups; the operator cannot shorten the fixed list), docker_socket_writable (Linux: /var/run/docker.sock writable by the user, one of its groups or others, by mode or ACL).
    • HD3: passwordless_sudo (sudo -n true succeeds for the service user or, in its helper, for a session user). On Windows HD3 has no probe and passes without code, because elevation needs the credentials of an administrator and HD2 covers Administrators. Ruling for docs/DECISIONS.md.
    • HD4: state_dir_owner, state_dir_mode, key_file_unprotected (mode not 0600, or on Windows not a DPAPI blob or a DACL with more than the three principals).
    • HD5: warning server_host_detected: the host part of the paired server URL resolves to an address of an own interface or to loopback; reported as is_server_host.
    • HD8: state_access_for_session_user (owner, mode, ACL or group give a session user or one of its groups an entry), helper_reads_state (a helper could open the state directory or the key file).
    • HD9: foreign_home_listable. Each helper lists the home of each of these accounts that is not a session user, and every listing must fail: root, the service user, every former session user (bound in bindings.json, not in host_service.session_users), and every person account of the platform. Person accounts are: on Linux an account with a UID from UID_MIN to UID_MAX of /etc/login.defs (the file's documented defaults when a key is missing); on macOS an account with a UniqueID of at least 501, the first ID macOS gives a regular user; on Windows every account with a profile under ProfileList (the ProfileImagePath of its SID subkey; the values Default and Public name no account). HD9 skips all other system accounts (Linux below UID_MIN, nobody; macOS below 501, nobody), because their homes are system folders that every user may list by design (macOS nobody /var/empty, _www /Library/WebServer, _networkd /var/networkd with mode 0755 and owned by itself; Debian daemon /usr/sbin, systemd-network /), and they hold no files of a person. Ruling for docs/DECISIONS.md; update 24 2.2 (the HD9 row, point 4, "Reason for HD9") and R-TM-17 with this rule and reason. Report: one entry with the name for each former session user (bound in bindings.json, not in host_service.session_users) whose home can be listed, one entry without a name for all others; the local output names every such user.
  7. Path resolution and identity. Resolver.Resolve(path) (Resolved, error) returns the final path (realpath semantics on Linux and macOS, GetFinalPathNameByHandle on Windows), each folder of the resolved chain, each component of the configured path, and each link followed (symbolic link, on Windows also a junction) with the folder it lies in; Resolver.Identity(path) returns device and inode, or on Windows the volume serial number and the 128-bit file ID of GetFileInformationByHandleEx with FileIdInfo. A missing path, a dangling link or a folder on the way the resolving user cannot open gives ErrUnresolvable: a working directory drops out of its session user's list, a command profile drops out, host_service.claude_bin counts as empty, and an unresolvable home fails HD11 for its session user (home_unresolvable). A link counts as an entry of its folder and as its target.

  8. HD10 helper integrity. The service user checks, after resolution, that each of these belongs to a trusted owner and is writable by no session user (the per-platform lists of 24 2.2 point 6 and D-W-86, unit-tested right by right): the helper binary, every service definition, every folder of a channel endpoint, the configuration file (Design note 3), and, when set, host_service.claude_bin with every folder on the way, every folder of the search list of HD6 and, for a file starting with #!, the interpreter the search list finds (for #!/usr/bin/env node: env and the node in the search list). Codes: helper_untrusted (binary, definition, endpoint folder, configuration file, or a setuid or setgid bit on the binary), claude_bin_untrusted (also a Windows value that is not an .exe), peer_mismatch (named with the session user of the channel), peer_check_unavailable (the platform has no proven peer check, Design note 11), ptrace_scope_low (Linux, kernel.yama.ptrace_scope below 1), helper_unprotected (macOS: the binary lacks the hardened runtime flag or carries get-task-allow). A missing interpreter in the search list leaves host_service.claude_bin checked but counting as empty for HD6 and the profile (D-W-85).

  9. HD6 and the fake claude. In each helper, at Full and Light: run the resolved host_service.claude_bin with the arguments auth status, the environment HOME (Windows USERPROFILE), CLAUDE_CONFIG_DIR of the check and PATH equal to the search list, no startup files, bounded by host_service.auth_status_timeout; once with the default folder in the home and once with .config/bothy-host/claude (the helper creates that folder with mode 0700, on Windows an ACL only for its user, if it is missing). Read only the JSON member authMethod; a value outside none, claude.ai, oauth_token, api_key, api_key_helper, third_party, an unreadable output, a timeout or an exit code other than 0 and 1 gives auth_status_unknown (fail closed, RK-14). Login locations: .claude/.credentials.json in the home, .credentials.json in each checked CLAUDE_CONFIG_DIR, and a probe of the session user's login shell (Windows: the user's environment in its registry hive) that prints only whether CLAUDE_CODE_OAUTH_TOKEN is set and the value of CLAUDE_CONFIG_DIR, never a token. A finding is authMethod claude.ai or oauth_token, a login file, or the variable set. Evaluation with the declaration: none or organization and a finding fails subscription_login_detected per session user; personal passes; an unknown declaration (never received) gives the warning declaration_unknown and the local output names the accounts, because no session can start before ready delivers it (Design note 15); an empty or empty-counting host_service.claude_bin gives the warning auth_status_unchecked and only the locations are asked. Ruling for docs/DECISIONS.md for auth_status_unknown and declaration_unknown. a. Before the build, check the cited Claude Code pages (cli-reference, authentication, setup) for the arguments and the JSON of claude auth status and the login locations; a difference from 24 7.4 updates 24 7.4 first (report). b. testdata/fakeclaude/main.go behaves like claude auth status: it prints the fixture named by the file fake-auth in the checked CLAUDE_CONFIG_DIR or .claude folder (none, claude.ai, oauth_token, api_key, unknown_method, garbage, hang), and appends one line with its name to a start log whose path the test sets at build time with -ldflags -X. A second build named node serves as interpreter and as the wrong node of TM-T27. c. Fixtures: one recorded from a pinned Claude Code CLI without login, in a throwaway directory with an empty HOME and CLAUDE_CONFIG_DIR and DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set; if auth status needs the network, record nothing and say so in the report. The others are derived from the documented format; README.md names the CLI version, the URLs and the date.

  10. HD7 and HD11. HD7 is warnings only: workdir_dropped (a key that is no session user name with its list, a folder its helper cannot resolve, a folder under two session users from both lists, a display name that is empty or repeated), claude_profile_unavailable (organization or unknown declaration and host_service.claude_bin empty or counting as empty), profile_dropped (a command program missing or not absolute); one entry, the code by this precedence, the local output lists every item by profile name or display name. Each helper reports the identity of each working directory of its list; the service user compares them and builds the lists that pair and report send. HD11, place of a session user (home, CLAUDE_CONFIG_DIR, working directories; checked by its helper): owner, mode, ACL and groups of the place, of every folder on the way and of the chain files in those folders give no other session user write access and belong to no other; the place itself gives no other session user read or execute access; codes, one entry per session user by precedence home_unresolvable, shared_location, foreign_entry, entry_scan_timeout, entry_scan_running. HD11, place for all (helper binary, host_service.claude_bin, command programs; checked by the service user): trusted owner, writable by no session user; one entry without a name, drive_root_writable before program_location_untrusted. Entry scan (Full only, in each helper, concurrently per session user, bounded by host_service.entry_scan_timeout): no entry in a place of the session user belongs to another session user (foreign_entry). Its finding, a timeout included, stays until a later Full run finishes and finds nothing; Light never runs it and never lifts it. Ruling for docs/DECISIONS.md: before the build, check the current Claude Code pages (memory, settings, mcp) and the git page for files loaded from folders above the working directory; any further name extends the list in 24 2.2 point 6, 16 4.4, SEC-TERM-13, T-TM-13, RK-88 and D-W-68 in the same change (report).

  11. Local channel and peer check (internal/localchan). Endpoints: Linux /run/bothy-host/helper-<name>.sock (systemd socket unit, SocketUser=root, SocketGroup=bothy-host, SocketMode=0660, DirectoryMode=0755, Accept=no), macOS /Library/Application Support/bothy-host/run/helper-<name>.sock (LaunchDaemon Sockets with inetdCompatibility Wait = true, so the listening socket arrives on standard input without cgo; owner and mode as on Linux), Windows \\.\pipe\bothy-host-helper-<name> created by the helper with FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_REJECT_REMOTE_CLIENTS and a DACL for the service account only. Ruling for docs/DECISIONS.md (update 24 2.2 PTY helper point 3): with socket activation the operating system reports the service manager as the peer of a connecting client, so on Linux and macOS the helper creates a socketpair, passes one end with SCM_RIGHTS over the accepted connection, and all further messages run over that pair; bothy-host reads the peer of its end (SO_PEERCRED on Linux, LOCAL_PEERCRED and LOCAL_PEERPID on macOS) and compares user ID and process ID with the session user and the main PID the service manager reports (systemctl show --property=MainPID --value, launchctl print system/<label>); on Windows it compares GetNamedPipeServerProcessId and the process token's user SID with the session user and the process ID of QueryServiceStatusEx. The helper accepts only the service user as client (SO_PEERCRED, LOCAL_PEERCRED, GetNamedPipeClientProcessId). Before both checks pass, no message flows. If the platform run of TM-T26 cannot prove the mechanism for a platform, HD10 fails there with peer_check_unavailable (RK-23: no terminal without proof) and the report says so. Codec: a 4-byte big-endian length, one kind byte and the body; kind 0x00 is a JSON control message {"op", "id", "d"}, kind 0x01 is reserved for the session frames of M2-45, any other kind closes the channel; the limit is frame.MaxLen of M2-38 plus the header (protocol constant, rule 9). Ops of this ticket: hello (helper, protocol version 1), check.run (mode, the other session users, resolved host_service.claude_bin and search list, declaration, working directories), check.result.

  12. The helper (internal/helper). At start: Linux prctl(PR_SET_DUMPABLE, 0); Windows a process DACL without rights for its own user, with an OWNER RIGHTS entry that removes the owner's implicit rights; macOS relies on the hardened runtime that install signs in (Design note 13) and HD10 checks. It serves one channel to bothy-host, runs HD3, HD6, the helper part of HD8, HD9 and HD11 and reports the working directory identities for HD7, all as its session user; results carry codes, names and identities, never paths or contents. It closes the channel on any malformed message.

  13. Installation (internal/install). install computes a plan of steps per platform from the configuration and the running system and executes it through hostos; every step is idempotent, nothing existing is removed or overwritten except by session-user remove. Places of the product name (update 02 "Places that carry the product name"): program folder Linux /opt/bothy-host/bin/, macOS /Library/Application Support/bothy-host/bin/, Windows %ProgramFiles%\bothy-host\, into which install copies the running binary owned by a trusted owner, mode 0755, no setuid; systemd units bothy-host.service, bothy-host-helper-<name>.socket and .service; launchd labels bothy-host and bothy-host.helper.<name>; Windows services bothy-host and bothy-host-helper-<name>; pipes bothy-host-admin and bothy-host-helper-<name>. macOS: after copying, install signs the program binary ad hoc with the hardened runtime: /usr/bin/codesign --force --sign - --options runtime <program folder>/bothy-host, with no entitlements and so no get-task-allow. It skips this step when the binary already carries a signature with the runtime flag, so the Developer ID signature of M7-23 stays intact. HD10 reads the flags with /usr/bin/codesign --display --verbose=4 <binary> and the entitlements with /usr/bin/codesign --display --entitlements - --xml <binary>. Ruling for docs/DECISIONS.md. Users: Linux useradd --system for the service user with the state directory as home and no login shell, useradd --create-home --user-group per session user, home mode 0700; macOS hidden users created with dscl, the service user with the state directory as NFSHomeDirectory (HD9 lists it), each session user with an own primary group and not in staff, home created with createhomedir and mode 0700; Windows the virtual account NT SERVICE\bothy-host for the host service, and per session user a local account with a random password handed only to the service control manager, SeServiceLogonRight, the profile created with CreateProfile and its folder ACL only for the user and the trusted owners. The way per platform is proven by the platform suite and described in the README (24 2.2 "the build ticket proves the way to do this"). a. session-user add <name>: refuses a name in bindings.json (bothy-host session-user add: <name> is bound to a person on this host; create a new name), refuses a name already configured, creates user, home and helper service, adds the name to host_service.session_users, restarts the host service. b. session-user remove <name>: removes the name from host_service.session_users and its entry from host_service.claude_workdirs, removes the helper service, closes the home (mode 0700, Windows ACL only for the user and the trusted owners), restarts the host service, keeps the binding; it ends no process of the user and prints the operator steps of 16 4.4 point 1. c. Windows drive root: a pure function on the parsed DACL removes exactly the access-allowed entry for Authenticated Users (S-1-5-11) with the rights of (AD) and no inheritance flags, keeps every other entry and the control bits, and the executor writes it with SetFileSecurityW (through golang.org/x/sys/windows lazy loading), never with SetNamedSecurityInfo or SetSecurityInfo. The roots are the system drive and the drive of every configured place. d. Before the build, measure owner and ACL of C:\, C:\Users, C:\Program Files and C:\Windows\System32 with icacls on Windows 10 and Windows 11 with default rights (report); a result that differs from 24 2.2 point 6 changes 24 first.

  14. Service definitions and the exit policy. Templates in internal/install/units/ rendered per platform and checked by golden tests. Linux: the host service User=bothy-host, Restart=on-failure, RestartPreventExitStatus=78, UMask=0077; each helper socket unit as in Design note 11 and its service User=<name>, Restart=on-failure. The host unit and each helper service unit set neither NoNewPrivileges= nor any option that systemd.exec lists as implying it for a unit without CAP_SYS_ADMIN (for example SystemCallFilter=, RestrictSUIDSGID=, DynamicUser=). Under the no_new_privs flag every sudo -n true of the service user (or, in its helper, of a session user) fails, which would hide a passwordless sudo rule from HD3 (24 HD3). TestServiceDefinitionsGolden asserts their absence. Ruling for docs/DECISIONS.md. macOS: LaunchDaemons with UserName, KeepAlive {Crashed: true} and EnvironmentVariables GOTRACEBACK=crash, so a Go crash raises SIGABRT and is restarted while every exit with a code, 78 included, is not. Windows: services with failure actions restart on a crash, FailureActionsOnNonCrashFailures false, and a refused start stops with SERVICE_STOPPED, ERROR_SERVICE_SPECIFIC_ERROR and the service-specific code 78; the service DACL of a helper gives its session user no right to stop or reconfigure it. Ruling for docs/DECISIONS.md (24 2.2: "the build ticket proves the method per platform"); the platform suite proves both directions per platform.

  15. Start sequence and the declaration (internal/startup). run: load the configuration (Design note 3 d), open the state, open the admin endpoint (Linux and macOS <state_dir>/admin.sock, clients only with user ID 0; Windows \\.\pipe\bothy-host-admin with PIPE_REJECT_REMOTE_CLIENTS, a DACL for SYSTEM and Administrators, clients only elevated members of Administrators), run Full. At start HD6 uses the declaration stored in declaration.json; it is unknown only if no ready was ever stored. If the host is paired, run calls Control.Login before deciding. The declaration of the ready replaces the stored one in declaration.json, and HD6 is evaluated again with it. A remaining finding with the consequence "Start refused" (HD1 to HD4, HD8, HD9, HD10, HD11 at a place for all, HD6 at a known none or organization) refuses the start: exactly one Control.Report with all checks, Control.Close, exit 78. Without such a finding, Control.Report follows and the host waits for M2-43 to M2-45. If the host is not paired or Login fails, the stored declaration decides: log the codes and, on such a finding, exit 78. A later change of a result (an entry scan ends, a ready with another declaration) sends a new report. The doctor command against a running service makes it run Full, return the result (op doctor.run of the admin endpoint, answering with the local result and the report entries as HostDoctorCheck items, which the platform suite reads directly), report it, and on a "Start refused" finding other than HD6, HD10 and HD11 end like a refused start. Readiness(sessionUser) tells M2-45 whether a session user may get sessions (HD11 per session user, entry_scan_running) and whether the host may (every other finding). Control is Login(ctx) (Ready, error), Report(ctx, Report) error, Close() error; this ticket wires NoControl, which answers every Login with ErrUnreachable, exactly the case 24 2.2 names, and M2-43 replaces the wiring. Ruling for docs/DECISIONS.md (update 24 2.2 and 7.4, openapi HostReady, asyncapi hostControl): the host service learns hosts.llm_credentials only from ready, the server sends ready again on every change of the declaration (M2-43 builds it), and the host treats each ready as the current declaration; until a ready was ever stored the declaration is unknown, and no session is possible then.

  16. pair (internal/pairing). Refuse any URL that is not https:// (pair: the server URL must use https); trust the system roots plus --ca-file. Generate the X25519 key pair with M2-38's noise.GenerateKeyPair(crypto/rand.Reader) and an Ed25519 key pair with crypto/ed25519; compute the fingerprint with fingerprint.Compute; POST <server>/api/v1/hosts/pair with HostPairRequest (noise_public_key base64url without padding, auth_public_jwk with kty, crv, x only, platform, os_version, host_version, profiles after HD7, session_users after HD7). The lists come from the last Full run of the running service; in the direct path without a running service the helpers are asked over their endpoints, and an unreachable helper gives its session user an empty list with the warning pair: working directories follow with the first report. On 201: if key_fingerprint differs from the own computation, store nothing and exit 1 (pair: fingerprint mismatch); else write keys and pairing.json atomically (a new pair replaces the old keys only now), print fingerprint: XXXX-XXXX-... and host: <host_id>, and, when an administrator called it, restart the host service. On a problem answer print pair: server refused: <code> with reason when present and, for 400 validation_failed, each errors[].code with its pointer, then exit 1; on 401 unauthenticated (M2-41 answers every redemption failure so, also a retry after a lost answer, because the code is used) add the hint ask an owner for a new pairing code. After a 400 the same code stays valid, so the operator fixes the configuration and runs pair again. The code, the private keys and the request body appear in no output or log. Ruling for docs/DECISIONS.md: pair needs the identity of the service user (Design note 1) and restarts the service so that M2-43 logs in with the new keys.

  17. 16 4.4 "Installation of the host service on a host" (English): the configuration file and its path (Design note 3), the program folder, the commands and flags of Design note 2, pair with administrator rights and the restart, the exit code 78 and bothy-host restart after a refused start, the drive root prompt and --drive-root-confirm, the way per platform (Design notes 13 and 14), the operator step for a platform without a proven peer check.

  18. 24 changes (English, each with its Ruling row): 2.2 the table "Codes of doctor" (Design notes 6, 8, 9, 10), the accounts HD9 checks in its row, point 4 and "Reason for HD9" (6), the socketpair of the peer check (11), the configuration file as a place checked by HD10 (3, 8), the state directory contents (4), the declaration from ready (15), the definitions of the exit policy (14); 4.2 the state files; 7.4 auth_status_unknown, declaration_unknown, host_service.auth_status_timeout; 16 R-TM-17 (the accounts of HD9, 6); 17 the new key and the measured default of host_service.entry_scan_timeout.

  19. Platform suite and measurements. test/platform (build tag platform) runs the built binary through install, session-user add, remove, restart, doctor, run and pair on a disposable machine with administrator rights and cleans up after itself; make -C services/host platform-test builds the binary and the fake claude and runs it with go test -tags platform -count=1 ./test/platform/... (on Windows the same go test line). Before each platform test that needs a declaration (organization in TestPlatform_TMT13, TestPlatform_TMT21_SessionUserAddRemove, TestPlatform_TMT27 and TestPlatform_TMT29_Parts (f)) or a binding (TestPlatform_TMT21_SessionUserAddRemove, TestPlatform_TMT24), the platform suite writes declaration.json and bindings.json through internal/state with the service user as owner, because NoControl never delivers a ready and no session of this ticket writes a binding. Each platform run writes a result file (platform, OS version, Go version, commit, test names, pass or fail, durations) that the report lists. Entry scan measurement: go run ./test/platform/bighome creates a synthetic home of one million entries (files and folders mixed, depth up to twelve) for a session user; the measured duration of the entry scan per platform goes into the report, and the default of host_service.entry_scan_timeout becomes three times the slowest measured duration rounded up to a whole minute (24 17, configuration reference, RK-90). Ruling for docs/DECISIONS.md.

  20. Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 2, 3 and 3c, 3d, 4, 5, 6 (codes, HD3 on Windows, the accounts of HD9), 9 (auth_status_unknown, declaration_unknown), 10 (chain file check result), 11, 13 (paths, users, the macOS ad hoc signature), 14 (also no NoNewPrivileges=), 15, 16, 19, the root Makefile cross-check. Facts already in the spec (the per-platform terms, the rule for names, the SetFileSecurityW choice) are not repeated there.

Tests (write them first)

Unit tests in services/host (make check on any build host, no administrator rights, hostostest and clocktest; world: service user bothy-host, session users bothy-s-alex (A), bothy-s-sam (B) and bothy-s-robin, server https://chat.example.com):

  • internal/config: TestLoadDefaultsPerPlatform, TestLoadRejectsUnknownAndDuplicateKeys, TestProfilesForms (shell, claude, a command mapping, a relative program), TestClaudeWorkdirsForm, TestDurationKeys (host_service.auth_status_timeout included), TestAddRemoveSessionUserKeepsComments (remove drops the name and its host_service.claude_workdirs entry, nothing else changes).
  • internal/state: TestLayoutModes (directory 0700, every file 0600), TestAtomicWrite (crash between temp and rename leaves the old file), TestPairingRecordHasNoPrivateMember, TestBindings, TestDeclaration.
  • internal/doctor: TestHD1 (empty list session_users_empty; root, Administrator, SYSTEM privileged_user; Bothy-S-Alex next to bothy-s-alex session_user_duplicate; the service user as session user; unknown user; an invalid name), TestHD2 (each fixed group, an extra group, Docker socket writable by mode, by ACL and by group, the fixed list cannot be shortened), TestHD3 (sudo probe succeeds for the service user and for one session user; Windows passes without code), TestHD4, TestHD5 (loopback, own interface, foreign address), TestHD6 (declaration none, organization, personal, unknown, each against authMethod none, claude.ai, oauth_token, api_key, api_key_helper, third_party, an unknown method, garbage, a timeout at host_service.auth_status_timeout, a login file in the home, in .config/bothy-host/claude, the variable set; host_service.claude_bin empty, unresolvable, script without interpreter in the search list: warning auth_status_unchecked; one entry per affected session user), TestHD7 (each drop rule and the precedence of the code; the lists after HD7), TestHD8, TestHD9 (former session user named, other users one entry without a name, local output names all; daemon (UID 1, home /usr/sbin, listable) skipped; a macOS system account (UID below 501) that owns a 0755 home skipped; the service user with a system UID checked; root checked; a person account (Linux UID 1000, macOS UID 501) with a listable home fails and is named locally; UID_MIN and UID_MAX from a login.defs fixture and their defaults when a key is missing; a Windows profile under ProfileList checked, the values Default and Public not), TestHD10 (trusted owner per platform; each write right of 24 2.2 point 6 and D-W-86 as its own row for Linux, macOS and Windows, an inherit-only entry ignored for the folder itself, GENERIC_WRITE and GENERIC_ALL expanded, an unmappable entry fails; configuration file; host_service.claude_bin chain, interpreter, each search list folder; .cmd on Windows; setuid bit; ptrace_scope_low; an adhoc,linker-signed macOS binary without runtime gives helper_unprotected; a macOS binary with get-task-allow gives helper_unprotected; peer_mismatch named; peer_check_unavailable), TestHD11 (place of a session user: writable or owned by another at the place, on the way and at each chain file, readable place; place for all; drive_root_writable; unresolvable home; the precedence of the codes).
  • internal/doctor: TestPathResolution (chain of links, a dangling link, a closed folder on the way, a link of a trusted owner pointing into a folder of A fails like (a), a configured component checked), TestIdentityCompare (the same folder through a link and in other case is one folder; dropped from both lists).
  • internal/doctor: TestEntryScanSemantics (finding stays after Light; a Full without finding lifts it; timeout gives entry_scan_timeout with the name; while running, entry_scan_running only for that session user and the others are ready), TestReportNamesRule (D-W-109 for HD6, HD9, HD10, HD11; check and session_user unique), TestReportHasNoPathsOrValues (a canary path, a canary token value and a canary file content appear nowhere in the report), TestDoctorOutputFormatAndExitCode (format of Design note 2, exit 0 with warnings only, 1 with one failure, HD9 user and drive named locally), TestDoctorOutputHasNoPathsOrValues.
  • internal/localchan: TestPeerCheckWrongUser, TestPeerCheckWrongPID, TestNoMessageBeforePeerCheck, TestHelperRejectsOtherClient, TestCodecLimits (oversized, unknown kind, kind 0x01 refused in this ticket), FuzzDecodeMessage (any input gives a message or an error, never a panic).
  • internal/install: TestInstallPlan_Linux, TestInstallPlan_Darwin, TestInstallPlan_Windows (golden step lists with argument vectors; TestInstallPlan_Darwin contains the codesign step of Design note 13 with its argument vector, and no such step for a binary already signed with runtime), TestServiceDefinitionsGolden (the exit policy of Design note 14 on each platform; no NoNewPrivileges= and none of the options implying it in the Linux host unit and helper service units), TestSessionUserAddRefusesBoundName, TestSessionUserAddCreatesAndRestarts, TestSessionUserRemove (config both keys, helper service removed, home closed, restart, binding kept), TestDriveRootDACL (only the (AD) entry of Authenticated Users removed; order and control bits kept; the inheritable (OI)(CI)(IO)(M) entry stays; a DACL without the entry is unchanged), TestDriveRootDeclined (no write; HD11 drive_root_writable), TestRestartPlan (per platform: restart of a running and of a stopped host service after a refused start; no other service touched).
  • internal/startup: TestRefusedStartReportsThenExits (fake Control: one Login, exactly one Report with all checks, Close, exit 78, no session accepted; TM-T21 (g) host part), TestRefusedStartUnpairedLocalOnly, TestDeclarationFromReady (HD6 finding with unknown declaration starts; ready with organization refuses after one report; personal passes; stored in declaration.json; a second ready re-evaluates; stored organization, HD6 finding, ready with personal: no refusal, one report with HD6 ok, declaration.json now personal; stored organization, HD6 finding, Login fails: the codes logged and exit 78), TestDoctorCommandInRunningService (full run, report, HD9 finding ends the service with 78, HD11 finding only blocks its session user), TestSessionUserAddReportedAfterRestart (after the restart the report contains the new name with its list; TM-T21 (f) host part), TestNoControlIsUnreachable, TestConfigReadOnlyAtStart (a change of host_service.session_users or host_service.claude_workdirs in the file while run runs changes neither the checks nor the report until a restart), TestNoNetworkListener (every listener of the package is a Unix socket or a pipe).
  • internal/pairing: TestPairRequestBody (members of HostPairRequest only, no d in the JWK, no path, lists after HD7 with a duplicated folder dropped; TM-T29 (c) with pair), TestPairRequiresHTTPS, TestPairStoresKeysOnlyAfterAcceptance, TestPairFingerprintMismatch, TestPairServerRefusal, TestPairNeverPrintsCodeOrKeys (canary code in stdout, stderr, logs and errors).
  • cmd/bothy-host: TestCommandsRegistered, TestAdminRightsRequired (exit 2 with the message for each command), TestExitCodes.

Integration in the server module (make check; PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain behind an httptest TLS server on 127.0.0.1 whose certificate goes to --ca-file; people from identitytest; Robin is the owner):

  • TestHostPairAgainstServer: build bothy-host from services/host with go build -o <tmp> ./cmd/bothy-host (working directory resolved from the test file, GOTOOLCHAIN=local); Robin creates a host with step-up (createHost) and issues a code (issueHostPairingCode); bothy-host pair --server <url> --code <code> --ca-file <pem> --config <tmp> runs in the direct path (the test user owns the temporary state directory); exit 0; stdout shows the fingerprint in groups of four; getHost shows confirming, the same key_fingerprint, the configured names in reported_session_users; the state directory holds keys with mode 0600 and both private keys, and the captured request holds neither private key nor the state path; the pairing code is in no output; a second pair with the same code exits 1 with 401 unauthenticated and the hint of Design note 16, and the stored keys of the first pairing stay unchanged.

Platform suite (make -C services/host platform-test, build tag platform, administrator rights, on Linux with systemd, macOS, Windows 10 and Windows 11; each test name in the run file):

  • TestPlatform_TMT13: doctor with the service user as root, in sudo (admin on macOS, Administrators on Windows), in docker, with a writable Docker socket (Linux), with passwordless sudo (Linux and macOS) once for the service user and once for a session user, each started through the service manager with bothy-host restart and refused with passwordless_sudo, with a subscription login (fake claude claude.ai) at organization, with the same service and session user, with an empty host_service.session_users, with two equal session users: each start refused with exit 78 and the code; doctor exits 1 and prints the code.
  • TestPlatform_TMT22: a process as session user cannot list host_service.state_dir or read the key file; a helper whose user gets read access to the folder makes HD8 fail and the start refused.
  • TestPlatform_TMT24: as 24 14 without sessions: a session user that can list the home of a person account (Design note 6) that is not a session user (macOS: in staff with default home rights) gives HD9 refused with the user named locally and an unnamed report entry; without the right it passes; A opens her home: HD11 fails only for A with her name and B stays ready; after a restart HD9 passes and HD11 names A; A's long runner (crontab entry, on Windows a scheduled task) reopens her home after session-user remove: doctor exits 1, the report entries the admin endpoint returns have one HD9 entry with A's name, and the service ends with 78; after deleting A with her long runners and restart, HD9 passes.
  • TestPlatform_TMT26: a process of B kills B's helper and listens on the endpoint or creates the named pipe first: it gets no message, HD10 fails with peer_mismatch, and the report entries the admin endpoint returns name B; a second process attaches to the running helper (ptrace, task_for_pid, OpenProcess with write access) and fails; on macOS the installed binary carries the runtime flag. A platform where this cannot be proven fails HD10 with peer_check_unavailable and the report says so.
  • TestPlatform_TMT27: organization, a subscription login in B's account; a claude in B's PATH and startup files reporting api_key: HD6 runs only host_service.claude_bin, detects the login, start refused; host_service.claude_bin writable by B: HD10 fails; #!/usr/bin/env node with a wrong node in B's PATH: the start log shows only the checked node; a node in the search list writable by B, and on Linux /bin writable by B with the checked node in the folder of host_service.claude_bin: HD10 fails.
  • TestPlatform_TMT29_Parts: (a) a working directory of B writable by A with .claude/settings.json from A: HD11 fails with B's name, the fake hook and apiKeyHelper never start (start log); (b) a folder above B's working directory writable by A; (d) B's CLAUDE_CONFIG_DIR readable by A; (h) a CLAUDE.md of A above B's working directory, one of root writable by A, a .claude folder of A: each HD11 failed for B only, A stays ready; (e) an entry of A in B's working directory: HD11 foreign_entry for B; removed, a Light run keeps it, doctor lifts it; a home of B whose scan exceeds host_service.entry_scan_timeout gives entry_scan_timeout with B's name while A is ready first; (f) a command program writable by a session user, owned by another user that is neither session user nor trusted owner, inside a folder of such a user, the same three for host_service.claude_bin at organization, a .cmd on Windows: start refused (HD10, HD11), the report names no session user; (j) the link cases of 24 14 (j); (c) a folder under A and B dropped from both lists in the request of pair; (i, Windows 10 and 11) the full case of 24 14 (i) with a test folder in C:\ whose DACL has an inherited entry before an own one: before install and after a declined prompt HD11 drive_root_writable, start refused, ACL of C:\ unchanged; after confirmation only (AD) is gone, order and control bits kept, every direct subfolder's security descriptor equal before and after; HD10 and HD11 pass; mkdir C:\.git as A fails; an old C:\.git of A fails HD11 for every other session user with its name and passes after removal.
  • TestPlatform_TMT21_SessionUserAddRemove: session-user add with a name bound in bindings.json fails; with a new name it creates the user with a home only for it and the helper service and restarts the service (start counted); session-user remove removes the name from both keys, the helper service, closes the home, restarts; the binding stays; a refused start (HD6 at organization) is not restarted by the service manager (exactly one start within a wait of host_service.credential_check_interval), doctor names the account and exits 1; after removing the login and restart, HD6 passes.
  • TestPlatform_ExitPolicy: a crash (SIGSEGV, on Windows TerminateProcess without SERVICE_STOPPED) is restarted; exit 78 is not.
  • TestPlatform_DPAPIAndModes: keys is 0600 and owned by the service user, on Windows a DPAPI blob only the service account decrypts.
  • TestPlatform_NoNetworkListener: no process of the service or the helpers holds a TCP or UDP listening socket (ss, lsof, netstat).
  • TestPlatform_AdminEndpointPeer: a session user cannot connect to the admin endpoint; on Windows a pipe created first by a session user under the admin pipe name makes doctor and pair refuse to send anything.

Commands that must pass, from the repository root:

  • cd services/host && go test -race -count=1 ./...
  • cd services/server && go test -race -count=1 ./test/terminal/...
  • make arch, make codegen-check, make api-lint, make api-checks, make api-tables
  • make config-reference then make config-doc-check
  • make licenses, make osv, make docs
  • make -C services/host platform-test on each platform machine
  • make check

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

Red team focus of the milestone (PLAN 8 "Red team", terminal: privilege escalation from the shell and bypassing doctor, replacing or tapping the PTY helper, hooks, apiKeyHelper or CLAUDE.md of another person, a readable CLAUDE_CONFIG_DIR, a program owned by another account, a drive root under Windows, standstill by HD6 or a chain file) is covered by TestPlatform_TMT13, TestPlatform_TMT26, TestPlatform_TMT27, TestPlatform_TMT29_Parts and TestPlatform_TMT24; name them in the red team request.

Acceptance criteria

  • install, session-user add, session-user remove, restart, pair, doctor, run and pty-helper exist with the flags, identities and exit codes of Design notes 1 and 2.
  • TM-T13: doctor refuses the start with a code in each case, on macOS, Linux and Windows.
  • TM-T22: no session user reads the state directory or the key file; a helper with read access fails HD8 and the start is refused, on all three platforms.
  • TM-T24 without sessions: HD9 only against the homes of root, the service user, former session users and person accounts that are not session users (other system accounts skipped, Design note 6), with the former session user named in the report and every user named locally; an open home of a session user blocks only that session user under HD11 with its name, on all three platforms.
  • TM-T26: a replaced helper or a pipe created first gets no message, HD10 names the session user of that channel; attaching to the helper fails (on macOS with the hardened runtime that install signs in); a platform without proof fails HD10 with peer_check_unavailable.
  • TM-T27: HD6 runs only host_service.claude_bin with the fixed search list; a writable file, interpreter or search list folder fails HD10; the wrong node never runs, on all three platforms.
  • TM-T29 (a) up to the result of doctor, (b), (d), (e), (f), (h), (i) and (j), and (c) with the request of pair, as in Tests; Windows 10 and Windows 11 with default rights pass HD11 after install with confirmation, and only (AD) is removed from the drive root.
  • TM-T21 (e), (f) and (g) host parts: a bound name is refused; a new name is reported after the restart without another step; a refused start reports once and is not restarted by the service manager; doctor names the account and exits non-zero.
  • doctor reports codes and names only, never paths, contents or values; HD6 and HD11 have one entry per affected session user, HD10 names the session user of a wrong peer, HD9 names each former session user.
  • pair against the real server shows the fingerprint, and both private keys exist only in the state directory with mode 0600 or as a DPAPI blob.
  • The measurements of the Windows default ACLs and of the entry scan per platform are in the report; 24 and the default of host_service.entry_scan_timeout follow them; the list of chain files is checked against the current documentation.
  • HostReady.llm_credentials, host_service.auth_status_timeout, the doctor codes and the rulings of Design note 20 are in the specification and 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): commands, identities, files and paths per platform, the configuration file, the state layout, the doctor codes, the exit policy, the way per platform for users, helpers and the peer check, what M2-43 and M2-45 add.
  • Rulings of Design note 20 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 24, 16, 02, openapi.yaml, asyncapi.yaml, ADR 0068 "Confirmation" and RISKS of the Scope made in the same change; configuration reference regenerated.
  • services/host/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • The platform runs of all four machines recorded with their result files; 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.
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.

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