Fold the per-bottle sidecar bundle into a single persistent per-host orchestrator: runs the sidecar functions (egress/git-gate/supervise), coordinates with the console, and brokers agent launches. Virtualized from the start with backend-native isolation (fc VM / apple ctr / docker ctr), fronted by a single backend-agnostic contract; per-backend variation lives on BottleBackend, not a parallel Orchestrator hierarchy. Leads with the security review (secret concentration, shared fate, the launch-broker-as-new-privileged-core, and the source-IP attribution invariant each backend must enforce). Proposes one SQLite DB owned by the orchestrator for runtime state (slot leases, approvals, registry) — distinct from build-time constants (flat .env) and user config (declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos, developing the service as a plain-process dev-harness before the VM. Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built fixed images. Tracking issue #351. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
13 KiB
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
-
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).
-
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.
-
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.
-
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
/31point-to-point TAP + thebot_bottle_fcnft table (strongest; already built). - Docker — the per-bottle
--internalnetwork + 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.
- Control plane (CLI / console → orchestrator) — an RPC:
launch_bottle,teardown_bottle,register_policy,deregister_bottle,supervise_queue. Fully backend-agnostic. Both the localcli.pyand the remote console funnel through it, so policy is uniform andcli.pybecomes a thin client rather than a parallel launcher. - 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. - 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
containerCLI/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):
- Docker orchestrator — nearly free (the sidecar bundle is already
containers; collapse N bundles into one persistent container). Proves
consolidation + the
BottleBackendseam with the least moving parts. - 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. - 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
gitearunner user drop the root-equivalentdockergroup — 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.