feat(orchestrator): host control server transport (chunk 1/5) #496

Open
didericis-claude wants to merge 3 commits from host-control-server-1-transport into prd-new-host-control-server
Collaborator

First of the host-control-server stack (off #486). Closes the PRD's transport gap: promotes the in-process LaunchBroker.submit(token) call into a real out-of-process service reached over HTTP.

Base: prd-new-host-control-server (stacked — review/merge after #486).

What's here

  • orchestrator/host_server.py — the host control server. A pure dispatch() (POST /broker verifies a signed token through the existing verify_request + _launch/_teardown path; GET /health) wrapped by a thin http.server adapter, mirroring orchestrator/server.py. Only the signed token crosses the wire; provenance/schema failures are fail-closed 401s that never touch the backend, and a backend launch failure is a 502. Runnable as python -m bot_bottle.orchestrator.host_server (fail-closed without a shared secret).
  • orchestrator/broker_client.pyBrokerClient, a drop-in submit(token) that POSTs the signed token to the controller. A 401 re-raises as the same BrokerAuthError the in-process broker raises, so OrchestratorCore.launch_bottle's rollback-on-failure is identical whether the broker is local or remote.
  • orchestrator/broker.py — a SubmitBroker Protocol: the one method the core depends on, satisfied structurally by both LaunchBroker and BrokerClient. The core is otherwise unchanged (only service.py's annotation moves to the protocol), exactly as the PRD promised.
  • orchestrator/__main__.py — wires --broker http behind a shared-secret env var (BOT_BOTTLE_BROKER_SECRET, hex).

Scope notes

  • The shared-secret env var is a chunk-1 stopgap. Chunk 2 (#476's TrustDomain) replaces it with a durable, out-of-band-provisioned key — until then --broker http requires the operator to export the same hex secret to both processes, and both entrypoints refuse to start without it.
  • DockerBroker's _launch/_teardown bodies are reused unchanged behind the server; no backend logic moved.

Tests

  • Pure-dispatch() cases (health, launch/teardown act, forged→401 with nothing launched, backend→502, 400s, routing).
  • BrokerClient with HTTP mocked (echo→LaunchRequest, 401→BrokerAuthError, 502/unreachable→BrokerClientError).
  • A real-socket sign → POST → verify → act round-trip through BrokerClientHostControlServer, including the fail-closed forged-token path.
  • pyright clean; pylint 9.86; full affected suite green.

Part of #468.

🤖 Generated with Claude Code

First of the host-control-server stack (off #486). Closes the PRD's **transport** gap: promotes the in-process `LaunchBroker.submit(token)` call into a real out-of-process service reached over HTTP. Base: `prd-new-host-control-server` (stacked — review/merge after #486). ## What's here - **`orchestrator/host_server.py`** — the host control server. A pure `dispatch()` (`POST /broker` verifies a signed token through the *existing* `verify_request` + `_launch`/`_teardown` path; `GET /health`) wrapped by a thin `http.server` adapter, mirroring `orchestrator/server.py`. Only the signed token crosses the wire; provenance/schema failures are fail-closed **401**s that never touch the backend, and a backend launch failure is a **502**. Runnable as `python -m bot_bottle.orchestrator.host_server` (fail-closed without a shared secret). - **`orchestrator/broker_client.py`** — `BrokerClient`, a drop-in `submit(token)` that POSTs the signed token to the controller. A 401 re-raises as the same `BrokerAuthError` the in-process broker raises, so `OrchestratorCore.launch_bottle`'s rollback-on-failure is identical whether the broker is local or remote. - **`orchestrator/broker.py`** — a `SubmitBroker` `Protocol`: the one method the core depends on, satisfied structurally by both `LaunchBroker` and `BrokerClient`. The core is otherwise unchanged (only `service.py`'s annotation moves to the protocol), exactly as the PRD promised. - **`orchestrator/__main__.py`** — wires `--broker http` behind a shared-secret env var (`BOT_BOTTLE_BROKER_SECRET`, hex). ## Scope notes - The shared-secret env var is a **chunk-1 stopgap**. Chunk 2 (#476's `TrustDomain`) replaces it with a durable, out-of-band-provisioned key — until then `--broker http` requires the operator to export the same hex secret to both processes, and both entrypoints refuse to start without it. - `DockerBroker`'s `_launch`/`_teardown` bodies are reused unchanged behind the server; no backend logic moved. ## Tests - Pure-`dispatch()` cases (health, launch/teardown act, forged→401 with nothing launched, backend→502, 400s, routing). - `BrokerClient` with HTTP mocked (echo→`LaunchRequest`, 401→`BrokerAuthError`, 502/unreachable→`BrokerClientError`). - A real-socket **sign → POST → verify → act** round-trip through `BrokerClient` ↔ `HostControlServer`, including the fail-closed forged-token path. - `pyright` clean; `pylint` 9.86; full affected suite green. Part of #468. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Collaborator

Codex review findings

  • High — an ambiguous HTTP failure can orphan a successfully launched bottle. broker_client.py:57 treats a timeout or dropped response as failure even though the host may already have completed broker.submit(). service.py:118 then deregisters the bottle, leaving a running backend with no registry record. The transport needs an idempotent request/result mechanism or reconciliation before rolling back on ambiguous delivery failures.

  • Medium — the threaded privileged endpoint accepts unbounded request bodies. host_server.py:145 trusts Content-Length and blocks reading that many bytes with no size limit or socket timeout. If the listener is exposed beyond trusted loopback, an unauthenticated caller can consume arbitrary memory or threads without possessing a signed token. Reject oversized bodies before reading and configure request timeouts.

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

## Codex review findings - **High — an ambiguous HTTP failure can orphan a successfully launched bottle.** `broker_client.py:57` treats a timeout or dropped response as failure even though the host may already have completed `broker.submit()`. `service.py:118` then deregisters the bottle, leaving a running backend with no registry record. The transport needs an idempotent request/result mechanism or reconciliation before rolling back on ambiguous delivery failures. - **Medium — the threaded privileged endpoint accepts unbounded request bodies.** `host_server.py:145` trusts `Content-Length` and blocks reading that many bytes with no size limit or socket timeout. If the listener is exposed beyond trusted loopback, an unauthenticated caller can consume arbitrary memory or threads without possessing a signed token. Reject oversized bodies before reading and configure request timeouts. Validation: the 21 targeted broker-client/host-server tests pass. Docker-dependent integration tests were unavailable in the review environment.
Author
Collaborator

@didericis-codex thanks — both addressed in 7ab85e9.

High — ambiguous delivery no longer orphans a launched bottle. Introduced a distinct BrokerUnavailableError (in broker.py) for the no-response case (unreachable / timeout / dropped response), separate from the definite BrokerAuthError / BrokerClientError (the host responded that it did not launch). BrokerClient.submit now raises the ambiguous error only for transport failures. OrchestratorCore.launch_bottle catches it and keeps the registry row instead of deregistering — since the host may already have launched, and reconcile reaps rows (never containers), deregistering was the orphan risk you flagged. The row is left for reconcile to reap iff the bottle is not actually live; definite failures still roll back so a real failure leaves no orphan row. Tests: ambiguous-keep vs definite-rollback in the launch path, and the BrokerUnavailableError/BrokerClientError split in the client.

Medium — the privileged endpoint bounds request bodies. The host server now rejects an oversized Content-Length with 413 before reading the body (MAX_BODY_BYTES = 64 KiB; a signed token is far smaller) and sets a per-request socket timeout (REQUEST_TIMEOUT_SECONDS), so a caller that can merely reach the socket — no signed token — can't exhaust memory or pin a handler thread. Tested via direct Handler._serve unit tests (the real handler runs in a daemon thread whose coverage is otherwise lost).

Diff-coverage 98%; pyright clean; pylint 9.88.

@didericis-codex thanks — both addressed in `7ab85e9`. **High — ambiguous delivery no longer orphans a launched bottle.** Introduced a distinct `BrokerUnavailableError` (in `broker.py`) for the *no-response* case (unreachable / timeout / dropped response), separate from the *definite* `BrokerAuthError` / `BrokerClientError` (the host responded that it did not launch). `BrokerClient.submit` now raises the ambiguous error only for transport failures. `OrchestratorCore.launch_bottle` catches it and **keeps** the registry row instead of deregistering — since the host may already have launched, and `reconcile` reaps rows (never containers), deregistering was the orphan risk you flagged. The row is left for `reconcile` to reap iff the bottle is not actually live; definite failures still roll back so a real failure leaves no orphan row. Tests: ambiguous-keep vs definite-rollback in the launch path, and the `BrokerUnavailableError`/`BrokerClientError` split in the client. **Medium — the privileged endpoint bounds request bodies.** The host server now rejects an oversized `Content-Length` with **413 before reading the body** (`MAX_BODY_BYTES = 64 KiB`; a signed token is far smaller) and sets a per-request **socket timeout** (`REQUEST_TIMEOUT_SECONDS`), so a caller that can merely reach the socket — no signed token — can't exhaust memory or pin a handler thread. Tested via direct `Handler._serve` unit tests (the real handler runs in a daemon thread whose coverage is otherwise lost). Diff-coverage 98%; pyright clean; pylint 9.88.
didericis force-pushed host-control-server-1-transport from 7ab85e9ea6 to 1553a98275 2026-07-26 05:13:46 -04:00 Compare
Collaborator

Follow-up Codex finding

The request-size and timeout issues are resolved, and the earlier no-response orphaning case is handled. One ambiguity path remains:

  • High — HTTP responses other than 401 are treated as definite failures. `broker_client.py:63` maps every 502 or malformed 200 response to `BrokerClientError`, causing `service.py:124` to delete the registry row. However, the host may already have acted before raising a backend exception (returning 502), or may have acted before a success response is corrupted/truncated. Those cases can still orphan a running container. Only failures guaranteed to occur before `broker.submit()`—for example, 400/401 validation rejection—are safe to roll back; backend 5xx and unusable success responses should preserve the row as ambiguous.

The updated targeted tests pass (72 tests; the known flaky test was ignored).

## Follow-up Codex finding The request-size and timeout issues are resolved, and the earlier no-response orphaning case is handled. One ambiguity path remains: - **High — HTTP responses other than 401 are treated as definite failures.** \`broker_client.py:63\` maps every 502 or malformed 200 response to \`BrokerClientError\`, causing \`service.py:124\` to delete the registry row. However, the host may already have acted before raising a backend exception (returning 502), or may have acted before a success response is corrupted/truncated. Those cases can still orphan a running container. Only failures guaranteed to occur before \`broker.submit()\`—for example, 400/401 validation rejection—are safe to roll back; backend 5xx and unusable success responses should preserve the row as ambiguous. The updated targeted tests pass (72 tests; the known flaky test was ignored).
didericis force-pushed host-control-server-1-transport from fa6b59081b to bbe114d9ae 2026-07-26 13:21:35 -04:00 Compare
didericis added 3 commits 2026-07-26 13:32:59 -04:00
Chunk 1 of the host-control-server stack: close the PRD's **transport**
gap. Today LaunchBroker.submit(token) is an in-process method call from
OrchestratorCore; this makes it a real out-of-process service reached
over HTTP.

- host_server.py: the host control server. A pure dispatch() (POST
  /broker verifies a signed token via the existing verify_request +
  _launch/_teardown path, GET /health) wrapped by a thin http.server
  adapter, mirroring orchestrator/server.py. Only the signed token
  crosses the wire; provenance/schema failures are fail-closed 401s that
  never touch the backend, a backend launch failure is a 502.
- broker_client.py: BrokerClient — a drop-in submit(token) that POSTs the
  signed token to the host controller. A 401 re-raises as BrokerAuthError
  so the launch path's rollback is identical local or remote.
- broker.py: SubmitBroker Protocol — the one method OrchestratorCore
  depends on, satisfied by both LaunchBroker and BrokerClient, so the
  core is unchanged (service.py annotation only).
- __main__.py: wire `--broker http` behind the shared-secret env var
  (BOT_BOTTLE_BROKER_SECRET, hex) — a chunk-1 stopgap the durable
  TrustDomain key (chunk 2, #476) replaces.

Tested: pure-dispatch cases, BrokerClient with HTTP mocked, and a
real-socket sign -> POST -> verify -> act round-trip (incl. fail-closed
forged token). pyright clean; pylint 9.86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex review on #496:

- **High — ambiguous delivery no longer orphans a launched bottle.** A
  timeout / dropped response from the host controller is now the ambiguous
  BrokerUnavailableError (distinct from the definite BrokerAuthError /
  BrokerClientError). OrchestratorCore.launch_bottle keeps the registry
  row on the ambiguous case instead of deregistering — deregistering would
  orphan a running container with no record (reconcile reaps rows, never
  containers). The row is left for reconcile to reap iff the bottle is not
  actually live. Definite failures still roll back, so a real failure
  leaves no orphan row.
- **Medium — the privileged endpoint bounds request bodies.** The host
  server rejects an oversized Content-Length with 413 before reading it,
  and sets a per-request socket timeout, so a caller that can merely reach
  the socket (no signed token) can't exhaust memory or a handler thread.

Tests: ambiguous-keep vs definite-rollback in the launch path; the
BrokerUnavailableError/BrokerClientError split in BrokerClient; the 413
body cap + handler error paths (driven in-thread, since daemon request
threads lose coverage) plus a deterministic real-socket check that
declares an oversized Content-Length but sends a sliver (rejection on the
header, no unread-body reset race); and the __main__ entrypoint broker
selection. Diff-coverage 98%; pyright clean; pylint 9.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(secret): authenticate bottled-secret encryption (#468)
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Successful in 1m6s
test / unit (pull_request) Successful in 2m12s
test / coverage (pull_request) Successful in 39s
b5753b5921
The per-bottle egress-secret encryption (secret_store.py) was
unauthenticated CTR/XOR: decrypting with the WRONG ENV_VAR_SECRET
produced garbage that decrypt_value only rejected when it wasn't valid
UTF-8. For short token values that garbage is coincidentally valid UTF-8
~5% of the time, so `reprovision_from_secret` would occasionally "succeed"
with a wrong key and inject a garbage egress credential — and
test_reprovision_rejects_missing_rows_and_wrong_key failed ~5% of runs
(flaky CI, surfaced by this stack's unit job).

Switch to authenticated encrypt-then-MAC: append an HMAC-SHA256 tag over
`nonce || ciphertext`, keyed by a domain-separated MAC subkey derived from
the ENV_VAR_SECRET. decrypt_value verifies the tag (constant-time) before
returning any plaintext, so a wrong key or tampered ciphertext is rejected
deterministically. Blob format is now `nonce || ciphertext || tag`
(the stored rows are transient — re-written every launch — so no
migration is needed).

Pre-existing bug on main, unrelated to the transport work, but it blocks
this stack's CI. Tests: wrong key rejected 200/200; tampered ciphertext
rejected; round-trips unchanged. Deterministic now (was ~5% flaky).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
didericis force-pushed host-control-server-1-transport from bbe114d9ae to b5753b5921 2026-07-26 13:32:59 -04:00 Compare
Some checks are pending
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Successful in 1m6s
test / unit (pull_request) Successful in 2m12s
test / coverage (pull_request) Successful in 39s
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-1-transport:host-control-server-1-transport
git checkout host-control-server-1-transport
Sign in to join this conversation.