refactor: unify component naming — control_plane/control_auth -> orchestrator
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / integration-docker (pull_request) Successful in 18s
test / unit (pull_request) Successful in 46s
lint / lint (push) Failing after 54s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 18s
test / publish-infra (pull_request) Has been skipped

The codebase used "control plane" both as an architectural role term AND
as an identifier alias for the orchestrator component, producing
duplicate names for one thing (control_plane_url vs orchestrator_url,
CONTROL_PLANE_PORT, host_control_plane_token, …). Going forward the
concrete component is always named for what it is — Gateway or
Orchestrator — and the plane vocabulary is reserved for prose (module
descriptions, the security argument).

Renamed (identifiers + the in-repo env/wire/file string values, all
setters/getters are in this repo so the change is atomic):

  ControlPlaneServer            -> OrchestratorServer
  control_plane_url             -> orchestrator_url
  probe_control_plane_url       -> probe_orchestrator_url
  host_control_plane_token      -> host_orchestrator_token
  CONTROL_PLANE_PORT            -> ORCHESTRATOR_PORT
  CONTROL_PLANE_TOKEN_ENV/FILE  -> ORCHESTRATOR_TOKEN_ENV/FILENAME
  BOT_BOTTLE_CONTROL_PLANE_TOKEN-> BOT_BOTTLE_ORCHESTRATOR_TOKEN
  control-plane-token (file)    -> orchestrator-token

  control_auth (module)         -> orchestrator_auth  (stays top-level;
                                   the gateway imports it and must not
                                   import the orchestrator/ package)
  CONTROL_AUTH_HEADER           -> ORCHESTRATOR_AUTH_HEADER
  x-bot-bottle-control-auth     -> x-bot-bottle-orchestrator-auth
  CONTROL_AUTH_JWT_ENV          -> ORCHESTRATOR_AUTH_JWT_ENV
  BOT_BOTTLE_CONTROL_AUTH_JWT   -> BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT
  _control_auth_headers         -> _orchestrator_auth_headers

