Commit Graph

17 Commits

Author SHA1 Message Date
didericis 18d9b81add feat(firecracker): split orchestrator and gateway into separate VMs (PRD 0070)
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 19s
lint / lint (push) Successful in 53s
test / unit (pull_request) Failing after 1m46s
test / integration-firecracker (pull_request) Failing after 2m42s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Now that #469 got the DB off the data plane, the Firecracker infra runs as
two microVMs instead of one — mirroring the docker/macos plane split:

  * orchestrator VM (ORCH_IFACE) — control plane + buildah image builds; sole
    DB opener; host-seeded signing key. No gateway daemons.
  * gateway VM (new GW_IFACE) — egress / git-http / supervise data plane;
    mitmproxy CA + a host-minted `gateway` JWT (never the key). Reaches the
    orchestrator only over the one nft forward rule its link allows.

Both boot the SAME shared infra rootfs; a `bb_role=` kernel-cmdline arg
selects which plane a VM's PID-1 init starts, so there is still one published
artifact. The gateway learns the orchestrator's address via `bb_orch=` on the
cmdline (no IP baked into the artifact).

Isolation is nearly free: agents were already nft-dropped except the DNAT'd
gateway ports, so re-pointing that single DNAT rule at the gateway VM
(`dnat to gw_guest`) severs every agent's L3 route to the control plane. The
only added nft is the second infra link's mirror block (masquerade egress +
forward accept, which subsumes gateway->orchestrator) in the shared shell
script and the NixOS module.

netpool gains GW_IFACE + gw_slot() (the /31 above the orch link);
firecracker_vm.boot gains extra_boot_args for the role cmdline; infra_vm
ensure_running() boots + adopts the pair (orchestrator first, then the gateway
that resolves policy against it) and returns an InfraEndpoint mirroring the
docker/macos shape. Builds stay in the orchestrator (PRD 0070 v1); the gateway
is the slim unit.

Unit-tested (test_firecracker_infra_vm rewritten for two VMs; gw_slot helper
test added); the KVM boot / L3-isolation checks are validated on a Firecracker
host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 03:44:15 -04:00
didericis a446551acb refactor(egress): make Egress a service class in an egress package
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Successful in 17s
test / unit (pull_request) Successful in 46s
lint / lint (push) Failing after 2m44s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 20s
test / publish-infra (pull_request) Has been skipped
Split the flat egress.py into an egress/ package: neutral DTOs
(EgressRoute, EgressPlan) in plan.py, and the concrete Egress service
class plus rendering/env helpers in service.py, behind a thin
__getattr__ facade so leaf imports stay cheap.

Egress drops ABC and becomes a concrete service the backends call:
resolve_token_values / agent_env_entries / prepare are now methods.
Backend launch paths (docker, firecracker, macos_container) call
Egress().<method>(...) instead of the module-level functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:43:59 -04:00
didericis 450037b7e9 refactor(git-gate): make GitGate a service class in a git_gate package
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / unit (pull_request) Successful in 47s
lint / lint (push) Failing after 58s
test / integration-firecracker (pull_request) Successful in 3m17s
test / coverage (pull_request) Successful in 20s
test / publish-infra (pull_request) Has been skipped
Turn the git_gate module into a package with GitGate as a concrete service class
(dropping the ABC), mirroring the Supervisor shape. The host-side git-gate
operations are now methods the backend drives:

  git_gate/
    __init__.py  — thin __getattr__ facade (keeps `from bot_bottle.git_gate
                   import …` working; render/hook names lazily forwarded to
                   gateway.git_gate_render)
    plan.py      — GitGatePlan (the launch DTO the backend contract references)
    service.py   — GitGate: prepare / provision_dynamic_keys /
                   revoke_provisioned_keys / preflight_host_keys
    provision.py — deploy-key lifecycle (was git_gate_provision.py)
    host_key.py  — host-key preflight (was git_gate_host_key.py)

The backend launch + base.py preflight now call GitGate() methods instead of the
free functions. The runtime rendering / in-gateway hook execution stay in
gateway/git_gate_render.py (data plane) — only the host-side service moved.

