Compare commits

...

12 Commits

Author SHA1 Message Date
didericis 32e9bb95e0 docs(prd): 0070 expand the consolidated-sidecar arc in the roadmap (#352)
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m2s
Split the sidecar work into explicit slices: lifecycle (done), build the
real bundle image (done), source-IP-keyed multi-tenant config +
registration/reload (the functional core, next), and routing agent bottles
through it. Renumber firecracker/macos to 8/9. Slices 1-5 implemented
(#352/#356/#357/#358/#360).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:01:27 -04:00
didericis 8e567edde4 docs(prd): 0070 add the slice roadmap incl. consolidated sidecar (#352)
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m2s
Enumerate the implementation slices in the Sequencing section: dev-harness
core, launch+broker, docker broker, consolidated per-host sidecar (new),
then firecracker and macOS. Slices 1-4 implemented (#352/#356/#357/#358).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:30:07 -04:00
didericis 6847fdf0ab refactor: drop the vestigial bot_bottle_root/host_db_path re-exports (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Successful in 1m6s
paths is the single home now, so stop re-exporting the path helpers from
supervise: remove host_db_path from supervise's imports + __all__ (it was
re-export-only) and drop bot_bottle_root from __all__ (kept as an import,
still used by audit_dir). supervise_types was already clean. Repoint the
last readers (test_supervise imports host_db_path from paths;
test_supervise_edge calls paths.bot_bottle_root) and refresh the doc
mentions. No supervise.bot_bottle_root / supervise.host_db_path references
remain.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:30:13 -04:00
didericis 421d31c32f refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 15s
test / coverage (pull_request) Successful in 1m1s
Create bot_bottle/paths.py as the canonical home for the app-root path
helpers (bot_bottle_root, host_db_path, HOST_DB_FILENAME) — foundational,
not supervise- or db-specific. `bot_bottle_root()` now honours a
BOT_BOTTLE_ROOT env override.

Repoint every consumer (supervise, supervise_types, db_store, queue_store,
audit_store, store_manager, bottle_state, cli/supervise, docker/cleanup,
orchestrator/registry) at paths; remove the definitions (and supervise's
duplicate host_db_path) and the now-dead `import sys`. Add paths.py to the
sidecar bundle (Dockerfile.sidecars) for the flat-import copies.

Tests: replace ~12 files' monkeypatching of supervise.bot_bottle_root (and
the flat/pkg/supervise_types triple-patch dance) with a single
`use_bottle_root()` helper that sets BOT_BOTTLE_ROOT — every module and
flat/package copy reads the same env var, so one override covers them all.
Net -97 lines. Behaviour-preserving: full unit suite unchanged (only the
pre-existing /bin/sleep sidecar-init errors remain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:04:23 -04:00
didericis b4b4e08f62 refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m0s
host_db_path is shared DB infrastructure, not supervise-specific, so its
canonical home is db_store (alongside DbStore). It resolves bot_bottle_root
from supervise_types lazily inside the function — no load-time cycle, and a
monkey-patch of supervise_types.bot_bottle_root still propagates.
supervise_types re-exports both names for the historical import path
(queue_store/audit_store unchanged); the orchestrator registry now imports
from db_store. Drops the now-unused `import sys` from supervise_types.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors remain); monkeypatch propagation verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:36:19 -04:00
didericis b174442c60 feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m5s
Per review: use one shared bot-bottle.db for all runtime state including
the registry (DbStore namespaces by schema_key), so it's one queryable
file for backup/console. default_db_path() -> host_db_path(). Drop the
unilateral WAL flip — WAL on the shared DB affects supervise/audit and is
finicky over guest shares, so it's a deliberate future change; keep a
busy_timeout for lock contention.

PRD State section updated: integrity now by SOLE ownership (only the
orchestrator opens bot-bottle.db; data plane + console reach state via the
control-plane RPC, never a file handle) rather than ro/rw mount-splitting,
which one shared file can't do. Notes the transitional caveat that the
supervise sidecar currently rw-mounts bot-bottle.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:26:03 -04:00
didericis ed7307e0e3 docs(prd): 0070 registry DB is host-resident with rw/ro access split (#352)
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m1s
Per review: the SQLite runtime-state DB lives on the host, not owned
inside the orchestrator unit. Durability (re-adoption must survive an
orchestrator restart) + integrity via access-scoping (control-plane rw,
data-plane ro) rather than location. Note the WAL-over-guest-share wrinkle
for the VM slices (may want a host-side DB owner reached over the RPC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:17:05 -04:00
didericis 1702664b81 feat(orchestrator): slice 1 — registry + attribution + HTTP control plane (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m2s
First implementation slice of PRD 0070, the backend-neutral consolidation
core as a plain-process dev-harness (no VM packaging yet):

  * orchestrator/registry.py — SQLite (WAL) runtime-state store on the
    existing DbStore/TableMigrations base. Live bottle registry keyed by
    source IP + per-bottle identity token, with fail-closed attribution:
    a request resolves to a bottle only when its source IP AND identity
    token both match exactly one active record (unknown/ambiguous IP,
    empty token, or token mismatch all deny). Tokens are 256-bit urandom.
  * orchestrator/control_plane.py — the HTTP control plane (the universal
    transport chosen in 0070): register / deregister / list / attribute /
    health. Routing is a pure dispatch() so it is socket-free testable;
    Handler/ControlPlaneServer/make_server are a thin stdlib adapter.
    register/deregister are the live-reload path; listing redacts tokens.
  * orchestrator/__main__.py — `python -m bot_bottle.orchestrator` harness.

Launch/teardown, the launch broker, and the egress/git/supervise data
plane come in later slices. 24 unit tests (attribution matrix, persistence,
dispatch, one real-socket round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:00:04 -04:00
didericis 8d54fc38ea docs(prd): 0070 VM-to-VM routing is not a blocker (#352)
Per review: resolvable at implementation time (host tweak acceptable, as
the pool setup already needs on NixOS); the earlier sidecars-in-VMs spike
showed feasibility. No need to hunt the spike now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:46:08 -04:00
didericis 73a7582abe docs(prd): 0070 fold in review decisions (#352)
Resolve open questions from review: consolidate egress (hardened minimal
surface + identity token + vault mitigations); HTTP control-plane
transport; broker schema = signed-JWT JSON of static flags+ids (provenance
+ un-coercible); state re-adoption procedure (singleton orchestrator →
wait-healthy → adopt via SQLite + process inspection before serving, with
write-ahead intent to close the in-flight-launch race). Add a per-bottle
identity-token defense-in-depth layer on the attribution invariant.
Remaining open: VM-to-VM routing (per-backend wire(), pending spike link),
live-reload protocol, identity-token delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:39:06 -04:00
didericis 095896817c docs(prd): 0070 secret-handling as a future pattern (SecretProvider, #355)
lint / lint (push) Successful in 2m3s
Add a "Secret handling — FUTURE pattern (not v1)" subsection: vault as a
separate trust domain holding long-lived roots, deriving short-lived
scoped creds where the upstream allows (with the honest limit that a
compromised proxy can still abuse currently-authorized access). The
mechanism is a generic, user-extensible SecretProvider that generalizes
PRD 0048's DeployKeyProvisioner and drops into the manifest wherever a raw
token is accepted — discovered from ~/.bot-bottle/contrib like user
AgentProviders. Marked explicitly as not required for the initial
orchestrator; tracked as #355.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
didericis a30dd49967 docs(prd): 0070 per-host orchestrator service
Fold the per-bottle sidecar bundle into a single persistent per-host
orchestrator: runs the sidecar functions (egress/git-gate/supervise),
coordinates with the console, and brokers agent launches. Virtualized
from the start with backend-native isolation (fc VM / apple ctr / docker
ctr), fronted by a single backend-agnostic contract; per-backend
variation lives on BottleBackend, not a parallel Orchestrator hierarchy.

Leads with the security review (secret concentration, shared fate, the
launch-broker-as-new-privileged-core, and the source-IP attribution
invariant each backend must enforce). Proposes one SQLite DB owned by the
orchestrator for runtime state (slot leases, approvals, registry) —
distinct from build-time constants (flat .env) and user config
(declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos,
developing the service as a plain-process dev-harness before the VM.

Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built
fixed images. Tracking issue #351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
33 changed files with 1286 additions and 223 deletions
+1
View File
@@ -66,6 +66,7 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py
COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/migrations.py /app/migrations.py
COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/db_store.py /app/db_store.py
COPY bot_bottle/supervise_types.py /app/supervise_types.py COPY bot_bottle/supervise_types.py /app/supervise_types.py
+4 -2
View File
@@ -6,11 +6,13 @@ import sqlite3
from pathlib import Path from pathlib import Path
try: try:
from .supervise_types import AuditEntry, host_db_path from .supervise_types import AuditEntry
from .paths import host_db_path
from .db_store import DbStore from .db_store import DbStore
from .migrations import TableMigrations from .migrations import TableMigrations
except ImportError: except ImportError:
from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
+2 -2
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
from ... import supervise as _supervise from ...paths import bot_bottle_root
from ...log import info, warn from ...log import info, warn
from . import util as docker_mod from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
@@ -94,7 +94,7 @@ def _list_orphan_state_dirs(
ANY backend — used so this docker-side check doesn't reap a ANY backend — used so this docker-side check doesn't reap a
running non-docker bottle's state dir (the layout is shared running non-docker bottle's state dir (the layout is shared
across backends).""" across backends)."""
state_root = _supervise.bot_bottle_root() / "state" state_root = bot_bottle_root() / "state"
if not state_root.is_dir(): if not state_root.is_dir():
return [] return []
orphans: list[str] = [] orphans: list[str] = []
+2 -2
View File
@@ -36,7 +36,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from . import supervise as _supervise from .paths import bot_bottle_root
# Directory layout: ~/.bot-bottle/state/<identity>/... # Directory layout: ~/.bot-bottle/state/<identity>/...
@@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
def bottle_state_dir(identity: str) -> Path: def bottle_state_dir(identity: str) -> Path:
"""Per-bottle state directory on the host. Created lazily by the """Per-bottle state directory on the host. Created lazily by the
write helpers; readers tolerate its absence.""" write helpers; readers tolerate its absence."""
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity return bot_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path: def per_bottle_dockerfile_path(identity: str) -> Path:
+2 -2
View File
@@ -19,7 +19,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from ..paths import bot_bottle_root
from ..bottle_state import read_metadata from ..bottle_state import read_metadata
from ..backend.docker.egress_apply import ( from ..backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
@@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path:
) )
entry = f"=== supervise crash {stamp} ===\n{body}\n" entry = f"=== supervise crash {stamp} ===\n{body}\n"
try: try:
log_dir = _supervise.bot_bottle_root() / "logs" log_dir = bot_bottle_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / "supervise-crash.log" path = log_dir / "supervise-crash.log"
with path.open("a", encoding="utf-8") as fh: with path.open("a", encoding="utf-8") as fh:
+30
View File
@@ -0,0 +1,30 @@
"""Per-host orchestrator service (PRD 0070).
A single persistent per-host service that will run the sidecar functions
(egress / git-gate / supervise), coordinate with the console, and broker
agent launches. This package is being built bottom-up, starting with the
backend-neutral "consolidation core" that needs no VM packaging:
* `registry` the SQLite runtime-state store + fail-closed
attribution (source IP + per-bottle identity token).
* `control_plane` the HTTP control-plane RPC (register / deregister /
list / attribute / health), with live reload.
Launch/teardown, the launch broker, and the actual egress/git/supervise
data plane land in later slices once this core is proven (see the PRD's
sequencing: plain-process dev-harness -> docker orchestrator -> firecracker).
"""
from __future__ import annotations
from .registry import BottleRecord, RegistryStore, new_identity_token
from .control_plane import ControlPlaneServer, dispatch, make_server
__all__ = [
"BottleRecord",
"RegistryStore",
"new_identity_token",
"ControlPlaneServer",
"dispatch",
"make_server",
]
+52
View File
@@ -0,0 +1,52 @@
"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness).
python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH]
The PRD sequences the orchestrator as a plain-process dev-harness first, so
the consolidation core (registry + attribution + HTTP control plane + live
reload) can be exercised with fast iteration, decoupled from any VM /
container packaging. Wrapping this exact service in a backend-native unit
(docker container, then Firecracker VM) comes later.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from .. import log
from .control_plane import make_server
from .registry import RegistryStore, default_db_path
def main(argv: list[str] | None = None) -> int:
"""Parse args, migrate the registry, and serve the control plane."""
parser = argparse.ArgumentParser(prog="bot_bottle.orchestrator")
parser.add_argument("--host", default="127.0.0.1", help="bind address")
parser.add_argument("--port", type=int, default=8080, help="bind port (0 = ephemeral)")
parser.add_argument(
"--db", type=Path, default=None,
help=f"registry DB path (default: {default_db_path()})",
)
args = parser.parse_args(argv)
registry = RegistryStore(args.db)
registry.migrate()
server = make_server(registry, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1]
log.info(
"orchestrator control plane listening",
context={"host": bound_host, "port": bound_port, "db": str(registry.db_path)},
)
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("orchestrator shutting down")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
"""Orchestrator HTTP control plane (PRD 0070).
The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
**HTTP** the universal transport chosen in 0070 (works on every host; no
vsock / unix-socket portability caveats). Endpoints mutate the live
registry, so register/deregister are the *live-reload* path no restart:
GET /health -> 200 {"status": "ok"}
GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
POST /bottles -> 201 {"bottle_id", "identity_token"}
body: {"source_ip", ["bottle_id"], ["metadata"]}
DELETE /bottles/<bottle_id> -> 200 {"deregistered": true} | 404
POST /attribute -> 200 {"bottle_id"} | 403
body: {"source_ip", "identity_token"}
Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
thin stdlib adapter around it.
Note the listing redacts identity tokens they are never returned except
once, to the caller that registers the bottle.
"""
from __future__ import annotations
import http.server
import json
import os
import socketserver
import typing
from urllib.parse import urlsplit
from .registry import RegistryStore
# JSON body payload type (parsed request / rendered response).
Json = dict[str, object]
def _parse_json_object(body: bytes) -> Json:
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
if not body:
return {}
obj = json.loads(body) # raises json.JSONDecodeError (a ValueError)
if not isinstance(obj, dict):
raise ValueError("request body must be a JSON object")
return obj
def dispatch( # pylint: disable=too-many-return-statements
registry: RegistryStore, method: str, path: str, body: bytes
) -> tuple[int, Json]:
"""Route one control-plane request to a (status, payload) pair. Pure —
no I/O beyond the registry so it is fully testable without a socket."""
route = urlsplit(path).path.rstrip("/") or "/"
if method == "GET" and route == "/health":
return 200, {"status": "ok"}
if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in registry.all()]}
if method == "POST" and route == "/bottles":
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
bottle_id = data.get("bottle_id")
metadata = data.get("metadata")
rec = registry.register(
source_ip,
bottle_id=bottle_id if isinstance(bottle_id, str) else None,
metadata=metadata if isinstance(metadata, str) else "",
)
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
if method == "DELETE" and route.startswith("/bottles/"):
bottle_id = route[len("/bottles/"):]
if registry.deregister(bottle_id):
return 200, {"deregistered": True}
return 404, {"error": "no such bottle"}
if method == "POST" and route == "/attribute":
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
if not isinstance(source_ip, str) or not isinstance(token, str):
return 400, {"error": "source_ip and identity_token (strings) required"}
rec = registry.attribute(source_ip, token)
if rec is None:
return 403, {"error": "unattributed"}
return 200, {"bottle_id": rec.bottle_id}
return 404, {"error": "not found"}
class Handler(http.server.BaseHTTPRequestHandler):
"""Thin stdlib adapter: read the body, call `dispatch`, write JSON."""
# Quiet by default (the orchestrator has its own logging); opt back into
# stdlib access logging with BOT_BOTTLE_ORCHESTRATOR_DEBUG.
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
if os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG"):
super().log_message(format, *args)
def _serve(self, method: str) -> None:
"""Read the request body, dispatch it, and write the JSON reply."""
server = self.server
assert isinstance(server, ControlPlaneServer)
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b""
status, payload = dispatch(server.registry, method, self.path, body)
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
self._serve("GET")
def do_POST(self) -> None:
self._serve("POST")
def do_DELETE(self) -> None:
self._serve("DELETE")
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the registry for its handlers."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None:
self.registry = registry
super().__init__(address, Handler)
def make_server(
registry: RegistryStore, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer:
"""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), registry)
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
+219
View File
@@ -0,0 +1,219 @@
"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070).
SQLite-backed registry mapping each live bottle to its source IP and
per-bottle identity token, plus the **fail-closed attribution** the data
plane relies on: a request is attributed to a bottle only when its source
IP *and* its identity token both match a single active record.
The registry co-tenants the shared host `bot-bottle.db` (the `DbStore`
framework namespaces each store by `schema_key`), so all bot-bottle
runtime state lives in one queryable file one place to back up, inspect,
and integrate a console against.
This is the "runtime state" tier of PRD 0070 (leases / approvals /
registry) deliberately NOT config (which stays declarative under
`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the
source of truth the orchestrator sweeps on restart to re-adopt running
bottles, so it lives in a durable store rather than process memory.
Attribution combines two independent signals, and needs both:
* source IP the network-layer invariant (on Firecracker the `/31` TAP
+ nft make it unspoofable by construction; weaker on other backends).
* identity token an application-layer per-bottle secret, defence in
depth that doesn't lean on network anti-spoof. A bottle can only ever
prove it is *itself* (it can't learn another bottle's token), so a
hostile agent gains nothing by presenting it.
"""
from __future__ import annotations
import hmac
import secrets
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..paths import host_db_path
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
IDENTITY_TOKEN_BYTES = 32
def new_identity_token() -> str:
"""A fresh per-bottle identity token (PRD 0070 attribution defence)."""
return secrets.token_urlsafe(IDENTITY_TOKEN_BYTES)
def default_db_path() -> Path:
"""The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime
state in one file, co-tenanted via schema_key. Host-resident so it
survives orchestrator restarts (re-adoption sweeps it)."""
return host_db_path()
@dataclass(frozen=True)
class BottleRecord:
"""One live bottle in the registry."""
bottle_id: str
source_ip: str
identity_token: str
state: str = "active"
created_at: float = 0.0
# Opaque JSON blob for forward-compat (policy refs, pool slot, ...) —
# the registry doesn't interpret it, so new fields need no migration.
metadata: str = ""
def redacted(self) -> dict[str, object]:
"""Public view for listing — never exposes the identity token."""
return {
"bottle_id": self.bottle_id,
"source_ip": self.source_ip,
"state": self.state,
"created_at": self.created_at,
"metadata": self.metadata,
}
_MIGRATIONS = TableMigrations(
"orchestrator_registry",
[
# v1 — the live bottle registry.
"""
CREATE TABLE IF NOT EXISTS orchestrator_bottles (
bottle_id TEXT PRIMARY KEY,
source_ip TEXT NOT NULL,
identity_token TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
created_at REAL NOT NULL,
metadata TEXT NOT NULL DEFAULT ''
)
""",
# source_ip is the data-plane attribution key — index it, and make
# the "one active bottle per source IP" check cheap.
"CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip "
"ON orchestrator_bottles (source_ip)",
],
)
def _row_to_record(row: sqlite3.Row) -> BottleRecord:
"""Build a BottleRecord from a registry row."""
return BottleRecord(
bottle_id=row["bottle_id"],
source_ip=row["source_ip"],
identity_token=row["identity_token"],
state=row["state"],
created_at=row["created_at"],
metadata=row["metadata"],
)
class RegistryStore(DbStore):
"""SQLite-backed registry of live bottles + fail-closed attribution."""
def __init__(self, db_path: Path | None = None) -> None:
super().__init__(db_path or default_db_path(), _MIGRATIONS)
def _connect(self) -> sqlite3.Connection:
"""Open a connection with a busy timeout for the shared DB."""
conn = super()._connect()
# The registry co-tenants the shared bot-bottle.db, so a busy_timeout
# rides out brief lock contention with the other stores. WAL for the
# shared DB is a deliberate future change (it affects supervise/audit
# and is finicky over guest shares) — not flipped here.
conn.execute("PRAGMA busy_timeout=5000")
return conn
def register(
self,
source_ip: str,
*,
bottle_id: str | None = None,
identity_token: str | None = None,
metadata: str = "",
) -> BottleRecord:
"""Register (or replace) a bottle. Mints a bottle_id / identity token
when not supplied. Live this is the control-plane reload path."""
rec = BottleRecord(
bottle_id=bottle_id or secrets.token_hex(8),
source_ip=source_ip,
identity_token=identity_token or new_identity_token(),
state="active",
created_at=time.time(),
metadata=metadata,
)
with self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
rec.bottle_id,
rec.source_ip,
rec.identity_token,
rec.state,
rec.created_at,
rec.metadata,
),
)
self._chmod()
return rec
def deregister(self, bottle_id: str) -> bool:
"""Remove a bottle. Returns True if a row was deleted."""
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
)
return cur.rowcount > 0
def get(self, bottle_id: str) -> BottleRecord | None:
"""Return the bottle by id, or None if absent."""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
).fetchone()
return _row_to_record(row) if row else None
def all(self) -> list[BottleRecord]:
"""Every registered bottle, oldest first."""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM orchestrator_bottles ORDER BY created_at"
).fetchall()
return [_row_to_record(r) for r in rows]
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution. Returns the bottle only when exactly one
active record has this source IP AND its identity token matches
(constant-time). Either signal alone is insufficient: an unknown IP,
an ambiguous IP (more than one active bottle a misconfiguration),
an empty token, or a token mismatch all deny."""
if not identity_token:
return None
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active'",
(source_ip,),
).fetchall()
if len(rows) != 1:
return None
rec = _row_to_record(rows[0])
if not hmac.compare_digest(rec.identity_token, identity_token):
return None
return rec
__all__ = [
"BottleRecord",
"RegistryStore",
"new_identity_token",
"default_db_path",
"IDENTITY_TOKEN_BYTES",
]
+43
View File
@@ -0,0 +1,43 @@
"""Foundational filesystem paths for bot-bottle.
`bot_bottle_root()` is the app data root state, queue, audit logs,
git-gate keys, and the shared DB all live under it. It defaults to
`~/.bot-bottle` and is overridable with the **`BOT_BOTTLE_ROOT`** env var.
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 sidecar bundle).
"""
from __future__ import annotations
import os
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"
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 sidecar without exposing the root's other contents (git-gate
keys, per-bottle state, ...)."""
return bot_bottle_root() / "db" / HOST_DB_FILENAME
__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"]
+4 -2
View File
@@ -7,11 +7,13 @@ import sqlite3
from pathlib import Path from pathlib import Path
try: try:
from .supervise_types import Proposal, Response, host_db_path from .supervise_types import Proposal, Response
from .paths import host_db_path
from .db_store import DbStore from .db_store import DbStore
from .migrations import TableMigrations from .migrations import TableMigrations
except ImportError: except ImportError:
from supervise_types import Proposal, Response, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
+2 -5
View File
@@ -23,13 +23,10 @@ class StoreManager:
def __init__(self, db_path: Path | None = None) -> None: def __init__(self, db_path: Path | None = None) -> None:
if db_path is None: if db_path is None:
# Lazy import to avoid a circular dependency: supervise imports
# StoreManager at module level; StoreManager must not import
# supervise at module level in return.
try: try:
from .supervise import host_db_path from .paths import host_db_path
except ImportError: except ImportError:
from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
db_path = host_db_path() db_path = host_db_path()
self.db_path = db_path self.db_path = db_path
+6 -16
View File
@@ -41,7 +41,6 @@ try:
from .supervise_types import ( from .supervise_types import (
ACTION_OPERATOR_EDIT, ACTION_OPERATOR_EDIT,
AuditEntry, AuditEntry,
HOST_DB_FILENAME,
Proposal, Proposal,
Response, Response,
STATUSES, STATUSES,
@@ -54,13 +53,11 @@ try:
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
bot_bottle_root,
) )
except ImportError: except ImportError:
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
ACTION_OPERATOR_EDIT, ACTION_OPERATOR_EDIT,
AuditEntry, AuditEntry,
HOST_DB_FILENAME,
Proposal, Proposal,
Response, Response,
STATUSES, STATUSES,
@@ -73,10 +70,15 @@ except ImportError:
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
bot_bottle_root,
) )
try:
from .paths import bot_bottle_root
except ImportError: # flat imports inside the sidecar bundle
from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
SUPERVISE_HOSTNAME = "supervise" SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
@@ -106,16 +108,6 @@ def audit_dir() -> Path:
return bot_bottle_root() / "audit" return bot_bottle_root() / "audit"
def host_db_path() -> Path:
# Calls bot_bottle_root() through this module's globals so that patches
# on supervise.bot_bottle_root propagate to callers going through
# supervise.host_db_path().
#
# Kept in its own "db" subdirectory (see supervise_types.host_db_path
# for why) — must stay in sync with that copy.
return bot_bottle_root() / "db" / HOST_DB_FILENAME
def audit_log_path(component: str, slug: str) -> Path: def audit_log_path(component: str, slug: str) -> Path:
return audit_dir() / f"{component}-{slug}.log" return audit_dir() / f"{component}-{slug}.log"
@@ -297,8 +289,6 @@ __all__ = [
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
"bot_bottle_root",
"host_db_path",
"list_pending_proposals", "list_pending_proposals",
"list_all_pending_proposals", "list_all_pending_proposals",
"read_audit_entries", "read_audit_entries",
+5 -32
View File
@@ -1,26 +1,19 @@
"""Shared types and path helpers for supervise stores (PRD 0013). """Shared types for supervise stores (PRD 0013).
Extracted from supervise.py so queue_store and audit_store can import Extracted from supervise.py so queue_store and audit_store can import
Proposal, Response, AuditEntry, and host_db_path without creating a Proposal, Response, and AuditEntry without creating a circular import
circular import (supervise imports from queue_store/audit_store and (supervise imports from queue_store/audit_store and vice-versa).
vice-versa).
Patching bot_bottle_root on this module propagates through host_db_path The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`)
because host_db_path looks up bot_bottle_root via sys.modules[__name__] live in `paths` import them from there.
at call time rather than capturing it at import time.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import sys
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
HOST_DB_FILENAME = "bot-bottle.db"
TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_EGRESS_ALLOW = "egress-allow" TOOL_EGRESS_ALLOW = "egress-allow"
@@ -43,23 +36,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
def bot_bottle_root() -> Path:
return Path.home() / ".bot-bottle"
def host_db_path() -> Path:
# Look up bot_bottle_root through this module's live namespace so that
# monkey-patches on supervise_types.bot_bottle_root take effect.
#
# Lives in its own "db" subdirectory, not directly under
# bot_bottle_root(), so backends that can only bind-mount
# directories (macos_container's `container run --mount`, which
# rejects file sources with "is not a directory") can share this
# one file with a sidecar without also exposing bot_bottle_root()'s
# other contents (git-gate keys, per-bottle state, etc.).
return sys.modules[__name__].bot_bottle_root() / "db" / HOST_DB_FILENAME
def _require_str(raw: dict[str, object], key: str) -> str: def _require_str(raw: dict[str, object], key: str) -> str:
value = raw.get(key) value = raw.get(key)
if not isinstance(value, str): if not isinstance(value, str):
@@ -171,7 +147,6 @@ class AuditEntry:
__all__ = [ __all__ = [
"ACTION_OPERATOR_EDIT", "ACTION_OPERATOR_EDIT",
"AuditEntry", "AuditEntry",
"HOST_DB_FILENAME",
"Proposal", "Proposal",
"Response", "Response",
"STATUSES", "STATUSES",
@@ -184,6 +159,4 @@ __all__ = [
"TOOL_EGRESS_TOKEN_ALLOW", "TOOL_EGRESS_TOKEN_ALLOW",
"TOOL_GITLEAKS_ALLOW", "TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
"bot_bottle_root",
"host_db_path",
] ]
@@ -1,10 +1,16 @@
# PRD 0069: Firecracker-native, Docker-free backend # PRD 0069: Firecracker-native, Docker-free backend
- **Status:** Draft - **Status:** Draft (partially superseded)
- **Author:** Claude - **Author:** Claude
- **Created:** 2026-07-12 - **Created:** 2026-07-12
- **Issue:** #348 - **Issue:** #348
> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):**
> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4,
> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still
> owns the docker-free **image-building** work — Stage 2 (nix-built fixed
> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
## Summary ## Summary
Make the Firecracker backend depend on **firecracker + KVM only**, removing Make the Firecracker backend depend on **firecracker + KVM only**, removing
+433
View File
@@ -0,0 +1,433 @@
# PRD 0070: Per-host orchestrator service
- **Status:** Draft
- **Author:** Claude
- **Created:** 2026-07-12
- **Issue:** #351
- **Supersedes:** the Stage-1 / Stage-4 sidecar-consolidation framing of
PRD 0069 (#348). Depends on 0069's nix-built fixed images (Stage 2) for
bootstrapping; 0069 still owns the docker-free image-building work.
## Summary
Replace the **per-bottle sidecar bundle** with a single **persistent,
per-host orchestrator**: one long-lived service that runs the sidecar
functions (egress / git-gate / supervise), coordinates with the console,
and brokers agent launches and teardown. It is **virtualized from the
start** using each backend's native isolation primitive — a Firecracker
microVM on the Firecracker backend, an Apple container on macOS, a Docker
container on the legacy backend — and is fronted by a single
**backend-agnostic contract**. Per-backend variation lives on
`BottleBackend`, not in the orchestrator.
## Motivation
Today each bottle spins up its own sidecar bundle (egress mitmproxy +
git-gate + supervise). That costs:
- **Resources.** N bottles → N heavy bundles booting and idling.
- **Operational churn.** Per-launch container/VM lifecycle for the
sidecars, a control path baked at launch and torn down at exit.
- **A blurry contract.** "How a bottle talks to its sidecar" is
re-implemented per backend instead of being one agreed interface.
A per-host orchestrator collapses the first two and forces the third to
be made explicit. It's also the component that will own per-host runtime
**state** (slot leases, the approval queue, the bottle registry) — today
that's ad-hoc `fcntl`-locked files.
## Security review (read this first)
Consolidation is a real change to the trust model. The goal is to **not
significantly weaken** the posture; some properties strengthen, some
weaken, and the weakened ones must be mitigated by design, not hand-waved.
### What gets stronger
- **Build/host isolation of untrusted inputs** (with 0069 Stage 3): user
Dockerfiles build in a disposable VM instead of on the host.
- **One audited privileged surface.** Today the launcher runs as the full
host user and needs the Docker socket (root-equivalent). The orchestrator
model replaces that with a **thin launch broker** (below) — a small,
structured, auditable privileged core instead of a fat socket.
- **Attribution is enforced, not assumed.** Making source-IP identity a
first-class contract invariant (below) means each backend must *prove*
it, rather than the sidecar implicitly trusting network position.
### What gets weaker, and the mitigation
1. **Secret concentration.** Per-bottle sidecars isolate secrets at the
process boundary — each holds only its bottle's tokens/keys. A host
orchestrator concentrates **every bottle's** egress tokens, git deploy
keys, and the console credential in one long-lived process. A single
attribution bug leaks bottle A's token into bottle B's request — a class
of bug that *cannot exist* per-bottle.
- *Mitigation:* lean on the enforced source-IP invariant for
attribution; keep the most secret-dense, least-shareable service
(**git-gate**, per-repo deploy keys, no natural source-IP scoping)
**per-bottle** unless there's a compelling reason; scope each secret
to the bottle in the state DB so a lookup can't return the wrong
bottle's secret by construction (key every secret access by the
verified source identity, never by ambient state).
2. **Shared fate.** Orchestrator down = no new launches, and running
agents lose egress / git / supervise. Compromise = the whole host's
fleet, plus launch authority, plus the console token.
- *Mitigation:* the orchestrator is itself confined (its own VM/container
with its own fail-closed egress); make it **restartable without killing
running agent VMs** (agents keep running; they briefly lose sidecar
connectivity until it's back); persist state to a host volume so a
restart re-adopts live bottles rather than losing them.
3. **The launch broker is the new privileged core.** We don't eliminate
host privilege — we shrink and relocate it. If the broker accepts
arbitrary paths/commands, the orchestrator VM can escape through it.
- *Mitigation:* the broker takes **structured requests only** — "launch
bottle from *this* content-addressed, nix-built rootfs on TAP slot
*k*", never "run this argv". It validates against a fixed image set,
not caller-supplied paths. It is small enough to audit line-by-line.
4. **The egress proxy now parses every bottle's traffic in one process.**
Higher blast radius for a mitmproxy/TLS-bump bug.
- *Mitigation:* this is the argument for virtualizing the orchestrator
from the start (Stage B, not a host daemon) — the code that TLS-bumps
and parses agent traffic and holds every token runs **inside its own
confined VM**, not as a host process. If egress sharing's blast radius
feels too high, egress can stay per-bottle while supervise (near-zero
secrets) goes host-level first.
### The attribution invariant
Source-IP attribution is what makes a shared orchestrator safe: one
process serves every bottle and tells them apart by source address. The
*mechanism* is identical everywhere (read source IP → look up bottle); the
**guarantee that the address can't be forged is a per-backend
responsibility** and part of the contract:
> **Invariant:** a packet's source address, as seen by the orchestrator,
> *provably* identifies the originating bottle.
- **Firecracker** — enforced by the `/31` point-to-point TAP + the
`bot_bottle_fc` nft table (strongest; already built).
- **Docker** — the per-bottle `--internal` network + anti-spoof; weaker,
must be made explicit.
- **Apple** — the host-only network.
If a backend can't honor the invariant, source-IP consolidation is not
safe there and that backend keeps per-bottle sidecars. The invariant is a
hard precondition, not an aspiration.
**Defense-in-depth — a per-bottle identity token.** On top of the
network-layer invariant, inject a per-bottle secret token into every
request the agent makes to the orchestrator (the agent already egresses
through the sidecar proxy, so this is cheap to add). It gives an
**application-layer** proof of identity independent of the network layer:
- On **Firecracker** the `/31` + nft already make source IP unspoofable
*by construction*, so the token is belt-and-suspenders there — but cheap
insurance against a misconfigured invariant.
- On **weaker backends** (Docker) it is load-bearing, providing attribution
that doesn't lean on network anti-spoof.
Requirements: the token is **per-bottle, unguessable, and
non-cross-leakable** — a bottle can only ever prove it is *itself* (it
can't learn another bottle's token, given bottle isolation), so a hostile
agent gains nothing by presenting it. The orchestrator provisions the
token at launch and the sidecar requires it to attribute + authorize.
Note this hardens *attribution*, not *secret exposure*: it's app-layer, so
a compromised orchestrator still sees every token (that's the
concentration problem, addressed separately under Secret handling).
### Secret handling — a FUTURE pattern (not v1)
> **Status: future / not required for the initial orchestrator.** The
> initial cut can inject secrets as today; this section records the
> direction so v1 doesn't paint itself into a corner. Tracked as its own
> work in **#355** (generic `SecretProvider`).
The residual weakness after all of the above is **long-lived credential
concentration** — the data-plane proxies must hold every bottle's upstream
tokens because the agent must never see them. You can't policy-gate the
component whose job is to *use* all the secrets, so a full RCE of a proxy
drains its authorized set regardless. Two moves bound this without
pretending to prevent it:
1. **Vault as a separate trust domain.** The long-lived *roots* live in a
distinct process (ideally its own VM) that the byte-parsing data plane
never shares memory with. The proxies request secrets from it; the
crown jewels are not in the process an agent-facing parser can pop.
2. **Derive short-lived, scoped creds where the upstream allows it.** The
vault holds the root and mints expiring, narrowly-scoped credentials
per bottle-start (or per request) — GitHub App installation tokens,
OAuth/STS token exchange, forge deploy tokens. A compromise then leaks
short-lived material, not permanent keys. For upstreams stuck on static
keys the vault passes the value through (only the at-rest / audit /
revocation benefits apply, not lifetime reduction) — this residue is
accepted and documented, not solved.
Even a plain per-request fetch (no derivation) still buys **at-rest**
reduction (process memory holds only in-flight secrets), a **detection /
rate-limit / revocation chokepoint**, and clean **cross-component
scoping** (an egress RCE can't request git-gate's creds). It does *not*
prevent abuse of currently-authorized access during the compromise window.
**Mechanism (see #355):** generalize the existing `DeployKeyProvisioner`
(PRD 0048) into a user-extensible **`SecretProvider`** droppable into the
manifest anywhere a raw token value is accepted, discovered from
`~/.bot-bottle/contrib/<name>/secret_provider.py` exactly as user
`AgentProvider`s are — because maintaining every forge/cloud/OAuth
provider in-tree is untenable. The orchestrator's vault mints via these
providers; but the abstraction is shippable independently and today's
per-bottle sidecars can use it too.
## Design
### The contract (backend-agnostic)
Three surfaces; only one is per-backend.
1. **Control plane (CLI / console → orchestrator)** — an RPC:
`launch_bottle`, `teardown_bottle`, `register_policy`,
`deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the
local `cli.py` and the remote console funnel through it, so policy is
uniform and `cli.py` becomes a thin client rather than a parallel
launcher. **Transport: HTTP** — the most universal/reliable choice on
every host (no vsock/unix-socket portability caveats); a local unix
socket is a fine optimization, but HTTP is the wire contract.
2. **Data plane (agent → orchestrator)** — the egress / git / supervise
endpoints. Already agnostic today (agents dial `http://sidecar:9099`);
only the *address* and *how packets get there* are per-backend.
3. **Launch / wire (orchestrator → backend)** — the irreducibly
backend-specific part; lives on `BottleBackend`.
### One `Orchestrator`, no subclass tree
The orchestrator is a **single concrete class** holding all the
backend-neutral logic — egress addon, git-gate, supervise, source-IP
attribution, live-reload control plane, console client. It never branches
on backend; it *composes* a `BottleBackend`. That composition is what makes
the contract agnostic: there is nothing backend-specific left in the
orchestrator to leak.
Rejected alternative: an `Orchestrator` ABC with per-backend
implementations. The interesting logic (proxies, attribution, control
plane) is backend-neutral, so three subclasses would triplicate the hard
part; and a second hierarchy paralleling `BottleBackend` reintroduces the
same hand-maintained lockstep coupling we just removed from the netpool
constants (PR #350). Composition over a parallel tree.
### `BottleBackend` absorbs the per-backend variation
A small, cohesive surface — reused for launching agent bottles *and* the
orchestrator's own unit (the orchestrator is just another native unit):
```
launch_unit(spec) -> Handle # agent bottle OR the orchestrator itself
# (fc microVM / apple ctr / docker ctr)
wire(unit, endpoint) -> None # DNAT+forward (fc) | attach shared net (docker/apple)
endpoint_of(unit) -> Endpoint # address resolution
health(unit) -> Status
```
Plus the **launch broker** — the answer to "a VM/container can't spawn its
own host-network siblings." The orchestrator can't directly open host
`/dev/kvm` + a host TAP fd (Firecracker), and a container can't spawn
siblings without a root-equivalent socket (Docker). So every backend
exposes a broker the orchestrator calls to launch an agent:
- **Firecracker** — a thin, structured host shim (see security #3). This
replaces today's implicit "launcher runs as host user."
- **Docker** — the socket today (fat, root-equivalent — the thing 0069's
Stage 3 removes); a narrower broker later.
- **Apple** — the `container` CLI/daemon.
**Broker request schema:** human-readable JSON of **static flags + ids
only** (never free-form paths/argv — that's what keeps it un-coercible
into arbitrary launches), wrapped as a **signed JWT** so the broker
verifies *provenance*: the request came from the real orchestrator, not a
forged one from a compromised co-located component. The orchestrator signs;
the broker verifies with the orchestrator's public key (provisioned at
broker install). This is the concrete form of security #3's "structured
requests only." Same JSON-with-ids + JWT shape for all
orchestrator↔broker/sidecar communication; cases that don't fit get
handled as they arise, with this as the default.
If `BottleBackend` bloats, the pressure valve is composition one level
down: vend a `backend.network()` / `Wiring` collaborator rather than
piling methods on — the same discipline, recursed.
### State: one SQLite DB, owned by the orchestrator
The orchestrator is the natural owner of per-host **runtime state**:
- pool **slot leases** (which bottle holds slot *i*) — replaces today's
`fcntl`-locked files with SQLite transactions;
- the **supervise approval queue** + remembered approvals;
- the **live bottle registry** (source IP → bottle → policy/secrets refs),
the lookup table the attribution invariant reads.
This is deliberately **not** a "single source of truth for all config."
Config splits into three tiers with different homes:
| Tier | Example | Home |
|---|---|---|
| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime |
| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" |
| Runtime state | slot leases, approvals, registry | one shared **`bot-bottle.db`**, solely owned by the orchestrator |
SQLite is right for the runtime tier (mutable, concurrent, queried) and
wrong for the other two (Nix can't read it at eval time; it fights the
declarative manifest trust model). Keep the tiers separate.
**One shared `bot-bottle.db` for all runtime state** (decided in review).
The registry co-tenants the existing host `bot-bottle.db` — the `DbStore`
framework already namespaces each store by `schema_key`, so slot
leases / approvals / registry share one file. One place to query, back up,
and integrate a console against.
- **Host-resident, for durability.** Re-adoption sweeps the registry after
an orchestrator restart, so state *must* outlive the orchestrator
instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`);
the orchestrator unit reaches it, it doesn't carry it.
- **Integrity by sole ownership, not mount permissions.** Agents can't
touch the DB directly wherever it lives (network-isolated in their
bottles). The risk is a *compromised agent-facing data-plane service*
(egress/git-gate, which parse hostile bytes) writing the registry and
forging attribution. Because it's now one shared file, coarse `ro`/`rw`
mount-splitting no longer isolates the registry — so the rule is stronger
and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`;
the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise sidecar
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
orchestrator; sidecar writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
Implementation note for the VM slices: SQLite **WAL** over a guest share
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
which is a second reason the DB wants a **host-side owner** the orchestrator
reaches over the RPC rather than a shared mount into the VM. WAL on the
shared DB is therefore a deliberate, tested future change — not enabled ad
hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost.
## Sequencing
Jump straight to the **virtualized** end state (not a host-daemon stepping
stone): a host daemon's agent→`localhost` transport is throwaway once the
orchestrator becomes a VM. Decouple the two risks instead:
- **Consolidation risk** (one process, all secrets, attribution, reload)
and **packaging/transport risk** (VM-to-VM wiring, the shim) are
independent. Develop the orchestrator **service as a plain process
dev-harness** first, so the consolidation logic (attribution, reload,
secret handling) is proven with fast iteration — *then* wrap that exact
service in the VM and solve wiring separately.
### Slices
Built bottom-up as a stack of small PRs off this one (status: **15
implemented** in #352#356#357#358#360):
1. **Dev-harness core** ✅ — SQLite registry (runtime state) + fail-closed
attribution (source IP + identity token) + the HTTP control plane.
2. **Launch lifecycle + broker contract** ✅ — `launch_bottle` /
`teardown_bottle` + the signed, structured `LaunchBroker` request
(HS256 JWT, static ids/flags only, provenance-verified, fail-closed),
with a stub broker for the harness and registry rollback on failure.
3. **Docker launch broker** ✅ — the first real broker: `docker run` / `rm`
per bottle, built only from the request's static fields; proves the
`BottleBackend` seam on the cheapest backend.
**The consolidated-sidecar arc** (the core consolidation win — one
persistent sidecar shared by all bottles instead of one per bottle):
4. **Sidecar — lifecycle** ✅ — the idempotent-singleton `Sidecar` /
`DockerSidecar` (ensure-running is a no-op when already up, stop is
idempotent); one container per host.
5. **Sidecar — build the real bundle image** ✅ — the orchestrator builds
the `bot-bottle-sidecars` bundle from `Dockerfile.sidecars` when missing
and launches *that* (not a placeholder) as the singleton.
6. **Sidecar — source-IP-keyed multi-tenant config** — the functional
core: the per-bottle CA / egress routes / git-gate keys / policy, today
baked per bottle, become **multi-tenant and selected by the verified
source IP**, with per-bottle add/remove (live reload) on launch/teardown
through the control plane. Until this lands, one shared instance can't
serve multiple bottles' distinct policies.
7. **Sidecar — route agent bottles through it** — wire each agent bottle's
egress/git to the shared sidecar (DNAT / network) instead of a per-bottle
bundle.
**Remaining backends** (docker done above; the rest against the proven
dev-harness):
8. **Firecracker broker + sidecar** — the real work: the shim + VM-to-VM
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
forward rules where today it drops all non-DNAT egress).
9. **macOS (Apple container)** — last (container-to-container networking).
Backends land cheapest-first (docker → firecracker → macOS); keep the
sidecar **service one shared thing** throughout.
## Non-goals
- Removing OCI/Dockerfile support for agent images (0069's concern).
- A single database for *all* config (see the three-tier table).
- Changing the per-bottle isolation of agent workloads — only the sidecar
is consolidated; agents stay one-VM/container-each.
## Relationship to other work
- **PRD 0069 (#348):** 0070 subsumes its Stage 1 (per-host sidecar) and
Stage 4 (sidecar-as-VM). 0069 retains Stage 2 (nix-built fixed images —
a **dependency** here: the orchestrator and agent base must be
nix-built so the broker launches from a fixed image set and bootstrapping
has no chicken-and-egg) and Stage 3 (in-VM Dockerfile builder).
- **Minimal CI runner (paused):** the Firecracker broker + no host Docker
is what lets a dedicated `gitea` runner user drop the root-equivalent
`docker` group — it only needs broker-socket access + `kvm`/pool group
membership. This work unblocks it.
- **Generic `SecretProvider` (#355):** the future secret-handling
mechanism (see "Secret handling") — generalizes PRD 0048's
`DeployKeyProvisioner` into a user-extensible provider that mints
short-lived creds. Shippable independently; the orchestrator's vault
mints through it.
- **PR #350 (netpool single-source):** the same "one source per fact,
composition over parallel hierarchies" discipline the contract follows.
## Decisions (review 2026-07-13)
- **Egress sharing:** **consolidate egress** (worth it). Treat it as a
minimal, hardened attack surface for a malicious agent rather than
keeping it per-bottle; pair it with the identity token above and the
short-lived-vault-token mitigations (Secret handling / #355).
- **Control-plane transport:** **HTTP** — most universal/reliable on every
host; unix socket is an optional local optimization (see the contract).
- **Broker request schema:** **signed-JWT JSON, static flags + ids only**
(see the launch broker) — provenance + un-coercible by construction.
- **State re-adoption:** the restart procedure is:
1. **Singleton:** a new orchestrator launch requires no pre-existing
orchestrator; any found (healthy or not) is fully shut down first.
2. Re-adoption **waits for the new orchestrator to be healthy**.
3. Once healthy, it discovers all agents needing adoption via **both the
SQLite registry and live process/VM inspection** *before serving any
other request*. The two-source sweep is what closes the *in-flight
launch* race — a launch that started (VM booting / slot claimed) but
hadn't committed to SQLite when the old orchestrator died would be
invisible to a SQLite-only sweep; process inspection catches it.
Launches should also write an **intent record ahead of committing
resources** so the sweep can reconcile intent vs. actual.
## Open questions
- **VM-to-VM routing:** per-backend, and in the design it *is*
`BottleBackend.wire()` (DNAT+forward for fc, shared-net for
docker/apple), not orchestrator logic. **Not a blocker** — resolvable at
implementation time (may require a bit of host modification, as the pool
setup already does on NixOS); an earlier "sidecars in VMs" spike showed
it's feasible.
- **Live-reload protocol** for per-bottle policy over the HTTP control
plane (add/remove routes/keys/proposals without a restart).
- **Identity-token delivery:** exactly how the per-bottle token is placed
where the agent can present it but not swap in another bottle's.
+22 -3
View File
@@ -2,7 +2,7 @@
Isolates ``HOME`` to a throwaway directory for the entire unit suite so Isolates ``HOME`` to a throwaway directory for the entire unit suite so
no test ever reads or writes the real ``~/.bot-bottle`` (state, queue, no test ever reads or writes the real ``~/.bot-bottle`` (state, queue,
and audit dirs all derive from ``supervise.bot_bottle_root()`` and audit dirs all derive from ``paths.bot_bottle_root()``
``Path.home()``). Without this, a test that takes a ``flock`` on the ``Path.home()``). Without this, a test that takes a ``flock`` on the
real audit log can **block indefinitely** when a live bottle's supervise real audit log can **block indefinitely** when a live bottle's supervise
sidecar holds that lock observed as a hung ``coverage run`` at 0% CPU sidecar holds that lock observed as a hung ``coverage run`` at 0% CPU
@@ -10,8 +10,12 @@ and unisolated tests otherwise pollute the developer's home dir.
Individual tests that need their own ``HOME`` still override Individual tests that need their own ``HOME`` still override
``os.environ['HOME']`` and restore it; they now restore to this isolated ``os.environ['HOME']`` and restore it; they now restore to this isolated
dir rather than the real one, so isolation holds either way. Tests that dir rather than the real one, so isolation holds either way.
patch ``supervise.bot_bottle_root`` directly are unaffected.
Tests that need their own app-data root call ``use_bottle_root`` (below),
which sets ``BOT_BOTTLE_ROOT`` the supported override read by
``paths.bot_bottle_root`` instead of monkey-patching. One env setting
covers every module and every flat/package copy of the helper.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,6 +24,9 @@ import atexit
import os import os
import shutil import shutil
import tempfile import tempfile
from collections.abc import Callable
from pathlib import Path
from unittest import mock
_real_home = os.environ.get("HOME") _real_home = os.environ.get("HOME")
_tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.")
@@ -35,3 +42,15 @@ def _restore_home() -> None:
atexit.register(_restore_home) atexit.register(_restore_home)
def use_bottle_root(root: Path) -> Callable[[], None]:
"""Redirect ``paths.bot_bottle_root()`` to ``root`` by setting
``BOT_BOTTLE_ROOT`` the supported override. Returns a callable that
undoes it (store it as the test's restore hook, or pass to addCleanup).
Replaces monkey-patching ``paths.bot_bottle_root``; one env var covers
every module and the flat/package copies alike (they all read it)."""
patcher = mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(root)})
patcher.start()
return patcher.stop
+3 -8
View File
@@ -7,7 +7,8 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import supervise, bottle_state from bot_bottle import bottle_state
from tests.unit import use_bottle_root
from bot_bottle.backend import ActiveAgent from bot_bottle.backend import ActiveAgent
from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.freeze import get_freezer
from bot_bottle.backend.docker.freezer import DockerFreezer from bot_bottle.backend.docker.freezer import DockerFreezer
@@ -18,13 +19,7 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.") self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.")
original = supervise.bot_bottle_root self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+3 -4
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug from bot_bottle.backend.resolve_common import mint_slug
@@ -55,11 +55,10 @@ class _FakeStateMixin:
def setUp(self) -> None: def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.")
self.root = Path(self.tmp.name) / ".bot-bottle" self.root = Path(self.tmp.name) / ".bot-bottle"
self.original_root = supervise.bot_bottle_root self._restore = use_bottle_root(self.root)
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None: def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment] self._restore()
self.tmp.cleanup() self.tmp.cleanup()
+3 -4
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.firecracker import FirecrackerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend
@@ -55,11 +55,10 @@ class _FakeStateMixin:
self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.")
self.tmp = Path(self.tmp_dir.name) self.tmp = Path(self.tmp_dir.name)
self.root = self.tmp / ".bot-bottle" self.root = self.tmp / ".bot-bottle"
self.original_root = supervise.bot_bottle_root self._restore = use_bottle_root(self.root)
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None: def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment] self._restore()
self.tmp_dir.cleanup() self.tmp_dir.cleanup()
+2 -8
View File
@@ -6,7 +6,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.bottle_state import ( from bot_bottle.bottle_state import (
BottleMetadata, BottleMetadata,
@@ -18,13 +18,7 @@ from bot_bottle.bottle_state import (
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.")
original = supervise.bot_bottle_root self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+2 -8
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.cli.commit import cmd_commit from bot_bottle.cli.commit import cmd_commit
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.freeze import CommitCancelled from bot_bottle.backend.freeze import CommitCancelled
@@ -16,13 +16,7 @@ from bot_bottle.backend.freeze import CommitCancelled
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.") self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.")
original = supervise.bot_bottle_root self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+3 -8
View File
@@ -8,7 +8,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.cli import start as start_mod from bot_bottle.cli import start as start_mod
@@ -16,15 +16,10 @@ from bot_bottle.cli import start as start_mod
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.")
self._original = supervise.bot_bottle_root self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
def _teardown_fake_home(self): def _teardown_fake_home(self):
supervise.bot_bottle_root = self._original # type: ignore[assignment] self._restore()
self._tmp.cleanup() self._tmp.cleanup()
+2 -8
View File
@@ -15,7 +15,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
@@ -23,13 +23,7 @@ from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self) -> None: def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.") self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.")
original = supervise.bot_bottle_root self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self) -> None: def _teardown_fake_home(self) -> None:
self._restore() self._restore()
+2 -8
View File
@@ -23,7 +23,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker import enumerate as _enumerate from bot_bottle.backend.docker import enumerate as _enumerate
@@ -75,13 +75,7 @@ class TestParseServicesByProject(unittest.TestCase):
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self) -> None: def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")
original = supervise.bot_bottle_root self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self) -> None: def _teardown_fake_home(self) -> None:
self._restore_home() self._restore_home()
+2 -8
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle.backend.egress_apply import EgressApplyError from bot_bottle.backend.egress_apply import EgressApplyError
from bot_bottle.backend.docker.egress_apply import applicator from bot_bottle.backend.docker.egress_apply import applicator
@@ -67,14 +67,8 @@ class TestValidateRoutesContent(unittest.TestCase):
class TestApplyRoutesChange(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.") self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original))
self.addCleanup(self._tmp.cleanup) self.addCleanup(self._tmp.cleanup)
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
def test_writes_live_routes_and_signals_reload(self): def test_writes_live_routes_and_signals_reload(self):
calls: list[list[str]] = [] calls: list[list[str]] = []
@@ -0,0 +1,150 @@
"""Unit tests for the orchestrator HTTP control plane (PRD 0070).
Mostly exercises the pure `dispatch()` (socket-free, like the supervise
server tests), plus one real-socket round-trip to prove the handler wiring.
"""
from __future__ import annotations
import json
import tempfile
import threading
import unittest
import urllib.request
from pathlib import Path
from bot_bottle.orchestrator.control_plane import dispatch, make_server
from bot_bottle.orchestrator.registry import RegistryStore
def _body(obj: object) -> bytes:
return json.dumps(obj).encode()
class TestDispatch(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
self.store.migrate()
def tearDown(self) -> None:
self._tmp.cleanup()
def test_health(self) -> None:
status, payload = dispatch(self.store, "GET", "/health", b"")
self.assertEqual(200, status)
self.assertEqual("ok", payload["status"])
def test_register_returns_id_and_token(self) -> None:
status, payload = dispatch(
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
)
self.assertEqual(201, status)
self.assertTrue(payload["bottle_id"])
self.assertTrue(payload["identity_token"])
def test_register_requires_source_ip(self) -> None:
status, _ = dispatch(self.store, "POST", "/bottles", _body({}))
self.assertEqual(400, status)
def test_register_rejects_bad_json(self) -> None:
status, _ = dispatch(self.store, "POST", "/bottles", b"{not json")
self.assertEqual(400, status)
def test_list_redacts_identity_token(self) -> None:
dispatch(self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, payload = dispatch(self.store, "GET", "/bottles", b"")
self.assertEqual(200, status)
bottles = payload["bottles"]
assert isinstance(bottles, list)
self.assertEqual(1, len(bottles))
first = bottles[0]
assert isinstance(first, dict)
self.assertNotIn("identity_token", first)
self.assertEqual("10.243.0.1", first["source_ip"])
def test_attribute_ok_and_forbidden(self) -> None:
_, reg = dispatch(
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
)
token = reg["identity_token"]
ok_status, ok = dispatch(
self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": token}),
)
self.assertEqual(200, ok_status)
self.assertEqual(reg["bottle_id"], ok["bottle_id"])
bad_status, _ = dispatch(
self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
)
self.assertEqual(403, bad_status)
def test_attribute_requires_both_fields(self) -> None:
status, _ = dispatch(
self.store, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
)
self.assertEqual(400, status)
def test_delete(self) -> None:
_, reg = dispatch(
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
)
bid = reg["bottle_id"]
status, payload = dispatch(self.store, "DELETE", f"/bottles/{bid}", b"")
self.assertEqual(200, status)
self.assertEqual(True, payload["deregistered"])
self.assertEqual([], self.store.all())
def test_delete_missing_404(self) -> None:
status, _ = dispatch(self.store, "DELETE", "/bottles/ghost", b"")
self.assertEqual(404, status)
def test_unknown_route_404(self) -> None:
status, _ = dispatch(self.store, "GET", "/nope", b"")
self.assertEqual(404, status)
def test_trailing_slash_normalized(self) -> None:
status, _ = dispatch(self.store, "GET", "/health/", b"")
self.assertEqual(200, status)
class TestServerRoundTrip(unittest.TestCase):
def test_http_register_health_attribute(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
store = RegistryStore(Path(tmp.name) / "r.db")
store.migrate()
server = make_server(store, "127.0.0.1", 0)
self.addCleanup(server.server_close)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
host, port = server.server_address[0], server.server_address[1]
base = f"http://{host}:{port}"
reg = json.load(urllib.request.urlopen(
urllib.request.Request(
f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}),
method="POST", headers={"Content-Type": "application/json"},
), timeout=5,
))
self.assertTrue(reg["bottle_id"])
health = json.load(urllib.request.urlopen(f"{base}/health", timeout=5))
self.assertEqual("ok", health["status"])
attr = json.load(urllib.request.urlopen(
urllib.request.Request(
f"{base}/attribute",
data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}),
method="POST", headers={"Content-Type": "application/json"},
), timeout=5,
))
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Unit tests for the orchestrator bottle registry + attribution (PRD 0070)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from bot_bottle.orchestrator.registry import (
BottleRecord,
RegistryStore,
new_identity_token,
)
class TestRegistryStore(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = Path(self._tmp.name) / "registry.db"
self.store = RegistryStore(self.db)
self.store.migrate()
def tearDown(self) -> None:
self._tmp.cleanup()
def test_register_mints_id_and_token(self) -> None:
rec = self.store.register("10.243.0.1")
self.assertTrue(rec.bottle_id)
self.assertTrue(rec.identity_token)
self.assertEqual("active", rec.state)
self.assertEqual(rec, self.store.get(rec.bottle_id))
def test_identity_tokens_are_unique(self) -> None:
tokens = {new_identity_token() for _ in range(200)}
self.assertEqual(200, len(tokens))
a = self.store.register("10.243.0.1")
b = self.store.register("10.243.0.3")
self.assertNotEqual(a.identity_token, b.identity_token)
def test_all_and_deregister(self) -> None:
a = self.store.register("10.243.0.1")
b = self.store.register("10.243.0.3")
self.assertEqual({a.bottle_id, b.bottle_id}, {r.bottle_id for r in self.store.all()})
self.assertTrue(self.store.deregister(a.bottle_id))
self.assertEqual([b.bottle_id], [r.bottle_id for r in self.store.all()])
def test_deregister_missing_is_false(self) -> None:
self.assertFalse(self.store.deregister("nope"))
def test_explicit_id_replaces(self) -> None:
self.store.register("10.243.0.1", bottle_id="fixed", metadata="a")
self.store.register("10.243.0.9", bottle_id="fixed", metadata="b")
rec = self.store.get("fixed")
assert rec is not None
self.assertEqual("10.243.0.9", rec.source_ip)
self.assertEqual("b", rec.metadata)
self.assertEqual(1, len(self.store.all()))
def test_attribute_success_requires_ip_and_token(self) -> None:
rec = self.store.register("10.243.0.1")
got = self.store.attribute("10.243.0.1", rec.identity_token)
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
def test_attribute_wrong_token_denied(self) -> None:
self.store.register("10.243.0.1")
self.assertIsNone(self.store.attribute("10.243.0.1", "wrong-token"))
def test_attribute_unknown_ip_denied(self) -> None:
rec = self.store.register("10.243.0.1")
self.assertIsNone(self.store.attribute("10.243.9.9", rec.identity_token))
def test_attribute_empty_token_denied(self) -> None:
self.store.register("10.243.0.1")
self.assertIsNone(self.store.attribute("10.243.0.1", ""))
def test_attribute_ambiguous_ip_denied(self) -> None:
# Two active bottles on one source IP is a misconfiguration — deny
# rather than guess (fail-closed), even with a valid token.
a = self.store.register("10.243.0.1", bottle_id="a")
self.store.register("10.243.0.1", bottle_id="b")
self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token))
def test_state_persists_across_reopen(self) -> None:
rec = self.store.register("10.243.0.1")
reopened = RegistryStore(self.db)
got = reopened.get(rec.bottle_id)
assert got is not None
self.assertEqual(rec, got)
# Attribution works against the reopened (durable) store too.
self.assertIsNotNone(reopened.attribute("10.243.0.1", rec.identity_token))
def test_redacted_hides_token(self) -> None:
rec = BottleRecord(
bottle_id="x", source_ip="10.243.0.1", identity_token="secret"
)
self.assertNotIn("identity_token", rec.redacted())
self.assertEqual("10.243.0.1", rec.redacted()["source_ip"])
if __name__ == "__main__":
unittest.main()
+5 -44
View File
@@ -8,7 +8,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle import supervise_types as sv_types from bot_bottle.paths import host_db_path
from tests.unit import use_bottle_root
from bot_bottle.audit_store import AuditStore from bot_bottle.audit_store import AuditStore
from bot_bottle.queue_store import QueueStore from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import ( from bot_bottle.supervise import (
@@ -21,7 +22,6 @@ from bot_bottle.supervise import (
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
archive_proposal, archive_proposal,
host_db_path,
list_pending_proposals, list_pending_proposals,
read_audit_entries, read_audit_entries,
read_proposal, read_proposal,
@@ -123,20 +123,7 @@ class TestQueueIO(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
original_sv = supervise.bot_bottle_root return use_bottle_root(fake_home / ".bot-bottle")
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_write_and_read_proposal(self): def test_write_and_read_proposal(self):
p = _proposal() p = _proposal()
@@ -240,20 +227,7 @@ class TestAuditLog(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
original_sv = supervise.bot_bottle_root return use_bottle_root(fake_home / ".bot-bottle")
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_write_then_read_single_entry(self): def test_write_then_read_single_entry(self):
e = AuditEntry( e = AuditEntry(
@@ -400,20 +374,7 @@ class TestSupervisePrepare(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
original_sv = supervise.bot_bottle_root return use_bottle_root(fake_home / ".bot-bottle")
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_prepare_creates_queue(self): def test_prepare_creates_queue(self):
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
+3 -16
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle import supervise_types as sv_types from tests.unit import use_bottle_root
from bot_bottle.audit_store import AuditStore from bot_bottle.audit_store import AuditStore
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.queue_store import QueueStore from bot_bottle.queue_store import QueueStore
@@ -51,24 +51,11 @@ def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal:
class _FakeHomeMixin: class _FakeHomeMixin:
"""Patch supervise.bot_bottle_root to a temp dir for the test.""" """Point bot_bottle_root at a temp dir (via BOT_BOTTLE_ROOT) for the test."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.")
original_sv = supervise.bot_bottle_root self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
self._restore_home = restore
QueueStore("").migrate() QueueStore("").migrate()
AuditStore().migrate() AuditStore().migrate()
+11 -12
View File
@@ -11,12 +11,13 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from bot_bottle import supervise from tests.unit import use_bottle_root
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.log import Die, die from bot_bottle.log import Die, die
@@ -39,18 +40,16 @@ class TestDieCarriesMessage(unittest.TestCase):
class _FakeHomeMixin: class _FakeHomeMixin:
"""Point supervise.bot_bottle_root (what _write_crash_log resolves """Point bot_bottle_root (what _write_crash_log resolves through) at a
through) at a temp dir so the crash log doesn't touch the real temp dir so the crash log doesn't touch the real ~/.bot-bottle."""
~/.bot-bottle."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.")
self._orig_root = supervise.bot_bottle_root
self._root = Path(self._tmp.name) / ".bot-bottle" self._root = Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] self._restore_root = use_bottle_root(self._root)
def _teardown_fake_home(self): def _teardown_fake_home(self):
supervise.bot_bottle_root = self._orig_root # type: ignore[assignment] self._restore_root()
self._tmp.cleanup() self._tmp.cleanup()
@@ -127,11 +126,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase):
# OSError and the helper must fall back to a tempfile. # OSError and the helper must fall back to a tempfile.
bad = Path(self._tmp.name) / "not-a-dir" bad = Path(self._tmp.name) / "not-a-dir"
bad.write_text("x") bad.write_text("x")
supervise.bot_bottle_root = lambda: bad # type: ignore[assignment] with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}):
try: try:
raise RuntimeError("explode2") raise RuntimeError("explode2")
except RuntimeError as e: except RuntimeError as e:
path = supervise_cli._write_crash_log(e) path = supervise_cli._write_crash_log(e)
self.assertTrue(path.exists()) self.assertTrue(path.exists())
self.assertIn("explode2", path.read_text()) self.assertIn("explode2", path.read_text())
+2 -1
View File
@@ -12,6 +12,7 @@ from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.audit_store import AuditStore from bot_bottle.audit_store import AuditStore
from bot_bottle.paths import bot_bottle_root
from bot_bottle.queue_store import QueueStore from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import ( from bot_bottle.supervise import (
AuditEntry, AuditEntry,
@@ -39,7 +40,7 @@ def _proposal() -> Proposal:
class TestPathHelpers(unittest.TestCase): class TestPathHelpers(unittest.TestCase):
def test_bot_bottle_root(self) -> None: def test_bot_bottle_root(self) -> None:
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle")) self.assertTrue(str(bot_bottle_root()).endswith(".bot-bottle"))
class TestReadMalformed(unittest.TestCase): class TestReadMalformed(unittest.TestCase):
+3 -19
View File
@@ -10,6 +10,8 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from tests.unit import use_bottle_root
# The server module loads `supervise` via same-directory import inside # The server module loads `supervise` via same-directory import inside
# the container (Dockerfile.supervise WORKDIRs into /app). For tests # the container (Dockerfile.supervise WORKDIRs into /app). For tests
@@ -19,10 +21,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bott
import supervise as _sv # noqa: E402 # type: ignore import supervise as _sv # noqa: E402 # type: ignore
import queue_store as _qs # noqa: E402 # type: ignore import queue_store as _qs # noqa: E402 # type: ignore
import audit_store as _as # noqa: E402 # type: ignore import audit_store as _as # noqa: E402 # type: ignore
import supervise_types as svt_flat # noqa: E402 # type: ignore
from bot_bottle import supervise_server # noqa: E402 from bot_bottle import supervise_server # noqa: E402
from bot_bottle import supervise_types as svt_pkg # noqa: E402
from bot_bottle.supervise_server import ( from bot_bottle.supervise_server import (
ERR_INTERNAL, ERR_INTERNAL,
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
@@ -279,23 +279,7 @@ class TestHandleToolsCall(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
original_sv = _sv.bot_bottle_root return use_bottle_root(fake_home / ".bot-bottle")
original_flat = svt_flat.bot_bottle_root
original_pkg = svt_pkg.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
_sv.bot_bottle_root = fake_root # type: ignore[assignment]
svt_flat.bot_bottle_root = fake_root # type: ignore[assignment]
svt_pkg.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
_sv.bot_bottle_root = original_sv # type: ignore[assignment]
svt_flat.bot_bottle_root = original_flat # type: ignore[assignment]
svt_pkg.bot_bottle_root = original_pkg # type: ignore[assignment]
return restore
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a """Background thread: poll the queue for a fresh proposal, write a