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
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 pool params (size, IP base, iface prefix, nft table) were triplicated
— hardcoded in netpool.py, scripts/firecracker-netpool.sh, and
nix/firecracker-netpool.nix — plus the IP math (3x) and the nft ruleset
(2x). Nothing enforced agreement; changing the base (ce3fad9, off CGNAT)
forced a coordinated three-file edit, and a missed one would silently
provision a range the launcher doesn't expect.
Collapse to one source of truth:
* netpool.defaults.env — a plain KEY=VALUE file (bash-sourceable,
systemd EnvironmentFile-compatible, Python- and Nix-parseable) holding
the four defaults. A BOT_BOTTLE_FC_* env var still overrides any key.
* netpool.py reads it for the Python defaults (missing file = hard
error, not confusing empty defaults).
* the shell script falls back to it (no literal `:-8` / `10.243.0.0`),
and its `up` is now non-destructive/idempotent (only creates a
missing TAP), so re-running never cuts a live VM.
* the Nix module readFile-parses it for its option defaults and
delegates bring-up to the SAME shell script (dropping its duplicate
IP math, nft ruleset, and TAP loop) — passing every value as
Environment= so the store-detached script never needs the file.
Net: defaults 3x -> 1x, nft ruleset 2x -> 1x, TAP loop 2x -> 1x. The one
remaining IP-math dup (Python launch-addressing vs bash bring-up) is
justified — different runtimes. Tests now guard the invariant (Python
reads the shared file; the script/module hold no literals) instead of
pinning duplicated strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Add a `group` option to the netpool NixOS module + BOT_BOTTLE_FC_GROUP to
the shell script: when set, the pool's TAP devices are owned by a group
instead of a single user, so any group member can open them (the kernel
lets a TAP's owning-group members attach). This lets an interactive user
and, say, a CI-runner user share one pool. `owner`/`group` are mutually
exclusive (asserted). Single-user `owner` remains the default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
The default TAP-pool base was 100.64.0.0/10 (RFC-6598 CGNAT) — chosen to
dodge RFC-1918, but that's exactly the range Tailscale assigns node
addresses from, so on a Tailscale host it's the worst pick. Move the
default to 10.243.0.0/16, an obscure RFC-1918 block that steers clear of
docker/libvirt/k8s/LAN and Tailscale.
No default is collision-proof, so add netpool.overlapping_routes(): it
parses `ip -json route show table all` and flags any route intersecting
the pool range (excluding our own bbfc* TAPs and the default route). The
launch preflight warns on overlap; `backend status` reports it.
Distribute the NixOS host setup as a flake module instead of a
copy-pasted blob: nix/firecracker-netpool.nix computes the taps / nft
table from typed options (poolSize, ipBase, ifacePrefix, owner) with a
/31-alignment assertion, and flake.nix exposes it as
nixosModules.firecracker-netpool. Defaults mirror the backend constants;
writeEnvFile emits the matching BOT_BOTTLE_FC_* so the host pool and the
launcher can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Adds a Firecracker-based backend for Linux, providing mature KVM-based
microVM isolation to replace smolmachines/libkrun (issue #342, closes
the dead-end tracked in #332).
Architecture:
- Guest control over SSH (dropbear injected into the rootfs) on a
point-to-point TAP link. `ssh -t` forwards SIGWINCH natively, so no
resize bridge is needed.
- Networking: a one-time, root-provisioned pool of user-owned TAP
devices (no shared bridge → no docker0/virbr0/cni0 collisions) plus a
dedicated `table inet bot_bottle_fc` nftables table (independent of
Docker/ufw/firewalld rules). `./cli.py firecracker setup` prints the
host-appropriate config (NixOS module or sudo script).
- Rootfs: `docker export` → ext4 via `mke2fs -d` (rootless, no mount),
cached by image digest; per-bottle SSH pubkey + IP passed via the
kernel cmdline.
- Sidecar: reuses the Docker bundle, published on the slot's host TAP IP.
- Fail-closed isolation: TAP pool verified at preflight; the egress
boundary is proven empirically post-boot (before the agent runs) by a
canary probe — the VM must fail to reach the host directly, or launch
is refused.
Linux hosts with Firecracker + KVM now default to this backend;
macOS stays on macos-container.
Not yet validated end-to-end on live hardware (requires the one-time
network pool). Unit tests + pyright pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8p32HJgPoS1hLPWubbftM
git_gate.py (699 LOC) mixed three responsibilities. Split into:
- git_gate_render.py — pure host-side rendering: the gate constants,
GitGateUpstream, gitconfig/known-hosts rendering, and the entrypoint /
pre-receive / access-hook script builders.
- git_gate_provision.py — the gitea deploy-key lifecycle
(_provision_dynamic_key / revoke / _resolve_identity_file).
- git_gate.py — the GitGate ABC + GitGatePlan, now 169 LOC, re-exporting
all moved names (see __all__) so the 19 importers are unchanged.
Host-side only (not flat-bundled), so no sidecar import shim. The one
test that patched the internal `_provision_dynamic_key` lookup is
repointed to its new module (public API unchanged). The two new modules
are added to scripts/critical-modules.txt so the decompose doesn't move
security code out of the measured core — critical aggregate stays 95%
(git_gate 100%, render 100%, provision 97%).
Closes#303
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Surface the metric ADR 0004 says matters — the critical security/logic
core, currently 95% — as a README badge, distinct from the
informational global `coverage` badge.
- scripts/critical-modules.txt: single source of truth for the core
module list. scripts/coverage.sh now reads it (instead of a hardcoded
string) and update-badges.yml reads the same file, so the badge and
the `critical` report cannot drift.
- update-badges.yml: a `core coverage` step reuses the unit-coverage
data (every core module is unit-tested, so unit-only is accurate for
it) and sed-updates the new badge, like the existing ones.
- README: `core coverage 95%` badge linking to ADR 0004 so a reader can
find out what "core" means.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Adopt ADR 0004: stop chasing a single global coverage number and
measure what matters instead.
- Omit the genuinely-interactive `cli/init.py` shell (read_tty_line
prompt loops) alongside the existing `cli/tui.py`, with a rationale
comment in .coveragerc. Subprocess/backend orchestration is NOT
omitted — it stays visible and is scored via the integration suite.
- scripts/coverage.sh runs unit + integration under one coverage
measurement (the policy's yardstick) and can report the critical
security/logic core held to the >=90% target.
- scripts/diff_coverage.py is a stdlib-only gate (no diff-cover dep):
new/changed executable lines must be >=90% covered. This is the
enforced regression guard; the global number is informational.
- CI gains a `coverage` job: combined report + the diff-coverage gate.
- Unit-test `cli/__init__.py` dispatch/exit-code mapping (it's logic,
not I/O, so it earns tests rather than an omit).
Combined unit+integration coverage now reports 83% global / 87% across
the critical modules; per-module ratcheting toward 90% is the ongoing
work this policy frames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Dockerfile.claude and Dockerfile.codex move from the repo root into
bot_bottle/contrib/claude/Dockerfile and bot_bottle/contrib/codex/Dockerfile
respectively, so all per-provider assets live alongside the provider code.
Closes#215
Squashes the demo-build arc: initial GIF + scripts, refactor to drive
recording through real cli.py, theme/timing tweaks, and the switch to
prompt-driven probes.