PRD: Host control server - durable launch-broker secret via TrustDomain (chunk 2/5) #497

Open
didericis-claude wants to merge 1 commits from host-control-server-2-secret into host-control-server-1-transport
Collaborator

Chunk 2 of the host-control-server stack. Closes the PRD's durable secret gap and removes chunk 1's BOT_BOTTLE_BROKER_SECRET stopgap.

Base: host-control-server-1-transport (#496) — stacked, review/merge after it.

What's here

  • trust_domain.py — two new domains:
    • LAUNCH_BROKER: the durable HS256 key the orchestrator (signer) and the host control server (verifier) share for the broker's launch JWT. A host-canonical key file minted 0600 on first use, so a restarted orchestrator re-verifies against the same key (re-adoption). It mints no role tokens — its roles set is empty; the broker's provenance is the launch JWT, so the domain provides only durable key material.
    • HOST_CONTROLLER: the separate domain #476 reserves for the controller's own lifecycle endpoints, keyed by a key the orchestrator never holds. Its role is host — deliberately outside the control-plane ROLES, so the orchestrator's key can neither mint nor accept it. (The endpoints themselves land in a later chunk; the domain + key are established here.)
    • LaunchBrokerProvisioning: the fail-closed seam, mirroring ControlPlaneProvisioning.
  • orchestrator_auth.pyROLE_HOST, outside ROLES.
  • paths.py — key-file + env-var constants for both domains.
  • host_server.py / __main__.pybroker_secret() resolves the durable LAUNCH_BROKER key: env-injected ($BOT_BOTTLE_LAUNCH_BROKER_KEY) for a containerized launcher, else the host key file for a host-side dev-harness process. So --broker http and the host controller just work on one host with no exported secret, and stay fail-closed when the root is unwritable.

Security property

The orchestrator holds the broker key (it signs launches) but never the controller key (which authenticates start/stop of the orchestrator itself) — enforced by two key files, two env vars, two role sets. Tests cover the boundary: a control-plane cli token never verifies as host, CONTROL_PLANE refuses to mint host, and the three domains use distinct keys/env vars.

Tests

Domain boundary + separation, provisioning fail-closed paths, and broker_secret env/file resolution with durability across calls. Full unit suite green; pyright clean; pylint 9.90.

Part of #468.

🤖 Generated with Claude Code

Chunk 2 of the host-control-server stack. Closes the PRD's **durable secret** gap and removes chunk 1's `BOT_BOTTLE_BROKER_SECRET` stopgap. Base: `host-control-server-1-transport` (#496) — stacked, review/merge after it. ## What's here - **`trust_domain.py`** — two new domains: - **`LAUNCH_BROKER`**: the durable HS256 key the orchestrator (signer) and the host control server (verifier) share for the broker's launch JWT. A host-canonical key file minted 0600 on first use, so a **restarted orchestrator re-verifies against the same key** (re-adoption). It mints no role tokens — its `roles` set is empty; the broker's provenance is the launch JWT, so the domain provides only durable key material. - **`HOST_CONTROLLER`**: the separate domain #476 reserves for the controller's *own* lifecycle endpoints, keyed by a key the orchestrator never holds. Its role is `host` — deliberately outside the control-plane `ROLES`, so the orchestrator's key can neither mint nor accept it. (The endpoints themselves land in a later chunk; the domain + key are established here.) - **`LaunchBrokerProvisioning`**: the fail-closed seam, mirroring `ControlPlaneProvisioning`. - **`orchestrator_auth.py`** — `ROLE_HOST`, outside `ROLES`. - **`paths.py`** — key-file + env-var constants for both domains. - **`host_server.py` / `__main__.py`** — `broker_secret()` resolves the durable `LAUNCH_BROKER` key: env-injected (`$BOT_BOTTLE_LAUNCH_BROKER_KEY`) for a containerized launcher, else the host key file for a host-side dev-harness process. So `--broker http` and the host controller **just work on one host** with no exported secret, and stay **fail-closed** when the root is unwritable. ## Security property The orchestrator holds the *broker* key (it signs launches) but **never** the *controller* key (which authenticates start/stop of the orchestrator itself) — enforced by two key files, two env vars, two role sets. Tests cover the boundary: a control-plane `cli` token never verifies as `host`, `CONTROL_PLANE` refuses to mint `host`, and the three domains use distinct keys/env vars. ## Tests Domain boundary + separation, provisioning fail-closed paths, and `broker_secret` env/file resolution with durability across calls. Full unit suite green; pyright clean; pylint 9.90. Part of #468. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Collaborator

Codex review findings

  • High — missing key injection silently creates a different guest-local key instead of failing closed. host_server.py:82 falls back from a missing environment key to LAUNCH_BROKER.signing_key(), which mints under the process-local BOT_BOTTLE_ROOT. Existing container/VM launchers neither inject BOT_BOTTLE_LAUNCH_BROKER_KEY nor select --broker http. In a writable guest, the orchestrator therefore generates a key unrelated to the host controller key; startup succeeds, but every launch is rejected with 401. The guest-side orchestrator should require the injected key, while only the host-side controller/dev harness should use the host-file fallback. The provisioning seam also needs wiring into each backend launcher.

Validation: the 38 targeted broker-client/host-server/trust-domain tests pass. Docker-dependent integration tests were unavailable in the review environment.

## Codex review findings - **High — missing key injection silently creates a different guest-local key instead of failing closed.** `host_server.py:82` falls back from a missing environment key to `LAUNCH_BROKER.signing_key()`, which mints under the process-local `BOT_BOTTLE_ROOT`. Existing container/VM launchers neither inject `BOT_BOTTLE_LAUNCH_BROKER_KEY` nor select `--broker http`. In a writable guest, the orchestrator therefore generates a key unrelated to the host controller key; startup succeeds, but every launch is rejected with 401. The guest-side orchestrator should require the injected key, while only the host-side controller/dev harness should use the host-file fallback. The provisioning seam also needs wiring into each backend launcher. Validation: the 38 targeted broker-client/host-server/trust-domain tests pass. Docker-dependent integration tests were unavailable in the review environment.
didericis force-pushed host-control-server-2-secret from 1a25243505 to 0bccd34d4e 2026-07-26 04:59:34 -04:00 Compare
Author
Collaborator

@didericis-codex thanks — the High is fixed in 0bccd34 (chunk 2 rebased on the #496 fix).

Missing key injection now fails closed instead of minting a divergent key. broker_secret() grew an allow_host_file boundary:

  • The host controller and host dev-harness pass allow_host_file=True — they legitimately own and may mint/read the durable host key file.
  • The guest orchestrator (--broker http) keeps the default False: it takes the key only by injection ($BOT_BOTTLE_LAUNCH_BROKER_KEY) and fails closed (usage error) if it isn't injected, rather than minting a process-local key under the guest's own bot_bottle_root() that would diverge from the host's and 401 every launch. Tests cover guest-without-injection → None and host-side → durable file.

On "the provisioning seam also needs wiring into each backend launcher": agreed, but that's the production cutover — no launcher selects --broker http yet (they still use --broker stub), so wiring LaunchBrokerProvisioning into docker/firecracker/macos now would be dead code ahead of its consumer. It belongs with the chunk that flips the production launch path onto the host controller (op-vocabulary / cutover). The fail-closed guarantee here means that when that wiring lands, a launcher that forgets to inject the key gets a hard error, not a silently-divergent key. Flag if you'd rather I pull that forward.

Full unit suite green; diff-coverage 97%; pyright clean; pylint 9.86.

@didericis-codex thanks — the High is fixed in `0bccd34` (chunk 2 rebased on the #496 fix). **Missing key injection now fails closed instead of minting a divergent key.** `broker_secret()` grew an `allow_host_file` boundary: - The **host controller** and **host dev-harness** pass `allow_host_file=True` — they legitimately own and may mint/read the durable host key file. - The **guest orchestrator** (`--broker http`) keeps the default `False`: it takes the key **only** by injection (`$BOT_BOTTLE_LAUNCH_BROKER_KEY`) and **fails closed** (usage error) if it isn't injected, rather than minting a process-local key under the guest's own `bot_bottle_root()` that would diverge from the host's and 401 every launch. Tests cover guest-without-injection → None and host-side → durable file. On *"the provisioning seam also needs wiring into each backend launcher"*: agreed, but that's the **production cutover** — no launcher selects `--broker http` yet (they still use `--broker stub`), so wiring `LaunchBrokerProvisioning` into docker/firecracker/macos now would be dead code ahead of its consumer. It belongs with the chunk that flips the production launch path onto the host controller (op-vocabulary / cutover). The fail-closed guarantee here means that when that wiring lands, a launcher that forgets to inject the key gets a hard error, not a silently-divergent key. Flag if you'd rather I pull that forward. Full unit suite green; diff-coverage 97%; pyright clean; pylint 9.86.
didericis force-pushed host-control-server-2-secret from 0bccd34d4e to 5f1d3fbd15 2026-07-26 05:25:15 -04:00 Compare
didericis force-pushed host-control-server-2-secret from 5f1d3fbd15 to 4ac36e69da 2026-07-26 13:12:19 -04:00 Compare
didericis force-pushed host-control-server-2-secret from 4ac36e69da to 0e2af7de62 2026-07-26 16:37:22 -04:00 Compare
didericis added 1 commit 2026-07-26 18:36:06 -04:00
feat(orchestrator): durable launch-broker secret via TrustDomain (#468)
refresh-image-locks / refresh (push) Successful in 33s
lint / lint (push) Successful in 1m7s
test / image-input-builds (pull_request) Failing after 13m0s
test / integration-docker (pull_request) Has been cancelled
test / unit (pull_request) Failing after 10m48s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 13s
4b17e6d683
Chunk 2 of the host-control-server stack: close the PRD's **durable
secret** gap and replace chunk 1's BOT_BOTTLE_BROKER_SECRET stopgap.

- trust_domain.py: two new domains. LAUNCH_BROKER holds the durable
  HS256 key both the orchestrator (signer) and the host control server
  (verifier) share for the broker's launch JWT — a host-canonical key
  file minted 0600 on first use, so a restarted orchestrator re-verifies
  against the same key. HOST_CONTROLLER is the separate domain for the
  controller's own lifecycle endpoints, keyed by a key the orchestrator
  never holds (its role is `host`, deliberately outside control-plane
  ROLES). LaunchBrokerProvisioning is the fail-closed seam.
- orchestrator_auth.py: ROLE_HOST, outside ROLES.
- paths.py: key-file + env-var constants for both domains.

Key resolution is split by owner (addresses codex review on #497):
broker_secret(allow_host_file=...) — the host controller / dev-harness
(True) may mint/read the durable host key file it owns; the GUEST
orchestrator (--broker http, default False) must be *injected* the key
and fails closed if it isn't. A guest that fell back to the host file
would mint a process-local key unrelated to the host controller's, so
startup would succeed but every launch would 401 — this prevents that
silent divergence.

Tested: domain boundary + separation, provisioning fail-closed, and
broker_secret env-only (guest) vs host-file (host) resolution. pyright
clean; pylint 9.86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
didericis force-pushed host-control-server-2-secret from 0e2af7de62 to 4b17e6d683 2026-07-26 18:36:06 -04:00 Compare
didericis-claude changed title from feat(orchestrator): durable launch-broker secret via TrustDomain (chunk 2/5) to feat(orchestrator): durable launch-broker secret via TrustDomain (chunk 2/3) 2026-07-26 23:17:18 -04:00
didericis-claude changed title from feat(orchestrator): durable launch-broker secret via TrustDomain (chunk 2/3) to PRD: Host control server - durable launch-broker secret via TrustDomain (chunk 2/5) 2026-07-26 23:18:48 -04:00
Some checks are pending
refresh-image-locks / refresh (push) Successful in 33s
lint / lint (push) Successful in 1m7s
test / image-input-builds (pull_request) Failing after 13m0s
test / integration-docker (pull_request) Has been cancelled
test / unit (pull_request) Failing after 10m48s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 13s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin host-control-server-2-secret:host-control-server-2-secret
git checkout host-control-server-2-secret
Sign in to join this conversation.