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

Open
didericis-claude wants to merge 2 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)
didericis-claude added 1 commit 2026-07-26 03:17:23 -04:00
feat(orchestrator): host control server transport (#468)
prd-number-check / require-numbered-prds (pull_request) Failing after 6s
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
lint / lint (push) Successful in 57s
test / unit (pull_request) Successful in 47s
test / coverage (pull_request) Failing after 16s
79dd4926bb
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>
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 added 1 commit 2026-07-26 05:13:46 -04:00
fix(orchestrator): address review on host control server transport (#468)
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 59s
test / unit (pull_request) Failing after 52s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 11m18s
1553a98275
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>
didericis force-pushed host-control-server-1-transport from 7ab85e9ea6 to 1553a98275 2026-07-26 05:13:46 -04:00 Compare
Some checks are pending
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 59s
test / unit (pull_request) Failing after 52s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 11m18s
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.