5395c7196f8106377ffdd6d71ae6948b1f28bc1e
1113 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5395c7196f |
feat(firecracker): route agent VMs to the gateway VM (Stage B, 3/n)
Agent VMs must reach the shared gateway that now runs in the infra VM (egress:9099 / supervise:9100 / git-http:9420 at the orchestrator link's guest IP). Add a PREROUTING DNAT: agents keep addressing their own host-side TAP IP on the gateway ports, and the rule redirects that to the infra VM. The isolation table's existing `ct status dnat accept` forward rule lets the DNAT'd traffic through; every other agent egress stays dropped, so a bottle still reaches only the gateway and nothing else. Source IP is deliberately NOT masqueraded: the gateway attributes each request to the originating bottle by its guest IP, which the /31 TAP + the bot_bottle_fc nft table make unspoofable. Keeping the agent addressed at its own host TAP IP means no per-bottle config change vs the docker-DNAT path it replaces. - scripts/firecracker-netpool.sh: `_install_gateway_route` adds `table ip <table>_gw` (prerouting dstnat -> orch_guest on the gateway ports); wired into up/down/status. The nix module needs no change — it runs this script, and the ports are baked in. Verified on a KVM host: an agent VM's `curl -x http://<its-host-tap>:9099` reaches mitmproxy in the infra VM and gets a 403 (correct policy denial for an unregistered bottle) — i.e. the route lands end-to-end. Persist with a nixos-rebuild; the imperative rule holds until then. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
8d7e5956e1 |
feat(firecracker): run the gateway data plane in the infra VM too (Stage B, 2/n)
The single infra VM now runs BOTH the orchestrator control plane and the gateway data plane (egress / supervise / git-http), multi-tenant against the local control plane — the single-VM shape from the Stage B design. - Dockerfile.infra: the firecracker infra image = the gateway image + the baked control-plane source (FROM bot-bottle-gateway, COPY bot_bottle). Reuses the gateway payload rather than copying mitmproxy/ gitleaks into a third image; the docker backend keeps its two separate images. - infra_vm: build from source (gateway then infra image), and the PID-1 init now also launches `gateway_init` with BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:8099. Adds `gateway_ca_pem` (fetch the mitmproxy CA over SSH) and the agent-facing port constants. - init exports PATH — a bare-init shell resolves its own execs via a built-in default path, but that isn't in the environment, so gateway_init's `python3 ...` daemons would otherwise fail to spawn. Verified on a KVM host: infra VM boots, control plane /health -> 200, and egress:9099 / supervise:9100 / git-http:9420 all listen and are reachable from the host over the TAP link; the gateway CA is retrievable. (git-gate stays down until a bottle provisions its per-bottle entrypoint/creds, same as a fresh docker gateway — non-fatal, the supervisor keeps the rest up.) Next: agent->gateway VM-to-VM routing so bbfc* VMs reach these ports at the infra VM and nowhere else. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
ebaa999c21 |
feat(firecracker): run the orchestrator control plane as a VM (Stage B, 1/n)
First slice of Stage B: the orchestrator control plane runs as a
persistent Firecracker infra VM instead of a Docker container. The host
CLI reaches it over HTTP at the orchestrator link's guest IP; agent VMs
will reach its gateway ports (added next) over VM-to-VM routing.
- Dockerfile.orchestrator bakes the stdlib-only control-plane source
(COPY bot_bottle) so the image is self-contained and runs from a built
image with no runtime bind-mount — a guest VM can't bind-mount host
source. (Build-from-source stays the default; a pull-from-registry mode
lands later. The docker backend's dev bind-mount still overlays this.)
- util.build_base_rootfs_dir / inject_guest_boot take a `variant` +
`init_script`, so the same orchestrator image is prepared two ways
without a cache collision: the builder VM keeps the SSH-only agent init;
the infra VM gets a control-plane PID-1 init.
- new firecracker/infra_vm.py: boot the infra VM on the orchestrator link,
run `python -m bot_bottle.orchestrator` as PID 1, and poll /health.
Verified on a KVM host: infra VM boots, control plane answers
`GET /health -> 200 {"status":"ok"}` from the host over the TAP link.
Next: fold gateway_init (egress/git-gate/supervise) into the same VM,
then agent->gateway routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
73ee081c65 |
feat(firecracker): build agent images in a builder VM, not host docker (Stage 3)
Replace the host `docker build` + `docker export` behind the Firecracker agent rootfs with an in-VM buildah build. `image_builder.build_agent_rootfs_dir` boots a throwaway builder VM (the orchestrator image, which carries buildah) on the NAT'd orchestrator link, sends the Dockerfile over SSH, `buildah build`s it, smoke-tests the result with `buildah run` (the image's own PATH, so it catches an npm silent-failure stub), and streams the rootfs tar back into the content-addressed cache dir — the same base dir `util.build_rootfs_ext4` already turns into a bootable ext4 with `mke2fs -d`. No host Docker daemon, no root-equivalent `docker` group; an untrusted Dockerfile runs in a confined microVM, not on the host. - new firecracker/image_builder.py (boot → build → smoke → stream). - launch.py: `_build_agent_image` → `_build_agent_base`, returning the base dir from the builder VM. The committed-snapshot (freeze/migrate) path still exports via host docker until it too is ported. - util: `_inject_guest_boot` → public `inject_guest_boot` (shared with the builder). unit tests for the cache decision + smoke-test paths. Verified on a KVM host end-to-end: builds the real claude Dockerfile (node:22-slim + npm claude-code) in-VM in ~60s, the produced agent VM boots and `claude --version` returns 2.1.172; the content cache skips the rebuild on a repeat launch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
95981ea9d3 |
feat(firecracker): NAT'd egress link for the orchestrator/builder VM
The orchestrator/gateway VM is trusted infra, not an isolated agent: it builds agent images in-VM (buildah must FROM-pull + apt/npm) and, in the Stage B cutover, forwards agent egress upstream. Give it a dedicated TAP (`bborch0`) on a /31 at the top of the IP_BASE /16 (clear of the bbfc* agent pool at the bottom), NAT'd out the host uplink — while agent VMs keep their fail-closed, gateway-only isolation table. - netpool.defaults.env / netpool.py: new BOT_BOTTLE_FC_ORCH_IFACE + `orch_slot()` (index -1 sentinel; host x.y.255.0 / guest x.y.255.1). - scripts/firecracker-netpool.sh: create + address the orchestrator TAP; `bot_bottle_fc_nat` table masquerades its /31 out the uplink and accepts its forward path. Because bootstrap still runs Docker (whose FORWARD policy is DROP), a best-effort, guarded, idempotent DOCKER-USER ACCEPT is added too (skipped once Docker is gone). down/status updated. - nix/firecracker-netpool.nix: mirror the option, pass it via the unit Environment= (the store-copied script can't read the defaults file), and add iptables to the unit path for the DOCKER-USER step. Agent isolation is unchanged: the new rules only ever accept/masquerade the orchestrator link and never drop, so they can't weaken the bbfc* drops. Applied by re-running `sudo ./scripts/firecracker-netpool.sh up`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
bab44dbe97 |
feat(orchestrator): add buildah for in-VM agent-image builds (PRD 0069 Stage 3)
The Firecracker backend's remaining host-Docker dependency is building users' agent Dockerfiles. Move that build *into the orchestrator VM*: give the orchestrator image buildah (rootless, daemonless) so it builds agent images itself, and the host needs no Docker daemon and no root-equivalent `docker` group. An untrusted Dockerfile then builds inside the confined orchestrator VM rather than on the host — strictly more isolated than host `docker build`. - buildah + crun (OCI runtime) + netavark/aardvark-dns (network backend for FROM pulls and RUN egress), installed explicitly since --no-install-recommends strips buildah's helper deps. - vfs storage + chroot isolation (STORAGE_DRIVER/BUILDAH_ISOLATION) so buildah needs neither fuse-overlayfs / an overlay kernel module nor configured subuid maps — it works unconditionally as root in a minimal microVM rootfs. (Slower than overlay; a build-cache pass is deferred.) Bootstrap: the orchestrator rootfs is still produced from this image via host docker export; a later step pulls a pre-built image instead. Verified: the orchestrator image boots as a Firecracker VM and buildah runs inside it as real VM root (reaches the base-image pull; build-time egress wiring is the next step). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
0a1f949937 |
fix(docker): use run_docker in docker_exec, docker_cp, verify_agent_image
The rebase onto lazy-backend-imports converts existing helpers (image_exists, container_exists, etc.) to run_docker; the three new functions added in this branch still called subprocess.run directly. Switch them over for consistency. |
||
|
|
64ca39ddd4 |
feat(firecracker): implement consolidated orchestrator launch (PRD 0070)
Replace the per-bottle Docker sidecar bundle with the shared per-host orchestrator + gateway, mirroring what the Docker backend already has. - Add `bot_bottle/backend/firecracker/consolidated_launch.py`: `_FirecrackerOrchestratorService` (subclasses `OrchestratorService`, overrides `_gateway()` to return a `DockerGateway` with host port bindings so Firecracker VMs can reach it via their TAP link); `launch_consolidated()` registers the bottle by guest IP (attribution key), provisions git-gate into the shared gateway, and returns the shared CA + orchestrator URL for teardown; `teardown_consolidated()` deregisters and cleans up. - Rewrite `bot_bottle/backend/firecracker/launch.py`: removes the per-bottle sidecar bundle (`_start_sidecar_bundle`, `_stage_git_gate`, etc.) and `_mint_certs`; wires `launch_consolidated()` instead. The VM still sends to `host_tap_ip:PORT` — Docker's PREROUTING DNAT + the nft `ct status dnat accept` rule in the forward chain route the traffic to the shared gateway container. - Extend `DockerGateway` with `host_port_bindings` so the Firecracker gateway publishes its ports on the host (`0.0.0.0:PORT`). - Parameterise `OrchestratorService` with `orchestrator_name` / `orchestrator_label` so Docker and Firecracker orchestrators can coexist on the same host (`bot-bottle-orchestrator` vs `bot-bottle-fc-orchestrator`). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
3c426f45f5 |
refactor(firecracker): use docker_mod instead of hand-rolled docker helpers
firecracker/launch.py reimplemented docker build/image-exists/rm/exec/cp as private functions instead of the shared docker_mod used by the docker and macos-container backends. Switching to docker_mod dedupes the logic and gets --no-cache support for free (docker_mod.build_image already reads BOT_BOTTLE_NO_CACHE); docker_mod gains docker_exec/ docker_cp general-purpose helpers to cover what the private versions did. |
||
|
|
1d3e9e859c |
fix: smoke-test agent images after build, add start --no-cache
npm treats optionalDependencies failures as non-fatal, so a transient network blip fetching claude-code's platform-native binary during `npm install -g` left a stub CLI in an image that still "built" successfully — then got baked into the Docker/Container layer cache until forced to rebuild. Post-build smoke test (provider-declared argv, run in a throwaway container of the freshly built image) fails the launch loudly instead of shipping a broken image; --no-cache gives an escape hatch to force a from-scratch rebuild. Closes #353. |
||
|
|
56d879f0b3 |
fix(tests): pyright strict — export _plan fixture, drop unused imports
test / unit (pull_request) Successful in 1m10s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Successful in 1m20s
lint / lint (push) Successful in 2m16s
test / unit (push) Successful in 1m12s
test / integration (push) Successful in 26s
test / coverage (push) Successful in 1m19s
Update Quality Badges / update-badges (push) Successful in 1m20s
- `tests/unit/_docker_bottle_plan.py`: add `__all__ = ["_plan"]` so the underscore-prefixed fixture isn't flagged reportUnusedFunction. - `tests/unit/test_egress_apply.py`: drop `SimpleNamespace` / `patch` imports left unused after the reload test became fail-closed. `pyright .` → 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
09393b354b |
refactor(de-sidecar): purge the "sidecar" name from live code, tests, and current docs
Completes the de-sidecar cleanup: no live code, test, current doc, script, or nix file mentions or is named "sidecar" any more. Only the dated PRD/research docs keep the term as historical record (agreed on the #385 thread). - Rename `sidecar_init.py`→`gateway_init.py` was done earlier; this pass sweeps the remaining descriptive uses: the egress / git-gate / supervise components are the gateway's *daemons*, the shared container is the *gateway*, the old per-bottle container was the *companion container*. - Rename `tests/integration/test_sidecar_bundle_image.py`→`test_gateway_image.py` and its class; update `docs/ci.md` + `tests/README.md` for the renamed/ removed integration tests. - `SIDECAR_PORTS` shell var in `scripts/firecracker-netpool.sh`→`GATEWAY_PORTS`. Full unit suite green (bar the pre-existing `/bin/sleep`-missing env errors in test_gateway_init); docker integration — gateway singleton, broker, real two-bottle multitenant isolation, and the gateway-image build — all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
77948ef56c |
refactor(de-sidecar): remove the per-bottle companion-container architecture
The per-agent companion container (the egress/git-gate/supervise data plane run once per bottle) is the pre-consolidation architecture. Remove it and disable the backends that still depend on it, per the #385 thread. - Delete `backend/docker/sidecar_bundle.py`; docker's live path uses the consolidated shared gateway, not a per-bottle bundle. - Disable the firecracker and macos-container backends: their `launch()` fails closed (they launched a per-bottle companion; firecracker's consolidated relaunch is #354, macos follows). Their `enumerate` return empty and `cleanup` drop the companion-container discovery (firecracker keeps VMM/run-dir cleanup). - Fail-close both backends' `egress_apply` reload (it signalled the per-bottle container); consolidated egress policy resolves per-request against the orchestrator, so gateway-side apply is a follow-up. - Rename `egress_sidecar_env_entries` → `egress_gateway_env_entries`, `SIDECAR_PORTS` → `GATEWAY_PORTS`. - Move the shared DockerBottlePlan fixture to `tests/unit/_docker_bottle_plan.py`; delete tests for the removed launch paths; update cleanup/egress-apply tests. Docker consolidated launch verified end-to-end (multitenant isolation integration test passes). macos/firecracker are intentionally disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
c10d1cb6e0 |
refactor(de-sidecar): rename sidecar_init→gateway_init, drop docker's dead per-bottle compose renderer
Part of the de-sidecar cleanup (#385 discussion): the per-bottle companion container is the old architecture. - Rename `bot_bottle/sidecar_init.py` → `gateway_init.py` (it's the gateway image's PID-1 supervisor); env var `BOT_BOTTLE_SIDECAR_DAEMONS` → `BOT_BOTTLE_GATEWAY_DAEMONS`; log prefix `sidecar-init:` → `gateway-init:`. Update Dockerfile.gateway COPY/ENTRYPOINT and the test. - Remove the dead per-bottle compose renderer from `backend/docker/compose.py` (`bottle_plan_to_compose`, `_sidecar_bundle_service`, `_agent_service`, and the network/bind/proxy helpers). Docker's live path uses `consolidated_agent_compose`; only the compose *lifecycle* helpers (up/down/ls/write) remain. Trim `test_compose.py` to the surviving helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
96b84eb84d |
refactor(orchestrator): split conflated sidecar image into orchestrator + gateway images
One image — `bot-bottle-sidecars:latest`, built from `Dockerfile.sidecars` — served two unrelated roles: the egress/git-gate/supervise *data plane* and the orchestrator *control plane* (which ran the same image with the entrypoint overridden to `python3 -m bot_bottle.orchestrator`). The control plane is stdlib-only, so it needed none of the mitmproxy/git/gitleaks payload it was riding on — while being the most secret-dense process on the host (PRD 0070's "secret concentration"). Split into two purpose-built images: - `Dockerfile.gateway` -> `bot-bottle-gateway:latest` — the data plane (renamed from Dockerfile.sidecars; identical contents). - `Dockerfile.orchestrator` -> `bot-bottle-orchestrator:latest` — a lean `python:3.12-slim` runtime; the bind-mounted `bot_bottle` package supplies the code (so the #381 source-hash recreate semantics are unchanged). `OrchestratorService` now takes distinct `image` (control plane, default `ORCHESTRATOR_IMAGE`) and `gateway_image` (data plane, default `GATEWAY_IMAGE`) instead of feeding one `self.image` to both, and builds the lean image (build-if-missing) before starting the container. The per-bottle bundle constants in `backend/docker/sidecar_bundle.py` now alias the gateway constants so a bundle and the shared gateway can never drift onto different images. The `bot-bottle-sidecars` *image* name and `Dockerfile.sidecars` are gone; the per-bottle *container* name prefix (`bot-bottle-sidecars-<slug>`) is intentionally left for a separate change. Verified end-to-end: both images build; the lean image runs the control plane; `ensure_running` brings up the orchestrator on `bot-bottle-orchestrator:latest` and the gateway on `bot-bottle-gateway:latest` (distinct images) and reports healthy. Closes #384. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
910267b8a8 |
fix(orchestrator): only recreate the orchestrator on a real source change
test / unit (pull_request) Successful in 1m7s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m19s
lint / lint (push) Successful in 2m7s
test / unit (push) Successful in 1m11s
test / integration (push) Successful in 28s
test / coverage (push) Successful in 1m18s
Update Quality Badges / update-badges (push) Successful in 1m9s
ensure_running() unconditionally recreated the bot-bottle-orchestrator container on every bottle launch, added so bind-mounted code changes take effect (the process never reloads). But the orchestrator's per-bottle egress auth tokens live only in its process memory (Orchestrator._tokens), by design never persisted to disk. So starting a second bottle recreated the container and silently dropped the FIRST bottle's already-injected token -- its next authed egress call 403s with "env var EGRESS_TOKEN_0 is unset", and it needs a full restart to recover. Now the orchestrator container is labeled with a content hash of its bind-mounted bot_bottle source, mirroring the gateway's existing image-staleness check. ensure_running only recreates on a hash mismatch (a real code change), so a bottle launch that isn't accompanied by a code change leaves a healthy orchestrator -- and every other active bottle's in-memory tokens -- alone. Fixes #381. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
03eacd9f57 |
chore: update quality badges
- Coverage: 83% - Core coverage: 95% [skip ci] |
||
|
|
c839cee319 |
test(orchestrator): skip multitenant isolation under act_runner
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
lint / lint (push) Successful in 2m17s
test / unit (push) Successful in 1m5s
test / integration (push) Successful in 31s
test / coverage (push) Successful in 1m22s
Update Quality Badges / update-badges (push) Successful in 1m15s
The test brings up the orchestrator container, which bind-mounts the repo
path into a container on the host daemon. Under the Gitea act_runner the job
runs in a container sharing the host docker socket, so that path
(/workspace/...) doesn't exist on the host daemon and the mount fails
('mkdir /workspace: read-only file system'). Same host-bind-mount constraint
that already skips test_sandbox_escape and the other bottle-bringup tests, so
gate it the same way (GITEA_ACTIONS). Runs for real on a normal docker host.
This unblocks the integration + coverage CI jobs, which failed only because
this test errored (coverage.sh runs under set -e; no percentage gate tripped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
ea75fab3b4 |
fix(orchestrator): supersede a prior active bottle at the same source IP
register() used INSERT OR REPLACE keyed on bottle_id, so re-registering an existing source_ip with a new bottle_id left TWO active rows for that IP. by_source_ip fail-closes on ambiguity (>1 active -> None), so the reused IP was then bricked to a 403 on egress resolve — even for hosts its own policy allowed. Happy path (fresh IPs, teardown on stop) never hit it, but IP reuse, crash recovery, or a persisted registry DB across an orchestrator restart (now that ensure_running always recreates the container) all could. register() now deletes any other active row at the same source_ip before insert; the fail-closed ambiguity guard stays as defense in depth. Adds a unit test for the supersede, and a docker integration test (test_multitenant_isolation) that drives two bottles through one shared gateway and asserts each gets only its own injected token and its own allowlist — the core PRD 0070 multi-tenancy invariant, previously only verified by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
13a8bdd7ca |
fix(orchestrator): recreate the orchestrator container on ensure_running
The orchestrator runs the repo's code bind-mounted, but the Python process loads it at startup and never reloads — and ensure_running reused a healthy-but-stale container. So after a code change (e.g. the token fix), the running orchestrator kept executing OLD control-plane code that dropped the new /bottles 'tokens' field, and egress auth injection stayed broken no matter how many times the gateway image was rebuilt. ensure_running now always recreates the orchestrator container (cheap; the registry DB persists and the current launch re-registers its in-memory state). Combined with the gateway's image-staleness recreate, a fresh 'start' now runs current code end to end. Validated live: register an authed route + in-memory token -> a client at the bottle IP curling through the gateway proxy receives the injected 'Authorization: Bearer <token>' header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
3318bdce28 |
fix(orchestrator): recreate the gateway when its image is stale
Container-level counterpart to the ensure_built cache-aware fix. ensure_built now rebuilds the gateway image on a source change, but ensure_running still reused an already-running container built from the OLD image — so a rebuild (even BOT_BOTTLE_NO_CACHE) never took effect on a warm gateway, and it kept running the pre-fix flat daemons (this is why token injection stayed broken after the rebuild). ensure_running now compares the running container's image to the current image and recreates on mismatch. Validated on docker: a stale gateway is detected and recreated with the current image + new egress addon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
b2f61053ad |
fix(egress+orchestrator): inject per-bottle auth tokens in the shared gateway
The cut-over dropped the per-bottle token flow, so an authed egress route on the shared gateway failed with 'env var EGRESS_TOKEN_0 is unset' — the gateway reads the token from its env, but a shared gateway has no per-bottle env. Now the bottle's egress auth tokens travel to the gateway over /resolve and the addon injects from them, mirroring what the per-bottle sidecar's env did: - launch resolves the token values from the host env and hands them to the orchestrator, which holds them IN MEMORY (keyed by bottle_id, never written to the registry DB) and serves them on /resolve; - PolicyResolver.resolve_policy_and_bottle_id + resolve_client_context now return the token map alongside policy + bottle_id (one round-trip); - the egress addon overlays the process env with the bottle's tokens per request and uses that env for auth injection AND DLP — the agent never sees the credential. Secrets stay off disk (validated: /resolve returns the token, the registry DB does not contain it). SecretProvider (#355) is the future hardening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
485d101430 |
fix(orchestrator): poll for the gateway CA (fresh-start readiness race)
On a fresh gateway container, mitmproxy writes mitmproxy-ca-cert.pem a beat after mitmdump boots, so ca_cert_pem() read it too early and launch died with 'gateway CA cert not available'. It now polls (up to 30s) until mitmproxy has generated it. Surfaced running the docker backend locally on a cold gateway. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
e97366dad5 |
fix(orchestrator): gateway image builds cache-aware, not build-if-missing
The cut-over dropped compose (whose `build:` rebuilt the sidecar image on every up), and DockerGateway.ensure_built was build-if-missing — so a change to the gateway's flat sources (egress addon / git-http / policy_resolver / supervise) left a STALE image silently running the old single-tenant daemons (this bit e2e validation). ensure_built now always `docker build`s (cache-aware: a cache check when nothing changed, a real rebuild when sources moved), matching the old compose behavior. BOT_BOTTLE_NO_CACHE forces a full rebuild (parity with #354's `start --no-cache`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
8e7f5c9572 |
fix(tests): annotate on_network param — pyright strict (CI lint)
The cut-over's edit to test_consolidated_launch left the new on_network kwarg untyped; pyright strict rejects it. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
69db629e16 |
feat(backend): slice 13d — cut docker launch over to the consolidated gateway (e2e green)
Rewire DockerBottleBackend.launch to the consolidated model, replacing the
per-bottle sidecar bundle. VALIDATED END-TO-END on real docker:
test_sandbox_escape passes all 5 attacks (egress DLP + git-gate gitleaks)
through the shared gateway.
launch now:
- mints git-gate dynamic keys (if any), then launch_consolidated() to
register the bottle + provision its git-gate state into the gateway and
get the agent's attach context (pinned source IP, gateway address);
- installs the SHARED gateway CA (gateway.ca_cert_pem) into the agent;
- renders the agent-only consolidated compose on the gateway network at the
pinned IP, proxied through the gateway;
- points the agent's git-gate insteadOf (http://<gw>:9420) and supervise
MCP (http://<gw>:9100) at the gateway (DockerBottlePlan.agent_git_gate_url
/ agent_supervise_url);
- teardown = compose down + teardown_consolidated (dereg + deprovision).
Dropped the per-bottle networks, per-bottle egress CA, and bundle service.
Fixes surfaced by the real e2e (couldn't be caught by unit mocks):
- provision_git_gate installs the bottle-agnostic pre-receive/access hooks
into the gateway (were cp'd per-bundle before);
- source-IP allocation reads the *actual* container IPs on the network
(gateway + orchestrator + agents), not just gateway + registry — the
orchestrator container sits on the network and was colliding.
Unit tests for the old bundle launch rewritten against the new collaborators.
NOTE for reviewers: the gateway/bundle image (bot-bottle-sidecars) must be
rebuilt when its flat sources change — `ensure_built` only builds-if-missing,
so a stale image silently runs the OLD single-tenant daemons. A content-hash
/ --rebuild path is a follow-up.
pyright 0 errors; pylint 9.83/10; unit suite green (1760 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise);
test_sandbox_escape green on real docker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
96f75599a1 |
feat(orchestrator): slice 13d(i) — containerize the orchestrator (validated on docker)
Real-on-host testing surfaced two issues the unit-mocked slices couldn't: 1. The host (NixOS) firewall DROPS container->host traffic, so a host-process orchestrator is unreachable from the gateway container. Fix: run the orchestrator AS a container on the shared gateway network (PRD 0070's "virtualize the orchestrator") — the gateway reaches it by container name over docker DNS (container<->container, no firewall), and the host CLI reaches it via a published loopback port. 2. The control plane crashed the connection on a dispatch error (e.g. a broker failure) instead of returning 500. Changes: - lifecycle: OrchestratorProcess (host process) -> OrchestratorService (containers). Runs the control plane in the bundle image with the repo bind-mounted (orchestrator is stdlib-only), register-only stub broker so it needs NO docker socket (the backend launches agents; the host manages both containers). Registry DB persists via a host-root mount. ensure_running is an idempotent singleton over both containers. - gateway: BOT_BOTTLE_ORCHESTRATOR_URL is now the orchestrator's *by-name* URL on the shared network (dropped the host.docker.internal hack). - control_plane: _serve wraps dispatch — a failure returns 500, never crashes the connection. - OrchestratorProcess default broker -> stub (register-only) for docker. Validated live end-to-end: both containers up, gateway->orchestrator by name OK, register -> resolve-by-source-IP returns the bottle's policy from inside the gateway. pyright 0 errors; pylint 9.83/10; unit suite green (1760 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
3062fc9230 |
feat(orchestrator): slice 13c(viii) — agent-only consolidated compose render
The per-bottle model rendered a compose project with the agent + a sidecar
bundle on two per-bottle networks. Consolidated has no sidecars — one shared
gateway serves everyone — so this renders JUST the agent, attached to the
external shared gateway network at the orchestrator-allocated pinned IP, and
pointed at the gateway's address for egress.
- consolidated_agent_compose(plan, *, gateway_ip, source_ip, network):
agent service only; networks {<gateway>: {ipv4_address: source_ip}} on the
external gateway network; HTTPS_PROXY -> http://<gateway_ip>:9099; NO_PROXY
includes the gateway address so git-http + supervise (also on the gateway)
bypass the proxy and are reached directly. No depends_on/sidecars.
- Pure (takes the launch-time LaunchContext values), so it's unit-testable
without docker.
pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init
/bin/sleep errors are pre-existing NixOS-local noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
404369a3e0 |
feat(orchestrator): slice 13c(vii) — stable shared gateway CA
The consolidated gateway TLS-intercepts every bottle, so all agents must trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own mitmproxy generate its CA into a persistent named volume and extract the cert for agents — no host openssl, keeps gateway.py lean. - DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the self-generated CA survives container recreation (it must not rotate, or already-launched agents would stop trusting the gateway). - ca_cert_pem(): read the CA cert (PEM) out of the running gateway (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its trust store to accept the gateway's bumped certs. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
327e756eef |
feat(orchestrator): slice 13c(vi) — consolidated launch sequence (composition)
Composes the orchestrator primitives into the register/teardown sequence
that replaces the per-bottle bundle:
launch_consolidated(egress_plan, git_gate_plan): ensure orchestrator +
gateway up -> read the gateway network's subnet + the gateway's own
address -> allocate the bottle a pinned source IP (skip the gateway +
live bottles) -> register (egress policy blob + slug metadata) ->
provision its git-gate repos/creds into the gateway. Returns a
LaunchContext (bottle_id, identity_token, source_ip, network, gateway_ip,
orchestrator_url) — everything the agent container needs to attach.
Rolls the registration back if provisioning fails, so no orphan.
teardown_consolidated(bottle_id): deregister + deprovision (idempotent).
The agent `docker run` itself stays the backend's job (provider
provisioning); this owns the orchestrator-facing wiring so the sequence is
unit-testable — collaborators mocked, next_free_ip + registration_inputs run
for real (IP allocation + policy blob asserted end to end).
pyright 0 errors; pylint 9.83/10; unit suite green (1755 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
444924d95d |
feat(git-gate+orchestrator): slice 13c(v) — provision git-gate into the running gateway
Places a bottle's git-gate credentials + bare repos into the live shared gateway container when it registers — the execution side of the 13c(i) provisioning render. - provision_git_gate(gateway, bottle_id, plan): mkdir the per-bottle creds dir, docker cp each upstream's identity key (+ known_hosts when present) into /git-gate/creds/<bottle_id>/, then docker exec the namespaced, init-only script from git_gate_render_provision. No-op with no upstreams. - deprovision_git_gate(gateway, bottle_id): rm the bottle's /git/<id> + creds on teardown (idempotent). - bottle_id is validated (shell/path-safe alphabet) BEFORE any docker cp/rm, so a traversal id can never reach a path arg — the creds/repo dirs land in docker cp/rm arguments. Registry ids are token_hex; defense in depth. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
118d81533b |
feat(orchestrator): slice 13c(iv) — gateway joins a shared network
The consolidated gateway ran on the default bridge; the model needs it on a dedicated user-defined network that every agent bottle also joins, reaching the gateway's egress / git-http / supervise ports by its address (no host port publishing) — and the source IP the gateway attributes by is the bottle's address on this network. - GATEWAY_NETWORK = "bot-bottle-gateway"; DockerGateway gains a `network` param. - ensure_running now ensures the network exists (idempotent create, tolerating a concurrent 'already exists') then runs with `--network`. - Docker picks the subnet; the launcher reads it back (13c source-IP allocator) to hand each bottle a pinned address. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
353b1ad984 |
feat(orchestrator): slice 13c(iii) — host-side control-plane client
The launch path's counterpart to the gateway-side PolicyResolver: a trusted host-side HTTP client for the orchestrator control plane, so the CLI can register/teardown/re-policy bottles. Stdlib-only. - OrchestratorClient.register_bottle(source_ip, *, image_ref, metadata, policy) -> RegisteredBottle(bottle_id, identity_token) (POST /bottles) - teardown_bottle(id) -> bool (DELETE; 404 -> False, idempotent for cleanup) - set_policy(id, policy) -> bool (PUT; live reload) - list_bottles() / health() - Unlike the fail-closed data-plane resolver, this is the trusted caller: a non-2xx (other than the meaningful 404s) raises OrchestratorClientError so the launch path surfaces failures rather than swallowing them. pyright 0 errors; pylint 9.82/10; unit suite green (1742 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
40ad2364ab |
feat(orchestrator): slice 13c(ii) — shared-gateway source-IP allocator
Deterministic per-bottle address assignment on the consolidated docker gateway's shared network — the address the gateway uses as its attribution key. Pure ipaddress logic; the docker-specific inputs (subnet CIDR, the gateway container's own address) are gathered by the caller and passed as `taken`, keeping it testable without docker. - next_free_ip(cidr, taken): lowest host address not reserved (network + broadcast excluded by hosts(); docker's router .1 excluded here) and not already assigned (gateway container + live bottles from the registry). NoFreeAddressError when the subnet is exhausted. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
9e5d00c6fa |
feat(git-gate+orchestrator): slice 13c(i) — per-bottle git-gate provisioning render
The consolidated gateway serves every bottle's repos under /git/<bottle_id>/ (slice 10), so each bottle's bare repos + per-repo credential config must be provisioned into that namespace. This adds the renderer for that; placing the creds + running it inside the gateway is the launch-wiring step. - Extract the credential-wiring `init_repo` shell into a shared `_git_gate_init_repo_fn(repo_root, creds_dir)` — one source for the security-sensitive logic. The single-tenant daemon entrypoint is byte-unchanged (still /git + /git-gate/creds; existing tests green). - git_gate_render_provision(bottle_id, upstreams): posix-sh that inits ONE bottle's bare repos under /git/<bottle_id>/ reading creds from /git-gate/creds/<bottle_id>/. Init-only (no `git daemon` — the shared gateway already serves). Isolating each bottle's repo root + creds dir by id is what keeps one bottle's push credentials out of another's repos. - bottle_id is embedded unquoted in the script, so it's restricted to a shell/path-safe alphabet (registry ids are token_hex; defense in depth). pyright 0 errors; pylint 9.83/10; unit suite green (1724 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
0c158c5718 |
feat(orchestrator): slice 13b — consolidated registration inputs (egress policy)
Bridges the per-bottle `prepare` output to the consolidated registry: `registration_inputs(plan)` turns a prepared egress plan into the backend-neutral inputs `Orchestrator.launch_bottle` takes. - egress_policy(plan): the policy blob = the exact routes YAML the per-bottle sidecar read from a file; the multi-tenant gateway's PolicyResolver now fetches it from the registry per request instead. Same egress_render_routes, so a bottle's allow-list is byte-identical moving onto the shared gateway. - RegistrationInputs bundles policy + metadata; metadata carries the human slug so the shared registry can map a minted bottle id back to a name (console / supervise display). Host-side glue (imports bot_bottle.egress) used by the launch path — not the lean orchestrator control-plane process. Not yet wired into launch (that's 13c/13d), consistent with the slice-6/10 pattern of landing the primitive before the cut-over. Key test: the policy round-trips through load_config back to the same routes + log level — the resolver-served policy reconstructs the per-bottle egress config exactly. pyright 0 errors; pylint 9.83/10; unit suite green (1718 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
fe25ad7623 |
feat(orchestrator): slice 13a — orchestrator process lifecycle (idempotent singleton)
First sub-slice of docker launch integration: the foundation the CLI needs to ensure exactly one orchestrator control plane + shared gateway is up before registering/launching bottles. Does NOT touch the real launch path yet — it's the lifecycle primitive the cut-over slices build on. - OrchestratorProcess.ensure_running(): idempotent singleton — returns the control-plane URL if a healthy one already answers /health, else spawns `python -m bot_bottle.orchestrator --broker docker --gateway` detached (start_new_session, output tee'd to <root>/orchestrator.log) and polls /health until healthy or timeout (OrchestratorStartError). - The control-plane port is the singleton key: a second orchestrator can't bind it, so a stray double-start fails fast rather than forking a rival. - Host-process (not container) per the PRD's dev-harness sequencing — it already has the host user's docker access to broker launches; the data-plane gateway it manages is the container. pyright 0 errors; pylint 9.83/10; unit suite green (1714 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
6570e9f044 |
feat(supervise+orchestrator): slice 12 — multi-tenant git-gate hook + supervise server
Finishes the supervise data-plane writers for the shared gateway. Both still keyed off a single SUPERVISE_BOTTLE_SLUG env; now each proposal is attributed to the calling bottle, keeping slices 10/11's per-bottle model. - git_http_backend: in consolidated mode stamp SUPERVISE_BOTTLE_SLUG= <bottle_id> into the CGI env. The gitleaks-allow pre-receive hook runs as a child of the `git http-backend` we spawn, so it inherits the stamp and queues under the right bottle — no change to the (fragile) embedded hook. The bottle id is the namespaced root's final component (slice 10), the same key egress uses. Single-tenant leaves the container-stamped slug. - supervise_server: resolve the proposal slug per request by source IP when BOT_BOTTLE_ORCHESTRATOR_URL is set (`_attributed_config`), fail-closed — an unattributed / unreachable source raises rather than queue under the wrong or empty slug. `serve`/`main` build + attach the resolver and make the env slug optional in consolidated mode. Single-tenant unchanged. Tests: a real git push proving the slug reaches the hook's env; the supervise attribution matrix (single-tenant slug kept, source-IP bottle bound, unattributed + resolver-error fail closed). Remaining supervise gap (noted): websocket DLP still self.config-only. pyright 0 errors; pylint 9.83/10; unit suite green (1705 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
39c823d3c0 |
feat(supervise+orchestrator): slice 11 — per-bottle supervise queue + DLP safelist
Consolidated egress ran every bottle through one process but keyed the supervise proposal queue off a single SUPERVISE_BOTTLE_SLUG env and kept one *global* DLP safelist — so in the shared gateway an operator's token approval for bottle A would (a) be attributed to the wrong bottle and (b) leak into bottle B's DLP scan (A's approved secret passes B's egress). This slice keys both per bottle, resolved by source IP. - policy_resolver: add `resolve_policy_and_bottle_id` — policy + bottle id in one `/resolve`, so egress keys routing *and* the supervise queue/safelist from a single round-trip. Fail-closed (403 -> (None,None)). - egress_addon_core: add `resolve_client_context` (+ `ContextResolverLike`) returning `(Config, bottle_id)`, sharing the fail-closed parse with `resolve_client_config` via `_config_from_policy`. - egress_addon: `_active_config` -> `_resolve_flow` returns `(Config, slug)`; `safe_tokens` set -> per-bottle `_safe_tokens_for(slug)`; the token-allow write/await/archive + the approved-token add all use the resolved slug. Single-tenant (no resolver) unchanged — slug = the env SUPERVISE_BOTTLE_SLUG. New tests cover the resolver, the fail-closed context matrix, and the cross-tenant isolation (an approval lands only in the calling bottle's safelist; the proposal is keyed by the source-IP-attributed bottle; unattributed IPs can't supervise). Out of scope (noted): the git-gate gitleaks-allow hook + supervise_server agent-proposal paths, and websocket DLP (still self.config-only, inert in consolidated mode) — follow-up slices. pyright 0 errors; pylint 9.83/10; unit suite green (1700 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
8a44537f7e |
fix(git-gate): review — sandbox naming, ResolverLike Protocol, token-required note
Addresses PR #365 review: - Type `resolve_sandbox_root`'s resolver param as a `ResolverLike` Protocol (structural, `resolve_bottle_id` only) — fixes the pyright errors from passing a duck-typed fake resolver in tests; mirrors egress_addon_core.PolicyResolverLike. - Rename `_project_root`/`project_root`/`resolve_repo_root`/ `DEFAULT_PROJECT_ROOT` -> `_sandbox_root`/`sandbox_root`/ `resolve_sandbox_root`/`DEFAULT_REPO_ROOT`: "sandbox" names the per-tenant scope; "project" was too amorphous. `GIT_PROJECT_ROOT` keeps git's own env-var name. - Note the single-tenant path is transitional (stripped once every backend runs the consolidated gateway). - policy_resolver: document that the optional identity token is transitional — the consolidated end state requires it (flips at the /resolve boundary once token *delivery* lands, a PRD 0070 open question). - Split the long line in main() (was 105 cols). pyright 0 errors; pylint 9.95/10; unit suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
72ee1f77da |
feat(git-gate): source-IP-keyed multi-tenant repo namespace (PRD 0070)
First slice of git-gate consolidation: make the smart-HTTP git backend serve every bottle from one process, selecting each request's repo root by the calling bottle's source IP — the same attribution invariant + resolver the multi-tenant egress addon uses. `git daemon` can't source-IP-route per connection, so the consolidated gateway serves git-gate over this HTTP backend (the transport firecracker/macOS already use); wiring the docker path onto it lands with the launch-integration slice. - policy_resolver: factor out `_post_resolve`; add `resolve_bottle_id` (source IP -> bottle id, same fail-closed 403->None contract as `resolve`) — the git-gate has no policy blob to parse, the bottle *is* the namespace. - git_http_backend: `resolve_repo_root(resolver, base, source_ip, token)` — single-tenant passthrough when no resolver; else `<base>/<bottle_id>`, fail-closed on unattributed / resolver error / namespace escape. `BOT_BOTTLE_ORCHESTRATOR_URL` (same env as egress) flips the shared gateway multi-tenant; the identity header is read for attribution and never forwarded to the CGI. Per-repo creds + hooks scope by repo dir, so isolating the root per bottle isolates its creds too. Single-tenant path unchanged (existing real-git-push tests green). New unit coverage for the resolver + repo-root selection matrix. Full unit suite green (1690 tests; the 13 test_sidecar_init /bin/sleep errors are the pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
00418a2834 |
refactor(orchestrator): rename Sidecar -> Gateway for the consolidated data plane
Retire "sidecar" for the consolidated per-host path (PRD 0070 naming decision): the orchestrator is the umbrella/control plane, and the egress/git/supervise data-plane unit it runs is the "gateway". - git mv sidecar.py -> gateway.py and the two integration + one unit test files; DockerSidecar->DockerGateway, Sidecar->Gateway, SidecarError->GatewayError, SIDECAR_*->GATEWAY_*, ensure_sidecar-> ensure_gateway, sidecar_status->gateway_status, container name bot-bottle-orch-sidecar->bot-bottle-orch-gateway. - Prose rename across broker/registry/egress/policy_resolver + PRD 0070. - Preserved: the image name bot-bottle-sidecars, the BOT_BOTTLE_SIDECAR_IMAGE env var, Dockerfile.sidecars, and PRD 0069's own stage-name cross-references (that doc still uses "sidecar"). No behavior change. Full unit suite green (1679 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
2cbb178f88 |
feat(orchestrator+egress): slice 8 — multi-tenant egress via the resolver (#352)
The egress addon now selects each request's Config by the calling bottle's
source IP, so one shared sidecar serves every bottle. Opt-in and fail-closed;
single-tenant behaviour is unchanged.
Orchestrator side (source-IP-primary attribution, per the PRD invariant):
* registry: `by_source_ip` (the single active bottle at a source IP —
network-layer attribution); `attribute` now composes it + the token.
* service: `resolve(source_ip, token="")` — with a token, strict
attribution; without, source IP alone.
* control_plane: `POST /resolve`'s identity_token is now OPTIONAL (absent
→ source-IP-only); split cleanly from the token-required `/attribute`.
* policy_resolver: `resolve` token now optional.
Egress side:
* egress_addon_core: `resolve_client_config(resolver, client_ip, token)` —
fetches + parses the client's Config, **fail-closed**: unattributed, a
resolver error, or an unparseable policy all yield deny-all (no routes).
Host-testable; `PolicyResolverLike` Protocol keeps it import-free.
* egress_addon: consolidated mode when `BOT_BOTTLE_ORCHESTRATOR_URL` is
set → `_active_config(flow)` resolves per client IP (reads + strips the
`x-bot-bottle-identity` header); `request()` uses it. Unset → the static
routes file, exactly as before. `PolicyResolver` added to the bundle.
Security note: source-IP-only resolution is safe where the IP is unspoofable
(Firecracker /31 + nft) AND the control plane is reachable only by the
trusted sidecar; the identity token, when the agent injects it, strengthens
it on weaker backends.
Scope note: the egress data plane is now multi-tenant. Remaining to be fully
live: the network topology routing every bottle's proxy to the one shared
sidecar, git-gate multitenancy, and agent-side identity-token injection.
Tests: registry by_source_ip; orchestrator resolve (with/without token);
control-plane /resolve token-optional; resolver token-optional;
resolve_client_config fail-closed matrix. All 182 egress tests still pass
(single-tenant unchanged). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
ed9a19bb38 |
refactor(orchestrator): drop the PolicyResolver cache (#352)
Review: the resolver is called rarely enough that a round-trip doesn't matter, and correctness beats speed — always fetching means a revocation, policy change, or teardown the orchestrator knows about is honored immediately instead of lingering for a cache TTL. Remove the TTL cache (and `invalidate`); `resolve` now hits the orchestrator every call. Noted in the docstring that any future caching should use orchestrator-driven invalidation, not a blind TTL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
c5ae93c8ba |
feat(orchestrator): slice 7 — sidecar-side PolicyResolver (#352)
The data-plane bridge that lets the consolidated sidecar apply each bottle's policy per request. `PolicyResolver` resolves a client's policy from the orchestrator's `POST /resolve` keyed on (source_ip, identity token) and caches it briefly (short TTL) so it isn't a round-trip per request; `invalidate()` drops an entry on teardown / live reload. Fail-closed: an unattributed client (orchestrator answers 403) resolves to None so the caller denies; unreachable / unexpected status raises so the caller can fail closed too rather than serve stale/empty policy. Stdlib only and free of bot-bottle imports, so it can be COPYed flat into the sidecar bundle. Scope note: this is the sidecar-side *client*. Wiring it into the live egress mitmproxy addon (select `Config` per client IP in the request path) and git-gate, plus routing all bottles' egress to the one shared sidecar, are the remaining data-plane pieces — a heavier change to the sidecar bundle's adversarial-input code, taken next. Tests: resolve returns/caches/expires/invalidates; 403 -> None (fail closed); other HTTP status + unreachable raise; missing policy -> empty; posts source_ip + identity_token. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
186b905dff |
feat(orchestrator): slice 6 — source-IP-keyed multi-tenant policy (#352)
The orchestrator side of the multi-tenant consolidated sidecar: hold each
bottle's sidecar policy and serve it by verified source IP, with live
reload. One shared sidecar can now get per-bottle config keyed on who's
calling.
* registry.py — a `policy` column (migration v3, opaque JSON the sidecar
interprets) on BottleRecord; `register(..., policy=)` stores it,
`set_policy(bottle_id, policy)` updates it live, and `attribute` returns
it (the source-IP-keyed resolution).
* service.py — `launch_bottle(..., policy=)` and `set_policy`.
* control_plane.py — `POST /bottles` accepts `policy`; `PUT
/bottles/<id>/policy` live-reloads it; `POST /resolve` returns
{bottle_id, policy} for a verified (source_ip, token) — the per-request
call the multi-tenant sidecar makes; `/attribute` stays identity-only.
Scope note: this is the control-plane / state half. The data-plane half —
the egress mitmproxy addon (and git-gate) selecting allowlist / DLP /
token-injection per client IP by calling `/resolve` — is the next slice
(route agent bottles through the shared sidecar). The orchestrator stays
policy-agnostic: it stores and serves the blob verbatim.
Tests: registry policy store/update/persist; Orchestrator launch-with-policy
+ live set_policy; control-plane resolve returns policy (403 on bad token),
PUT policy updates / 404 / 400. Verified live over HTTP (launch -> resolve
-> PUT reload -> resolve reflects). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
3519b924cb |
feat(orchestrator): slice 5 — build the consolidated sidecar bundle image (#352)
Answers "where do we build the consolidated sidecar": nowhere, until now.
* sidecar.py — `Sidecar.ensure_built()` (default no-op) + `DockerSidecar`
now defaults its image to the real bundle (`bot-bottle-sidecars`) and
`ensure_built()` builds it from `Dockerfile.sidecars` when
`docker image inspect` shows it's missing (no-op when present or when no
dockerfile is configured, e.g. a pre-pulled image). `image_exists()`
added.
* service.py — `ensure_sidecar()` now builds then runs.
* __main__.py — `--sidecar` runs the consolidated bundle (build-if-missing).
Scope note: this builds + launches the bundle *container*; making the
running instance functional across bottles needs the per-bottle,
source-IP-keyed multi-tenant config + registration/reload, and routing
agent bottles to it — the next slices (added to PRD 0070's roadmap).
Tests: unit (docker mocked) — image_exists, ensure_built builds when
missing / no-op when present / no-op without a dockerfile / raises on build
failure; ensure_sidecar builds-then-runs; integration (gated, no heavy
build) — image_exists reflects real docker state. Full suite green (only
pre-existing /bin/sleep errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
c77f578fb4 |
refactor(orchestrator): move run_docker to a lean top-level docker_cmd (#352)
Review feedback: don't bury a parallel docker util in the orchestrator. But reusing backend.docker.util (docker_mod) isn't right either — importing it runs backend/__init__.py, which eagerly loads all three backends (docker + firecracker + macos) plus the manifest/egress/git-gate/supervise framework (~76 modules), so every orchestrator import would drag the whole backend layer in. Compromise: promote the helper to a top-level, framework-free bot_bottle/docker_cmd.py (single stdlib import), a proper shared home the orchestrator's docker components use now and backend.docker.util can adopt later. Verified `import bot_bottle.orchestrator` stays lean (12 modules, no firecracker/macos backends). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
bc52c3525c |
feat(orchestrator): slice 4 — consolidated per-host sidecar (#352)
The core consolidation win: one persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. Safe to share
because the attribution invariant (source IP + identity token) lets the
sidecar map each request to the right bottle.
* orchestrator/sidecar.py — a backend-neutral `Sidecar` lifecycle
contract (mirrors LaunchBroker) + a `DockerSidecar` impl. The defining
behaviour is idempotent singleton: `ensure_running` starts the instance
if absent and is a no-op if it's already up, so N launches never spawn
N sidecars; `stop` is idempotent.
* orchestrator/dockerutil.py — a shared `run_docker` helper; DockerBroker
now uses it too (DRY with slice 3).
* service.py — the Orchestrator holds an optional `Sidecar`, exposes
`ensure_sidecar()` + `sidecar_status()`.
* control_plane.py — `GET /sidecar` reports it; __main__ gains
`--sidecar-image` and ensures the single sidecar on startup.
Tests: unit (docker mocked) — is_running, ensure idempotent (no-op when up,
starts when absent), failure raises, stop idempotent; Orchestrator sidecar
wiring/status; control-plane /sidecar; integration (gated) — ensure is a
real idempotent singleton (one container after two ensures), stop removes.
Full suite green (only pre-existing /bin/sleep errors); integration
verified locally against real docker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
b70f3228ca |
fix(orchestrator): pyright — pass explicit LaunchRequest in the docker integration test (#352)
The **kw unpacking put str into slot: int|None. Pass explicit LaunchRequests instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |