Commit Graph

270 Commits

Author SHA1 Message Date
didericis ce744a85c4 refactor(gateway): split data-plane files into egress/supervisor/git_gate services
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / integration-docker (pull_request) Successful in 17s
test / unit (pull_request) Successful in 49s
lint / lint (push) Failing after 2m49s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 18s
test / publish-infra (pull_request) Has been skipped
Group the gateway's data-plane modules into three service sub-packages
mirroring the host-side trio (bot_bottle.egress / .supervisor / .git_gate):

  gateway/egress/     addon_core, addon, dlp_config, dlp_detectors
  gateway/supervisor/ server            (was supervise_server)
  gateway/git_gate/   render, http_backend

Prefix-stripped filenames now that the package namespaces them; each
sub-package has a thin docstring __init__ (no eager imports, cheap leaf
loads). The two cross-cutting files stay at the gateway root:
policy_resolver (shared per-client lookup) and gateway_init, renamed to
bootstrap now that gateway/ already namespaces it.

Updated all importers (bot_bottle + tests), the in-VM/container `-m`
launch strings, the Dockerfile.gateway addon shim + ENTRYPOINT, and the
five gateway entries in scripts/critical-modules.txt. Full unit suite
green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:00:51 -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 eab9d15130 refactor(docker): fold run_docker into backend/docker/util; drop the docker_cmd shim
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-docker (pull_request) Successful in 13s
test / unit (pull_request) Successful in 45s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m17s
test / coverage (pull_request) Successful in 35s
test / publish-infra (pull_request) Has been skipped
docker_cmd.py existed as a top-level module solely so the orchestrator's docker
components could share run_docker without dragging the (then-heavy) backend
layer in. Now that backend/__init__ and backend/docker/__init__ are thin,
importing backend.docker.util costs 6 modules instead of 76, so the shim's whole
reason to exist is gone.

Move run_docker into backend/docker/util.py (where the other docker subprocess
primitives live) and delete docker_cmd.py. Backend siblings import it via
`from .util import run_docker`; the two docker-specific orchestrator modules
(docker_broker, rotate_ca) import `from ..backend.docker.util import run_docker`.
No import cycle (backend.docker.util pulls nothing from orchestrator);
orchestrator.__main__ stays lean at 28 modules. run_docker patch targets are
unchanged (tests patch it in the importing module).

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:58:23 -04:00
didericis 923d44bc09 refactor(backend): thin the docker/firecracker/macos-container package inits
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 53s
test / unit (pull_request) Failing after 2m5s
test / integration-firecracker (pull_request) Successful in 3m27s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
The three concrete-backend package inits eagerly imported their
`*BottleBackend` (which pulls the whole framework), so importing any leaf under
them (e.g. `backend.docker.util`) paid ~78 modules. Give each the same thin
`__getattr__` treatment as the top-level backend package: re-export the public
class(es) lazily on first access, with a TYPE_CHECKING block for checkers.

`import bot_bottle.backend.docker.util` drops from 78 -> 6 bot_bottle modules;
the docker/firecracker/macos-container packages themselves are ~3 each. All
`from bot_bottle.backend.docker import DockerBottleBackend` call-sites and the
lazy selection path keep working.

This is what makes backend leaves cheap — and unblocks moving docker_cmd into
backend/docker/util if we want.

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:53:48 -04:00
didericis 3e62f31d8b refactor(backend): split the heavy backend/__init__ into base + selection, thin the package init
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 38s
test / unit (pull_request) Successful in 52s
lint / lint (push) Successful in 1m1s
test / integration-firecracker (pull_request) Successful in 3m33s
test / coverage (pull_request) Successful in 17s
test / publish-infra (pull_request) Has been skipped
backend/__init__.py eagerly imported the whole framework (manifest, egress,
git-gate, env, workspace, agent-provider), so importing ANY backend.* submodule
paid ~32 modules just to run the package init. Split it:

  - backend/base.py     — the abstract contract (BottleSpec, BottlePlan,
                          BottleCleanupPlan, ExecResult, ActiveAgent, Bottle,
                          BottleImages, BottleBackend). Carries the framework
                          imports; loaded only when a caller needs the contract.
  - backend/selection.py — backend registry / selection / enumeration
                          (get_bottle_backend, _get_backends, _auto_select_backend,
                          has_backend, known_backend_names, enumerate_active_agents).
                          Concrete backends still imported lazily inside.
  - backend/__init__.py  — thin: a __getattr__ that resolves the public names
                          from those submodules on first access (+ a TYPE_CHECKING
                          block for checkers). Existing `from bot_bottle.backend
                          import X` and patch.object call-sites keep working.

`import bot_bottle.backend` drops from 32 -> 2 bot_bottle modules. Repointed the
backend-selection test's patch targets to the `selection` module (the functions
reference each other there now; the FirecrackerBottleBackend class-method
patches are unchanged — same class object).

