feat(orchestrator): durable launch-broker secret via TrustDomain (#468)
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / integration-docker (pull_request) Failing after 45s
test / unit (pull_request) Successful in 56s
test / coverage (pull_request) Has been skipped
lint / lint (push) Failing after 14m57s

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>
This commit is contained in:
2026-07-26 07:34:36 +00:00
parent 1553a98275
commit 5f1d3fbd15
8 changed files with 288 additions and 59 deletions
+87 -1
View File
@@ -29,8 +29,13 @@ from collections.abc import Mapping
from dataclasses import dataclass
from . import orchestrator_auth
from .orchestrator_auth import ROLE_GATEWAY
from .orchestrator_auth import ROLE_GATEWAY, ROLE_HOST
from .paths import (
HOST_CONTROLLER_AUTH_JWT_ENV,
HOST_CONTROLLER_KEY_ENV,
HOST_CONTROLLER_KEY_FILENAME,
LAUNCH_BROKER_KEY_ENV,
LAUNCH_BROKER_KEY_FILENAME,
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
ORCHESTRATOR_TOKEN_FILENAME,
@@ -99,6 +104,40 @@ CONTROL_PLANE = TrustDomain(
)
# The launch-broker domain (#468): durable key material for the broker's own
# signed launch requests (`broker.py`'s HS256 launch JWT), shared by the
# orchestrator (signer) and the host control server (verifier). Unlike
# `CONTROL_PLANE` it mints no role tokens — the broker's provenance is the launch
# JWT, not a role token — so its `roles` set is empty and it is used only as a
# provider of durable, host-canonical key material (`signing_key` / `key_from_env`).
# The durability is the point: the key survives orchestrator restarts, so a
# restarted orchestrator re-verifies against the same key instead of the
# ephemeral per-process secret the in-process broker used.
LAUNCH_BROKER = TrustDomain(
name="launch-broker",
key_filename=LAUNCH_BROKER_KEY_FILENAME,
roles=frozenset(),
key_env=LAUNCH_BROKER_KEY_ENV,
token_env="",
)
# The host controller's own domain (#468) — the SECOND domain #476 reserves. Its
# key, which the orchestrator never holds, signs the `host`-role tokens the CLI
# presents on the host controller's lifecycle endpoints (start / restart / status
# of the orchestrator itself). Keeping it separate from `CONTROL_PLANE` is the
# whole point: the host controller starts and stops the orchestrator, so the
# orchestrator must not be able to mint the credentials used to drive it. (The
# lifecycle endpoints themselves arrive in a later chunk; the domain is
# established here alongside the durable launch-broker key.)
HOST_CONTROLLER = TrustDomain(
name="host-controller",
key_filename=HOST_CONTROLLER_KEY_FILENAME,
roles=frozenset({ROLE_HOST}),
key_env=HOST_CONTROLLER_KEY_ENV,
token_env=HOST_CONTROLLER_AUTH_JWT_ENV,
)
@dataclass(frozen=True)
class ControlPlaneProvisioning:
"""The one seam every backend launcher uses to provision control-plane auth,
@@ -132,9 +171,56 @@ class ControlPlaneProvisioning:
return self.domain.mint(ROLE_GATEWAY)
@dataclass(frozen=True)
class LaunchBrokerProvisioning:
"""The seam that provisions the host-side launch broker's durable keys (#468),
the counterpart to `ControlPlaneProvisioning`. Both the orchestrator (signer)
and the host control server (verifier) receive the SAME launch-broker key
(carry it in `broker_domain.key_env`); the host controller ALSO receives its
own lifecycle key (`controller_domain.key_env`) the orchestrator never holds.
Fail-closed like the control-plane seam: minting returns "" only if the host
root is unwritable, and an empty launch-broker key would leave the verifier
unable to authenticate any launch — so we raise rather than hand back a key
that would make the host controller reject (or, if a caller defaulted it,
accept) unsigned input."""
broker_domain: TrustDomain = LAUNCH_BROKER
controller_domain: TrustDomain = HOST_CONTROLLER
def broker_key(self) -> str:
"""The durable launch-broker key both the orchestrator and the host
control server must receive (in `broker_domain.key_env`). Raises rather
than return ""."""
key = self.broker_domain.signing_key()
if not key:
raise ProvisioningError(
f"refusing to provision the {self.broker_domain.name} broker "
"without a signing key: the host controller could then verify no "
"launch request's provenance"
)
return key
def controller_key(self) -> str:
"""The host controller's own lifecycle key — provisioned ONLY to the host
controller (in `controller_domain.key_env`), never to the orchestrator, so
the orchestrator cannot mint the `host`-role tokens that start and stop
it. Raises rather than return ""."""
key = self.controller_domain.signing_key()
if not key:
raise ProvisioningError(
f"refusing to provision the {self.controller_domain.name} without "
"a signing key: its lifecycle endpoints would authenticate no one"
)
return key
__all__ = [
"ProvisioningError",
"TrustDomain",
"CONTROL_PLANE",
"LAUNCH_BROKER",
"HOST_CONTROLLER",
"ControlPlaneProvisioning",
"LaunchBrokerProvisioning",
]