The gateway used `FROM zricethezav/gitleaks AS gitleaks-src` purely to
COPY the binary out — a supply-chain surface (a whole third-party image as
a build input, tying us to its cadence). Install gitleaks from its official
release instead, pinned by version + SHA256 and verified. python (already
in the image) does the download, so no curl/wget is added. trixie apt also
ships gitleaks but an older 8.16; the pinned download keeps the verified
8.30.1 (byte-identical to what the image provided).
Verified: gateway + infra images build, gitleaks 8.30.1 runs in both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Addresses the review finding that buildah lived only in the orchestrator
image, so the persistent infra VM wasn't the builder — a separate throwaway
builder VM contended with it for the orchestrator TAP. Consolidate:
- **Rebase the gateway (and thus infra) on `python:3.12-slim` = Debian
trixie**, pip-installing mitmproxy instead of `FROM mitmproxy/mitmproxy`
(Debian bookworm). trixie ships buildah 1.39, which can build agent
Dockerfiles that use heredocs; bookworm's 1.28 can't (`Unknown
instruction: "{"`). CA path is unchanged (set via `--set confdir=`).
- **buildah lives only in `Dockerfile.infra`** now (removed from the
orchestrator image, which is lean/stdlib-only again).
- **Shared orchestrator content**: `Dockerfile.orchestrator` is the single
definition of the control-plane payload; the infra image `COPY --from`s
it (same trixie base → clean copy, and future deps like iroh are added
once). The docker backend runs the orchestrator image directly.
- **`image_builder` builds inside the infra VM** (which now has buildah)
over SSH — no throwaway builder VM, so the `bborch0` contention is gone.
`ensure_built` builds orchestrator + gateway before infra (FROM gateway,
COPY --from orchestrator).
Verified on a KVM host: images build (buildah 1.39 in infra), the agent
image builds *inside* the infra VM (heredoc Dockerfile and all), the infra
VM stays healthy, the agent boots and `claude --version` = 2.1.172. The
rebased gateway still starts as a docker container and generates its CA
(docker backend unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Each nft table (isolation, orchestrator-egress nat, agent->gateway route)
was re-applied as a plain `table {...}` block, which on a bare `up`
re-apply (not the systemd down->up path) would append duplicate rules or
error on the existing base chains. Use the standard delete-first pattern
(create empty, delete, recreate) so `up` lands identical state regardless
of history — the setup reproduces cleanly on a fresh install and on
re-apply, not just via a full down->up cycle.
No functional change to the resulting ruleset; only its idempotency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
git-gate now works end-to-end for git-upstream bottles on the infra VM.
Three fixes surfaced by driving a real clone through git-http:
- `SshGatewayTransport.cp_into` preserves the source file mode (docker cp
does). The access-hook is staged 0700 and git-http execs it directly; a
plain `cat >` landed it 0644 -> EACCES. Keys stay 0600.
- the infra init runs `BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise`:
the VM backend reaches git over git-http (9420), so the git:// daemon
(git-gate, whose /git-gate-entrypoint.sh the consolidated model doesn't
stage) is left out instead of crash-looping.
- `build_infra_rootfs_dir` folds the init's content-hash into the rootfs
cache key, so an init change actually rebuilds the rootfs (the base image
digest alone wouldn't catch it).
Verified on a KVM host: launch_consolidated provisions a git-upstream
bottle's repo + creds into the gateway VM over SSH; an agent VM clones via
git-http (source-IP attributed) and the access-hook resolves the
provisioned key + known_hosts and attempts the upstream SSH fetch —
failing closed only because the test used a dummy key + fake upstream
("refusing to serve stale data"). With real creds the fetch serves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
The infra VM's rootfs is ephemeral (rebuilt each boot), so the bottle
registry DB needs durable storage across restarts. Give the infra VM a
firecracker analogue of a docker volume: a host-side ext4 file attached as
a second virtio-block device (guest /dev/vdb), mounted at the control
plane's DB dir (/var/lib/bot-bottle, where host_db_path lives at
db/bot-bottle.db).
- firecracker_vm.boot/_config take an optional `data_drive` (a non-root,
RW second drive).
- infra_vm creates the volume on first use (`mke2fs` an empty ext4 at
<fc-cache>/infra/registry.ext4) and mounts /dev/vdb in the PID-1 init
before the control plane starts. It's a plain ext4 file, so
`sudo mount -o loop <path>` (VM stopped) inspects bot-bottle.db directly.
Verified on a KVM host: register a bottle, restart the infra VM (fresh
rootfs, same volume) — /dev/vdb re-mounts and the registry `db/` dir + a
marker file survive the restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
consolidated_launch now drives the persistent infra VM instead of the
`_FirecrackerOrchestratorService` Docker containers — the point Docker
leaves the Firecracker launch path.
- launch_consolidated: `infra_vm.ensure_running()` (singleton) for the
control plane + gateway; register the bottle over HTTP at the infra VM's
guest IP; provision git-gate via `SshGatewayTransport` (over SSH into the
gateway VM); fetch the gateway CA via `InfraVm.gateway_ca_pem()`.
- teardown_consolidated deregisters + deprovisions but does NOT stop the
infra VM (persistent per-host singleton shared by every bottle).
- new `infra_vm.SshGatewayTransport` + `gateway_transport()` (built from the
stable key + orchestrator link IP, so teardown needs no live handle).
- drop the DockerGateway/OrchestratorService machinery from the firecracker
consolidated path (still used by the docker backend).
launch.py already calls launch_consolidated, so the whole firecracker
launch path now uses the VM. pyright + unit suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
git-gate provisioning into the running gateway was hard-wired to docker
exec/cp. Extract a backend-neutral `GatewayTransport` (exec + cp_into) so
the same provisioning logic serves both the docker gateway container and
the firecracker gateway VM (over SSH, added with the launch swap).
- `provision_git_gate` / `deprovision_git_gate` now take a transport
instead of a gateway name; `DockerGatewayTransport` wraps the existing
docker exec/cp behavior. deprovision is best-effort (catches the
transport error) — matching the prior idempotent teardown.
- both consolidated_launch callers pass `DockerGatewayTransport(name)` —
no behavior change; the firecracker swap flips only its own to SSH.
Pure refactor: docker path unchanged, gateway_provision tests updated to
construct the transport, all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
The infra VM must outlive the short-lived `start` launcher and be reused
across launches. Add an idempotent singleton:
- `ensure_running()` adopts the infra VM when its control plane is already
healthy (a prior launcher booted it), else clears any stale VM and boots
a fresh one. Returns a handle usable for CA fetch / git-gate provisioning
whether we booted it or adopted it.
- boot is `detached` (firecracker in its own session via start_new_session)
so it survives the launcher exiting; its PID is recorded so a later
process can `stop()` it. `_kill_pidfile` SIGTERM/SIGKILLs but only if the
PID is still a firecracker process (guards a recycled PID).
- a STABLE SSH key (generated once under the infra cache dir, re-injected
each boot via the cmdline) so any launcher can SSH in to fetch the CA /
provision, not just the one that booted the VM. `InfraVm.vm` is None in
the adopted case; teardown then goes through the PID file.
Verified on a KVM host: first ensure_running boots; a second adopts it
(same PID, no reboot) and can still reach /health and fetch the gateway CA
over SSH; stop() tears it down (control plane then unreachable).
Next: git-gate provisioning into the VM over SSH (today docker exec/cp),
then swap consolidated_launch.py onto the infra VM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
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
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
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
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
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
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
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.
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>
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.
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.
- `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
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
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
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
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
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>
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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