Note: backend.docker.util is still heavy because backend/docker/__init__ eagerly
imports DockerBottleBackend — slimming the three sub-package inits is the
follow-up that makes the leaves cheap.

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:47:29 -04:00
didericis b96a8b44e0 refactor(docker): rename OrchestratorService -> DockerInfraService, move to backend/docker
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Successful in 1m7s
test / unit (pull_request) Successful in 2m10s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 15s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 13m2s
OrchestratorService wasn't the orchestrator — it's the host-side lifecycle of
the docker infra *container* (the one that runs the Orchestrator). It read as
"the orchestrator as a service" and lived in orchestrator/lifecycle.py, while
its siblings (macOS MacosInfraService, Firecracker infra_vm) live under their
backend package. Rename it DockerInfraService and move it to
backend/docker/infra.py alongside the docker backend, with its docker-only
constants (INFRA_*/ORCHESTRATOR_* image + container names, daemon list, mount
paths).

orchestrator/lifecycle.py keeps only the backend-neutral pieces the other infra
services share — DEFAULT_PORT, DEFAULT_STARTUP_TIMEOUT_SECONDS,
OrchestratorStartError, source_hash — which macOS / firecracker / client still
import from there. backend/docker/infra.py imports those (backend -> orchestrator
is an allowed direction). Renamed the unit test to test_docker_infra.py.

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:26:52 -04:00
didericis 3e2cbcab88 refactor(supervise): fold Supervise into the facade as Supervisor; drop dead flat-fallbacks
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-docker (pull_request) Successful in 12s
test / unit (pull_request) Successful in 43s
lint / lint (push) Failing after 59s
test / integration-firecracker (pull_request) Successful in 3m16s
test / coverage (pull_request) Successful in 16s
test / publish-infra (pull_request) Has been skipped
Move the `Supervise` lifecycle out of its own `orchestrator/supervisor/
supervise.py` and into the package `__init__`, renaming the class to
`Supervisor`. Callers now import it from `bot_bottle.orchestrator.supervisor`
alongside the queue surface it belongs with.

Remove the dead `try/except ImportError` flat-import fallbacks from the
package-only store modules (db_store, audit_store, config_store, queue_store)
and image_cache. Those fallbacks existed for when the store files were
flat-copied into the gateway; post-PRD-0070 the data plane never opens the DB,
so these modules are only ever imported as part of the package. The two gateway
data-plane files that may still be loaded flat (egress_addon_core,
git_gate_render) keep their fallbacks.

