Compare commits

..

1 Commits

Author SHA1 Message Date
didericis-codex f33566941b fix(gateway): bound stdlib HTTP request work
test / image-input-builds (pull_request) Successful in 1m2s
test / unit (pull_request) Has started running
test / coverage (pull_request) Blocked by required conditions
test / integration-docker (pull_request) Blocked by required conditions
tracker-policy-pr / check-pr (pull_request) Failing after 12m29s
2026-07-27 04:46:00 +00:00
6 changed files with 280 additions and 49 deletions
+109
View File
@@ -0,0 +1,109 @@
"""Shared resource boundaries for gateway stdlib HTTP services."""
from __future__ import annotations
import http.server
import socket
import threading
import time
from dataclasses import dataclass
from typing import Any, Protocol
class Readable(Protocol):
def read(self, size: int = -1, /) -> bytes: ...
@dataclass(frozen=True)
class BodyReadError(Exception):
status: int
message: str
def read_declared_body(
stream: Readable,
connection: socket.socket,
raw_length: str | None,
*,
maximum: int,
timeout_seconds: float,
require_length: bool,
) -> bytes:
"""Validate and read exactly one declared body under a read deadline."""
if raw_length is None:
if require_length:
raise BodyReadError(411, "Content-Length required")
raw_length = "0"
try:
length = int(raw_length)
except ValueError as exc:
raise BodyReadError(400, "invalid Content-Length") from exc
if length < 0:
raise BodyReadError(400, "invalid Content-Length")
if length > maximum:
raise BodyReadError(413, "request body too large")
previous_timeout = connection.gettimeout()
deadline = time.monotonic() + timeout_seconds
chunks: list[bytes] = []
remaining = length
try:
while remaining:
timeout = deadline - time.monotonic()
if timeout <= 0:
raise BodyReadError(408, "request body read timed out")
connection.settimeout(timeout)
chunk = stream.read(min(remaining, 64 * 1024))
if not chunk:
raise BodyReadError(400, "incomplete request body")
chunks.append(chunk)
remaining -= len(chunk)
except TimeoutError as exc:
raise BodyReadError(408, "request body read timed out") from exc
finally:
connection.settimeout(previous_timeout)
return b"".join(chunks)
class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
"""ThreadingHTTPServer with a hard cap on in-flight request threads."""
daemon_threads = True
def __init__( # pylint: disable=consider-using-with
self, *args, max_workers: int = 32, **kwargs, # type: ignore[no-untyped-def]
):
if max_workers < 1:
raise ValueError("max_workers must be positive")
self._request_slots = threading.BoundedSemaphore(max_workers)
super().__init__(*args, **kwargs)
def process_request(
self, request: Any, client_address: Any,
) -> None:
if not self._request_slots.acquire( # pylint: disable=consider-using-with
blocking=False,
):
try:
request.sendall(
b"HTTP/1.1 503 Service Unavailable\r\n"
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
)
finally:
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self._request_slots.release()
raise
def process_request_thread(
self, request: Any, client_address: Any,
) -> None:
try:
super().process_request_thread(request, client_address)
finally:
self._request_slots.release()
__all__ = ["BodyReadError", "BoundedThreadingHTTPServer", "read_declared_body"]
+21 -13
View File
@@ -22,11 +22,16 @@ import os
import subprocess import subprocess
import sys import sys
import typing import typing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from bot_bottle.gateway.bounded_http import (
BodyReadError,
BoundedThreadingHTTPServer,
read_declared_body,
)
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
@@ -77,6 +82,8 @@ def resolve_sandbox_root(
# Bound memory use while still allowing ordinary git push packfiles. # Bound memory use while still allowing ordinary git push packfiles.
MAX_BODY_BYTES = 100 * 1024 * 1024 MAX_BODY_BYTES = 100 * 1024 * 1024
REQUEST_BODY_TIMEOUT_SECONDS = 30.0
MAX_REQUEST_WORKERS = 16
class GitHttpHandler(BaseHTTPRequestHandler): class GitHttpHandler(BaseHTTPRequestHandler):
@@ -184,19 +191,18 @@ class GitHttpHandler(BaseHTTPRequestHandler):
value = self.headers.get(header) value = self.headers.get(header)
if value: if value:
env[variable] = value env[variable] = value
raw_length = self.headers.get("content-length", "0") or "0"
try: try:
length = int(raw_length) body = read_declared_body(
except ValueError: self.rfile,
self.send_error(400, "Bad Content-Length") self.connection,
self.headers.get("content-length"),
maximum=MAX_BODY_BYTES,
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
require_length=False,
)
except BodyReadError as exc:
self.send_error(exc.status, exc.message)
return return
if length < 0:
self.send_error(400, "Negative Content-Length")
return
if length > MAX_BODY_BYTES:
self.send_error(413, "Request body too large")
return
body = self.rfile.read(length) if length else b""
proc = subprocess.run( proc = subprocess.run(
["git", "http-backend"], ["git", "http-backend"],
input=body, input=body,
@@ -273,7 +279,9 @@ def main() -> int:
"(no single-tenant flat-root fallback)\n" "(no single-tenant flat-root fallback)\n"
) )
return 1 return 1
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) server = BoundedThreadingHTTPServer(
("0.0.0.0", port), GitHttpHandler, max_workers=MAX_REQUEST_WORKERS,
)
# Resolve each request's sandbox namespace by source IP against the # Resolve each request's sandbox namespace by source IP against the
# orchestrator control plane. # orchestrator control plane.
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined] server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
+20 -7
View File
@@ -6,9 +6,8 @@ import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Protocol from typing import Callable, Protocol
from bot_bottle.gateway.egress.context import resolve_client_context from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
from bot_bottle.gateway.egress.schema import route_to_yaml_dict from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.gateway.policy_resolver import PolicyResolver
from bot_bottle.supervisor import types as _sv from bot_bottle.supervisor import types as _sv
@@ -24,6 +23,10 @@ class MethodNotFoundError(Exception):
"""Raised when a JSON-RPC method has no MCP handler.""" """Raised when a JSON-RPC method has no MCP handler."""
class RouteResolutionError(Exception):
"""The caller's live route table could not be resolved authoritatively."""
Handler = Callable[[dict[str, object]], object] Handler = Callable[[dict[str, object]], object]
@@ -60,10 +63,19 @@ def resolved_routes_payload(
source_ip: str, source_ip: str,
identity_token: str, identity_token: str,
) -> dict[str, object]: ) -> dict[str, object]:
"""Render the calling bottle's routes, failing closed to an empty list.""" """Render an authoritatively resolved route table for the calling bottle."""
config, _slug, _tokens = resolve_client_context( try:
resolver, source_ip, identity_token, policy, bottle_id, _tokens = resolver.resolve_policy_and_bottle_id(
) source_ip, identity_token,
)
except PolicyResolveError as exc:
raise RouteResolutionError("orchestrator unavailable") from exc
if not bottle_id:
raise RouteResolutionError("request source is not attributed to a bottle")
try:
config = load_config(policy or "")
except ValueError as exc:
raise RouteResolutionError("resolved policy is invalid") from exc
body = json.dumps( body = json.dumps(
{"routes": [route_to_yaml_dict(route) for route in config.routes]}, {"routes": [route_to_yaml_dict(route) for route in config.routes]},
indent=2, indent=2,
@@ -74,6 +86,7 @@ def resolved_routes_payload(
__all__ = [ __all__ = [
"Handlers", "Handlers",
"MethodNotFoundError", "MethodNotFoundError",
"RouteResolutionError",
"dispatch", "dispatch",
"resolved_routes_payload", "resolved_routes_payload",
] ]
+38 -24
View File
@@ -51,19 +51,24 @@ from __future__ import annotations
import http.server import http.server
import json import json
import os import os
import socketserver
import sys import sys
import time import time
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from bot_bottle.constants import IDENTITY_HEADER from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.gateway.bounded_http import (
BodyReadError,
BoundedThreadingHTTPServer,
read_declared_body,
)
from bot_bottle.gateway.egress.schema import load_config from bot_bottle.gateway.egress.schema import load_config
from bot_bottle.gateway.egress.types import LOG_OFF from bot_bottle.gateway.egress.types import LOG_OFF
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.gateway.supervisor.mcp_dispatch import ( from bot_bottle.gateway.supervisor.mcp_dispatch import (
Handlers as DispatchHandlers, Handlers as DispatchHandlers,
MethodNotFoundError, MethodNotFoundError,
RouteResolutionError,
dispatch, dispatch,
resolved_routes_payload, resolved_routes_payload,
) )
@@ -570,6 +575,8 @@ def format_unknown_proposal_text(proposal_id: str) -> str:
# Max request body the server accepts. 1 MB is well above any realistic # Max request body the server accepts. 1 MB is well above any realistic
# routes.yaml proposal. # routes.yaml proposal.
MAX_BODY_BYTES = 1 * 1024 * 1024 MAX_BODY_BYTES = 1 * 1024 * 1024
REQUEST_BODY_TIMEOUT_SECONDS = 10.0
MAX_REQUEST_WORKERS = 32
class MCPHandler(http.server.BaseHTTPRequestHandler): class MCPHandler(http.server.BaseHTTPRequestHandler):
@@ -592,19 +599,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
self._write_text(405, "use POST for MCP requests\n") self._write_text(405, "use POST for MCP requests\n")
def do_POST(self) -> None: def do_POST(self) -> None:
length_header = self.headers.get("Content-Length")
if length_header is None:
self._write_text(411, "Content-Length required\n")
return
try: try:
length = int(length_header) body = read_declared_body(
except ValueError: self.rfile,
self._write_text(400, "invalid Content-Length\n") self.connection,
self.headers.get("Content-Length"),
maximum=MAX_BODY_BYTES,
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
require_length=True,
)
except BodyReadError as exc:
self._write_text(exc.status, exc.message + "\n")
return return
if length < 0 or length > MAX_BODY_BYTES:
self._write_text(413, "request body too large\n")
return
body = self.rfile.read(length)
try: try:
req = parse_jsonrpc(body) req = parse_jsonrpc(body)
@@ -660,20 +666,25 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
identity_token=self._identity_token(), identity_token=self._identity_token(),
) )
return dispatch( def list_routes(_params: dict[str, object]) -> object:
req, try:
DispatchHandlers( return resolved_routes_payload(
initialize=handle_initialize,
tools_list=handle_tools_list,
list_routes=lambda _params: resolved_routes_payload(
self._resolver_or_fail(), self._resolver_or_fail(),
self.client_address[0], self.client_address[0],
self._identity_token(), self._identity_token(),
), )
check_proposal=check, except RouteResolutionError as exc:
propose=propose, raise _RpcInternalError(
), f"could not resolve live egress routes: {exc}"
) ) from exc
return dispatch(req, DispatchHandlers(
initialize=handle_initialize,
tools_list=handle_tools_list,
list_routes=list_routes,
check_proposal=check,
propose=propose,
))
def _identity_token(self) -> str: def _identity_token(self) -> str:
"""The agent's per-bottle identity token from the request header (the """The agent's per-bottle identity token from the request header (the
@@ -711,7 +722,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(encoded) self.wfile.write(encoded)
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class MCPServer(BoundedThreadingHTTPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
config: ServerConfig = ServerConfig() config: ServerConfig = ServerConfig()
@@ -720,6 +731,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
# closed per request (see `_resolver_or_fail`). # closed per request (see `_resolver_or_fail`).
policy_resolver: "PolicyResolver | None" = None policy_resolver: "PolicyResolver | None" = None
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
super().__init__(*args, max_workers=MAX_REQUEST_WORKERS, **kwargs)
# --- Entry point ----------------------------------------------------------- # --- Entry point -----------------------------------------------------------
+79
View File
@@ -0,0 +1,79 @@
"""Unit tests for shared gateway stdlib HTTP resource boundaries."""
# pylint: disable=protected-access
from __future__ import annotations
import io
import socket
import unittest
from bot_bottle.gateway.bounded_http import (
BodyReadError,
BoundedThreadingHTTPServer,
read_declared_body,
)
class _Handler:
pass
class _TimeoutStream:
def read(self, _size: int = -1, /) -> bytes:
raise TimeoutError
class TestDeclaredBody(unittest.TestCase):
def setUp(self) -> None:
self.left, self.right = socket.socketpair()
def tearDown(self) -> None:
self.left.close()
self.right.close()
def test_rejects_incomplete_body(self) -> None:
with self.assertRaisesRegex(BodyReadError, "incomplete"):
read_declared_body(
io.BytesIO(b"short"),
self.left,
"10",
maximum=100,
timeout_seconds=1,
require_length=True,
)
def test_maps_read_timeout(self) -> None:
with self.assertRaises(BodyReadError) as caught:
read_declared_body(
_TimeoutStream(),
self.left,
"1",
maximum=100,
timeout_seconds=1,
require_length=True,
)
self.assertEqual(408, caught.exception.status)
class TestBoundedServer(unittest.TestCase):
def test_saturated_server_rejects_without_spawning_thread(self) -> None:
client, peer = socket.socketpair()
with BoundedThreadingHTTPServer(
("127.0.0.1", 0), _Handler, max_workers=1, # type: ignore[arg-type]
) as server:
with client, peer:
# Directly reserve the only slot to model an in-flight handler.
self.assertTrue(
server._request_slots.acquire( # pylint: disable=consider-using-with
blocking=False,
),
)
try:
server.process_request(client, ("127.0.0.1", 1))
self.assertIn(b"503 Service Unavailable", peer.recv(1024))
finally:
server._request_slots.release()
if __name__ == "__main__":
unittest.main()
+13 -5
View File
@@ -717,18 +717,26 @@ class TestResolvedRoutesPayload(unittest.TestCase):
hosts = {r["host"] for r in data["routes"]} hosts = {r["host"] for r in data["routes"]}
self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts) self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts)
def test_orchestrator_error_fails_closed_to_empty(self) -> None: def test_orchestrator_error_is_not_reported_as_empty_policy(self) -> None:
# resolve_client_context swallows resolver errors → deny-all (empty), with self.assertRaises(supervise_server.RouteResolutionError):
# never another bottle's routes. resolved_routes_payload(
typing.cast(
supervise_server.PolicyResolver,
_FakeSuperviseResolver(raises=True),
),
_SRC,
_TOK,
)
def test_authoritative_empty_policy_remains_successful(self) -> None:
payload = resolved_routes_payload( payload = resolved_routes_payload(
typing.cast( typing.cast(
supervise_server.PolicyResolver, supervise_server.PolicyResolver,
_FakeSuperviseResolver(raises=True), _FakeSuperviseResolver(bottle_id="b1", policy="routes: []\n"),
), ),
_SRC, _SRC,
_TOK, _TOK,
) )
assert payload is not None
data = json.loads(payload["content"][0]["text"]) # type: ignore[index] data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
self.assertEqual([], data["routes"]) self.assertEqual([], data["routes"])