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

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>
This commit is contained in:
2026-07-26 08:43:00 +00:00
parent 79dd4926bb
commit 1553a98275
8 changed files with 340 additions and 30 deletions
+129
View File
@@ -7,10 +7,14 @@ the full sign -> POST -> verify -> act seam over HTTP.
from __future__ import annotations
import http.client
import io
import json
import secrets
import threading
import typing
import unittest
from unittest.mock import MagicMock, patch
from bot_bottle.orchestrator.broker import (
BrokerAuthError,
@@ -21,8 +25,12 @@ from bot_bottle.orchestrator.broker import (
)
from bot_bottle.orchestrator.broker_client import BrokerClient
from bot_bottle.orchestrator.host_server import (
MAX_BODY_BYTES,
Handler,
HostControlServer,
broker_secret_from_env,
dispatch,
main,
make_host_server,
)
@@ -97,6 +105,16 @@ class TestDispatch(unittest.TestCase):
status, _ = dispatch(self.broker, "POST", "/broker", b"{not json")
self.assertEqual(400, status)
def test_empty_body_is_missing_token_400(self) -> None:
# Empty body parses to {} (no token) → 400, never reaching the broker.
status, _ = dispatch(self.broker, "POST", "/broker", b"")
self.assertEqual(400, status)
self.assertEqual([], self.broker.launched)
def test_non_object_body_is_400(self) -> None:
status, _ = dispatch(self.broker, "POST", "/broker", b"[1, 2]")
self.assertEqual(400, status)
def test_unknown_route_404(self) -> None:
status, _ = dispatch(self.broker, "GET", "/nope", b"")
self.assertEqual(404, status)
@@ -152,5 +170,116 @@ class TestSeamRoundTrip(unittest.TestCase):
self.assertEqual([], broker.launched) # fail-closed across the wire
class TestRequestLimits(unittest.TestCase):
"""The privileged listener must not let a caller that can merely reach the
socket (no signed token) exhaust it via an oversized declared body — and it
rejects on the Content-Length *header*, before reading the body."""
def _addr(self) -> tuple[str, int]:
self.broker = StubBroker(secrets.token_bytes(16))
server = make_host_server(self.broker, "127.0.0.1", 0)
self.addCleanup(server.server_close)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.shutdown)
host, port = server.server_address[:2]
return typing.cast(str, host), port
def test_oversized_content_length_is_rejected_before_reading(self) -> None:
host, port = self._addr()
conn = http.client.HTTPConnection(host, port, timeout=5)
self.addCleanup(conn.close)
# Declare an oversized body but send only a sliver: the server must reject
# on the header before reading, so the caller gets a clean, deterministic
# 413 (no large unread body to race a connection reset).
conn.putrequest("POST", "/broker", skip_accept_encoding=True)
conn.putheader("Content-Type", "application/json")
conn.putheader("Content-Length", str(MAX_BODY_BYTES + 1))
conn.endheaders()
conn.send(b"{}") # far short of the declared length; never read
resp = conn.getresponse()
self.assertEqual(413, resp.status)
self.assertEqual([], self.broker.launched) # never reached the broker
class TestServeUnit(unittest.TestCase):
"""Drive `Handler._serve` directly (no socket). The real per-request handler
runs in a daemon thread whose coverage/trace data is lost, so the
bounded-body and error paths are exercised here in the main thread instead."""
def _handler(self, broker: LaunchBroker, headers: dict[str, str],
body: bytes = b"") -> tuple[Handler, MagicMock]:
server = HostControlServer.__new__(HostControlServer)
server.broker = broker
h = Handler.__new__(Handler)
h.server = server
h.headers = headers # type: ignore[assignment] — dict is a valid .get() stand-in
h.path = "/broker"
h.rfile = io.BytesIO(body)
h.wfile = io.BytesIO()
send_response = MagicMock()
h.send_response = send_response # type: ignore[method-assign]
h.send_header = MagicMock() # type: ignore[method-assign]
h.end_headers = MagicMock() # type: ignore[method-assign]
return h, send_response
def test_oversized_content_length_is_413(self) -> None:
broker = StubBroker(secrets.token_bytes(16))
h, send_response = self._handler(broker, {"Content-Length": str(MAX_BODY_BYTES + 1)})
h.do_POST() # exercises do_POST -> _serve
send_response.assert_called_once_with(413)
self.assertEqual([], broker.launched) # rejected before the broker
def test_invalid_content_length_is_400(self) -> None:
h, send_response = self._handler(StubBroker(secrets.token_bytes(16)),
{"Content-Length": "not-a-number"})
h._serve("POST")
send_response.assert_called_once_with(400)
def test_valid_request_dispatches_200(self) -> None:
secret = secrets.token_bytes(16)
broker = StubBroker(secret)
body = _body({"token": sign_request(
LaunchRequest(op="teardown", bottle_id="b1"), secret)})
h, send_response = self._handler(broker, {"Content-Length": str(len(body))}, body)
h._serve("POST")
send_response.assert_called_once_with(200)
self.assertEqual(["b1"], [r.bottle_id for r in broker.torn_down])
def test_dispatch_exception_becomes_500(self) -> None:
# dispatch is total, but the handler still guards it: a raised dispatch
# returns 500 rather than dropping the connection.
h, send_response = self._handler(
StubBroker(secrets.token_bytes(16)), {"Content-Length": "0"})
with patch("bot_bottle.orchestrator.host_server.dispatch",
side_effect=RuntimeError("boom")):
h._serve("POST")
send_response.assert_called_once_with(500)
def test_health_over_do_get(self) -> None:
h, send_response = self._handler(StubBroker(secrets.token_bytes(16)), {})
h.path = "/health"
h.do_GET()
send_response.assert_called_once_with(200)
class TestMain(unittest.TestCase):
def test_fail_closed_without_secret(self) -> None:
with patch("bot_bottle.orchestrator.host_server.broker_secret_from_env",
return_value=None):
self.assertEqual(2, main(["--port", "0"]))
def test_serves_then_shuts_down_cleanly(self) -> None:
fake = MagicMock()
fake.server_address = ("127.0.0.1", 0)
fake.serve_forever.side_effect = KeyboardInterrupt
with patch("bot_bottle.orchestrator.host_server.broker_secret_from_env",
return_value=b"k"), \
patch("bot_bottle.orchestrator.host_server.make_host_server",
return_value=fake):
self.assertEqual(0, main(["--port", "0"]))
fake.serve_forever.assert_called_once()
fake.server_close.assert_called_once()
if __name__ == "__main__":
unittest.main()