Prose plane-terms ("control plane", "data plane") are preserved,
including the test name test_data_plane_daemons_get_jwt_not_key (it
names the security invariant). Gateway and orchestrator verified to
agree on the renamed wire header; full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 18:17:48 -04:00
parent 4166057abc
commit ca1d341d4f
25 changed files with 146 additions and 147 deletions
+5 -5
View File
@@ -4,11 +4,11 @@ import os
import time
from pathlib import Path
from ...control_auth import ROLE_GATEWAY, mint
from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker
from ...paths import (
CONTROL_AUTH_JWT_ENV,
host_control_plane_token,
ORCHESTRATOR_AUTH_JWT_ENV,
host_orchestrator_token,
host_gateway_ca_dir,
)
from ...gateway import (
@@ -158,8 +158,8 @@ class DockerGateway(Gateway):
# a `cli` token, so a compromised data-plane process can't drive the
# operator routes (issue #469 review). Bare `--env NAME` keeps the value
# off argv / `docker inspect`; only the gateway (not the agent) is given it.
argv += ["--env", CONTROL_AUTH_JWT_ENV]
run_env[CONTROL_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_control_plane_token())
argv += ["--env", ORCHESTRATOR_AUTH_JWT_ENV]
run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token())
argv.append(self.image_ref)
proc = run_docker(argv, env=run_env)
if proc.returncode != 0:
+9 -9
View File
@@ -25,13 +25,13 @@ import urllib.request
from pathlib import Path
from ... import log
from ...control_auth import ROLE_GATEWAY, mint
from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker
from ...paths import (
CONTROL_AUTH_JWT_ENV,
CONTROL_PLANE_TOKEN_ENV,
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root,
host_control_plane_token,
host_orchestrator_token,
host_gateway_ca_dir,
)
from ...gateway import (
@@ -176,7 +176,7 @@ class DockerInfraService:
so a later `ensure_running` can detect a real code change."""
self._ensure_network()
run_docker(["docker", "rm", "--force", self._infra_name])
_signing_key = host_control_plane_token()
_signing_key = host_orchestrator_token()
proc = run_docker([
"docker", "run", "--detach",
"--name", self._infra_name,
@@ -204,8 +204,8 @@ class DockerInfraService:
# pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init
# scopes each to its process, so a compromised data-plane daemon never
# sees the key and can't mint a `cli` token (issue #469 review).
"--env", CONTROL_PLANE_TOKEN_ENV,
"--env", CONTROL_AUTH_JWT_ENV,
"--env", ORCHESTRATOR_TOKEN_ENV,
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
# Gateway daemons reach the orchestrator over loopback at its
# fixed internal port (DEFAULT_PORT), independent of self.port.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
@@ -214,8 +214,8 @@ class DockerInfraService:
self.image,
], env={
**os.environ,
CONTROL_PLANE_TOKEN_ENV: _signing_key,
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
ORCHESTRATOR_TOKEN_ENV: _signing_key,
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
})
if proc.returncode != 0:
raise OrchestratorStartError(
+1 -1
View File
@@ -120,4 +120,4 @@ class FirecrackerBottleBackend(
def ensure_orchestrator(self) -> str:
from . import infra_vm
return infra_vm.ensure_running().control_plane_url
return infra_vm.ensure_running().orchestrator_url
@@ -124,7 +124,7 @@ def launch_consolidated(
provision its git-gate state into the gateway VM. Returns the context the
agent-VM launch needs. Raises on failure — the caller tears down."""
infra = infra_vm.ensure_running()
url = infra.control_plane_url
url = infra.orchestrator_url
client = OrchestratorClient(url)
_reprovision_running_bottles(client)
+16 -16
View File
@@ -33,7 +33,7 @@ from pathlib import Path
from typing import Generator
from ...log import die, info
from ...paths import CONTROL_PLANE_TOKEN_FILENAME, bot_bottle_root
from ...paths import ORCHESTRATOR_TOKEN_FILENAME, bot_bottle_root
from .. import util as backend_util
from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError
@@ -42,7 +42,7 @@ from . import firecracker_vm, infra_artifact, netpool, util
# Where the infra VM keeps its control-plane signing key (generated on the
# persistent /dev/vdb volume mounted at BOT_BOTTLE_ROOT). The host mirrors it
# back so the CLI signs `cli` tokens the VM verifies (issue #469 review).
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/control-plane-token"
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/orchestrator-token"
# The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default;
@@ -52,7 +52,7 @@ _GATEWAY_IMAGE = "bot-bottle-gateway:latest"
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
_REPO_ROOT = Path(__file__).resolve().parents[3]
CONTROL_PLANE_PORT = 8099
ORCHESTRATOR_PORT = 8099
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
# git-http. Reached by agent VMs over VM-to-VM routing (added next).
EGRESS_PORT = 9099
@@ -84,8 +84,8 @@ class InfraVm:
vm: firecracker_vm.VmHandle | None = None
@property
def control_plane_url(self) -> str:
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}"
def orchestrator_url(self) -> str:
return f"http://{self.guest_ip}:{ORCHESTRATOR_PORT}"
def terminate(self) -> None:
"""Stop the infra VM — via the live handle if we booted it, else the
@@ -168,7 +168,7 @@ def ensure_running() -> InfraVm:
flock, so two simultaneous first launches don't both boot on the same
rootfs/PID. The healthy fast-path takes no lock."""
slot = netpool.orch_slot()
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
url = f"http://{slot.guest_ip}:{ORCHESTRATOR_PORT}"
key = _infra_dir() / "id_ed25519"
want = _expected_version()
if _adoptable(key, url, want):
@@ -192,7 +192,7 @@ def ensure_running() -> InfraVm:
def _with_signing_key(infra: InfraVm) -> InfraVm:
"""Mirror the infra VM's control-plane signing key (generated on its
persistent volume) into the host's control-plane-token file, so the host CLI
persistent volume) into the host's orchestrator-token file, so the host CLI
signs `cli` tokens the VM verifies (issue #469 review). Best-effort: an
unreadable key is logged, not fatal — the VM still enforces auth, but the CLI
may then be rejected until the key is readable. Returns `infra` for chaining."""
@@ -205,7 +205,7 @@ def _with_signing_key(infra: InfraVm) -> InfraVm:
if proc.returncode != 0 or not signing_key:
info("infra signing key not yet readable; control-plane auth may fail")
return infra
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
@@ -458,7 +458,7 @@ def wait_for_health(
) -> None:
"""Poll the control plane's /health until it answers 200 or the deadline
passes. Dies (with the console tail) if the VMM exits early."""
url = f"{infra.control_plane_url}/health"
url = f"{infra.orchestrator_url}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if infra.vm is not None and not infra.vm.is_alive():
@@ -467,7 +467,7 @@ def wait_for_health(
try:
with urllib.request.urlopen(url, timeout=1.0) as resp:
if resp.status == 200:
info(f"infra control plane healthy at {infra.control_plane_url}")
info(f"infra control plane healthy at {infra.orchestrator_url}")
return
except (urllib.error.URLError, TimeoutError, OSError):
pass
@@ -525,11 +525,11 @@ cd /app
# same VM — reaching the orchestrator over 127.0.0.1, past the nft boundary that
# only fences off the separate agent VM — could drive the operator routes
# (approve its own supervise proposals, rewrite policy, read injected tokens).
CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_control_plane_token as t; print(t())')
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.control_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_orchestrator_token as t; print(t())')
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.orchestrator_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_ORCHESTRATOR_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {ORCHESTRATOR_PORT} --broker stub &
# Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over
@@ -540,8 +540,8 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" pyt
# the pre-minted `gateway` JWT; gateway_init keeps the signing key out of the
# data-plane daemons' env.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{ORCHESTRATOR_PORT} \\
BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT="$GW_JWT" \\
python3 -m bot_bottle.gateway.bootstrap &
# Reap as PID 1; children are backgrounded, so `wait` blocks.
@@ -102,7 +102,7 @@ class MacosContainerBottleBackend(
(`supervise`) call when no control plane is running yet. Mirrors
firecracker's infra-VM bring-up."""
from .infra import MacosInfraService
return MacosInfraService().ensure_running().control_plane_url
return MacosInfraService().ensure_running().orchestrator_url
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
return _cleanup.prepare_cleanup()
@@ -90,7 +90,7 @@ def ensure_gateway(
service = service or MacosInfraService()
infra = service.ensure_running()
endpoint = GatewayEndpoint(
orchestrator_url=infra.control_plane_url,
orchestrator_url=infra.orchestrator_url,
gateway_ip=infra.gateway_ip,
gateway_ca_pem=service.ca_cert_pem(),
network=service.network,
+13 -13
View File
@@ -48,12 +48,12 @@ from ...orchestrator.lifecycle import (
OrchestratorStartError,
source_hash,
)
from ...control_auth import ROLE_GATEWAY, mint
from ...orchestrator_auth import ROLE_GATEWAY, mint
from ...paths import (
CONTROL_AUTH_JWT_ENV,
CONTROL_PLANE_TOKEN_ENV,
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
HOST_DB_FILENAME,
host_control_plane_token,
host_orchestrator_token,
host_gateway_ca_dir,
)
from .. import util as backend_util
@@ -117,7 +117,7 @@ class InfraEndpoint:
"""How to reach the running infra container. The control plane and the
gateway are the same container, so one address serves both."""
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
orchestrator_url: str # http://<infra ip>:8099 — host CLI + registration
gateway_ip: str # same container; agents' proxy / git-http / MCP target
@@ -181,7 +181,7 @@ class MacosInfraService:
return None
url = self._resolve_url()
if url and self.is_healthy(url):
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
return None
def ensure_built(self) -> None:
@@ -247,17 +247,17 @@ class MacosInfraService:
# run process below, so neither lands on argv or in `container
# inspect`'s command line. The agent runs in a SEPARATE container that
# is never given these vars, which is the whole point.
"--env", CONTROL_PLANE_TOKEN_ENV,
"--env", CONTROL_AUTH_JWT_ENV,
"--env", ORCHESTRATOR_TOKEN_ENV,
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
"--entrypoint", "sh",
self.image,
"-c", _init_script(self.port),
]
_signing_key = host_control_plane_token()
_signing_key = host_orchestrator_token()
run_env = {
**os.environ,
CONTROL_PLANE_TOKEN_ENV: _signing_key,
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
ORCHESTRATOR_TOKEN_ENV: _signing_key,
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
}
result = container_mod.run_container_argv(argv, env=run_env)
if result.returncode != 0:
@@ -272,7 +272,7 @@ class MacosInfraService:
url = self._resolve_url()
if url and self.is_healthy(url):
log.info("infra container healthy", context={"url": url})
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
if time.monotonic() >= deadline:
raise OrchestratorStartError(
f"infra container did not become healthy within "
@@ -306,7 +306,7 @@ def _ip_of(url: str) -> str:
return url.split("://", 1)[-1].rsplit(":", 1)[0]
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
def probe_orchestrator_url(port: int = DEFAULT_PORT) -> str:
"""The running infra container's control-plane URL, or "" if it isn't up.
Used by host-side control-plane discovery (`discover_orchestrator_url`);
safe to call on any host — returns "" when the container or the `container`
+4 -4
View File
@@ -58,10 +58,10 @@ _READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
# The data-plane daemons instead hold the pre-minted `gateway` JWT they present.
# Scoping each to its process (even in the combined infra container) keeps a
# compromised data-plane daemon from reading the key and minting a `cli` token
# (issue #469 review). Values match paths.CONTROL_PLANE_TOKEN_ENV /
# CONTROL_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
_SIGNING_KEY_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
_GATEWAY_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT"
# (issue #469 review). Values match paths.ORCHESTRATOR_TOKEN_ENV /
# ORCHESTRATOR_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
_SIGNING_KEY_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
_GATEWAY_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS
# and are NOT started in the default (env-var-unset) case. The orchestrator
+6 -6
View File
@@ -41,16 +41,16 @@ DEFAULT_TIMEOUT_SECONDS = 2.0
# rather than imported because this module is COPYed flat into the gateway image,
# free of bot-bottle imports — same rationale as IDENTITY_HEADER in egress_addon
# / git_http_backend.
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
CONTROL_AUTH_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT"
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
def _control_auth_headers() -> dict[str, str]:
def _orchestrator_auth_headers() -> dict[str, str]:
"""The auth header to send, or {} when no token is configured (an open
control plane, e.g. Firecracker behind its nft boundary sending nothing
is correct there and harmlessly ignored)."""
token = os.environ.get(CONTROL_AUTH_JWT_ENV, "").strip()
return {CONTROL_AUTH_HEADER: token} if token else {}
token = os.environ.get(ORCHESTRATOR_AUTH_JWT_ENV, "").strip()
return {ORCHESTRATOR_AUTH_HEADER: token} if token else {}
class PolicyResolveError(RuntimeError):
@@ -74,7 +74,7 @@ class PolicyResolver:
body = json.dumps(payload).encode()
req = urllib.request.Request(
f"{self._base}{path}", data=body, method="POST",
headers={"Content-Type": "application/json", **_control_auth_headers()},
headers={"Content-Type": "application/json", **_orchestrator_auth_headers()},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
+3 -3
View File
@@ -17,7 +17,7 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the
shared gateway, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown /
* `server` the HTTP control-plane RPC (launch / teardown /
list / attribute / gateway / health).
The actual backend-native launch (a real docker/firecracker broker) and
@@ -40,7 +40,7 @@ from .broker import (
from .docker_broker import DockerBroker, DockerBrokerError
from ..gateway import Gateway, GatewayError
from .service import Orchestrator
from .server import ControlPlaneServer, dispatch, make_server
from .server import OrchestratorServer, dispatch, make_server
__all__ = [
"BottleRecord",
@@ -57,7 +57,7 @@ __all__ = [
"sign_request",
"verify_request",
"Orchestrator",
"ControlPlaneServer",
"OrchestratorServer",
"dispatch",
"make_server",
]
+9 -9
View File
@@ -18,9 +18,9 @@ import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from ..control_auth import ROLE_CLI, mint
from ..paths import host_control_plane_token
from .server import CONTROL_AUTH_HEADER
from ..orchestrator_auth import ROLE_CLI, mint
from ..paths import host_orchestrator_token
from .server import ORCHESTRATOR_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
"" means 'send no auth header' correct against an open (unconfigured)
control plane, and harmlessly rejected by a secured one."""
try:
return mint(ROLE_CLI, host_control_plane_token())
return mint(ROLE_CLI, host_orchestrator_token())
except (OSError, ValueError):
return ""
@@ -83,7 +83,7 @@ class OrchestratorClient:
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
if self._auth_token:
headers[CONTROL_AUTH_HEADER] = self._auth_token
headers[ORCHESTRATOR_AUTH_HEADER] = self._auth_token
req = urllib.request.Request(
f"{self._base}{path}", data=data, method=method, headers=headers,
)
@@ -252,14 +252,14 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}")
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
except Exception: # noqa: BLE001 — backend optional / not firecracker
pass
try: # macOS: infra container control plane on its host-only address
from ..backend.macos_container.infra import probe_control_plane_url
url = probe_control_plane_url()
from ..backend.macos_container.infra import probe_orchestrator_url
url = probe_orchestrator_url()
if url:
candidates.append(url)
except Exception: # noqa: BLE001 — backend optional / not macOS
+16 -16
View File
@@ -48,7 +48,7 @@ via the orchestrator. Register/deregister without a launch are internal to
`Orchestrator`, not exposed here.
Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
without a socket; `Handler` / `OrchestratorServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens they are
returned only once, to the caller that launches the bottle.
"""
@@ -63,8 +63,8 @@ import sys
import typing
from urllib.parse import urlsplit
from ..control_auth import ROLE_CLI, ROLES, verify
from ..paths import CONTROL_PLANE_TOKEN_ENV
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
from ..paths import ORCHESTRATOR_TOKEN_ENV
from ..supervisor.types import TOOLS
from .service import Orchestrator
@@ -72,13 +72,13 @@ from .service import Orchestrator
Json = dict[str, object]
# The request header carrying the caller's role-scoped control-plane token (a
# signed JWT naming the caller's role — see control_auth). The role gates which
# signed JWT naming the caller's role — see orchestrator_auth). The role gates which
# routes the caller may reach: the data plane holds a `gateway` token good only
# for the agent-facing lookups; the host CLI holds a `cli` token for the
# operator/mutating routes. An agent that can merely *reach* the port holds no
# token at all, and a compromised gateway holds only `gateway` — neither can
# drive the operator routes (approve proposals, rewrite policy, read tokens).
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
# The routes the data plane (role `gateway`) is allowed to reach — exactly the
# per-request lookups PolicyResolver makes. Every other authenticated route is
@@ -116,7 +116,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
`role` is the caller's verified control-plane role (`gateway` or `cli`), or
None for an unauthenticated request; an open-mode server (no signing key
configured see `ControlPlaneServer`) passes `cli`. Every route except
configured see `OrchestratorServer`) passes `cli`. Every route except
`GET /health` requires a role: a missing role is 401, and a role that
doesn't cover the route is 403 — so a `gateway` data-plane token can reach
`/resolve` + `/supervise/{propose,poll}` but not the operator routes
@@ -365,10 +365,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
crashing the connection, so one bad request can't take the control
plane down for the caller."""
server = self.server
assert isinstance(server, ControlPlaneServer)
assert isinstance(server, OrchestratorServer)
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b""
role = server.role_for(self.headers.get(CONTROL_AUTH_HEADER, ""))
role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
try:
status, payload = dispatch(
server.orchestrator, method, self.path, body, role=role)
@@ -396,11 +396,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._serve("DELETE")
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the orchestrator for its handlers.
Holds the per-host control-plane *signing key* (from
`$BOT_BOTTLE_CONTROL_PLANE_TOKEN`, injected by the launcher into the
`$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
orchestrator process only) and verifies each request's role-scoped token
against it. When a key is set, every route but `/health` requires a valid
token whose role covers the route; when it is unset the server runs **open**
@@ -413,11 +413,11 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator
self._signing_key = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
if not self._signing_key:
sys.stderr.write(
"orchestrator: WARNING — no control-plane signing key "
f"(${CONTROL_PLANE_TOKEN_ENV}); running WITHOUT caller "
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n"
@@ -438,13 +438,13 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer:
) -> OrchestratorServer:
"""Build (but do not start) a control-plane server. `port=0` binds an
ephemeral port read `server.server_address` for the actual one."""
return ControlPlaneServer((host, port), orchestrator)
return OrchestratorServer((host, port), orchestrator)
__all__ = [
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json",
"CONTROL_AUTH_HEADER",
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
"ORCHESTRATOR_AUTH_HEADER",
]
+11 -12
View File
@@ -36,17 +36,16 @@ HOST_DB_FILENAME = "bot-bottle.db"
# reading route (see orchestrator/server.py); it is held only by the
# trusted callers (control plane, gateway, host CLI) and never handed to an
# agent, so an agent that can reach the control-plane port still can't drive it.
CONTROL_PLANE_TOKEN_FILENAME = "control-plane-token"
# The env var carrying the control-plane *signing key* — held only by the
ORCHESTRATOR_TOKEN_FILENAME = "orchestrator-token"
# The env var carrying the orchestrator's *signing key* — held only by the
# orchestrator (to verify tokens) and the host CLI (to mint its own), never by
# the data plane. Same value as the host token file; the name is unchanged for
# backward compatibility with existing launchers.
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
# the data plane. Same value as the host token file.
ORCHESTRATOR_TOKEN_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
# The env var carrying the data plane's pre-minted `gateway`-role token (a
# signed JWT the launcher mints from the signing key). The gateway presents this
# on /resolve + /supervise/{propose,poll}; it never holds the signing key, so it
# cannot forge a higher-privilege `cli` token (issue #469 review).
CONTROL_AUTH_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT"
ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# The host directory holding the gateway's persistent mitmproxy CA. Bind-mounted
# into the infra/gateway container at mitmproxy's confdir so the self-generated
@@ -98,7 +97,7 @@ def host_gateway_ca_dir() -> Path:
return ca_dir
def host_control_plane_token() -> str:
def host_orchestrator_token() -> str:
"""The per-host control-plane secret, minted (256-bit, url-safe) and
persisted 0600 on first use, then reused.
@@ -107,7 +106,7 @@ def host_control_plane_token() -> str:
*host* artifact the file lives under the root the agent never mounts, and
the env var is set only on the trusted containers so reading it here is
safe on the host launch path but the value never reaches a bottle."""
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
try:
existing = path.read_text().strip()
if existing:
@@ -131,13 +130,13 @@ def host_control_plane_token() -> str:
__all__ = [
"HOST_DB_FILENAME",
"CONTROL_PLANE_TOKEN_FILENAME",
"CONTROL_PLANE_TOKEN_ENV",
"CONTROL_AUTH_JWT_ENV",
"ORCHESTRATOR_TOKEN_FILENAME",
"ORCHESTRATOR_TOKEN_ENV",
"ORCHESTRATOR_AUTH_JWT_ENV",
"GATEWAY_CA_DIRNAME",
"bot_bottle_root",
"host_db_path",
"host_db_dir",
"host_gateway_ca_dir",
"host_control_plane_token",
"host_orchestrator_token",
]