Full unit suite green (2251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:24:06 -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 f77023db1d refactor(gateway): move the data-plane daemons into a bot_bottle.gateway package
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 7s
Separate the gateway (data plane) from the orchestrator (control plane) at the
module level. The gateway runtime files move out of the package root — and the
backend-neutral Gateway lifecycle ABC + GATEWAY_* constants move out of
orchestrator/ — into a new bot_bottle/gateway/ package:

  gateway/__init__.py      (was orchestrator/gateway.py: Gateway ABC + consts
                            + rotate_gateway_ca)
  gateway/gateway_init.py  (the PID-1 daemon supervisor)
  gateway/egress_addon.py, egress_addon_core.py, egress_dlp_config.py,
          dlp_detectors.py            (the egress mitmproxy daemon)
  gateway/git_http_backend.py         (the git-http daemon)
  gateway/git_gate_render.py          (the git-gate pre-receive rendering)
  gateway/supervise_server.py         (the supervise MCP daemon)
  gateway/policy_resolver.py          (the data-plane control-plane RPC client)

orchestrator/ now holds only control-plane files. The shared plan/types/auth
layer (egress.py=EgressPlan, git_gate.py=GitGatePlan, supervise.py,
supervise_types.py, control_auth.py) and the launch-time git-gate provisioning
helpers stay at root, so orchestrator/ and backend/ still own them.

Because these daemons are invoked as `python3 -m bot_bottle.<name>`, loaded flat
by mitmproxy, and referenced in Dockerfile.gateway, the move updates more than
Python imports: the `-m` invocations (firecracker/macOS infra scripts), the
Dockerfile.gateway addon shim + ENTRYPOINT, gateway_init's _DAEMONS module
paths, and the git-gate CGI heredocs all now point at bot_bottle.gateway.*.

No behavior change; full unit suite green (2251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:25:10 -04:00
didericis dfce3d9505 refactor(gateway): move DockerGateway to the backend layer, drop the dead standalone-gateway path
lint / lint (push) Successful in 54s
The `DockerGateway` container-lifecycle impl now lives in
`backend/docker/gateway.py` (the shape backend gateway classes will share);
`orchestrator/gateway.py` keeps only the backend-neutral pieces (the `Gateway`
ABC, constants, `rotate_gateway_ca`).

Delete the standalone-gateway path it was the only consumer of. `--gateway` on
`python -m bot_bottle.orchestrator` was invoked nowhere — the production docker
flow runs the gateway data plane inside the combined `bot-bottle-infra`
container via `OrchestratorService`, never this class. Removing it takes with it
`Orchestrator.ensure_gateway()` and the `gateway` ctor arg; `gateway_status()`
becomes a stub reporting `configured: false` so the documented `GET /gateway`
control-plane route keeps its contract.

Fix a latent bug the move surfaced: `launch.py` read the shared gateway CA via
`docker exec bot-bottle-orch-gateway`, a container the consolidated flow never
creates — so the agent CA install was reaching a nonexistent name. Read it from
`bot-bottle-infra` (INFRA_NAME) instead.

Repoint the affected tests to the new module; drop the unit test + fake that
covered the deleted standalone wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:39:58 -04:00
didericis-claude aac27d8a40 fix(firecracker): provision role-scoped control-plane auth in the infra VM
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / integration-docker (pull_request) Successful in 18s
test / unit (pull_request) Failing after 45s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m29s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
The Firecracker infra VM started the control plane with no signing key, so it
ran open and granted every unauthenticated caller the `cli` role. The nft
boundary only fences off the separate agent VM — the egress / supervise /
git-http daemons run in this same VM and reach the orchestrator over 127.0.0.1,
so a compromised data-plane daemon could still drive the operator routes
(approve its own supervise proposals, rewrite policy, read injected tokens).

Generate the signing key on the persistent volume, hand it only to the
orchestrator process, mint a `gateway` JWT for the data-plane daemons, and
mirror the key back to the host token file so the CLI signs `cli` tokens the VM
verifies — the same per-process scoping the docker / macOS launchers use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 07:03:05 +00:00
didericis-claude 1db2a9eb67 feat(control-plane): role-scoped signed tokens so the gateway can't drive operator routes
test / integration-docker (pull_request) Successful in 17s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / unit (pull_request) Successful in 39s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
Review follow-up on #469: the data plane held the same control-plane secret
that authorizes every route, so a compromised egress/git-gate could queue a
supervise proposal AND approve it (or rewrite policy, read injected tokens) —
the (source_ip, identity_token) checks attribute the *bottle*, not the caller.

Replace the single shared bearer secret with role-scoped, HMAC-signed tokens
(compact HS256 JWTs, stdlib-only — no new dependency):

  * new `control_auth` mints/verifies `{role}` tokens; roles are `gateway`
    (data plane) and `cli` (host operator/launcher).
  * the orchestrator holds only the signing *key* and verifies; `dispatch`
    gates each route by role — `gateway` reaches /resolve + /supervise/
    {propose,poll}, everything else is `cli`-only (401 unauthenticated,
    403 wrong role).
  * the gateway is handed a pre-minted `gateway` token it cannot rewrite into
    `cli`; the host CLI mints its own `cli` token from the host key.
  * `gateway_init` scopes the signing key to the orchestrator process and the
    gateway token to the data-plane daemons, so even in the combined infra
    container a compromised data-plane daemon never sees the key.

Launchers (docker gateway + infra, macOS infra) inject the minted token(s);
Firecracker stays open behind its nft boundary. Open mode (no key) still grants
full `cli` access — the fail-visible fallback for tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 05:22:56 +00:00
didericis-claude 2c496dc3d0 fix(supervise): reach the queue over RPC, get bot-bottle.db off the data plane
PRD 0070's rule — only the orchestrator opens bot-bottle.db; the data
plane reaches state through the control-plane RPC — was not in force.
Three data-plane daemons held a direct read-write handle on the shared
SQLite file: the supervise MCP server, the egress DLP addon (the most
attack-exposed process, TLS-bumping hostile traffic), and the git-gate
pre-receive hook. An RCE in any of them could read every bottle's
plaintext identity_token and forge attribution fleet-wide (issue #469).

Add the agent half of the supervise flow to the control plane:

  POST /supervise/propose  -> queue a proposal, 201 {proposal_id}
  POST /supervise/poll     -> non-blocking decision poll, 200 {status,...}

Both attribute the caller by (source_ip, identity_token) exactly like
/resolve — never a caller-supplied slug — so a bottle can only ever queue
or read its own proposals even if the data plane is compromised. A decided
poll archives server-side, preserving the archive-after-read contract.

Data plane: the supervise server, egress addon, and git-gate hook now
queue/poll through PolicyResolver.propose_supervise / poll_supervise
instead of opening the DB. supervise_server keeps its ~30s grace window
by polling the RPC; egress keeps its safelist keyed by resolved bottle;
the git-gate hook gets (source_ip, identity_token) from the CGI env.

Packaging: drop the DB bind-mount and SUPERVISE_DB_PATH from the
data-plane containers/VMs (docker gateway + infra, macOS infra,
firecracker infra). The orchestrator remains the sole opener of the one
file via BOT_BOTTLE_ROOT / host_db_path().

Update PRD 0070: the rule is now in force; remove the transitional caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 02:49:51 +00:00
didericis-codex 854f6b5696 fix(secrets): recover encrypted tokens on all backends 2026-07-23 21:39:07 +00:00
didericis-claude 9014c07b86 feat(secrets): encrypt egress tokens at rest with per-bottle ENV_VAR_SECRET
Implements the interim secret-provider design (PRD prd-new-secret-provider):
each agent receives a random ENV_VAR_SECRET injected into its container env
at launch. The host uses this key to encrypt each egress auth token value
(HMAC-SHA256 CTR mode, stdlib-only) and store it in a new
bottled_agent_secrets table (one row per env var, key column plaintext for
auditing). The key never touches the DB.

On infra container restart the in-memory token map is lost. launch_consolidated
now calls _reprovision_running_bottles after ensure_running: for each
registered bottle still alive on the gateway network it execs
`printenv ENV_VAR_SECRET` into the agent container and posts the result to the
new POST /bottles/<id>/reprovision_gateway control-plane endpoint, which
decrypts the stored rows and restores _tokens — no manual intervention needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 21:38:33 +00:00
didericis-claude 9a04ab262b review: remove security-boundary note, move podman into nested-containers layer
test / integration-docker (pull_request) Successful in 19s
test / unit (pull_request) Failing after 39s
lint / lint (push) Successful in 56s
tracker-policy-pr / check-pr (pull_request) Failing after 13m51s
test / integration-firecracker (pull_request) Failing after 14m1s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
- Drop the module-level paragraph emphasizing the single-UID security
  boundary (#issuecomment-5010): redundant given the safe environment.
- Move `podman` from each agent image (claude, codex, pi) into the
  `build_image` derived layer so bottles without `nested_containers`
  pay no image-size cost (#issuecomment-5013).
- Update `build_image` docstring and inline comments to reflect the move.
- Add TODO(#394) noting the planned docker-layer abstraction refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 17:30:58 +00:00
didericis dba48706de fix(macos): put the gateway address in the Docker CLI proxy config
This is the layer that actually decides it, and it was in the script
from the start: ~/.docker/config.json's proxies block is what the Docker
CLI copies into every container it starts. Being client-side it beats
the podman service outright, which is why containers.conf `env`,
http_proxy=false, and the service environment each looked correct on
disk and changed nothing a nested container saw.

Also corrects the comment on the service-env substitution, which
claimed to be the one place the address sticks. It is not; it covers
podman's own pulls and non-CLI API clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis 854a8956ad fix(macos): substitute the gateway address before the service starts
Third attempt at the same symptom, and the first at the right layer.
podman's Docker-compatible API injects proxy settings from the *daemon*
environment after containers.conf is applied, so neither the env list
(892bfc16) nor http_proxy=false (21c144ae) could override it — the same
compat-path blind spot already seen with hosts_file. Nested containers
kept reporting `wget: bad address 'bot-bottle-gateway:9099'` through
both.

Substituting the resolved address into the service's own environment,
before `podman system service` starts, is the one place it sticks.
NO_PROXY keeps the name, which is matched against what a client asks
for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis 7a9628fc03 fix(macos): stop podman re-injecting the gateway name into containers
The address-bearing proxy URLs from 892bfc16 were written correctly but
never took effect: podman copies the host's proxy environment into every
container by default, and that copy overrides containers.conf `env`,
restoring the `bot-bottle-gateway` name a nested container cannot
resolve. Verified on the macOS host — containers still reported
`wget: bad address 'bot-bottle-gateway:9099'` with the addresses in
place.

Setting http_proxy=false disables only podman's own passthrough; the
proxy vars still reach containers, by address, from the env list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis ca8b2a9f2c fix(macos): reach the egress proxy by address in nested containers
podman's containers.conf `hosts_file` turns out to apply only to native
`podman run`; the Docker-compatible API ignores it, and the agent types
`docker`. (`base_hosts_file`, used before this, is a no-op in both
paths on podman 5.4.2.) So no config key can put the gateway name in a
nested container's /etc/hosts.

The name is therefore resolved in the bottle, where /etc/hosts does
carry it, and the address goes into the proxy URL handed to nested
containers. NO_PROXY keeps the name, since that is matched against what
a client asks for.

Verified on the macOS host: with the gateway reachable, a nested
container gets 200 from an allowlisted host and 403 from one that is
not — the egress boundary applies inside nested containers too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis 82cf9bab5a feat(macos): give nested containers working egress
Answers the last open question in #392: DNS from inside a nested
container was not a phantom, but it was also not a DNS problem. Public
resolution fails there by design — everything egresses through the
proxy — and the actual breakage was two missing pieces:

- The proxy URL names `bot-bottle-gateway`, which resolves only through
  the bottle's own /etc/hosts. Containers got their own hosts file, so
  they failed at "Could not resolve proxy", which reads like broken DNS.
  base_hosts_file propagates the bottle's entries.
- The gateway TLS-intercepts, so a container that does not trust the
  bottle's CA bundle fails with "unable to get local issuer
  certificate". The bundle is now mounted read-only and the usual env
  vars point at it, which covers curl, wget, python, and node without
  distro-specific trust commands.

Verified on the macOS host by reproducing each failure and confirming
that these three conditions applied by hand produced HTTP 200 from
inside a nested container. podman already forwards the proxy env, so
that needed nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis 3c92e79775 fix(macos): install podman 5's full networking stack
netavark shells out to `nft` for the bridge network every compose file
expects, and aardvark-dns serves name resolution inside nested
containers. Neither arrives with the base image's
`--no-install-recommends` podman, and each fails at a different and
misleading layer: without nft containers are created but never start;
without aardvark-dns DNS inside a container fails while the engine, the
pulls, and the bridge all look healthy. That last one is the likeliest
explanation for the "DNS inside nested containers fails entirely" note
in #392 — a missing package, not a design gap.

Verified on the macOS host: with passt alone, `docker run` reached
netavark and died on "unable to execute nft".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis 220620bfcc fix(macos): install passt for podman 5 rootless networking
podman 5 uses pasta as the default rootless network helper; podman 4
used slirp4netns. The spike was written against bookworm's podman 4.3.1,
so its package list never included passt — and the trixie bump (#451)
changed the default out from under it. Every nested container failed to
start with "could not find pasta", while image pulls and the service
itself worked, which disguised a missing dependency as a compat-API bug.

The bootstrap now checks for pasta up front so this fails with a clear
message instead of surfacing at the first `docker run`.

Note the sun_path fix in a12d2191 did NOT cause or cure this; that path
was over the 108-byte limit independently and would have bitten as soon
as attach was reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis b0f012b8e6 fix(macos): shorten the podman runtime dir to fit sun_path
podman derives conmon's attach socket as
`$XDG_RUNTIME_DIR/libpod/tmp/socket/<64-hex-id>/attach`. With the
descriptive `/tmp/bot-bottle-podman-run` that path is 116 bytes, past
the 108-byte sun_path limit, so every attached `docker run` failed with
"unable to upgrade to tcp, received 500" — while image pulls and the
service itself worked, which is what made it look like a compat-API bug
rather than a path-length one.

Found on the macOS host: quay.io pulled fine and then no container
could be started. Test pins the budget so a more readable name cannot
silently reintroduce it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis d0b595828f feat(macos): run containers inside a bottle via guest-local podman
Adds the `nested_containers` bottle flag. On the macOS backend it starts
a rootless podman service inside the bottle and exposes its
Docker-compatible API socket, so the agent still runs `docker` and
`docker compose`. No host daemon socket is mounted and the guest gains
no capabilities; backends that cannot run a guest-local engine reject
the flag in the shared prepare template rather than ignoring it.

Podman rather than rootless Docker because Apple Container's capability
bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a
multi-range uid_map via newuidmap. With no subordinate UID range podman
falls back to a single-UID self-mapping an unprivileged process may
write itself, so the image build strips /etc/subuid and /etc/subgid
entries rather than adding them.

That mapping is also why nested containers are not an isolation layer:
root inside one is the agent user outside it. They are a build/test
convenience; the bottle remains the boundary.

Ports the spike branch onto main, renaming docker_access — it implied
Docker and granted access to nothing on the host — and drops podman
from the derived layer now that every built-in image ships it (#451).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:54:26 -04:00
didericis cfb2284b99 fix(firecracker): make cleanup reap orphans only, never live VMs
`cleanup()` enumerated *every* firecracker process under the run root and
*every* run dir, so `./cli.py cleanup` would kill running bottles and
delete their rootfs — despite being described as orphan cleanup. The
backend's `enumerate_active` registry is still a stub (#354), so there
was no live/dead signal.

Use the running firecracker processes themselves as that signal: a run
dir with a live VM is protected (never killed, never removed); only run
dirs with no live process (leaked by a hard-killed launch) are reaped,
plus firecracker pids whose run dir is already gone (lingering VMMs).

This makes cleanup safe to run alongside live bottles and lets it back a
periodic GC for the run-dir leak the launch.py teardown fix closes on the
clean-exit path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:47:44 -04: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 2cd44cf79a fix(macos): persist gateway CA on host
test / integration-docker (push) Successful in 19s
Update Quality Badges / update-badges (push) Failing after 42s
lint / lint (push) Successful in 52s
test / unit (push) Successful in 1m43s
test / integration-firecracker (push) Successful in 4m56s
test / coverage (push) Successful in 17s
test / publish-infra (push) Successful in 1m49s
2026-07-21 15:01:38 -04:00
didericis-codex 95220b4808 fix(firecracker): restore agent home ownership at boot 2026-07-21 14:52:44 -04:00
didericis-codex b032562d74 fix(firecracker): pin the provisioned Git config 2026-07-21 14:52:44 -04:00
didericis-claude 17ac1be93b fix: return None from image_created_at when timestamp is absent
FROM-scratch images (commit_container output) and registry images built
for reproducibility often omit the created field entirely. Both backends
were calling die() in that case, crashing the cached-image quickstart
before any container started.

image_created_at now returns datetime | None — None when the field is
absent or unparseable, die() only on real inspect failures (non-zero
exit, malformed JSON). stale_checks in both backends skips the staleness
check when None is returned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 13:56:29 -04:00
didericis-codex c53254e9d5 fix(firecracker): honor cached image policy 2026-07-21 13:56:29 -04:00
didericis-claude 310b36196d fix(lint): remove unused BottleImages imports from two test files
pyright strict reportUnusedImport flagged BottleImages in
test_docker_launch_committed_image.py and test_macos_container_launch.py;
neither file references the type by name (they only use the returned
value's attributes).
2026-07-21 13:56:29 -04:00
didericis-claude c473e5e5d8 fix: remove redundant quoted annotations in BottleImages and abstract methods
`from __future__ import annotations` already defers all annotation
evaluation, so quoting `str | Path`, `BottleImages` inside the same
module was redundant and tripped pyright strict mode.
2026-07-21 13:56:29 -04:00
didericis-claude 137426d9ac refactor: BottleImages dataclass, prelaunch_checks, build_or_load_images
Addresses review comments 3098, 3099, 3100 on PR #336:

- Add BottleImages(agent, sidecar) dataclass to backend/__init__.py.
  Docker/macOS backends use str image refs; smolmachines uses Path
  artifacts. Replaces the singular `image` variable from the canonical
  pattern in comment 3100.

- Replace _image_stale_checks/skip_stale with public prelaunch_checks().
  CLI now calls backend.prelaunch_checks(plan) before backend.launch(plan);
  if StaleImageError is raised and the operator confirms, launch proceeds
  without re-checking. Removes the while-True/skip_stale retry loop.

- Add abstract _build_or_load_images(plan) -> BottleImages to
  BottleBackend. launch() calls it then passes images to _launch_impl.
  Each backend implements both methods.

- Fix comment 3098 (macos-container): _build_images is removed.
  build_or_load_images() has separate fresh/cached code paths — the
  cached path never calls a build helper.

- Update _start_bundle (smolmachines) to accept sidecar_artifact: Path
  directly. Sidecar artifact resolution moves to _sidecar_from_path(),
  called by build_or_load_images alongside _agent_from_path().
2026-07-21 13:56:29 -04:00
didericis-claude 0f1734b823 refactor(image-cache): replace warn_if_stale with StaleImageError; add launch template
- image_cache: StaleImageError exception + check_stale/check_stale_path (raise instead of warn)
- BottleBackend.launch: template method (skip_stale flag) that calls _image_stale_checks then _launch_impl
- Each backend: _image_stale_checks delegates to a stale_checks() function in its launch module; _launch_impl replaces launch override
- macos_container: adds image_created_at to util, cached-image support in _build_images, stale_checks
- cli/start.py: catches StaleImageError, prompts interactively, retries with skip_stale=True; headless mode dies on it
2026-07-21 13:56:29 -04:00
didericis-codex d3428b8c14 Add cached image quickstart 2026-07-21 13:56:29 -04:00
didericis-claude 0c91c75a05 fix(ci): use COVERAGE_FILE env var for reliable artifact paths; add --reuse-published to infra build
coverage run's --data-file flag can be overridden or ignored in some
runner environments (Nix Python, older act-based runners). Switching to
the COVERAGE_FILE env var with an absolute ${{ github.workspace }} path
ensures coverage.py writes to a known location in every runner context,
so upload-artifact can find the file.

Also adds --reuse-published to the infra build step: if the artifact for
this content hash already exists in the registry, download it instead of
running the full docker build → mke2fs → gzip pipeline.
2026-07-21 12:43:26 -04:00
didericis-claude 8e43c26ab4 fix(backend): extract poll_ca_cert helper and fix PRD port docs
Extract the shared CA cert polling loop into `backend/util.poll_ca_cert`
(firecracker and macos backends were duplicating deadline/sleep/raise logic).
Each caller now wraps a fetch lambda and converts TimeoutError to its own
error type. Also corrects the PRD port publication line from {port}:{port}
to {host_port}:8099.
2026-07-21 04:54:40 +00:00
didericis-claude 2f45f5afec feat(docker): consolidate to single infra container under gateway_init supervise tree
Collapses the two-container Docker model (gateway + orchestrator) into one
bot-bottle-infra container, matching the macOS and Firecracker backends.

- Dockerfile.infra: now a shared gateway+orchestrator base (COPY bot_bottle
  from orchestrator build, no CMD override)
- Dockerfile.infra.fc: new Firecracker-specific layer (buildah/crun/netavark)
- gateway_init: adds orchestrator daemon with _OPT_IN_DAEMONS gating so it
  only starts when BOT_BOTTLE_GATEWAY_DAEMONS explicitly includes it
- orchestrator/lifecycle: OrchestratorService manages one infra container;
  builds orchestrator (intermediate) then infra; live source bind-mounted at
  /bot-bottle-src with PYTHONPATH so the subprocess uses the checkout
- backend/consolidated_util: extracts provision_bottle + teardown_consolidated
  shared across all three backends; removes duplication in docker/fc/macos
  consolidated_launch modules
- firecracker/infra_vm: builds four images (orchestrator→gateway→infra→infra.fc)
- All unit tests updated and passing (1878 tests)
- PRD status: Draft → Active
2026-07-21 04:54:40 +00: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-claude 571030b8e8 fix: require authoritative container snapshot before reconciling
test / stage-firecracker-inputs (pull_request) Successful in 5s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 33s
test / unit (pull_request) Successful in 39s
lint / lint (push) Successful in 49s
test / build-infra (pull_request) Successful in 3m23s
test / integration-firecracker (pull_request) Successful in 1m42s
test / coverage (pull_request) Failing after 1m26s
test / publish-infra (pull_request) Has been skipped
A failed container list or a partial per-container inspect were
previously indistinguishable from a legitimately empty/partial live
set, so reconciliation could silently unregister healthy bottles.

- enumerate_active() now raises EnumerationError instead of returning []
  when `container list` fails
- Add inspect_container_network_ip() to util, which returns None on
  inspect failure (vs "" for "no DHCP address yet"), so live_source_ips
  can tell the two apart
- live_source_ips() raises EnumerationError on either failure mode;
  register_agent() catches it and skips reconciliation, same as
  OrchestratorClientError
- Update tests: rename test_empty_when_the_cli_fails to
  test_raises_when_the_cli_fails, update patching to the new function,
  add coverage for list failure, per-container inspect failure, and the
  launch-not-blocked path
2026-07-21 03:39:18 +00:00
didericis e4d53fd360 fix(orchestrator): reap registry rows whose bottle is no longer running
A registry row only ever left the registry two ways: an explicit
teardown_bottle (the launcher's cleanup callback) or the same-IP supersede
sweep in register(). Neither runs when the launching CLI dies hard, so the
row outlives its container.

That orphan is not inert. Source IPs are recycled by the backend's DHCP and
by_source_ip fail-closes on ambiguity, so a leftover row at a reused address
resolves no policy at all for the next bottle that lands there — and a
bottle with no policy denies every host, which surfaces to the agent as
"host X is not in the allowlist" for hosts that were never the problem.

Add reap_absent/reconcile and call it from the macOS launch path before
registering, so each launch self-heals the registry. Restores the invariant
the data plane needs: at most one active row per live address, and none for
a dead one. The second half matters as much as the first — when several rows
claim a *live* address the newest wins and the rest are swept, otherwise a
recycled address stays ambiguous, which is exactly the bricked state.

The host supplies the live set because the orchestrator runs inside the
infra container and cannot see the backend. A grace window exempts rows
younger than it, so reconciliation cannot race a bottle still coming up, and
a reconcile failure is logged rather than blocking an otherwise-fine launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:27:16 +00:00
didericis 2f8539c2c7 fix(macos): name the gateway instead of addressing it, so bottles survive it moving
test / integration-docker (pull_request) Successful in 14s
test / unit (pull_request) Successful in 38s
tracker-policy-pr / check-pr (pull_request) Successful in 25s
lint / lint (push) Successful in 49s
test / stage-firecracker-inputs (pull_request) Successful in 5s
test / build-infra (pull_request) Successful in 3m31s
test / integration-firecracker (pull_request) Successful in 1m51s
test / coverage (pull_request) Failing after 1m33s
test / publish-infra (pull_request) Has been skipped
The shared gateway's address is DHCP-assigned and changes whenever the infra
container is recreated — a source-hash bump, an image upgrade, a crash. Every
agent-facing URL embedded that address, and the proxy URL reaches the agent
as process environment at `container exec` time. A running process's environ
cannot be rewritten from outside, so a moved gateway stranded every running
bottle permanently: not degraded, unreachable, until relaunched and its agent
session thrown away.

Give the agent a stable name instead. `GATEWAY_HOSTNAME` replaces the address
in the egress proxy URL, NO_PROXY, git-http, and supervise URLs, and resolves
through the bottle's own /etc/hosts. Unlike environ that is a file, so it can
be rewritten inside a container that is already running — which is the whole
point: a gateway that returns at a new address is picked up by live bottles.

Launch writes the entry before anything execs (every agent URL names the
gateway, so it must resolve for the first connection), and re-points every
running bottle once the gateway is up, so one stranded by an earlier restart
re-attaches instead of needing a relaunch.

The write needs root and the agent runs as `node`: the host can repoint a
bottle's gateway name, the agent cannot repoint its own. Keep that asymmetry.

Apple Container 1.0 has no container-name DNS on a user network and
`container run` has no --add-host, so the entry is written by exec after
start rather than declared at run.

Does not address the other half of #443: per-bottle egress auth tokens are
held in memory by the orchestrator and are still lost across a restart, so a
re-attached bottle resolves its policy but not its injected credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:08:59 -04:00
didericis ad100b8a84 fix(macos): supply proxy env only at exec, so Codex keeps its identity token
test / stage-firecracker-inputs (push) Successful in 4s
test / integration-docker (push) Successful in 17s
test / unit (push) Successful in 34s
lint / lint (push) Successful in 46s
Update Quality Badges / update-badges (push) Failing after 35s
test / build-infra (push) Successful in 9m18s
test / integration-firecracker (push) Successful in 1m46s
test / coverage (push) Successful in 2m3s
test / publish-infra (push) Successful in 2m31s
`container exec --env` does not override the run-time environment on Apple
Container — it appends. The launch path baked a token-less `*_PROXY` into
`container run` and relied on the exec-time, token-bearing value superseding
it, so the agent's `environ` ended up with two `HTTPS_PROXY` entries,
token-less first.

Which entry a runtime reads is then luck. Node reads the last, so Claude
bottles picked up the token and worked. Rust's `std::env::var` reads the
first, so Codex proxied with no identity token at all; `/resolve` requires a
matching (source_ip, identity_token) pair and fail-closes, so every request
from a Codex bottle was denied — its model calls, its `wss://` reconnects,
and its MCP servers alike. The registry row was correct the whole time,
which is what made this read as a registration bug.

Drop the run-time proxy vars entirely. A token-less proxy URL has no
legitimate consumer: the init process is `sleep` and everything that
egresses arrives by exec. Its only benefit was a tidy 403 for unattributed
callers, which is not worth silently dropping attribution for — and a
process that egresses before the exec-time env still fails closed, since the
agent network is host-only and the gateway is the only route off it.

Also corrects the two comments that asserted the false "exec --env wins"
invariant, so the next reader doesn't rebuild the same assumption.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:54:52 -04:00
didericis-claude d9e685e860 fix(backend): don't prompt in headless/non-interactive backend selection
lint / lint (push) Failing after 45s
test / unit (pull_request) Successful in 1m34s
test / integration-docker (pull_request) Successful in 12s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
test / stage-firecracker-inputs (pull_request) Successful in 3s
test / build-infra (pull_request) Successful in 3m21s
test / integration-firecracker (pull_request) Successful in 1m30s
test / coverage (pull_request) Successful in 1m30s
test / publish-infra (pull_request) Has been skipped
_auto_select_backend gains a prompt parameter (default True). When
prompt=False the docker-fallback [i/d/q] menu is skipped and the call
dies immediately with an actionable message ("set
BOT_BOTTLE_BACKEND=docker or install a VM backend"), preventing hangs
in CI, webhook dispatch, and orchestrator launches.

prepare_with_preflight passes prompt=not spec.headless so the headless
start path can never block waiting for TTY input it cannot receive.
2026-07-20 19:58:14 +00:00
didericis-claude b4b73a8acc refactor: consolidate read_tty_line into bot_bottle/util.py
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Successful in 26s
test / unit (pull_request) Successful in 36s
lint / lint (push) Failing after 42s
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / build-infra (pull_request) Successful in 3m57s
test / integration-firecracker (pull_request) Successful in 1m55s
test / coverage (pull_request) Successful in 2m14s
test / publish-infra (pull_request) Has been skipped
Remove the private _read_tty_line duplicate from backend/__init__.py and
the local definition from cli/_common.py. Both now import from the
shared bot_bottle.util module.
2026-07-20 19:44:32 +00:00
didericis-claude 8b5b5730ae fix: remove CLI backend assumptions and add docker fallback prompt
test / stage-firecracker-inputs (pull_request) Successful in 3s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
lint / lint (push) Successful in 42s
test / integration-docker (pull_request) Successful in 33s
test / unit (pull_request) Successful in 37s
test / build-infra (pull_request) Successful in 3m47s
test / integration-firecracker (pull_request) Successful in 1m39s
test / coverage (pull_request) Failing after 1m27s
test / publish-infra (pull_request) Has been skipped
- Remove hardcoded --backend=macos-container flag reference in
  firecracker/util.py require_firecracker() error message
- Remove --backend flag from cli.py start; backend selection now
  driven exclusively by BOT_BOTTLE_BACKEND env var or auto-selection
- Skip unavailable backends in cli.py cleanup (fixes crash on Linux
  when macos-container.prepare_cleanup calls require_container())
- Add two-tier auto-selection: VM backend first (macos-container on
  macOS, firecracker on Linux+KVM); fall back to docker with a
  security warning and interactive i/d/q prompt; exit if docker
  also unavailable and print VM install instructions

Closes #344
2026-07-20 19:13:08 +00:00