Files
bot-bottle/bot_bottle/paths.py
T
didericis-claude 5f1d3fbd15
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / integration-docker (pull_request) Failing after 45s
test / unit (pull_request) Successful in 56s
test / coverage (pull_request) Has been skipped
lint / lint (push) Failing after 14m57s
feat(orchestrator): durable launch-broker secret via TrustDomain (#468)
Chunk 2 of the host-control-server stack: close the PRD's **durable
secret** gap and replace chunk 1's BOT_BOTTLE_BROKER_SECRET stopgap.

- trust_domain.py: two new domains. LAUNCH_BROKER holds the durable
  HS256 key both the orchestrator (signer) and the host control server
  (verifier) share for the broker's launch JWT — a host-canonical key
  file minted 0600 on first use, so a restarted orchestrator re-verifies
  against the same key. HOST_CONTROLLER is the separate domain for the
  controller's own lifecycle endpoints, keyed by a key the orchestrator
  never holds (its role is `host`, deliberately outside control-plane
  ROLES). LaunchBrokerProvisioning is the fail-closed seam.
- orchestrator_auth.py: ROLE_HOST, outside ROLES.
- paths.py: key-file + env-var constants for both domains.

Key resolution is split by owner (addresses codex review on #497):
broker_secret(allow_host_file=...) — the host controller / dev-harness
(True) may mint/read the durable host key file it owns; the GUEST
orchestrator (--broker http, default False) must be *injected* the key
and fails closed if it isn't. A guest that fell back to the host file
would mint a process-local key unrelated to the host controller's, so
startup would succeed but every launch would 401 — this prevents that
silent divergence.

Tested: domain boundary + separation, provisioning fail-closed, and
broker_secret env-only (guest) vs host-file (host) resolution. pyright
clean; pylint 9.86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:14:07 +00:00

174 lines
7.8 KiB
Python

"""Foundational filesystem paths for bot-bottle.
`bot_bottle_root()` is the app data root — per-bottle state, git-gate
keys, the gateway CA, and the shared SQLite DB all live under it. It
defaults to `~/.bot-bottle` and is overridable with the
**`BOT_BOTTLE_ROOT`** env var.
Note that the supervise queue and the audit log are *tables in the shared
DB*, not directories under the root — see `queue_store.py` / `audit_store.py`.
The root held a `queue/` directory before the SQLite migration (PRD 0067);
nothing writes there now.
The env override is the single knob for redirecting the root: the test
suite points it at a throwaway dir instead of monkey-patching the function
(every module and every flat/package copy reads the same env var, so one
override covers them all), and operators can relocate the root if needed.
This module has no bot-bottle imports, so it is safe to import from any
layer (and to COPY flat into the gateway).
"""
from __future__ import annotations
import os
import secrets
import stat
from pathlib import Path
# The single shared host state DB. All bot-bottle SQLite stores (supervise
# queue, audit, the orchestrator registry) co-tenant this one file — the
# TableMigrations schema_key namespaces each store's tables.
HOST_DB_FILENAME = "bot-bottle.db"
# The per-host control-plane secret file, and the env var the launchers inject
# its value into. The control plane requires this secret on every mutating /
# 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.
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.
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).
ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# The durable launch-broker signing key: the HS256 secret the orchestrator
# (signer) and the host control server (verifier) share to sign/verify launch
# requests (#468). A host-canonical key file (minted 0600 on first use) so it
# survives orchestrator restarts — re-adoption re-verifies against the same key —
# instead of the ephemeral per-process secret of the in-process broker.
LAUNCH_BROKER_KEY_FILENAME = "launch-broker-key"
LAUNCH_BROKER_KEY_ENV = "BOT_BOTTLE_LAUNCH_BROKER_KEY"
# The host controller's OWN key, for its lifecycle endpoints (the direct
# cli -> host controller path that starts/stops the orchestrator). Separate from
# the launch-broker key and never held by the orchestrator: the controller starts
# and stops the orchestrator, so the orchestrator must not be able to mint the
# credentials used to drive it (#468/#476).
HOST_CONTROLLER_KEY_FILENAME = "host-controller-key"
HOST_CONTROLLER_KEY_ENV = "BOT_BOTTLE_HOST_CONTROLLER_KEY"
HOST_CONTROLLER_AUTH_JWT_ENV = "BOT_BOTTLE_HOST_CONTROLLER_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
# CA survives container recreation — every agent installs this one CA to trust
# the shared gateway's TLS interception, so it must not rotate on restart. See
# host_gateway_ca_dir() for why this is a host bind-mount, not a named volume.
GATEWAY_CA_DIRNAME = "gateway-ca"
def bot_bottle_root() -> Path:
"""The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`."""
override = os.environ.get("BOT_BOTTLE_ROOT")
return Path(override) if override else Path.home() / ".bot-bottle"
def host_db_path() -> Path:
"""Path to the shared host state DB, `<root>/db/bot-bottle.db`.
Kept in its own `db/` subdirectory (not directly under the root) so a
backend that can only bind-mount *directories* can share this one file
with a gateway without exposing the root's other contents (git-gate
keys, per-bottle state, ...)."""
return bot_bottle_root() / "db" / HOST_DB_FILENAME
def host_db_dir() -> Path:
"""The directory holding the shared host state DB, created if missing.
Backends bind-mount this into their gateway so the supervise daemon writes
to the one DB the orchestrator (and the operator over HTTP) reads."""
db_dir = host_db_path().parent
db_dir.mkdir(parents=True, exist_ok=True)
return db_dir
def host_gateway_ca_dir() -> Path:
"""The directory holding the gateway's persistent mitmproxy CA, created if
missing. Backends bind-mount this into the infra/gateway container at
mitmproxy's confdir so the CA persists across container recreation.
A host bind-mount under the app-data root — deliberately NOT a Docker
named volume. A named volume survives `docker rm` but is silently wiped by
`docker volume prune` / `docker system prune --volumes` during routine host
maintenance; the gateway then mints a fresh CA that every already-running
bottle distrusts, failing the TLS handshake even after it reconnects to the
moved gateway (issue #450). A path under the root docker never prunes it,
and it stays directly inspectable + rotatable from the host."""
ca_dir = bot_bottle_root() / GATEWAY_CA_DIRNAME
ca_dir.mkdir(parents=True, exist_ok=True)
return ca_dir
def host_signing_key(filename: str) -> str:
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
and persisted 0600 on first use, then reused.
The generic form of `host_orchestrator_token()`: each service names its own
key file (`trust_domain.py`), so the orchestrator and a separate service like
the host controller (#468) get distinct keys neither can read. It is a *host*
artifact — the file lives under the root the agent never mounts, and its value
is injected only into the trusted control-plane process — so reading it here
is safe on the launch path but the value never reaches a bottle."""
path = bot_bottle_root() / filename
try:
existing = path.read_text().strip()
if existing:
return existing
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
# Create 0600 up front (O_EXCL loses a concurrent race harmlessly — we
# re-read the winner's token below) so the secret is never briefly world-
# readable between write and chmod.
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return path.read_text().strip()
with os.fdopen(fd, "w") as f:
f.write(token)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
return token
def host_orchestrator_token() -> str:
"""The per-host control-plane signing key — the host-canonical key the
launchers inject into the control-plane process and the host CLI mints its
own `cli` token from. The `control-plane` trust domain's specialization of
`host_signing_key()`."""
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
__all__ = [
"HOST_DB_FILENAME",
"ORCHESTRATOR_TOKEN_FILENAME",
"ORCHESTRATOR_TOKEN_ENV",
"ORCHESTRATOR_AUTH_JWT_ENV",
"LAUNCH_BROKER_KEY_FILENAME",
"LAUNCH_BROKER_KEY_ENV",
"HOST_CONTROLLER_KEY_FILENAME",
"HOST_CONTROLLER_KEY_ENV",
"HOST_CONTROLLER_AUTH_JWT_ENV",
"GATEWAY_CA_DIRNAME",
"bot_bottle_root",
"host_db_path",
"host_db_dir",
"host_gateway_ca_dir",
"host_signing_key",
"host_orchestrator_token",
]