Because the facade forwards names lazily, the ~25 `from bot_bottle.git_gate
import GitGatePlan` call-sites are unchanged, and importing git_gate.plan (the
contract's dependency) no longer drags in the provisioning / forge-API code.

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:27:56 -04:00
didericis 44e2b5a897 refactor(supervise): split the supervise plane by tier; per-service store managers
test / integration-docker (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / unit (pull_request) Successful in 42s
lint / lint (push) Failing after 54s
test / integration-firecracker (pull_request) Successful in 3m17s
test / coverage (pull_request) Successful in 17s
test / publish-infra (pull_request) Has been skipped
Give each service its own store package + manager, and cut the supervise module
along the control/data-plane boundary so nothing in the shared layer reaches up
into the orchestrator.

Stores, by owner:
  - bot_bottle/store/ keeps only the shared base (DbStore, migrations) and the
    concrete stores that aren't service-owned (audit_store, config_store).
  - bot_bottle/orchestrator/store/ now houses the orchestrator-owned stores —
    queue_store (supervise queue), secret_store, config_store — plus a new
    orchestrator store_manager that migrates them (composing audit/config
    downward from the base). The old shared store_manager is gone.

Supervise plane, by tier:
  - bot_bottle/supervisor/ (NEUTRAL, importable by every tier including the
    gateway): types.py (the Proposal/Response/AuditEntry wire types + the tool/
    status/poll constants + the shared daemon constants moved out of
    supervise.py) and plan.py (SupervisePlan, a pure DTO).
  - bot_bottle/orchestrator/supervisor/ (orchestrator-only): queue.py (the
    queue/audit I/O wrappers + render_diff + sha256_hex) and supervise.py (the
    Supervise lifecycle that stages the DB via the store manager). Its __init__
    re-exports the neutral vocabulary so orchestrator-side callers import from
    one place.

The gateway now imports only bot_bottle.supervisor.types (never
bot_bottle.supervise), so the data plane holds no code dependency on the
orchestrator — it reaches the queue over the control-plane RPC. This removes
the circular import that moving queue_store under orchestrator introduced
(supervise -> orchestrator -> service -> supervise).

supervise_types.py -> supervisor/types.py; supervise.py deleted (split). Full
unit suite green (2251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:15:04 -04:00
didericis-codex 854f6b5696 fix(secrets): recover encrypted tokens on all backends 2026-07-23 21:39:07 +00:00
didericis 2cd06814e6 fix(firecracker): remove per-bottle run dir on teardown
Each Firecracker bottle created its run dir (holding an ~1G
rootfs.ext4) but never removed it — launch teardown only terminated
the VM. Every run leaked its rootfs, filling the CI runner's disk
(187 stale sandbox-tester dirs / 141G in one incident).

Register a run-dir rmtree callback on the ExitStack before the
vm.terminate callback so, LIFO, the VM is gone before its rootfs is
removed. ignore_errors keeps teardown resilient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:47:44 -04:00
didericis-codex b032562d74 fix(firecracker): pin the provisioned Git config 2026-07-21 14:52:44 -04:00
didericis-codex c53254e9d5 fix(firecracker): honor cached image policy 2026-07-21 13:56:29 -04:00
didericis-claude cf9a53d582 fix: resolve teardown timeout before registration to prevent orphaned state
If resolve_teardown_timeout() raised after launch_consolidated()/register_agent()
returned, the teardown callback was never registered, leaving the bottle
registered with no cleanup path. Resolve the timeout into a local variable
before the registration call so any failure aborts before the bottle exists.

Add tests covering OrchestratorConfigStore, resolve_teardown_timeout priority
ordering (env > db > default), and the source-order invariant that the resolver
runs before registration in all three backends.
2026-07-21 00:40:59 -04:00
didericis-claude 0b36c3eb48 fix: make orchestrator teardown timeout configurable (closes #435)
Resolves via ENV VAR -> orchestrator DB config -> default (30 s, up from 5 s):
  BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS
  teardown_timeout_seconds key in new orchestrator_config table (bot-bottle.db)

New OrchestratorConfigStore (same DbStore/TableMigrations pattern as the
registry) stores the DB-level setting. resolve_teardown_timeout() implements
the priority chain and is called at stack.callback registration time in all
three backends (macos_container, docker, firecracker).
2026-07-21 00:40:59 -04:00
didericis 5c08701983 feat(firecracker): port freeze/migrate off host Docker (PRD 0069 / #397)
lint / lint (push) Failing after 2m9s
test / unit (pull_request) Successful in 1m7s
test / integration (pull_request) Successful in 24s
test / coverage (pull_request) Successful in 1m22s
The last host-Docker dependency in the Firecracker launch path. Freeze
and resume no longer touch the docker daemon, so the backend needs
firecracker + KVM only — completing #348.

Freeze: stream the guest rootfs over SSH straight into a persistent
committed-rootfs.tar (the resumable/migratable artifact) instead of
round-tripping through `docker build` from a scratch image. Written to
a .partial sibling and atomically renamed so a failed freeze leaves no
truncated artifact.

Resume: extract the snapshot tar into a cached base dir and feed it to
the existing rootless `mke2fs -d` pipeline, replacing the
`docker create` + `docker export | tar` path. Recreate the
proc/sys/dev/run mount points the freezer excludes so the guest init
can mount them.

`util.build_base_rootfs_dir` / `docker_image_id` stay — they still back
the opt-in BOT_BOTTLE_INFRA_BUILD=local dev path and off-host
publish_infra, which are out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
2026-07-17 00:31:00 -04:00
didericis d4b27ebf1f feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
lint / lint (push) Successful in 2m12s
test / unit (pull_request) Successful in 1m12s
test / integration (pull_request) Successful in 27s
test / coverage (pull_request) Successful in 1m21s
Codex review: the /31 TAP doesn't make source IP unspoofable, and the
app-layer token was returned by launch but never delivered or enforced, so
a spoofed source could select a victim bottle's policy/tokens. Make the
token mandatory and deliver it on each attributed plane (anti-spoof landed
separately as the network boundary).

Enforcement (control plane):
- `Orchestrator.resolve` now requires a matching (source_ip, identity_token)
  pair (constant-time) — no source-IP-only fallback. `/resolve` fail-closes
  (403) on a missing/empty/mismatched token.

Delivery, per plane (the token is `token_urlsafe`, safe in a URL):
- egress: proxy credentials (`HTTPS_PROXY=http://bottle:<token>@gw`). The
  addon reads `Proxy-Authorization` — from the request (HTTP) or captured at
  the CONNECT for HTTPS tunnels (keyed by client conn, cleared on disconnect)
  — validates, and strips it (+ the legacy header) before upstream.
- git-http: a URL-scoped `http.<gate>/.extraHeader: x-bot-bottle-identity`
  in the agent's git config (only over the http transport).
- supervise: `mcp add --header x-bot-bottle-identity: <token>` (claude +
  codex); the server reads the header and passes it to resolve.

Wiring: thread `ctx.identity_token` onto the firecracker + docker plans and
into the agent env/config at launch.

Verified on a KVM host: egress with the correct proxy-cred token returns
200 (HTTP and HTTPS/CONNECT), and no-token / wrong-token return 403; a real
`cli.py start --backend=firecracker` launch provisions git config + the
supervise MCP header and reaches the agent session, all under mandatory
enforcement. Fixed a `claude mcp add` arg-order bug (--header must follow
the positional name/url) found by that launch.

Transparent proxy for tools that ignore proxy env is deferred to a
follow-up (see thread); anti-spoof + host firewall remain the fail-closed
boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-16 17:02:41 -04:00
didericis 4edd7803e8 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
2026-07-16 14:36:54 -04:00
didericis-claude 9369fb7de5 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>
2026-07-16 14:36:54 -04:00
didericis 09393b354b refactor(de-sidecar): purge the "sidecar" name from live code, tests, and current docs
lint / lint (push) Failing after 2m3s
test / unit (pull_request) Successful in 1m11s
test / integration (pull_request) Successful in 26s
test / coverage (pull_request) Successful in 1m22s
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
2026-07-14 17:07:56 -04:00
didericis 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
2026-07-14 17:02:08 -04:00
didericis c276f7b0b1 feat(firecracker): add Linux microVM backend to replace smolmachines
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m7s
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
2026-07-11 10:32:55 -04:00