From a30dd4996701f79662cc67d5567d79d6cde41438 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 12 Jul 2026 17:42:56 -0400 Subject: [PATCH 01/12] docs(prd): 0070 per-host orchestrator service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- .../0069-firecracker-native-docker-free.md | 8 +- docs/prds/0070-per-host-orchestrator.md | 266 ++++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 docs/prds/0070-per-host-orchestrator.md diff --git a/docs/prds/0069-firecracker-native-docker-free.md b/docs/prds/0069-firecracker-native-docker-free.md index 0a24167..c771c6c 100644 --- a/docs/prds/0069-firecracker-native-docker-free.md +++ b/docs/prds/0069-firecracker-native-docker-free.md @@ -1,10 +1,16 @@ # PRD 0069: Firecracker-native, Docker-free backend -- **Status:** Draft +- **Status:** Draft (partially superseded) - **Author:** Claude - **Created:** 2026-07-12 - **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 Make the Firecracker backend depend on **firecracker + KVM only**, removing diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md new file mode 100644 index 0000000..e10fdf1 --- /dev/null +++ b/docs/prds/0070-per-host-orchestrator.md @@ -0,0 +1,266 @@ +# 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. + +## 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. +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. + +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 WAL-mode 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 | **SQLite**, 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. + +## 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. + +Backend order (cheapest proof → hardest → last): + +1. **Docker orchestrator** — nearly free (the sidecar bundle is already + containers; collapse N bundles into one persistent container). Proves + consolidation + the `BottleBackend` seam with the least moving parts. +2. **Firecracker orchestrator** — 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). Built against + the dev-harness so the app logic is already proven. +3. **macOS (Apple container)** — last (container-to-container networking). + +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. +- **PR #350 (netpool single-source):** the same "one source per fact, + composition over parallel hierarchies" discipline the contract follows. + +## Open questions + +- **Egress sharing tradeoff:** is the secret-concentration blast radius of + one shared mitmproxy worth the resource win, or share only supervise + (near-zero secrets) and keep egress + git-gate per-bottle initially? +- **Control-plane shape:** RPC transport (unix socket / vsock / HTTP over + the TAP) and the live-reload protocol for per-bottle policy. +- **State re-adoption:** exact scheme for an orchestrator restart to + re-adopt running agent VMs from the SQLite registry without racing + in-flight launches. +- **VM-to-VM routing:** the nft forward rules + addressing for a + per-host orchestrator VM on its own TAP. +- **Broker request schema:** the exact structured contract that stays + auditable and can't be coerced into launching arbitrary payloads. -- 2.52.0 From 095896817c39f5e0c9b7fd30cda9c5b2b0cdfb04 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 12:10:45 -0400 Subject: [PATCH 02/12] docs(prd): 0070 secret-handling as a future pattern (SecretProvider, #355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index e10fdf1..701a33a 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -117,6 +117,48 @@ 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. +### 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//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) @@ -247,6 +289,11 @@ Keep the sidecar **service one shared thing** throughout. 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. -- 2.52.0 From 73a7582abe712fd1cc5fbe87435b13aeee6c70a6 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 12:39:06 -0400 Subject: [PATCH 03/12] docs(prd): 0070 fold in review decisions (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 80 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index 701a33a..77e2af6 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -117,6 +117,27 @@ 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 @@ -170,7 +191,9 @@ Three surfaces; only one is per-backend. `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. + 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. @@ -218,6 +241,17 @@ exposes a broker the orchestrator calls to launch an agent: 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. @@ -297,17 +331,37 @@ Keep the sidecar **service one shared thing** throughout. - **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 -- **Egress sharing tradeoff:** is the secret-concentration blast radius of - one shared mitmproxy worth the resource win, or share only supervise - (near-zero secrets) and keep egress + git-gate per-bottle initially? -- **Control-plane shape:** RPC transport (unix socket / vsock / HTTP over - the TAP) and the live-reload protocol for per-bottle policy. -- **State re-adoption:** exact scheme for an orchestrator restart to - re-adopt running agent VMs from the SQLite registry without racing - in-flight launches. -- **VM-to-VM routing:** the nft forward rules + addressing for a - per-host orchestrator VM on its own TAP. -- **Broker request schema:** the exact structured contract that stays - auditable and can't be coerced into launching arbitrary payloads. +- **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. Likely already prototyped in the + earlier "sidecars in VMs" spike — **link the spike** and this drops from + open question to "implement per the spike." +- **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. -- 2.52.0 From 8d54fc38ea04fa133db87f4f831295210218297b Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 12:46:08 -0400 Subject: [PATCH 04/12] 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index 77e2af6..aab0800 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -358,9 +358,10 @@ Keep the sidecar **service one shared thing** throughout. - **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. Likely already prototyped in the - earlier "sidecars in VMs" spike — **link the spike** and this drops from - open question to "implement per the spike." + 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 -- 2.52.0 From 1702664b8192945be3c47e96c11cc07dd262f7d6 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 13:00:04 -0400 Subject: [PATCH 05/12] =?UTF-8?q?feat(orchestrator):=20slice=201=20?= =?UTF-8?q?=E2=80=94=20registry=20+=20attribution=20+=20HTTP=20control=20p?= =?UTF-8?q?lane=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/orchestrator/__init__.py | 30 +++ bot_bottle/orchestrator/__main__.py | 52 +++++ bot_bottle/orchestrator/control_plane.py | 154 +++++++++++++ bot_bottle/orchestrator/registry.py | 212 ++++++++++++++++++ tests/unit/test_orchestrator_control_plane.py | 150 +++++++++++++ tests/unit/test_orchestrator_registry.py | 102 +++++++++ 6 files changed, 700 insertions(+) create mode 100644 bot_bottle/orchestrator/__init__.py create mode 100644 bot_bottle/orchestrator/__main__.py create mode 100644 bot_bottle/orchestrator/control_plane.py create mode 100644 bot_bottle/orchestrator/registry.py create mode 100644 tests/unit/test_orchestrator_control_plane.py create mode 100644 tests/unit/test_orchestrator_registry.py diff --git a/bot_bottle/orchestrator/__init__.py b/bot_bottle/orchestrator/__init__.py new file mode 100644 index 0000000..b04bab4 --- /dev/null +++ b/bot_bottle/orchestrator/__init__.py @@ -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", +] diff --git a/bot_bottle/orchestrator/__main__.py b/bot_bottle/orchestrator/__main__.py new file mode 100644 index 0000000..7a2fd9a --- /dev/null +++ b/bot_bottle/orchestrator/__main__.py @@ -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()) diff --git a/bot_bottle/orchestrator/control_plane.py b/bot_bottle/orchestrator/control_plane.py new file mode 100644 index 0000000..772eae2 --- /dev/null +++ b/bot_bottle/orchestrator/control_plane.py @@ -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": [ , ... ]} + POST /bottles -> 201 {"bottle_id", "identity_token"} + body: {"source_ip", ["bottle_id"], ["metadata"]} + DELETE /bottles/ -> 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"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py new file mode 100644 index 0000000..df35a12 --- /dev/null +++ b/bot_bottle/orchestrator/registry.py @@ -0,0 +1,212 @@ +"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070). + +SQLite-backed (WAL) 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. + +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 + +# 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: + """Host location for the orchestrator's runtime-state DB. Runtime state + (not config), so it lives under the cache dir like the pool state.""" + return Path.home() / ".cache" / "bot-bottle" / "orchestrator" / "registry.db" + + +@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 WAL connection (concurrent data-plane reads + writer).""" + conn = super()._connect() + # WAL lets the data-plane attribution reads run concurrently with the + # control-plane writer without blocking; busy_timeout avoids spurious + # "database is locked" under that concurrency (PRD 0070 state tier). + conn.execute("PRAGMA journal_mode=WAL") + 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", +] diff --git a/tests/unit/test_orchestrator_control_plane.py b/tests/unit/test_orchestrator_control_plane.py new file mode 100644 index 0000000..8310df1 --- /dev/null +++ b/tests/unit/test_orchestrator_control_plane.py @@ -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() diff --git a/tests/unit/test_orchestrator_registry.py b/tests/unit/test_orchestrator_registry.py new file mode 100644 index 0000000..6962cb1 --- /dev/null +++ b/tests/unit/test_orchestrator_registry.py @@ -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() -- 2.52.0 From ed7307e0e320cec4943448c15d3750f7c8d4c736 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 13:17:05 -0400 Subject: [PATCH 06/12] docs(prd): 0070 registry DB is host-resident with rw/ro access split (#352) 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index aab0800..c79bc18 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -279,6 +279,33 @@ 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. +**The DB file is host-resident, not owned inside the orchestrator unit** +(decided in review). Two reasons: + +- **Durability across restarts.** Re-adoption sweeps the registry after an + orchestrator restart, so the state *must* outlive the orchestrator + instance's lifecycle. A host-resident file (the slice-1 default under + `~/.cache/bot-bottle/orchestrator/`) is the simplest durable store; the + orchestrator VM/container mounts it rather than carrying it. +- **Integrity via access-scoping, not location.** Agents can't touch the + DB directly regardless of where it lives — they're network-isolated in + their bottles and only ever speak to the control/data plane. The real + risk is a *compromised agent-facing data-plane service* (egress/git-gate, + which parse hostile bytes) writing the registry and forging attribution. + The control is a **read/write split**: writes (register/deregister) come + only from the **control plane**; the **data plane gets the DB read-only** + for attribution lookups. A host-resident file makes that split + enforceable (mount `ro` into the data plane, `rw` into the control + plane) — which owning it "entirely inside" a monolithic orchestrator + would not. + +Implementation note for the VM slices: SQLite **WAL** over a guest share +(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking), +so the durable-DB-on-host may want a small **host-side owner** the +orchestrator reaches over the control-plane RPC, rather than a shared +mount. Decide this at the Firecracker slice; `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 -- 2.52.0 From b174442c60ff8e40ef12152d4f31ba1747bd4e45 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 13:26:03 -0400 Subject: [PATCH 07/12] feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/orchestrator/registry.py | 25 +++++++----- docs/prds/0070-per-host-orchestrator.md | 54 ++++++++++++++----------- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py index df35a12..b8655ae 100644 --- a/bot_bottle/orchestrator/registry.py +++ b/bot_bottle/orchestrator/registry.py @@ -1,10 +1,15 @@ """Orchestrator bottle registry — the per-host runtime-state store (PRD 0070). -SQLite-backed (WAL) registry mapping each live bottle to its source IP and +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 @@ -32,6 +37,7 @@ from pathlib import Path from ..db_store import DbStore from ..migrations import TableMigrations +from ..supervise_types import host_db_path # 256 bits of urandom, URL-safe — unguessable per-bottle identity token. IDENTITY_TOKEN_BYTES = 32 @@ -43,9 +49,10 @@ def new_identity_token() -> str: def default_db_path() -> Path: - """Host location for the orchestrator's runtime-state DB. Runtime state - (not config), so it lives under the cache dir like the pool state.""" - return Path.home() / ".cache" / "bot-bottle" / "orchestrator" / "registry.db" + """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) @@ -113,12 +120,12 @@ class RegistryStore(DbStore): super().__init__(db_path or default_db_path(), _MIGRATIONS) def _connect(self) -> sqlite3.Connection: - """Open a WAL connection (concurrent data-plane reads + writer).""" + """Open a connection with a busy timeout for the shared DB.""" conn = super()._connect() - # WAL lets the data-plane attribution reads run concurrently with the - # control-plane writer without blocking; busy_timeout avoids spurious - # "database is locked" under that concurrency (PRD 0070 state tier). - conn.execute("PRAGMA journal_mode=WAL") + # 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 diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index c79bc18..08b8bd5 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -261,7 +261,7 @@ piling methods on — the same discipline, recursed. 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 WAL-mode transactions; + `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. @@ -273,38 +273,44 @@ Config splits into three tiers with different homes: |---|---|---| | 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 | **SQLite**, owned by the orchestrator | +| 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. -**The DB file is host-resident, not owned inside the orchestrator unit** -(decided in review). Two reasons: +**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. -- **Durability across restarts.** Re-adoption sweeps the registry after an - orchestrator restart, so the state *must* outlive the orchestrator - instance's lifecycle. A host-resident file (the slice-1 default under - `~/.cache/bot-bottle/orchestrator/`) is the simplest durable store; the - orchestrator VM/container mounts it rather than carrying it. -- **Integrity via access-scoping, not location.** Agents can't touch the - DB directly regardless of where it lives — they're network-isolated in - their bottles and only ever speak to the control/data plane. The real - risk is a *compromised agent-facing data-plane service* (egress/git-gate, - which parse hostile bytes) writing the registry and forging attribution. - The control is a **read/write split**: writes (register/deregister) come - only from the **control plane**; the **data plane gets the DB read-only** - for attribution lookups. A host-resident file makes that split - enforceable (mount `ro` into the data plane, `rw` into the control - plane) — which owning it "entirely inside" a monolithic orchestrator - would not. +- **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), -so the durable-DB-on-host may want a small **host-side owner** the -orchestrator reaches over the control-plane RPC, rather than a shared -mount. Decide this at the Firecracker slice; `sqlite3` itself is stdlib, so -"the host needs SQLite" is a non-cost. +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 -- 2.52.0 From b4b4e08f62328be42661407f64b09476c06f5c31 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 13:36:19 -0400 Subject: [PATCH 08/12] refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/db_store.py | 26 +++++++++++++++++++++++- bot_bottle/orchestrator/registry.py | 3 +-- bot_bottle/supervise_types.py | 31 +++++++++++------------------ 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/bot_bottle/db_store.py b/bot_bottle/db_store.py index 5d4bf37..8950401 100644 --- a/bot_bottle/db_store.py +++ b/bot_bottle/db_store.py @@ -11,6 +11,30 @@ except ImportError: from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module +# 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 host_db_path() -> Path: + """Path to the shared host state DB, `/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, ...). + + Resolves `bot_bottle_root` from `supervise_types` at call time (a lazy + import — avoids a load-time cycle, and lets a monkey-patch of + `supervise_types.bot_bottle_root` propagate here).""" + try: + from . import supervise_types as _st + except ImportError: # flat imports inside the sidecar bundle + import supervise_types as _st # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + return _st.bot_bottle_root() / "db" / HOST_DB_FILENAME + + class DbVersionError(Exception): """Raised when the on-disk schema is behind the current migration list.""" @@ -56,4 +80,4 @@ class DbStore: pass -__all__ = ["DbStore", "DbVersionError"] +__all__ = ["DbStore", "DbVersionError", "HOST_DB_FILENAME", "host_db_path"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py index b8655ae..be835c0 100644 --- a/bot_bottle/orchestrator/registry.py +++ b/bot_bottle/orchestrator/registry.py @@ -35,9 +35,8 @@ import time from dataclasses import dataclass from pathlib import Path -from ..db_store import DbStore +from ..db_store import DbStore, host_db_path from ..migrations import TableMigrations -from ..supervise_types import host_db_path # 256 bits of urandom, URL-safe — unguessable per-bottle identity token. IDENTITY_TOKEN_BYTES = 32 diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervise_types.py index 80af0ae..1e579f0 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervise_types.py @@ -5,22 +5,28 @@ Proposal, Response, AuditEntry, and host_db_path without creating a circular import (supervise imports from queue_store/audit_store and vice-versa). -Patching bot_bottle_root on this module propagates through host_db_path -because host_db_path looks up bot_bottle_root via sys.modules[__name__] -at call time rather than capturing it at import time. +host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra) and +are re-exported here for the historical import path. db_store.host_db_path +resolves bot_bottle_root from this module lazily, so patching +supervise_types.bot_bottle_root still propagates to the DB path. """ from __future__ import annotations import dataclasses -import sys import uuid from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path - -HOST_DB_FILENAME = "bot-bottle.db" +# host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra); +# re-exported here for the historical import path. db_store resolves +# bot_bottle_root from this module lazily, so patching +# supervise_types.bot_bottle_root still propagates. +try: + from .db_store import HOST_DB_FILENAME, host_db_path +except ImportError: # flat imports inside the sidecar bundle + from db_store import HOST_DB_FILENAME, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_ALLOW = "egress-allow" @@ -47,19 +53,6 @@ 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: value = raw.get(key) if not isinstance(value, str): -- 2.52.0 From 421d31c32fdc86d98c78feaa4f8e4e7edf7ee7fc Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 14:04:23 -0400 Subject: [PATCH 09/12] refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- Dockerfile.sidecars | 1 + bot_bottle/audit_store.py | 6 ++- bot_bottle/backend/docker/cleanup.py | 4 +- bot_bottle/bottle_state.py | 4 +- bot_bottle/cli/supervise.py | 4 +- bot_bottle/db_store.py | 26 +--------- bot_bottle/orchestrator/registry.py | 3 +- bot_bottle/paths.py | 43 +++++++++++++++++ bot_bottle/queue_store.py | 6 ++- bot_bottle/store_manager.py | 7 +-- bot_bottle/supervise.py | 20 +++----- bot_bottle/supervise_types.py | 30 ++---------- tests/unit/__init__.py | 23 ++++++++- tests/unit/test_backend_freezer.py | 11 ++--- tests/unit/test_backend_prepare.py | 7 ++- tests/unit/test_backend_workspace.py | 7 ++- tests/unit/test_bottle_state.py | 10 +--- tests/unit/test_cli_commit.py | 10 +--- tests/unit/test_cli_start_settle.py | 11 ++--- tests/unit/test_docker_cleanup.py | 10 +--- tests/unit/test_docker_enumerate_active.py | 10 +--- tests/unit/test_egress_apply.py | 10 +--- tests/unit/test_supervise.py | 47 ++----------------- tests/unit/test_supervise_cli.py | 17 +------ .../unit/test_supervise_cli_crash_logging.py | 18 +++---- tests/unit/test_supervise_server.py | 22 ++------- 26 files changed, 135 insertions(+), 232 deletions(-) create mode 100644 bot_bottle/paths.py diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index e581856..dc7e911 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -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/dlp_detectors.py /app/dlp_detectors.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/db_store.py /app/db_store.py COPY bot_bottle/supervise_types.py /app/supervise_types.py diff --git a/bot_bottle/audit_store.py b/bot_bottle/audit_store.py index 7f25e9e..c01d2bb 100644 --- a/bot_bottle/audit_store.py +++ b/bot_bottle/audit_store.py @@ -6,11 +6,13 @@ import sqlite3 from pathlib import Path 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 .migrations import TableMigrations 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 migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 254c63f..298d5fe 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -26,7 +26,7 @@ from __future__ import annotations import shutil import subprocess -from ... import supervise as _supervise +from ...paths import bot_bottle_root from ...log import info, warn from . import util as docker_mod 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 running non-docker bottle's state dir (the layout is shared across backends).""" - state_root = _supervise.bot_bottle_root() / "state" + state_root = bot_bottle_root() / "state" if not state_root.is_dir(): return [] orphans: list[str] = [] diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index b97c89b..1ac5bb0 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from . import supervise as _supervise +from .paths import bot_bottle_root # Directory layout: ~/.bot-bottle/state//... @@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None: def bottle_state_dir(identity: str) -> Path: """Per-bottle state directory on the host. Created lazily by the 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: diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index bf0766b..ff66ba6 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from .. import supervise as _supervise +from ..paths import bot_bottle_root from ..bottle_state import read_metadata from ..backend.docker.egress_apply import ( EgressApplyError, @@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path: ) entry = f"=== supervise crash {stamp} ===\n{body}\n" try: - log_dir = _supervise.bot_bottle_root() / "logs" + log_dir = bot_bottle_root() / "logs" log_dir.mkdir(parents=True, exist_ok=True) path = log_dir / "supervise-crash.log" with path.open("a", encoding="utf-8") as fh: diff --git a/bot_bottle/db_store.py b/bot_bottle/db_store.py index 8950401..5d4bf37 100644 --- a/bot_bottle/db_store.py +++ b/bot_bottle/db_store.py @@ -11,30 +11,6 @@ except ImportError: from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module -# 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 host_db_path() -> Path: - """Path to the shared host state DB, `/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, ...). - - Resolves `bot_bottle_root` from `supervise_types` at call time (a lazy - import — avoids a load-time cycle, and lets a monkey-patch of - `supervise_types.bot_bottle_root` propagate here).""" - try: - from . import supervise_types as _st - except ImportError: # flat imports inside the sidecar bundle - import supervise_types as _st # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module - return _st.bot_bottle_root() / "db" / HOST_DB_FILENAME - - class DbVersionError(Exception): """Raised when the on-disk schema is behind the current migration list.""" @@ -80,4 +56,4 @@ class DbStore: pass -__all__ = ["DbStore", "DbVersionError", "HOST_DB_FILENAME", "host_db_path"] +__all__ = ["DbStore", "DbVersionError"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py index be835c0..dd962a5 100644 --- a/bot_bottle/orchestrator/registry.py +++ b/bot_bottle/orchestrator/registry.py @@ -35,8 +35,9 @@ import time from dataclasses import dataclass from pathlib import Path -from ..db_store import DbStore, host_db_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 diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py new file mode 100644 index 0000000..8ac64ad --- /dev/null +++ b/bot_bottle/paths.py @@ -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, `/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"] diff --git a/bot_bottle/queue_store.py b/bot_bottle/queue_store.py index 0fe3295..cd6b188 100644 --- a/bot_bottle/queue_store.py +++ b/bot_bottle/queue_store.py @@ -7,11 +7,13 @@ import sqlite3 from pathlib import Path 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 .migrations import TableMigrations 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 migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/store_manager.py b/bot_bottle/store_manager.py index 70b1f46..bb1c494 100644 --- a/bot_bottle/store_manager.py +++ b/bot_bottle/store_manager.py @@ -23,13 +23,10 @@ class StoreManager: def __init__(self, db_path: Path | None = None) -> 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: - from .supervise import host_db_path + from .paths import host_db_path 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() self.db_path = db_path diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index cfa6a51..d1d9596 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -41,7 +41,6 @@ try: from .supervise_types import ( ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -54,13 +53,11 @@ try: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) except ImportError: from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -73,10 +70,15 @@ except ImportError: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) +try: + from .paths import bot_bottle_root, host_db_path +except ImportError: # flat imports inside the sidecar bundle + from paths import bot_bottle_root, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + + SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 @@ -106,16 +108,6 @@ def audit_dir() -> Path: 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: return audit_dir() / f"{component}-{slug}.log" diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervise_types.py index 1e579f0..d62343d 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervise_types.py @@ -1,14 +1,11 @@ -"""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 -Proposal, Response, AuditEntry, and host_db_path without creating a -circular import (supervise imports from queue_store/audit_store and -vice-versa). +Proposal, Response, and AuditEntry without creating a circular import +(supervise imports from queue_store/audit_store and vice-versa). -host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra) and -are re-exported here for the historical import path. db_store.host_db_path -resolves bot_bottle_root from this module lazily, so patching -supervise_types.bot_bottle_root still propagates to the DB path. +The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`) +live in `paths` — import them from there. """ from __future__ import annotations @@ -17,16 +14,6 @@ import dataclasses import uuid from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path - -# host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra); -# re-exported here for the historical import path. db_store resolves -# bot_bottle_root from this module lazily, so patching -# supervise_types.bot_bottle_root still propagates. -try: - from .db_store import HOST_DB_FILENAME, host_db_path -except ImportError: # flat imports inside the sidecar bundle - from db_store import HOST_DB_FILENAME, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_ALLOW = "egress-allow" @@ -49,10 +36,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) ACTION_OPERATOR_EDIT = "operator-edit" -def bot_bottle_root() -> Path: - return Path.home() / ".bot-bottle" - - def _require_str(raw: dict[str, object], key: str) -> str: value = raw.get(key) if not isinstance(value, str): @@ -164,7 +147,6 @@ class AuditEntry: __all__ = [ "ACTION_OPERATOR_EDIT", "AuditEntry", - "HOST_DB_FILENAME", "Proposal", "Response", "STATUSES", @@ -177,6 +159,4 @@ __all__ = [ "TOOL_EGRESS_TOKEN_ALLOW", "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", - "bot_bottle_root", - "host_db_path", ] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index da5784b..7e69d1d 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -10,8 +10,12 @@ and unisolated tests otherwise pollute the developer's home dir. Individual tests that need their own ``HOME`` still override ``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 -patch ``supervise.bot_bottle_root`` directly are unaffected. +dir rather than the real one, so isolation holds either way. + +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 @@ -20,6 +24,9 @@ import atexit import os import shutil import tempfile +from collections.abc import Callable +from pathlib import Path +from unittest import mock _real_home = os.environ.get("HOME") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") @@ -35,3 +42,15 @@ def _restore_home() -> None: 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 ``supervise.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 diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index 40241b3..3cb7d54 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -7,7 +7,8 @@ import unittest from pathlib import Path 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.freeze import get_freezer from bot_bottle.backend.docker.freezer import DockerFreezer @@ -18,13 +19,7 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="freezer-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._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index 6423ccd..6013229 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import patch 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.docker import DockerBottleBackend from bot_bottle.backend.resolve_common import mint_slug @@ -55,11 +55,10 @@ class _FakeStateMixin: def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.root = Path(self.tmp.name) / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp.cleanup() diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py index f57fa02..a7463f3 100644 --- a/tests/unit/test_backend_workspace.py +++ b/tests/unit/test_backend_workspace.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import MagicMock, call, patch 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.docker import DockerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend @@ -55,11 +55,10 @@ class _FakeStateMixin: self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp = Path(self.tmp_dir.name) self.root = self.tmp / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp_dir.cleanup() diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index c9abd92..df7a200 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -6,7 +6,7 @@ import tempfile import unittest 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.bottle_state import ( BottleMetadata, @@ -18,13 +18,7 @@ from bot_bottle.bottle_state import ( class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-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._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_commit.py b/tests/unit/test_cli_commit.py index 2dd2708..6a7e7ff 100644 --- a/tests/unit/test_cli_commit.py +++ b/tests/unit/test_cli_commit.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch 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.backend.freeze import CommitCancelled @@ -16,13 +16,7 @@ from bot_bottle.backend.freeze import CommitCancelled class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-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._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 9a94ba5..4b1c405 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -8,7 +8,7 @@ import tempfile import unittest 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.cli import start as start_mod @@ -16,15 +16,10 @@ from bot_bottle.cli import start as start_mod class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") - self._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._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): - supervise.bot_bottle_root = self._original # type: ignore[assignment] + self._restore() self._tmp.cleanup() diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py index aeab400..730f20f 100644 --- a/tests/unit/test_docker_cleanup.py +++ b/tests/unit/test_docker_cleanup.py @@ -15,7 +15,7 @@ import tempfile import unittest 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.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: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-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._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore() diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index b6aca74..c774c28 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -23,7 +23,7 @@ import tempfile import unittest 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.backend.docker import enumerate as _enumerate @@ -75,13 +75,7 @@ class TestParseServicesByProject(unittest.TestCase): class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") - 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._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore_home() diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index de51c57..12d3b1a 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace 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.docker.egress_apply import applicator @@ -67,14 +67,8 @@ class TestValidateRoutesContent(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase): def setUp(self): 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(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) def test_writes_live_routes_and_signals_reload(self): calls: list[list[str]] = [] diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index bc69b89..01198a8 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from pathlib import Path 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.queue_store import QueueStore from bot_bottle.supervise import ( @@ -123,20 +123,7 @@ class TestQueueIO(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - 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 + return use_bottle_root(fake_home / ".bot-bottle") def test_write_and_read_proposal(self): p = _proposal() @@ -240,20 +227,7 @@ class TestAuditLog(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - 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 + return use_bottle_root(fake_home / ".bot-bottle") def test_write_then_read_single_entry(self): e = AuditEntry( @@ -400,20 +374,7 @@ class TestSupervisePrepare(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - 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 + return use_bottle_root(fake_home / ".bot-bottle") def test_prepare_creates_queue(self): plan = _StubSupervise().prepare("dev", self.stage_dir) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9903da3..9b74ec5 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch 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.cli import supervise as supervise_cli from bot_bottle.queue_store import QueueStore @@ -55,20 +55,7 @@ class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") - original_sv = supervise.bot_bottle_root - 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 + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") QueueStore("").migrate() AuditStore().migrate() diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 56cb6f0..3fca465 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -11,12 +11,13 @@ from __future__ import annotations import contextlib import io +import os import tempfile import unittest from pathlib import Path 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.log import Die, die @@ -45,12 +46,11 @@ class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") - self._orig_root = supervise.bot_bottle_root 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): - supervise.bot_bottle_root = self._orig_root # type: ignore[assignment] + self._restore_root() self._tmp.cleanup() @@ -127,11 +127,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): # OSError and the helper must fall back to a tempfile. bad = Path(self._tmp.name) / "not-a-dir" bad.write_text("x") - supervise.bot_bottle_root = lambda: bad # type: ignore[assignment] - try: - raise RuntimeError("explode2") - except RuntimeError as e: - path = supervise_cli._write_crash_log(e) + with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}): + try: + raise RuntimeError("explode2") + except RuntimeError as e: + path = supervise_cli._write_crash_log(e) self.assertTrue(path.exists()) self.assertIn("explode2", path.read_text()) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 9ea47e5..a29f032 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -10,6 +10,8 @@ import unittest from pathlib import Path from unittest.mock import patch +from tests.unit import use_bottle_root + # The server module loads `supervise` via same-directory import inside # 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 queue_store as _qs # 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_types as svt_pkg # noqa: E402 from bot_bottle.supervise_server import ( ERR_INTERNAL, ERR_INVALID_PARAMS, @@ -279,23 +279,7 @@ class TestHandleToolsCall(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = _sv.bot_bottle_root - 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 + return use_bottle_root(fake_home / ".bot-bottle") def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: """Background thread: poll the queue for a fresh proposal, write a -- 2.52.0 From 6847fdf0abf62d969bd99b07cf681d1cbf454d0c Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 14:30:13 -0400 Subject: [PATCH 10/12] refactor: drop the vestigial bot_bottle_root/host_db_path re-exports (#352) 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/supervise.py | 6 ++---- tests/unit/__init__.py | 6 +++--- tests/unit/test_supervise.py | 2 +- tests/unit/test_supervise_cli.py | 2 +- tests/unit/test_supervise_cli_crash_logging.py | 5 ++--- tests/unit/test_supervise_edge.py | 3 ++- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index d1d9596..b086ad4 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -74,9 +74,9 @@ except ImportError: try: - from .paths import bot_bottle_root, host_db_path + from .paths import bot_bottle_root except ImportError: # flat imports inside the sidecar bundle - from paths import bot_bottle_root, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module SUPERVISE_HOSTNAME = "supervise" @@ -289,8 +289,6 @@ __all__ = [ "archive_proposal", "audit_dir", "audit_log_path", - "bot_bottle_root", - "host_db_path", "list_pending_proposals", "list_all_pending_proposals", "read_audit_entries", diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 7e69d1d..be349dc 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,7 +2,7 @@ Isolates ``HOME`` to a throwaway directory for the entire unit suite so 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 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 — @@ -49,8 +49,8 @@ def use_bottle_root(root: Path) -> Callable[[], None]: ``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 ``supervise.bot_bottle_root``; one env var - covers every module and the flat/package copies alike (they all read it).""" + 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 diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 01198a8..6381af0 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from pathlib import Path from bot_bottle import supervise +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.queue_store import QueueStore @@ -21,7 +22,6 @@ from bot_bottle.supervise import ( TOOL_EGRESS_ALLOW, TOOL_GITLEAKS_ALLOW, archive_proposal, - host_db_path, list_pending_proposals, read_audit_entries, read_proposal, diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9b74ec5..9c680c9 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -51,7 +51,7 @@ def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal: 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): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 3fca465..05c5463 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -40,9 +40,8 @@ class TestDieCarriesMessage(unittest.TestCase): class _FakeHomeMixin: - """Point supervise.bot_bottle_root (what _write_crash_log resolves - through) at a temp dir so the crash log doesn't touch the real - ~/.bot-bottle.""" + """Point bot_bottle_root (what _write_crash_log resolves through) at a + temp dir so the crash log doesn't touch the real ~/.bot-bottle.""" def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") diff --git a/tests/unit/test_supervise_edge.py b/tests/unit/test_supervise_edge.py index 3cde845..4cedbfa 100644 --- a/tests/unit/test_supervise_edge.py +++ b/tests/unit/test_supervise_edge.py @@ -12,6 +12,7 @@ from unittest.mock import patch from bot_bottle import supervise 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.supervise import ( AuditEntry, @@ -39,7 +40,7 @@ def _proposal() -> Proposal: class TestPathHelpers(unittest.TestCase): 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): -- 2.52.0 From 8e567edde4898386c7fcf77ece319f04e18f37f8 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 15:30:07 -0400 Subject: [PATCH 11/12] docs(prd): 0070 add the slice roadmap incl. consolidated sidecar (#352) 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index 08b8bd5..fec1974 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -325,18 +325,31 @@ orchestrator becomes a VM. Decouple the two risks instead: secret handling) is proven with fast iteration — *then* wrap that exact service in the VM and solve wiring separately. -Backend order (cheapest proof → hardest → last): +### Slices -1. **Docker orchestrator** — nearly free (the sidecar bundle is already - containers; collapse N bundles into one persistent container). Proves - consolidation + the `BottleBackend` seam with the least moving parts. -2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM +Built bottom-up as a stack of small PRs off this one (status: **1–4 +implemented** in #352 → #356 → #357 → #358): + +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. +4. **Consolidated per-host sidecar** ✅ — one persistent sidecar shared by + all bottles (idempotent singleton) instead of one per bottle — the core + consolidation win, source-IP-keyed via the attribution invariant. +5. **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). Built against - the dev-harness so the app logic is already proven. -3. **macOS (Apple container)** — last (container-to-container networking). + forward rules where today it drops all non-DNAT egress), against the + already-proven dev-harness. +6. **macOS (Apple container)** — last (container-to-container networking). -Keep the sidecar **service one shared thing** throughout. +Backends land cheapest-first (docker → firecracker → macOS); keep the +sidecar **service one shared thing** throughout. ## Non-goals -- 2.52.0 From 32e9bb95e081352203cef4cb1ff6891d42ddd89d Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 17:01:27 -0400 Subject: [PATCH 12/12] docs(prd): 0070 expand the consolidated-sidecar arc in the roadmap (#352) 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- docs/prds/0070-per-host-orchestrator.md | 37 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index fec1974..1e8986d 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -327,8 +327,8 @@ orchestrator becomes a VM. Decouple the two risks instead: ### Slices -Built bottom-up as a stack of small PRs off this one (status: **1–4 -implemented** in #352 → #356 → #357 → #358): +Built bottom-up as a stack of small PRs off this one (status: **1–5 +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. @@ -339,14 +339,33 @@ implemented** in #352 → #356 → #357 → #358): 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. -4. **Consolidated per-host sidecar** ✅ — one persistent sidecar shared by - all bottles (idempotent singleton) instead of one per bottle — the core - consolidation win, source-IP-keyed via the attribution invariant. -5. **Firecracker broker + sidecar** — the real work: the shim + VM-to-VM + + **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), against the - already-proven dev-harness. -6. **macOS (Apple container)** — last (container-to-container networking). + 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. -- 2.52.0