Compare commits

..

17 Commits

Author SHA1 Message Date
didericis f754c575d7 feat(orchestrator): slice 6 — source-IP-keyed multi-tenant policy (#352)
lint / lint (push) Successful in 2m1s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m16s
The orchestrator side of the multi-tenant consolidated sidecar: hold each
bottle's sidecar policy and serve it by verified source IP, with live
reload. One shared sidecar can now get per-bottle config keyed on who's
calling.

  * registry.py — a `policy` column (migration v3, opaque JSON the sidecar
    interprets) on BottleRecord; `register(..., policy=)` stores it,
    `set_policy(bottle_id, policy)` updates it live, and `attribute` returns
    it (the source-IP-keyed resolution).
  * service.py — `launch_bottle(..., policy=)` and `set_policy`.
  * control_plane.py — `POST /bottles` accepts `policy`; `PUT
    /bottles/<id>/policy` live-reloads it; `POST /resolve` returns
    {bottle_id, policy} for a verified (source_ip, token) — the per-request
    call the multi-tenant sidecar makes; `/attribute` stays identity-only.

Scope note: this is the control-plane / state half. The data-plane half —
the egress mitmproxy addon (and git-gate) selecting allowlist / DLP /
token-injection per client IP by calling `/resolve` — is the next slice
(route agent bottles through the shared sidecar). The orchestrator stays
policy-agnostic: it stores and serves the blob verbatim.

Tests: registry policy store/update/persist; Orchestrator launch-with-policy
+ live set_policy; control-plane resolve returns policy (403 on bad token),
PUT policy updates / 404 / 400. Verified live over HTTP (launch -> resolve
-> PUT reload -> resolve reflects). Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:09:32 -04:00
didericis a092d00312 feat(orchestrator): slice 5 — build the consolidated sidecar bundle image (#352)
lint / lint (push) Successful in 2m7s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m12s
Answers "where do we build the consolidated sidecar": nowhere, until now.

  * sidecar.py — `Sidecar.ensure_built()` (default no-op) + `DockerSidecar`
    now defaults its image to the real bundle (`bot-bottle-sidecars`) and
    `ensure_built()` builds it from `Dockerfile.sidecars` when
    `docker image inspect` shows it's missing (no-op when present or when no
    dockerfile is configured, e.g. a pre-pulled image). `image_exists()`
    added.
  * service.py — `ensure_sidecar()` now builds then runs.
  * __main__.py — `--sidecar` runs the consolidated bundle (build-if-missing).

Scope note: this builds + launches the bundle *container*; making the
running instance functional across bottles needs the per-bottle,
source-IP-keyed multi-tenant config + registration/reload, and routing
agent bottles to it — the next slices (added to PRD 0070's roadmap).

Tests: unit (docker mocked) — image_exists, ensure_built builds when
missing / no-op when present / no-op without a dockerfile / raises on build
failure; ensure_sidecar builds-then-runs; integration (gated, no heavy
build) — image_exists reflects real docker state. Full suite green (only
pre-existing /bin/sleep errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:00:18 -04:00
didericis 67652899eb refactor(orchestrator): move run_docker to a lean top-level docker_cmd (#352)
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m7s
Review feedback: don't bury a parallel docker util in the orchestrator.
But reusing backend.docker.util (docker_mod) isn't right either — importing
it runs backend/__init__.py, which eagerly loads all three backends
(docker + firecracker + macos) plus the manifest/egress/git-gate/supervise
framework (~76 modules), so every orchestrator import would drag the whole
backend layer in.

Compromise: promote the helper to a top-level, framework-free
bot_bottle/docker_cmd.py (single stdlib import), a proper shared home the
orchestrator's docker components use now and backend.docker.util can adopt
later. Verified `import bot_bottle.orchestrator` stays lean (12 modules, no
firecracker/macos backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 16:24:37 -04:00
didericis f85cbdeebf feat(orchestrator): slice 4 — consolidated per-host sidecar (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m7s
The core consolidation win: one persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. Safe to share
because the attribution invariant (source IP + identity token) lets the
sidecar map each request to the right bottle.

  * orchestrator/sidecar.py — a backend-neutral `Sidecar` lifecycle
    contract (mirrors LaunchBroker) + a `DockerSidecar` impl. The defining
    behaviour is idempotent singleton: `ensure_running` starts the instance
    if absent and is a no-op if it's already up, so N launches never spawn
    N sidecars; `stop` is idempotent.
  * orchestrator/dockerutil.py — a shared `run_docker` helper; DockerBroker
    now uses it too (DRY with slice 3).
  * service.py — the Orchestrator holds an optional `Sidecar`, exposes
    `ensure_sidecar()` + `sidecar_status()`.
  * control_plane.py — `GET /sidecar` reports it; __main__ gains
    `--sidecar-image` and ensures the single sidecar on startup.

Tests: unit (docker mocked) — is_running, ensure idempotent (no-op when up,
starts when absent), failure raises, stop idempotent; Orchestrator sidecar
wiring/status; control-plane /sidecar; integration (gated) — ensure is a
real idempotent singleton (one container after two ensures), stop removes.
Full suite green (only pre-existing /bin/sleep errors); integration
verified locally against real docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:28:29 -04:00
didericis 44e611d14e fix(orchestrator): pyright — pass explicit LaunchRequest in the docker integration test (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m6s
The **kw unpacking put str into slot: int|None. Pass explicit
LaunchRequests instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:05:55 -04:00
didericis 4fb0f64249 feat(orchestrator): slice 3 — real Docker launch broker (#352)
lint / lint (push) Failing after 1m59s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s
The first concrete LaunchBroker, proving the orchestrator -> backend seam
on the cheapest backend (the sidecar bundle is already containers):

  * orchestrator/docker_broker.py — DockerBroker runs a container on a
    verified launch (`docker run --detach --name <bottle> --label ...
    <image_ref>`) and removes it on teardown (`docker rm --force`,
    idempotent on an already-absent container). The argv is built only from
    the request's static ids/flags, so nothing free-form reaches docker;
    provenance/schema verification is inherited from LaunchBroker.submit.
  * __main__.py gains `--broker {stub,docker}` so the harness can drive real
    containers.

Slice 3 launches a single container from image_ref (the seam); the full
agent + sidecar bundle is a later slice.

Tests: unit (docker mocked) — argv from static fields, launch/teardown call
the right commands, missing-image and docker-failure raise, teardown
idempotent on missing, forged token never touches docker; integration
(gated on a reachable daemon) — launch creates a real container, teardown
removes it. Full suite green (only pre-existing /bin/sleep errors);
integration verified locally against real docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:02:11 -04:00
didericis 9226d45041 feat(orchestrator): slice 2 — launch lifecycle + signed launch-broker (#352)
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m6s
Second slice of PRD 0070, still a backend-neutral dev-harness:

  * orchestrator/broker.py — the launch-broker contract. A LaunchRequest is
    structured (static ids/flags only — bottle id, pool slot, a
    content-addressed image_ref; never a path/argv) and signed as a compact
    HS256 JWT so the broker verifies PROVENANCE before acting: a compromised
    co-located component can't forge a launch without the shared secret.
    verify_request is fail-closed (bad sig / malformed / off-schema -> raise).
    Stdlib only (no runtime deps). Ships a StubBroker that records verified
    requests for the harness/tests.
  * orchestrator/service.py — the Orchestrator: owns the registry and brokers
    the lifecycle. launch_bottle mints the bottle + sends a signed launch,
    rolling the registry entry back if the launch fails (no orphans);
    teardown_bottle brokers teardown then deregisters; attribute delegates.
  * control_plane.py — POST /bottles now launches, DELETE tears down (both go
    through the Orchestrator + broker). dispatch/server take an Orchestrator.
  * __main__.py wires an ephemeral secret + StubBroker for the harness.

Tests: broker sign/verify round-trip, tamper/wrong-secret/malformed/off-schema
rejection, StubBroker fail-closed; Orchestrator launch->registry->attribute,
teardown, rollback-on-broker-failure; control-plane updated for launch/teardown.
Full suite green (only the pre-existing /bin/sleep errors); harness does
launch -> attribute -> teardown over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:42:35 -04:00
didericis 6847fdf0ab refactor: drop the vestigial bot_bottle_root/host_db_path re-exports (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Successful in 1m6s
paths is the single home now, so stop re-exporting the path helpers from
supervise: remove host_db_path from supervise's imports + __all__ (it was
re-export-only) and drop bot_bottle_root from __all__ (kept as an import,
still used by audit_dir). supervise_types was already clean. Repoint the
last readers (test_supervise imports host_db_path from paths;
test_supervise_edge calls paths.bot_bottle_root) and refresh the doc
mentions. No supervise.bot_bottle_root / supervise.host_db_path references
remain.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:30:13 -04:00
didericis 421d31c32f refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 15s
test / coverage (pull_request) Successful in 1m1s
Create bot_bottle/paths.py as the canonical home for the app-root path
helpers (bot_bottle_root, host_db_path, HOST_DB_FILENAME) — foundational,
not supervise- or db-specific. `bot_bottle_root()` now honours a
BOT_BOTTLE_ROOT env override.

Repoint every consumer (supervise, supervise_types, db_store, queue_store,
audit_store, store_manager, bottle_state, cli/supervise, docker/cleanup,
orchestrator/registry) at paths; remove the definitions (and supervise's
duplicate host_db_path) and the now-dead `import sys`. Add paths.py to the
sidecar bundle (Dockerfile.sidecars) for the flat-import copies.

Tests: replace ~12 files' monkeypatching of supervise.bot_bottle_root (and
the flat/pkg/supervise_types triple-patch dance) with a single
`use_bottle_root()` helper that sets BOT_BOTTLE_ROOT — every module and
flat/package copy reads the same env var, so one override covers them all.
Net -97 lines. Behaviour-preserving: full unit suite unchanged (only the
pre-existing /bin/sleep sidecar-init errors remain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:04:23 -04:00
didericis b4b4e08f62 refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m0s
host_db_path is shared DB infrastructure, not supervise-specific, so its
canonical home is db_store (alongside DbStore). It resolves bot_bottle_root
from supervise_types lazily inside the function — no load-time cycle, and a
monkey-patch of supervise_types.bot_bottle_root still propagates.
supervise_types re-exports both names for the historical import path
(queue_store/audit_store unchanged); the orchestrator registry now imports
from db_store. Drops the now-unused `import sys` from supervise_types.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors remain); monkeypatch propagation verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:36:19 -04:00
didericis b174442c60 feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m5s
Per review: use one shared bot-bottle.db for all runtime state including
the registry (DbStore namespaces by schema_key), so it's one queryable
file for backup/console. default_db_path() -> host_db_path(). Drop the
unilateral WAL flip — WAL on the shared DB affects supervise/audit and is
finicky over guest shares, so it's a deliberate future change; keep a
busy_timeout for lock contention.

PRD State section updated: integrity now by SOLE ownership (only the
orchestrator opens bot-bottle.db; data plane + console reach state via the
control-plane RPC, never a file handle) rather than ro/rw mount-splitting,
which one shared file can't do. Notes the transitional caveat that the
supervise sidecar currently rw-mounts bot-bottle.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:26:03 -04:00
didericis ed7307e0e3 docs(prd): 0070 registry DB is host-resident with rw/ro access split (#352)
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m1s
Per review: the SQLite runtime-state DB lives on the host, not owned
inside the orchestrator unit. Durability (re-adoption must survive an
orchestrator restart) + integrity via access-scoping (control-plane rw,
data-plane ro) rather than location. Note the WAL-over-guest-share wrinkle
for the VM slices (may want a host-side DB owner reached over the RPC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:17:05 -04:00
didericis 1702664b81 feat(orchestrator): slice 1 — registry + attribution + HTTP control plane (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m2s
First implementation slice of PRD 0070, the backend-neutral consolidation
core as a plain-process dev-harness (no VM packaging yet):

  * orchestrator/registry.py — SQLite (WAL) runtime-state store on the
    existing DbStore/TableMigrations base. Live bottle registry keyed by
    source IP + per-bottle identity token, with fail-closed attribution:
    a request resolves to a bottle only when its source IP AND identity
    token both match exactly one active record (unknown/ambiguous IP,
    empty token, or token mismatch all deny). Tokens are 256-bit urandom.
  * orchestrator/control_plane.py — the HTTP control plane (the universal
    transport chosen in 0070): register / deregister / list / attribute /
    health. Routing is a pure dispatch() so it is socket-free testable;
    Handler/ControlPlaneServer/make_server are a thin stdlib adapter.
    register/deregister are the live-reload path; listing redacts tokens.
  * orchestrator/__main__.py — `python -m bot_bottle.orchestrator` harness.

Launch/teardown, the launch broker, and the egress/git/supervise data
plane come in later slices. 24 unit tests (attribution matrix, persistence,
dispatch, one real-socket round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:00:04 -04:00
didericis 8d54fc38ea docs(prd): 0070 VM-to-VM routing is not a blocker (#352)
Per review: resolvable at implementation time (host tweak acceptable, as
the pool setup already needs on NixOS); the earlier sidecars-in-VMs spike
showed feasibility. No need to hunt the spike now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:46:08 -04:00
didericis 73a7582abe docs(prd): 0070 fold in review decisions (#352)
Resolve open questions from review: consolidate egress (hardened minimal
surface + identity token + vault mitigations); HTTP control-plane
transport; broker schema = signed-JWT JSON of static flags+ids (provenance
+ un-coercible); state re-adoption procedure (singleton orchestrator →
wait-healthy → adopt via SQLite + process inspection before serving, with
write-ahead intent to close the in-flight-launch race). Add a per-bottle
identity-token defense-in-depth layer on the attribution invariant.
Remaining open: VM-to-VM routing (per-backend wire(), pending spike link),
live-reload protocol, identity-token delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:39:06 -04:00
didericis 095896817c docs(prd): 0070 secret-handling as a future pattern (SecretProvider, #355)
lint / lint (push) Successful in 2m3s
Add a "Secret handling — FUTURE pattern (not v1)" subsection: vault as a
separate trust domain holding long-lived roots, deriving short-lived
scoped creds where the upstream allows (with the honest limit that a
compromised proxy can still abuse currently-authorized access). The
mechanism is a generic, user-extensible SecretProvider that generalizes
PRD 0048's DeployKeyProvisioner and drops into the manifest wherever a raw
token is accepted — discovered from ~/.bot-bottle/contrib like user
AgentProviders. Marked explicitly as not required for the initial
orchestrator; tracked as #355.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
didericis a30dd49967 docs(prd): 0070 per-host orchestrator service
Fold the per-bottle sidecar bundle into a single persistent per-host
orchestrator: runs the sidecar functions (egress/git-gate/supervise),
coordinates with the console, and brokers agent launches. Virtualized
from the start with backend-native isolation (fc VM / apple ctr / docker
ctr), fronted by a single backend-agnostic contract; per-backend
variation lives on BottleBackend, not a parallel Orchestrator hierarchy.

Leads with the security review (secret concentration, shared fate, the
launch-broker-as-new-privileged-core, and the source-IP attribution
invariant each backend must enforce). Proposes one SQLite DB owned by the
orchestrator for runtime state (slot leases, approvals, registry) —
distinct from build-time constants (flat .env) and user config
(declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos,
developing the service as a plain-process dev-harness before the VM.

Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built
fixed images. Tracking issue #351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
220 changed files with 4653 additions and 16238 deletions
+5 -2
View File
@@ -22,7 +22,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# No actions/setup-python: canaries are stdlib unittest on the image's - name: Set up Python
# system Python 3.12 (older act_runner mishandles setup-python's PATH). uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run canaries - name: Run canaries
run: python3 -m unittest discover -t . -s tests/canaries -v run: python3 -m unittest discover -t . -s tests/canaries -v
+10 -20
View File
@@ -13,30 +13,20 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
# No actions/setup-python: the runner image already ships Python 3.12, - name: Set up Python
# and older act_runner engines mishandle setup-python's PATH. Install uses: actions/setup-python@v4
# into the ephemeral job container's system Python — the pylint/pyright with:
# console scripts land on /usr/local/bin (on PATH) so the steps below python-version: "3.12"
# still resolve. --break-system-packages is safe: the container is
# disposable.
- name: Install dev dependencies - name: Install dev dependencies
run: python3 -m pip install --break-system-packages -r requirements-dev.txt run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run pylint - name: Run pylint
run: | run: |
# Pylint's normal exit code is nonzero for any emitted finding, # Run pylint on all Python files in the repo
# regardless of --fail-under. Preserve the full report but enforce find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
# the aggregate score this workflow promises.
set +e
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' \
| xargs pylint --fail-under=8.0 \
| tee /tmp/pylint-output.txt
set -e
SCORE=$(sed -n \
's/^Your code has been rated at \([-0-9.]*\)\/10.*/\1/p' \
/tmp/pylint-output.txt | tail -1)
test -n "$SCORE"
awk -v score="$SCORE" 'BEGIN { exit !(score >= 8.0) }'
- name: Run pyright - name: Run pyright
run: | run: |
+5 -2
View File
@@ -37,8 +37,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# No actions/setup-python: the inline script is stdlib-only on the - name: Set up Python
# image's system Python 3.12 (older act_runner mishandles its PATH). uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Configure git - name: Configure git
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
+36 -209
View File
@@ -4,15 +4,16 @@
# dependencies are required to execute it. Tests are split by directory: # dependencies are required to execute it. Tests are split by directory:
# #
# tests/unit/ — pure unit tests; always run # tests/unit/ — pure unit tests; always run
# tests/integration/ — need a reachable backend; skip cleanly when # tests/integration/ — need a reachable Docker daemon; skip cleanly
# the backend isn't available on the runner # (via tests/_docker.py:skip_unless_docker) when
# Docker isn't available on the runner
# tests/canaries/ — upstream regression canaries; run on a separate # tests/canaries/ — upstream regression canaries; run on a separate
# schedule (see canaries.yml), not here # schedule (see canaries.yml), not here
# #
# Integration tests run once per backend in separate jobs. Each job sets # This workflow assumes the Gitea Actions runner exposes the host Docker
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend. # socket to the job container so `docker` commands inside the job can
# Backends that aren't available on the runner fail the preflight step # reach the daemon. If that's not yet configured on the runner the
# rather than silently skipping inside the test output. # integration tests will skip rather than fail.
name: test name: test
@@ -22,84 +23,24 @@ on:
- main - main
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
# Dockerfiles and pyproject.toml are baked into the infra rootfs; a
# change here alters what the integration/coverage jobs build locally.
- 'Dockerfile*'
- 'pyproject.toml'
pull_request: pull_request:
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
- 'Dockerfile*'
- 'pyproject.toml'
workflow_dispatch:
jobs: jobs:
stage-firecracker-inputs:
runs-on: [self-hosted, kvm]
# Same guard as the other KVM-runner jobs: don't spin the privileged
# runner for fork PRs (this only copies a non-secret static binary, but
# keep the posture consistent — build-infra/integration/coverage all
# depend on it, so gating here gates the whole Firecracker chain).
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Stage the provisioned static dropbear
run: |
mkdir -p firecracker-inputs
cp /var/cache/bot-bottle-fc/dropbear firecracker-inputs/dropbear
- name: Upload Firecracker build inputs
uses: actions/upload-artifact@v3
with:
name: firecracker-inputs
path: firecracker-inputs/
build-infra:
needs: stage-firecracker-inputs
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Firecracker build inputs
uses: actions/download-artifact@v3
with:
name: firecracker-inputs
path: firecracker-inputs
- name: Build infra candidate from this checkout
env:
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate
- name: Upload infra candidate
uses: actions/upload-artifact@v3
with:
name: infra-candidate
path: infra-candidate/
unit: unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# No actions/setup-python: the runner image already ships Python 3.12, - name: Set up Python
# and older act_runner engines mishandle setup-python's PATH (coverage uses: actions/setup-python@v5
# lands in one interpreter, `python3` resolves to another). Install with:
# straight into the ephemeral job container's system Python — python-version: "3.12"
# --break-system-packages is safe because the container is disposable.
- name: Install dev requirements - name: Install dev requirements
run: python3 -m pip install --break-system-packages -r requirements-dev.txt run: python3 -m pip install -r requirements-dev.txt
- name: Run unit tests - name: Run unit tests
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
@@ -107,14 +48,17 @@ jobs:
- name: Report unit coverage - name: Report unit coverage
run: python3 -m coverage report -m run: python3 -m coverage report -m
integration-docker: integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# No actions/setup-python (see the note in the `unit` job); the - name: Set up Python
# container's system Python 3.12 runs the stdlib test suite directly. uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Show environment - name: Show environment
run: | run: |
python3 --version python3 --version
@@ -124,150 +68,33 @@ jobs:
echo "docker not on PATH — integration tests will skip" echo "docker not on PATH — integration tests will skip"
fi fi
- name: Run integration tests (docker) - name: Run integration tests
env:
BOT_BOTTLE_BACKEND: docker
run: python3 -m unittest discover -t . -s tests/integration -v run: python3 -m unittest discover -t . -s tests/integration -v
# Integration tests against the Firecracker backend. Runs on a self-hosted # Combined unit+integration coverage report (informational). See
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available. # docs/decisions/0004-coverage-policy.md.
# #
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork # The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
# PRs don't execute untrusted code on the privileged runner. # Firecracker backend's VM/SSH orchestration is covered by the integration
# # suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
# Runner prerequisites (provision once; see README "Firecracker on Linux"): # container-based runner skips it and those lines read uncovered, so the
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel + # gate can't pass here. Re-enabling it on a self-hosted KVM runner is
# static dropbear, and the pool as a persistent systemd unit. # tracked separately (see PRD 0069 / #348 and the ci-runner branch).
integration-firecracker:
needs: build-infra
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight — Firecracker host is ready
run: |
command -v firecracker >/dev/null || {
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Download the candidate built from this checkout
uses: actions/download-artifact@v3
with:
name: infra-candidate
path: infra-candidate
- name: Replace the persistent infra VM with the candidate
run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'
# No dev-requirements install: the integration suite runs on stdlib
# `unittest` (pylint/pyright are lint.yml's concern, not this job's),
# and the self-hosted runner's Nix python env has no `pip` module
# (`python3 -m pip` → "No module named pip"). Nothing to install.
- name: Run integration tests (firecracker)
env:
BOT_BOTTLE_BACKEND: firecracker
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage + the diff-coverage gate (the hard
# gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
#
# This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
# because the Firecracker backend's subprocess/VM orchestration
# (launch/boot/SSH/isolation-probe) is covered by the integration suite,
# and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
# container-based runner doesn't have. On such a runner the firecracker
# integration test skips and its ~230 orchestration lines read as
# uncovered, so the gate can't pass there.
#
# Restricted to the same events as integration-firecracker (same-repo PRs,
# push, workflow_dispatch) for the same security reason.
#
# See #414 for the planned follow-up: artifact-based coverage combination
# (run tests once in their respective jobs, combine .coverage files here).
#
# build-infra creates one candidate from the checkout. This job boots that
# same candidate after integration-firecracker has exercised it; the main
# push path publishes the identical bytes only after every required job.
coverage: coverage:
needs: [build-infra, integration-firecracker] runs-on: ubuntu-latest
timeout-minutes: 15
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Preflight — Firecracker host is ready - name: Set up Python
run: | uses: actions/setup-python@v5
command -v firecracker >/dev/null || {
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Download the candidate already exercised by integration
uses: actions/download-artifact@v3
with: with:
name: infra-candidate python-version: "3.12"
path: infra-candidate
# No dev-requirements install: `coverage` is already provided by the - name: Install dev requirements
# self-hosted runner's Nix python env, and that env has no `pip` run: python3 -m pip install -r requirements-dev.txt
# module to install into anyway. `scripts/coverage.sh` +
# `diff_coverage.py` need only `coverage` (not pylint/pyright). - name: Combined coverage report (unit + integration)
- name: Combined coverage (unit + integration, incl. firecracker)
env:
BOT_BOTTLE_CI_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
run: PYTHON=python3 bash scripts/coverage.sh critical run: PYTHON=python3 bash scripts/coverage.sh critical
- name: Diff-coverage gate (changed lines >= 90%)
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90
publish-infra:
needs: [stage-firecracker-inputs, build-infra, unit, integration-docker, integration-firecracker, coverage]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout the tested revision
uses: actions/checkout@v4
- name: Download the tested candidate
uses: actions/download-artifact@v3
with:
name: infra-candidate
path: infra-candidate
# publish_infra re-derives the version from the checkout to confirm the
# bundle matches before uploading, and the version hashes the dropbear
# bytes. Stage the SAME dropbear build-infra used, or the recheck
# computes a "<missing>"-dropbear version and rejects the candidate.
- name: Download the staged dropbear (matches build-infra's version)
uses: actions/download-artifact@v3
with:
name: firecracker-inputs
path: firecracker-inputs
- name: Publish the tested candidate
env:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
@@ -1,17 +0,0 @@
name: tracker-policy-issues
on:
issues:
types: [opened, unlabeled]
jobs:
label-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v4
- name: Ensure the issue has a label
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/tracker_policy.py label-issue
-18
View File
@@ -1,18 +0,0 @@
name: tracker-policy-pr
on:
pull_request:
types: [opened, edited, reopened, synchronize, labeled, unlabeled]
jobs:
check-pr:
runs-on: ubuntu-latest
permissions:
issues: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Require an unlabeled PR linked to an issue
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/tracker_policy.py check-pr
+11 -8
View File
@@ -20,18 +20,21 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# No actions/setup-python: the runner image ships Python 3.12 and older - name: Set up Python
# act_runner engines mishandle setup-python's PATH. Install into the uses: actions/setup-python@v4
# ephemeral job container's system Python (--break-system-packages is with:
# safe because the container is disposable). python-version: '3.12'
- name: Install dev dependencies - name: Install dev dependencies
run: python3 -m pip install --break-system-packages -r requirements-dev.txt run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run coverage and extract percentage - name: Run coverage and extract percentage
id: coverage id: coverage
run: | run: |
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1) PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
echo "percent=$PERCENT" >> $GITHUB_OUTPUT echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Coverage: $PERCENT%" echo "Coverage: $PERCENT%"
@@ -42,7 +45,7 @@ jobs:
# the single source of truth in scripts/critical-modules.txt; every # the single source of truth in scripts/critical-modules.txt; every
# core module is unit-tested, so the unit-only run is accurate for it. # core module is unit-tested, so the unit-only run is accurate for it.
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -) INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1) PERCENT=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
echo "percent=$PERCENT" >> $GITHUB_OUTPUT echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Core coverage: $PERCENT%" echo "Core coverage: $PERCENT%"
+3 -3
View File
@@ -8,10 +8,10 @@ broad permissions inside a sandbox, so a misbehaving agent cannot reach the
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the runtime lifecycle and the copying of skills and env vars into it. the runtime lifecycle and the copying of skills and env vars into it.
The default backend on compatible macOS hosts is macos-container: The default backend on compatible macOS hosts is macos-container:
agents and gateways run through Apple's `container` CLI without agents and sidecar bundles run through Apple's `container` CLI without
requiring Docker. On KVM-capable Linux hosts the default is firecracker: requiring Docker. On KVM-capable Linux hosts the default is firecracker:
agents run in a Firecracker microVM reached over SSH on a point-to-point agents run in a Firecracker microVM reached over SSH on a point-to-point
TAP, while the gateway still uses Docker. The legacy Docker TAP, while the sidecar bundle still uses Docker. The legacy Docker
backend remains available with `BOT_BOTTLE_BACKEND=docker` or backend remains available with `BOT_BOTTLE_BACKEND=docker` or
`--backend=docker`. `--backend=docker`.
@@ -59,7 +59,7 @@ backend remains available with `BOT_BOTTLE_BACKEND=docker` or
in a PRD, research note, or decision record. in a PRD, research note, or decision record.
- Low dependencies by default. The project is Python, stdlib-first (no - Low dependencies by default. The project is Python, stdlib-first (no
runtime pip dependencies in the package itself; the only language runtime pip dependencies in the package itself; the only language
runtime is the Python 3.13 used by the CLI + companion containers). Ask before runtime is the Python 3.13 used by the CLI + sidecars). Ask before
adding new tools, runtimes, or package managers. adding new tools, runtimes, or package managers.
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`, `<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
-125
View File
@@ -1,125 +0,0 @@
# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway).
#
# The egress / git-gate / supervise *data plane* — one image, run by
# the consolidated per-host gateway (PRD 0070). It is NOT the
# orchestrator control plane: that is the separate, lean
# `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which
# ships only python + the stdlib-only `bot_bottle` package and none of
# this image's mitmproxy / git / gitleaks payload.
#
# Collapses the prior per-daemon images (egress, git-gate,
# supervise) into one. A small stdlib-Python init supervisor at
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and
# propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale.
#
# Layout:
#
# /usr/bin/gitleaks gitleaks binary
# /app/egress_addon.py mitmproxy addon entry point
# /app/egress-entrypoint.sh mitmdump launcher
# /usr/local/lib/python*/bot_bottle/ installed package (all daemons + shared modules)
# /app/egress_addon.py one-line shim: re-exports addons from package
# (mitmdump -s requires a file path, not a module)
# /etc/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime
# /run/supervise/bot-bottle.db bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
#
# Exposed ports inside the container:
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
# 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
# 9100 supervise (MCP HTTP)
# Based on `python:3.12-slim` (Debian trixie) rather than the
# `mitmproxy/mitmproxy` image (Debian bookworm) so the whole stack —
# gateway here, and the firecracker infra image that builds FROM this —
# lands on trixie, whose buildah (1.39) can build agent Dockerfiles that
# use heredocs. mitmproxy is pip-installed to the same effect as the
# upstream image. (bookworm's buildah is 1.28, which can't parse
# `RUN ... <<EOF`; see the infra image + PR discussion.)
FROM python:3.12-slim
# Runtime system deps:
# git supplies the `git daemon` subcommand (no separate package)
# plus the core `git` binary the pre-receive hook invokes.
# openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for mitmdump upstream TLS.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# mitmdump (the egress data plane). The upstream mitmproxy image baked
# this in; on the plain python base we pip-install the same pinned
# version. Its CA dir is set explicitly via `--set confdir=` in
# egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user.
RUN pip install --no-cache-dir mitmproxy==11.1.3
# gitleaks (the pre-receive hook's secret scanner). Installed from its
# official release, pinned by version + SHA256 and verified — rather than
# using a third-party image as a build stage (supply-chain surface, and it
# would pin us to that image's cadence). python (already present) does the
# download so we add no curl/wget. trixie apt also ships gitleaks, but an
# older 8.16; the pinned download keeps the verified 8.30.1.
#
# Arch-aware: the asset + SHA are picked from the build's target
# architecture so an arm64 host (Apple Silicon) gets the arm64 binary
# rather than an x86_64 one that dies with "Exec format error" the first
# time the pre-receive hook runs it. TARGETARCH is auto-populated by
# BuildKit; the dpkg fallback keeps it correct under a legacy builder.
ARG GITLEAKS_VERSION=8.30.1
ARG GITLEAKS_SHA256_AMD64=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
ARG GITLEAKS_SHA256_ARM64=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080
ARG TARGETARCH
RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" \
&& case "$arch" in \
amd64) asset="linux_x64"; sha="${GITLEAKS_SHA256_AMD64}" ;; \
arm64) asset="linux_arm64"; sha="${GITLEAKS_SHA256_ARM64}" ;; \
*) echo "unsupported gitleaks target arch: $arch" >&2; exit 1 ;; \
esac \
&& url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${asset}.tar.gz" \
&& python3 -c "import sys,urllib.request; urllib.request.urlretrieve(sys.argv[1], '/tmp/gitleaks.tar.gz')" "$url" \
&& echo "${sha} /tmp/gitleaks.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/bin gitleaks \
&& rm /tmp/gitleaks.tar.gz
# Install bot_bottle as a proper package so entry-point scripts can use
# `from bot_bottle.X import Y` absolute imports. A rename or a missing
# module is caught at pip-install time — not at container runtime.
COPY pyproject.toml /src/
COPY bot_bottle/ /src/bot_bottle/
RUN pip install --no-cache-dir /src/
# mitmdump -s requires a file path, not a module. Write a one-line shim that
# re-exports `addons` from the installed package; mitmdump finds it there.
# WORKDIR here also creates /app so the shim + COPYs below can write into it
# (nothing created /app before this point).
WORKDIR /app
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh
# Pre-create runtime directories the compose renderer + start
# step expect to exist. `docker cp` does not create intermediate
# dirs, and bind mounts won't either if the parent is missing.
RUN mkdir -p \
/etc/egress \
/etc/git-gate \
/git-gate/creds \
/git \
/run/supervise \
/home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever
# subset the bottle uses.
EXPOSE 8888 9099 9418 9420 9100
# PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
-22
View File
@@ -1,22 +0,0 @@
# Shared infra image: gateway data plane + orchestrator control plane.
#
# Used directly by the Docker backend (run as one `bot-bottle-infra`
# container, replacing the prior two-container split). The Firecracker
# backend extends this via Dockerfile.infra.fc, adding buildah/crun/
# netavark for in-VM agent-image building.
#
# Dockerfile.orchestrator is the single definition of the orchestrator
# content (the lean `bot_bottle` package on python:3.12-slim). Both this
# image and Dockerfile.infra.fc pull it in via `COPY --from`.
#
# multi-`FROM` can't union two bases (that's multi-stage, not multiple
# inheritance), so the orchestrator content is pulled in via `COPY --from`
# rather than a second base. Both images share the trixie `python:3.12-slim`
# base, so the copy is clean (same python; future installed deps copy too).
FROM bot-bottle-gateway:latest
# The orchestrator content, from its single definition. The gateway image
# already has the flat daemon modules under /app; this adds the full
# `bot_bottle` package so `python3 -m bot_bottle.orchestrator` resolves —
# used by gateway_init when BOT_BOTTLE_GATEWAY_DAEMONS includes `orchestrator`.
COPY --from=bot-bottle-orchestrator:latest /app/bot_bottle /app/bot_bottle
-23
View File
@@ -1,23 +0,0 @@
# Firecracker infra VM image (PRD 0070 Stage B).
#
# Extends the shared infra base (Dockerfile.infra: gateway + orchestrator
# control plane) with the in-VM agent-image builder. The Firecracker backend
# builds users' agent Dockerfiles *inside this VM* with buildah (rootless,
# daemonless) instead of on the host — no host Docker daemon, no
# root-equivalent `docker` group.
#
# Requires the trixie base from bot-bottle-gateway (buildah 1.39: bookworm's
# 1.28 can't parse Dockerfile heredocs that agent images use).
#
# `crun` is the OCI runtime; `netavark` + `aardvark-dns` are the network
# backend for `FROM` pulls + `RUN` egress. `vfs` + `chroot`: buildah works
# as root in the bare microVM (no fuse-overlayfs / overlay module / subuid
# maps). Matches image_builder.
FROM bot-bottle-infra:latest
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
buildah crun netavark aardvark-dns \
&& rm -rf /var/lib/apt/lists/*
ENV STORAGE_DRIVER=vfs \
BUILDAH_ISOLATION=chroot
-36
View File
@@ -1,36 +0,0 @@
# Orchestrator control-plane image (PRD 0070, #384).
#
# This is the **single definition of the orchestrator's content** — the
# `bot_bottle` package baked onto a Python runtime — referenced by BOTH:
# * the docker backend, which runs this image directly as the lean
# control-plane container; and
# * the firecracker infra image (Dockerfile.infra), which `COPY --from`s
# this image's `/app/bot_bottle` so the single infra VM runs the same
# control plane. Keeping it in one place means future orchestrator deps
# (e.g. iroh) are added here once, not duplicated per backend.
#
# It stays deliberately lean: the control plane is **stdlib-only** today, so
# no third-party payload — none of the gateway's mitmproxy/git/gitleaks
# (that's Dockerfile.gateway) and no buildah (that's the firecracker
# builder, and lives only in Dockerfile.infra). Keeping the secret-dense
# control plane on a minimal dependency surface is the point (PRD 0070's
# "secret concentration").
#
# Shares the trixie `python:3.12-slim` base with the gateway image, so when
# the orchestrator grows real deps they can be `COPY --from`'d into the
# infra image cleanly (same base/python — installed packages copy safely).
FROM python:3.12-slim
WORKDIR /app
# The orchestrator content. Baked so the image is self-contained (runs from
# a built image, no runtime bind-mount); the docker backend may still
# bind-mount /app for dev live-reload, which simply overlays this copy.
# `.dockerignore` keeps .git/docs/*.md out of the context. (Future deps like
# iroh go here too — a shared requirements installed on this same base.)
COPY bot_bottle /app/bot_bottle
# Documentation only; lifecycle.py overrides the entrypoint to
# `python3 -m bot_bottle.orchestrator` with the runtime flags.
ENTRYPOINT ["python3", "-m", "bot_bottle.orchestrator"]
+104
View File
@@ -0,0 +1,104 @@
# Per-bottle sidecar bundle image (PRD 0024).
#
# Collapses the prior per-sidecar images (egress, git-gate,
# supervise) into one. A small stdlib-Python init supervisor at
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
# propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale.
#
# Layout:
#
# /usr/bin/gitleaks gitleaks binary
# /app/egress_addon.py + siblings mitmproxy addon (egress)
# /app/egress-entrypoint.sh mitmdump launcher
# /app/supervise_server.py + .py supervise MCP server
# /app/sidecar_init.py PID 1 supervisor
# /etc/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime
# /run/supervise/bot-bottle.db bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
#
# Exposed ports inside the container:
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
# 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
# 9100 supervise (MCP HTTP)
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
# Python + mitmdump pre-installed — heavier than the others, so
# this stage starts there and pulls the standalone binaries in.
FROM mitmproxy/mitmproxy:11.1.3
# Run as root inside the bundle. The bundle is the isolation
# boundary; per-daemon user separation inside it is not load-bearing
# and complicates the supervisor's spawn path.
USER root
# Runtime system deps:
# git supplies the `git daemon` subcommand (no separate package)
# plus the core `git` binary the pre-receive hook invokes.
# openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for mitmdump upstream TLS (the
# base image already has it; listed for explicitness).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Pull the standalone binaries into the final image.
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Project Python: addon + server modules + the init supervisor.
# Kept flat under /app/ so mitmdump's loader resolves them as
# top-level siblings (absolute imports), matching the prior
# Dockerfile.egress / Dockerfile.supervise layout.
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py
COPY bot_bottle/migrations.py /app/migrations.py
COPY bot_bottle/db_store.py /app/db_store.py
COPY bot_bottle/supervise_types.py /app/supervise_types.py
COPY bot_bottle/queue_store.py /app/queue_store.py
COPY bot_bottle/audit_store.py /app/audit_store.py
COPY bot_bottle/store_manager.py /app/store_manager.py
COPY bot_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh
# Pre-create runtime directories the compose renderer + start
# step expect to exist. `docker cp` does not create intermediate
# dirs, and bind mounts won't either if the parent is missing.
RUN mkdir -p \
/etc/egress \
/etc/git-gate \
/git-gate/creds \
/git \
/run/supervise \
/home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever
# subset the bottle uses.
EXPOSE 8888 9099 9418 9420 9100
# WORKDIR matches Dockerfile.supervise's prior layout so the
# in-app same-dir import in supervise_server.py stays deterministic.
WORKDIR /app
# PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
+11 -22
View File
@@ -5,8 +5,8 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-81%25-brightgreen)](https://coverage.readthedocs.io/) [![coverage](https://img.shields.io/badge/coverage-82%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-94%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md) [![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
@@ -16,7 +16,7 @@
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny. - **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
- **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
@@ -24,17 +24,17 @@
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Apple Container backend (macOS default when available)** — runs the agent and gateway with Apple's `container` CLI, using a host-only agent network plus a separate gateway egress network. - **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the gateway in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup. - **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the sidecar bundle in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup.
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
## Architecture ## Architecture
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege. On the Firecracker backend, a bottle is an agent microVM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the sidecars. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `companion containers` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
The Docker topology looks like this: The Docker topology looks like this:
@@ -67,11 +67,11 @@ The Docker topology looks like this:
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
When the agent exits, `cli.py` tears down every gateway and both networks; nothing about a bottle persists between runs. When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
## Quickstart ## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the sidecar bundle plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
@@ -81,7 +81,7 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing. - **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing. - **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
- **Docker** for the gateway and image build. - **Docker** for the sidecar bundle and image build.
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host. - **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
```sh ```sh
@@ -90,8 +90,6 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. > **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. A Docker-capable hosted job builds the candidate once; KVM tests boot those exact bytes, and a successful main run publishes them. The unit/lint jobs still run on `ubuntu-latest`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
@@ -173,15 +171,6 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Tracker policy
Issues are the canonical work items and own all tracker labels; every issue
must have at least one. Pull requests stay unlabeled and deliberately reference
an issue with `Closes #…`, `Part of #…`, or another form defined in
[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions
enforces the convention for new work from 2026-07-18 onward. Earlier closed
PRs are grandfathered rather than given artificial retrospective issues.
## Trademarks ## Trademarks
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox. bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
+2 -14
View File
@@ -45,10 +45,6 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
# forward_host_credentials is enabled. Pipelock must pass these through # forward_host_credentials is enabled. Pipelock must pass these through
# (no TLS MITM) or its header DLP blocks the injected JWT. # (no TLS MITM) or its header DLP blocks the injected JWT.
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
# Host that egress injects the host Claude bearer on when Claude
# forward_host_credentials is enabled.
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
PromptMode = Literal[ PromptMode = Literal[
"append_file", "append_file",
"read_prompt_file", "read_prompt_file",
@@ -65,13 +61,6 @@ class AgentProviderRuntime:
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_args: tuple[str, ...] resume_args: tuple[str, ...]
# argv run inside a throwaway container of a freshly built agent
# image, right after `build_image()`, to catch a build that
# exited 0 but produced a broken CLI (e.g. an npm
# optionalDependencies fetch for a platform-native binary that
# silently no-ops on a transient failure). Empty tuple skips the
# check — not every provider has opted in yet.
smoke_test: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -215,9 +204,9 @@ class AgentProvider(ABC):
bottle: "Bottle", bottle: "Bottle",
supervise_url: str, supervise_url: str,
) -> None: ) -> None:
"""Register the per-bottle supervise daemon as an MCP server """Register the per-bottle supervise sidecar as an MCP server
in the provider's in-guest config. Called by the backend after in the provider's in-guest config. Called by the backend after
the supervise daemon is reachable. No-op when the supervise sidecar is reachable. No-op when
`plan.supervise_plan is None`.""" `plan.supervise_plan is None`."""
@abstractmethod @abstractmethod
@@ -270,7 +259,6 @@ class AgentProvider(ABC):
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git") gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
content = git_gate_render_gitconfig( content = git_gate_render_gitconfig(
manifest_bottle.git, gate_host, scheme=gate_scheme, manifest_bottle.git, gate_host, scheme=gate_scheme,
identity_token=getattr(plan, "identity_token", ""),
) )
guest_gitconfig = f"{plan.guest_home}/.gitconfig" guest_gitconfig = f"{plan.guest_home}/.gitconfig"
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
+2 -2
View File
@@ -42,7 +42,7 @@ class AuditStore(DbStore):
super().__init__(db_path or host_db_path(), migrations) super().__init__(db_path or host_db_path(), migrations)
def write_audit_entry(self, entry: AuditEntry) -> Path: def write_audit_entry(self, entry: AuditEntry) -> Path:
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO supervise_audit_entries ( INSERT INTO supervise_audit_entries (
@@ -66,7 +66,7 @@ class AuditStore(DbStore):
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]: def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT * FROM supervise_audit_entries SELECT * FROM supervise_audit_entries
+44 -164
View File
@@ -40,13 +40,12 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan from ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info, warn from ..log import die, info
from ..util import read_tty_line
from ..manifest import Manifest, ManifestIndex from ..manifest import Manifest, ManifestIndex
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
@@ -55,9 +54,6 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
if TYPE_CHECKING:
from .freeze import CommitCancelled, Freezer, get_freezer
@dataclass(frozen=True) @dataclass(frozen=True)
class BottleSpec: class BottleSpec:
@@ -203,7 +199,7 @@ class ActiveAgent:
bottle is the container, the agent is what runs in it.) bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set Fields are deliberately backend-neutral. `services` is the set
of gateway daemons currently up for this bottle (`egress`, of sidecar daemons currently up for this bottle (`egress`,
`git-gate`, `supervise`); the dashboard uses it to `git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active- `_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
@@ -255,7 +251,7 @@ class Bottle(ABC):
`user` (default `node`, matching the agent image's USER `user` (default `node`, matching the agent image's USER
directive) and return the captured stdout/stderr/returncode. directive) and return the captured stdout/stderr/returncode.
The bottle's environment (including HTTPS_PROXY pointing at The bottle's environment (including HTTPS_PROXY pointing at
the egress daemon) is inherited by the child. Non-zero the egress sidecar) is inherited by the child. Non-zero
exit does not raise — callers inspect `returncode` exit does not raise — callers inspect `returncode`
themselves. themselves.
@@ -458,7 +454,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
declarative provision-plan apply, supervise MCP registration) declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin. The backend only owns the live on the `AgentProvider` plugin. The backend only owns the
steps that are about backend infrastructure (CA, workspace, steps that are about backend infrastructure (CA, workspace,
git) and surfaces the supervise daemon URL its launch step git) and surfaces the supervise sidecar URL its launch step
knows about via `supervise_mcp_url`. knows about via `supervise_mcp_url`.
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc, PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
@@ -506,7 +502,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def supervise_mcp_url(self, plan: PlanT) -> str: def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise """Return the agent-side URL of the per-bottle supervise
gateway, or "" when this bottle has no gateway. The provider sidecar, or "" when this bottle has no sidecar. The provider
plugin's `provision_supervise_mcp` uses it to register the plugin's `provision_supervise_mcp` uses it to register the
MCP entry inside the guest. MCP entry inside the guest.
@@ -515,18 +511,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
del plan del plan
return "" return ""
def ensure_orchestrator(self) -> str:
"""Bring up this backend's per-host orchestrator + shared gateway
(idempotent) and return the host-reachable control-plane URL.
This is the backend-agnostic bring-up entry point: `launch` calls
it as part of starting a bottle, and operator tools (`supervise`)
call it to start the control plane on demand when none is running
yet. Docker starts the orchestrator + gateway containers;
firecracker boots the infra VM. Backends with no orchestrator
(macos-container) die with a pointer — the default here."""
die(f"backend {self.name!r} has no orchestrator control plane")
@abstractmethod @abstractmethod
def prepare_cleanup(self) -> CleanupT: def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side """Enumerate orphaned resources from previous bottles. No side
@@ -540,7 +524,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
"""Return every currently-running agent on this backend. """Return every currently-running agent on this backend.
Empty when none. Backend-specific: docker queries `docker Empty when none. Backend-specific: docker queries `docker
compose ls`; firecracker cross-references its running gateway compose ls`; firecracker cross-references its running sidecar
containers against per-bottle metadata.""" containers against per-bottle metadata."""
@classmethod @classmethod
@@ -588,170 +572,68 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
Not called by the launch path or the test suite.""" Not called by the launch path or the test suite."""
# _backends is None until the first call to _get_backends(), at which # Import concrete backend classes AFTER the base types are defined, so
# point all three concrete backend classes are imported and instantiated. # each backend module can pull BottleSpec / BottlePlan / BottleBackend
# Keeping the imports out of module scope means that importing any # via `from . import ...` without hitting a partially-initialized module.
# backend sub-module (e.g. `backend.docker.util`) no longer drags the from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# firecracker and macos-container implementations into memory. from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it # Freezer is imported after the backend classes for the same reason:
# is not None, so test fakes take effect without triggering real imports. # Freezer.commit_slug constructs ActiveAgent, which must be fully
_backends: dict[str, BottleBackend[Any, Any]] | None = None # defined first.
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
def _get_backends() -> dict[str, BottleBackend[Any, Any]]: # The dict is heterogeneous: each value is a BottleBackend specialized
"""Return the registry of all backend instances, loading lazily on first call.""" # over its own plan type. Concrete plan types are erased here because
global _backends # pylint: disable=global-statement # the registry is selected at runtime and the CLI only needs the
if _backends is None: # unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
from .docker import DockerBottleBackend _BACKENDS: dict[str, BottleBackend[Any, Any]] = {
from .firecracker import FirecrackerBottleBackend "docker": DockerBottleBackend(),
from .macos_container import MacosContainerBottleBackend "firecracker": FirecrackerBottleBackend(),
_backends = { "macos-container": MacosContainerBottleBackend(),
"docker": DockerBottleBackend(), }
"firecracker": FirecrackerBottleBackend(),
"macos-container": MacosContainerBottleBackend(),
}
return _backends
def __getattr__(name: str) -> Any:
"""Lazily surface concrete backend classes and freeze symbols at the
package level so existing `from bot_bottle.backend import X` and
`patch.object(backend_mod, X, ...)` call-sites keep working without
forcing an import of every backend at module-init time."""
if name == "DockerBottleBackend":
from .docker import DockerBottleBackend
globals()[name] = DockerBottleBackend
return DockerBottleBackend
if name == "FirecrackerBottleBackend":
from .firecracker import FirecrackerBottleBackend
globals()[name] = FirecrackerBottleBackend
return FirecrackerBottleBackend
if name == "MacosContainerBottleBackend":
from .macos_container import MacosContainerBottleBackend
globals()[name] = MacosContainerBottleBackend
return MacosContainerBottleBackend
if name == "CommitCancelled":
from .freeze import CommitCancelled
globals()[name] = CommitCancelled
return CommitCancelled
if name == "Freezer":
from .freeze import Freezer
globals()[name] = Freezer
return Freezer
if name == "get_freezer":
from .freeze import get_freezer
globals()[name] = get_freezer
return get_freezer
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def get_bottle_backend( def get_bottle_backend(
name: str | None = None, name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any]: ) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend. """Resolve the bottle backend.
`name` precedence: `name` precedence:
1. explicit arg (e.g. resume passes the recorded backend name) 1. explicit arg (CLI `--backend=<name>` passes through here)
2. BOT_BOTTLE_BACKEND env var 2. BOT_BOTTLE_BACKEND env var
3. auto-selection: VM backend first, docker fallback with prompt 3. `macos-container` on compatible macOS hosts
4. `firecracker` on KVM-capable Linux hosts
`prompt` controls whether auto-selection may block on an interactive 5. default `docker`
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
non-interactive contexts (headless launches, CI) so the call dies
with an actionable message instead of hanging.
Dies with a pointer at the known backends if the chosen name Dies with a pointer at the known backends if the chosen name
isn't implemented.""" isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
if resolved is None: if resolved not in _BACKENDS:
resolved = _auto_select_backend(prompt=prompt) known = ", ".join(sorted(_BACKENDS))
backends = _get_backends()
if resolved not in backends:
known = ", ".join(sorted(backends))
die(f"unknown backend {resolved!r}; known backends: {known}") die(f"unknown backend {resolved!r}; known backends: {known}")
return backends[resolved] return _BACKENDS[resolved]
def _platform_vm_suggestion() -> str: def _default_backend_name() -> str:
"""Platform-appropriate VM backend name for install suggestions."""
return "macos-container" if sys.platform == "darwin" else "firecracker"
def _print_vm_install_instructions() -> None:
"""Print platform-appropriate VM backend install instructions to stderr."""
vm = _platform_vm_suggestion()
if vm == "macos-container":
info("Install Apple Container: https://github.com/apple/container/releases")
info("Then start the service: container system start")
else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup")
def _auto_select_backend(prompt: bool = True) -> str:
"""Tier-1 / tier-2 backend auto-selection.
Tier 1: VM backend — macos-container on macOS when Apple Container is
installed; firecracker on KVM-capable Linux even before the binary is
present (its preflight prints an install pointer).
Tier 2: docker, with a security warning and an interactive prompt.
When `prompt=False` (headless / CI), dies with an actionable message
instead of blocking on a TTY read. When docker is also absent, prints
VM install instructions and exits.
"""
# --- Tier 1: VM backend -----------------------------------------
if has_backend("macos-container"): if has_backend("macos-container"):
return "macos-container" return "macos-container"
# A KVM-capable Linux host defaults to firecracker even when the # A KVM-capable Linux host defaults to firecracker even when the
# `firecracker` binary isn't installed yet: selecting it here routes # `firecracker` binary isn't installed yet: selecting it here routes
# start through firecracker's preflight, which prints an install # start through firecracker's preflight, which prints an install
# pointer, instead of silently falling back to docker. # pointer, instead of silently falling back to docker.
from .firecracker import FirecrackerBottleBackend
if FirecrackerBottleBackend.is_host_capable(): if FirecrackerBottleBackend.is_host_capable():
return "firecracker" return "firecracker"
return "docker"
# --- Tier 2: docker fallback ------------------------------------
if not has_backend("docker"):
info("No backend available on this host.")
_print_vm_install_instructions()
die("no backend available; install a VM backend and re-run")
vm = _platform_vm_suggestion()
warn(
"docker is less secure than VM backends — "
"containers share the host kernel."
)
if not prompt:
die(
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
f"with docker, or install the {vm!r} backend."
)
sys.stderr.write(
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
f" [i] show {vm} install instructions and exit\n"
" [d] use docker anyway\n"
" [q] quit\n"
"bot-bottle: choice [i/d/q]: "
)
sys.stderr.flush()
reply = read_tty_line().strip().lower()
if reply == "d":
return "docker"
if reply == "i":
_print_vm_install_instructions()
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
def known_backend_names() -> tuple[str, ...]: def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_get_backends()`. Used by """Sorted tuple of all backend keys in `_BACKENDS`. Used by
argparse (`--backend` choices) and the dashboard's backend argparse (`--backend` choices) and the dashboard's backend
picker.""" picker."""
return tuple(sorted(_get_backends())) return tuple(sorted(_BACKENDS))
def has_backend(name: str) -> bool: def has_backend(name: str) -> bool:
@@ -763,10 +645,9 @@ def has_backend(name: str) -> bool:
Returns False for unknown names so callers can pass Returns False for unknown names so callers can pass
arbitrary input without separate validation.""" arbitrary input without separate validation."""
backends = _get_backends() if name not in _BACKENDS:
if name not in backends:
return False return False
return backends[name].is_available() return _BACKENDS[name].is_available()
def enumerate_active_agents() -> list[ActiveAgent]: def enumerate_active_agents() -> list[ActiveAgent]:
@@ -782,11 +663,10 @@ def enumerate_active_agents() -> list[ActiveAgent]:
deterministic tiebreaker. Agents with missing metadata deterministic tiebreaker. Agents with missing metadata
(`started_at == ""`) sort first.""" (`started_at == ""`) sort first."""
out: list[ActiveAgent] = [] out: list[ActiveAgent] = []
backends = _get_backends() for name in known_backend_names():
for name in sorted(backends): if not has_backend(name):
if not backends[name].is_available():
continue continue
out.extend(backends[name].enumerate_active()) out.extend(_BACKENDS[name].enumerate_active())
out.sort(key=lambda a: (a.started_at, a.slug)) out.sort(key=lambda a: (a.started_at, a.slug))
return out return out
-60
View File
@@ -1,60 +0,0 @@
"""Shared helpers for the consolidated launch sequence (PRD 0070).
Logic that was duplicated across the docker, macos_container, and
firecracker consolidated_launch modules — extracted so each backend
imports it rather than re-implementing it.
"""
from __future__ import annotations
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..orchestrator.client import OrchestratorClient
from ..orchestrator.registration import registration_inputs
from .docker.gateway_provision import GatewayTransport, deprovision_git_gate, provision_git_gate
def provision_bottle(
client: OrchestratorClient,
source_ip: str,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
transport: GatewayTransport,
*,
image_ref: str = "",
tokens: dict[str, str] | None = None,
):
"""Register the bottle and provision its git-gate state. Rolls back the
registration if provisioning fails so no orphan is left. Returns the
`RegisteredBottle` from the orchestrator."""
inputs = registration_inputs(egress_plan)
reg = client.register_bottle(
source_ip, image_ref=image_ref, policy=inputs.policy,
metadata=inputs.metadata, tokens=tokens,
)
try:
provision_git_gate(transport, reg.bottle_id, git_gate_plan)
except Exception:
client.teardown_bottle(reg.bottle_id)
raise
return reg
def teardown_consolidated(
bottle_id: str,
transport: GatewayTransport,
*,
orchestrator_url: str,
timeout: float | None = None,
) -> None:
"""Deregister the bottle and remove its git-gate state. Both steps are
idempotent so this is safe from a cleanup trap."""
from ..orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
OrchestratorClient(
orchestrator_url,
timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
).teardown_bottle(bottle_id)
deprovision_git_gate(transport, bottle_id)
__all__ = ["provision_bottle", "teardown_consolidated"]
+1 -10
View File
@@ -105,21 +105,12 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
def ensure_orchestrator(self) -> str:
from ...orchestrator.lifecycle import OrchestratorService
return OrchestratorService().ensure_running()
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
"""Docker bottles reach the supervise daemon via the """Docker bottles reach the supervise sidecar via the
compose-network alias `supervise:9100`. No per-bottle URL compose-network alias `supervise:9100`. No per-bottle URL
plumbing needed; the alias resolves inside the bridge.""" plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return "" return ""
# Consolidated: the supervise daemon lives on the shared gateway, so
# the agent registers the gateway address (NO_PROXY bypasses egress),
# not the per-bottle `supervise` alias.
if plan.agent_supervise_url:
return plan.agent_supervise_url
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan: def prepare_cleanup(self) -> DockerBottleCleanupPlan:
-23
View File
@@ -28,34 +28,11 @@ class DockerBottlePlan(BottlePlan):
# accidental log of the plan dataclass. # accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
use_runsc: bool use_runsc: bool
# Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the
# shared gateway's address (`http://<gateway_ip>:9420`), set at launch.
# Empty → single-tenant defaults (the `git-gate` alias over git://).
agent_git_gate_url: str = ""
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
# empty → the single-tenant `supervise` alias.
agent_supervise_url: str = ""
# Per-bottle identity token the agent presents on every attributed request
# (egress proxy credentials, git-gate/supervise headers); set by launch
# from the orchestrator registration. Empty pre-registration.
identity_token: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
return self.agent_provision.instance_name return self.agent_provision.instance_name
@property
def git_gate_insteadof_host(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
return super().git_gate_insteadof_host
@property
def git_gate_insteadof_scheme(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return "http"
return super().git_gate_insteadof_scheme
@property @property
def image(self) -> str: def image(self) -> str:
return self.agent_provision.image return self.agent_provision.image
+243 -6
View File
@@ -1,10 +1,20 @@
"""Docker compose lifecycle helpers (PRD 0018). """Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
Serialize a compose spec to disk, drive `docker compose up/down`, `bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
dump the merged log on teardown, and enumerate `bot-bottle-*` describing the per-bottle container topology — one project per
projects. The spec itself is built by `consolidated_compose.py` bottle instance, services for the agent + every applicable sidecar,
(the consolidated per-host gateway topology); this module owns the two networks, no named volumes.
I/O side that persists and runs it.
Pure function. No I/O, no subprocess. Expects every launch-time
field (network names, CA host paths, etc.) on the plan's inner
plans to be populated; chunks 2+3 own that ordering.
Conditional services follow the plan content:
- agent + sidecars bundle: always.
- git-gate: iff plan.git_gate_plan.upstreams.
- egress: iff plan.egress_plan.routes.
- supervise: iff plan.supervise_plan is not None.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,7 +25,233 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from ...egress import (
EGRESS_HOSTNAME,
EGRESS_ROUTES_IN_CONTAINER,
egress_agent_env_entries,
egress_sidecar_env_entries,
)
from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn from ...log import die, warn
from ...supervise import (
DB_PATH_IN_CONTAINER,
SUPERVISE_HOSTNAME,
SUPERVISE_PORT,
)
from ...util import expand_tilde
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan
from .egress import (
EGRESS_CA_IN_CONTAINER,
EGRESS_PORT,
)
from .git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from . import network as network_mod
from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
sidecar_bundle_container_name,
)
# Repo root, used as the build context for the bundle Dockerfile.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
"""Render a Compose v2 spec dict from a fully-resolved
DockerBottlePlan.
The plan must have its inner plans (`git_gate_plan`,
`egress_plan`, `supervise_plan`) populated with launch-time
fields — network names, CA host paths. The renderer doesn't
validate; callers feed it a fully-resolved plan or get an
incomplete compose spec back.
"""
project = f"bot-bottle-{plan.slug}"
services: dict[str, Any] = {
"sidecars": _sidecar_bundle_service(plan),
"agent": _agent_service(plan),
}
return {
"name": project,
"services": services,
"networks": _networks(plan),
}
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
"""Compose-managed networks with explicit `name:` matching the
existing slug-suffixed convention. Compose creates them on `up`
and destroys them on `down`. The internal one is `--internal`
(no default gateway); the egress one is a normal user-defined
bridge."""
return {
"internal": {
"name": network_mod.network_name_for_slug(plan.slug),
"internal": True,
},
"egress": {
"name": network_mod.network_egress_name_for_slug(plan.slug),
},
}
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
"""One bind-mount entry in the long-form `volumes:` shape.
Long form is preferred over `host:target:ro` strings because
it's easier to inspect in tests and survives whitespace in
host paths."""
return {
"type": "bind",
"source": str(host),
"target": target,
"read_only": read_only,
}
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""The `sidecars` service: one container per bottle, bundle
image, all daemons under a Python init supervisor.
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
egress is always present; git-gate / supervise are conditional.
"""
daemons: list[str] = ["egress"]
if plan.git_gate_plan.upstreams:
daemons.append("git-gate")
if plan.supervise_plan is not None:
daemons.append("supervise")
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = []
# --- egress -------------------------------------------------------
ep = plan.egress_plan
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
if ep.routes:
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
env.extend(egress_sidecar_env_entries(ep))
# --- git-gate -----------------------------------------------------
gp = plan.git_gate_plan
if gp.upstreams:
volumes += [
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
]
for u in gp.upstreams:
keypath = expand_tilde(u.identity_file)
volumes.append(_bind(
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
))
if u.known_hosts_file:
volumes.append(_bind(
u.known_hosts_file,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
))
# --- supervise ----------------------------------------------------
sp = plan.supervise_plan
if sp is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append({
"type": "bind",
"source": str(sp.db_path),
"target": DB_PATH_IN_CONTAINER,
"read_only": False,
})
internal_aliases = [EGRESS_HOSTNAME]
if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME)
if sp is not None:
internal_aliases.append(SUPERVISE_HOSTNAME)
service: dict[str, Any] = {
"image": SIDECAR_BUNDLE_IMAGE,
"build": {
"context": _REPO_DIR,
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
},
"container_name": sidecar_bundle_container_name(plan.slug),
"networks": {
"internal": {"aliases": internal_aliases},
"egress": None,
},
"environment": env,
"volumes": volumes,
}
return service
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Agent container. Runs `sleep infinity`; claude is `docker
exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
egress sidecar."""
proxy_url = _agent_proxy_url(plan)
no_proxy = _agent_no_proxy(plan)
env: list[str] = [
f"HTTPS_PROXY={proxy_url}",
f"HTTP_PROXY={proxy_url}",
f"https_proxy={proxy_url}",
f"http_proxy={proxy_url}",
f"NO_PROXY={no_proxy}",
f"no_proxy={no_proxy}",
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
]
for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}")
# Forwarded vars (OAuth token, manifest host-interpolations):
# bare name → inherits from compose-up process env, value
# never lands on argv or in the compose file.
for name in sorted(plan.forwarded_env.keys()):
env.append(name)
env.extend(egress_agent_env_entries(plan.egress_plan))
service: dict[str, Any] = {
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
"networks": {"internal": None},
"environment": env,
}
if plan.use_runsc:
service["runtime"] = "runsc"
# The init supervisor inside the bundle owns intra-bundle
# daemon ordering, so the agent only waits for the bundle
# container itself.
service["depends_on"] = ["sidecars"]
return service
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
"""Agent's HTTP_PROXY — always points at egress."""
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
"""NO_PROXY for the agent: loopback always; supervise hostname
when the supervise sidecar is up (MCP long-poll must bypass
the egress proxy)."""
hosts = ["localhost", "127.0.0.1"]
if plan.supervise_plan is not None:
hosts.append(SUPERVISE_HOSTNAME)
return ",".join(hosts)
# --- Lifecycle helpers (PRD 0018 chunk 3) ---------------------------------- # --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
@@ -206,6 +442,7 @@ __all__ = [
"COMPOSE_FILE_NAME", "COMPOSE_FILE_NAME",
"COMPOSE_LOG_NAME", "COMPOSE_LOG_NAME",
"COMPOSE_PROJECT_PREFIX", "COMPOSE_PROJECT_PREFIX",
"bottle_plan_to_compose",
"compose_down", "compose_down",
"compose_dump_logs", "compose_dump_logs",
"compose_file_path", "compose_file_path",
@@ -1,84 +0,0 @@
"""Agent-only compose for the consolidated docker backend (PRD 0070).
The per-bottle model rendered a compose project with the agent *and* a
gateway on two per-bottle networks. In the consolidated model the
per-bottle companion containers are gone — one shared gateway serves every bottle — so this renders
just the agent, attached to the **external shared gateway network** with the
pinned source IP the orchestrator allocated, and pointed at the gateway's
address for egress (and, around the proxy, for git-http / supervise).
Pure: it takes the launch-time `LaunchContext` values (gateway address,
source IP, network) and the prepared plan, and returns a compose dict — no
docker, so it's testable in isolation.
"""
from __future__ import annotations
from typing import Any
from ...egress import egress_agent_env_entries
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan
from .egress import EGRESS_PORT
def consolidated_agent_compose(
plan: DockerBottlePlan,
*,
gateway_ip: str,
source_ip: str,
network: str,
) -> dict[str, Any]:
"""A compose spec with only the agent service, on the external gateway
network at `source_ip`, proxying egress through `gateway_ip`."""
# Deliver the identity token as egress proxy credentials — the gateway
# reads Proxy-Authorization, validates the (source_ip, token) pair, and
# strips it before upstream. git-http/supervise get it via their own
# headers (git config extraHeader / MCP header).
token = getattr(plan, "identity_token", "")
cred = f"bottle:{token}@" if token else ""
proxy_url = f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
# git-http + supervise live on the gateway too and must NOT go through the
# egress proxy — the agent reaches them directly by the gateway address.
no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
env: list[str] = [
f"HTTPS_PROXY={proxy_url}",
f"HTTP_PROXY={proxy_url}",
f"https_proxy={proxy_url}",
f"http_proxy={proxy_url}",
f"NO_PROXY={no_proxy}",
f"no_proxy={no_proxy}",
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
]
for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}")
# Forwarded vars: bare name → inherits from the compose-up process env so
# the secret value never lands on argv or in the compose file.
for name in sorted(plan.forwarded_env.keys()):
env.append(name)
env.extend(egress_agent_env_entries(plan.egress_plan))
service: dict[str, Any] = {
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
# Pinned address on the shared gateway network — the orchestrator
# registered this IP, and the gateway attributes the bottle by it.
"networks": {network: {"ipv4_address": source_ip}},
"environment": env,
}
if plan.use_runsc:
service["runtime"] = "runsc"
return {
"name": f"bot-bottle-{plan.slug}",
"services": {"agent": service},
# The gateway network is created + owned by the orchestrator; compose
# attaches to it (external) and must not create or destroy it.
"networks": {network: {"external": True}},
}
__all__ = ["consolidated_agent_compose"]
@@ -1,137 +0,0 @@
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
Composes the orchestrator primitives into the register/teardown sequence:
1. ensure the single infra container (control plane + gateway) is up;
2. allocate the bottle a pinned source IP on the gateway network;
3. register it and provision its git-gate repos/creds into the gateway.
Returns a `LaunchContext` with everything the agent container needs to
attach. The agent `docker run` itself is the backend's job; this owns the
orchestrator-facing wiring so that sequence stays testable in isolation.
"""
from __future__ import annotations
from dataclasses import dataclass
from ...docker_cmd import run_docker
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...orchestrator.client import OrchestratorClient
from ...orchestrator.gateway import GATEWAY_NETWORK
from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService
from ..consolidated_util import provision_bottle
from ..consolidated_util import teardown_consolidated as _teardown_util
from .gateway_provision import DockerGatewayTransport
from .gateway_net import next_free_ip
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class LaunchContext:
"""What the agent container needs to join the shared gateway."""
bottle_id: str
identity_token: str
source_ip: str # the agent's pinned address (attribution key)
network: str # the shared gateway network to attach to
gateway_ip: str # the gateway's address — the agent's proxy target
orchestrator_url: str
def _network_cidr(network: str) -> str:
"""The gateway network's IPv4 subnet, or raise."""
proc = run_docker([
"docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network,
])
cidr = proc.stdout.strip()
if proc.returncode != 0 or not cidr:
raise ConsolidatedLaunchError(
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
)
return cidr
def _container_ip(name: str, network: str) -> str:
"""A container's IPv4 address on `network`, or raise."""
proc = run_docker([
"docker", "inspect", "--format",
f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name,
])
ip = proc.stdout.strip()
if proc.returncode != 0 or not ip:
raise ConsolidatedLaunchError(
f"container {name} has no address on {network}: {proc.stderr.strip()}"
)
return ip
def _network_container_ips(network: str) -> list[str]:
"""Every address currently assigned on the gateway network — the ground
truth for "in use": the infra container and every live agent. Read from
the network so a new bottle can't collide with anything actually attached."""
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
])
ips: list[str] = []
for entry in proc.stdout.split():
ips.append(entry.split("/", 1)[0])
return ips
def launch_consolidated(
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
*,
image_ref: str = "",
tokens: dict[str, str] | None = None,
service: OrchestratorService | None = None,
infra_name: str = INFRA_NAME,
network: str = GATEWAY_NETWORK,
) -> LaunchContext:
"""Ensure the infra container is up, allocate + register the bottle, and
provision its git-gate state. Returns the agent's attach context."""
service = service or OrchestratorService()
url = service.ensure_running()
client = OrchestratorClient(url)
cidr = _network_cidr(network)
gateway_ip = _container_ip(infra_name, network)
source_ip = next_free_ip(cidr, _network_container_ips(network))
transport = DockerGatewayTransport(infra_name)
reg = provision_bottle(
client, source_ip, egress_plan, git_gate_plan, transport,
image_ref=image_ref, tokens=tokens,
)
return LaunchContext(
bottle_id=reg.bottle_id,
identity_token=reg.identity_token,
source_ip=source_ip,
network=network,
gateway_ip=gateway_ip,
orchestrator_url=url,
)
def teardown_consolidated(
bottle_id: str, *, orchestrator_url: str, infra_name: str = INFRA_NAME,
timeout: float | None = None,
) -> None:
"""Deregister the bottle and remove its git-gate state. Idempotent."""
_teardown_util(bottle_id, DockerGatewayTransport(infra_name),
orchestrator_url=orchestrator_url, timeout=timeout)
__all__ = [
"LaunchContext",
"launch_consolidated",
"teardown_consolidated",
"ConsolidatedLaunchError",
]
+1 -1
View File
@@ -4,7 +4,7 @@ prepare-time routes-yaml rendering itself lives on the
platform-neutral `Egress` ABC — backends instantiate it directly. platform-neutral `Egress` ABC — backends instantiate it directly.
The per-container `.start()` / `.stop()` lifecycle was removed in The per-container `.start()` / `.stop()` lifecycle was removed in
PRD 0024 chunk 3; the gateway (PRD 0024) runs egress PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
under its python init supervisor.""" under its python init supervisor."""
from __future__ import annotations from __future__ import annotations
+44 -13
View File
@@ -1,29 +1,60 @@
"""Host-side egress route-apply for the docker backend. """Host-side helper for egress sidecar inspection and live updates.
The per-bottle companion container this used to signal (`docker kill The approve path uses this module to validate a proposed routes file,
--signal HUP <container>`) was removed in the companion-container removal (#385). write it to the bottle's live egress state dir, and signal the sidecar
In the consolidated model the shared gateway resolves egress policy bundle so the mitmproxy addon reloads it.
per-request against the orchestrator rather than reloading a per-bottle
routes file, so the live per-bottle reload is not supported here and
fails closed until the gateway-side apply lands.
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess
from ...egress import EGRESS_ROUTES_IN_CONTAINER
from ...log import warn
from ..egress_apply import EgressApplicator, EgressApplyError from ..egress_apply import EgressApplicator, EgressApplyError
from .sidecar_bundle import sidecar_bundle_container_name
def fetch_current_routes(slug: str) -> str:
container = sidecar_bundle_container_name(slug)
r = subprocess.run(
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
raise EgressApplyError(
f"could not read routes.yaml from {container}: "
f"{(r.stderr or '').strip() or 'container not running?'}"
)
return r.stdout
class DockerEgressApplicator(EgressApplicator): class DockerEgressApplicator(EgressApplicator):
def _signal_bundle_reload(self, slug: str) -> None: def _signal_bundle_reload(self, slug: str) -> None:
del slug container = sidecar_bundle_container_name(slug)
raise EgressApplyError( result = subprocess.run(
"live egress route-apply was removed with the per-bottle " ["docker", "kill", "--signal", "HUP", container],
"companion container (#385); route changes will flow through " capture_output=True, text=True, check=False, env=os.environ,
"the consolidated gateway in a follow-up."
) )
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'docker kill failed'}"
)
raise EgressApplyError(
f"could not reload egress bundle {container}: "
f"{last_error or 'docker kill failed'}"
)
applicator = DockerEgressApplicator() applicator = DockerEgressApplicator()
__all__ = ["DockerEgressApplicator", "EgressApplyError", "applicator"] __all__ = [
"DockerEgressApplicator",
"EgressApplyError",
"applicator",
"fetch_current_routes",
]
-47
View File
@@ -1,47 +0,0 @@
"""Shared-gateway source-IP allocation for the consolidated docker backend
(PRD 0070).
In the consolidated model one gateway container serves every bottle over a
single shared docker network, and each agent bottle attaches with a pinned,
deterministic address that the gateway uses as its **attribution key**. This
allocates those addresses from the network's subnet, skipping the reserved
ones — the network address and broadcast (excluded by `hosts()`), docker's
router `.1`, and everything already in use (`taken`: the gateway container
plus every live bottle, which the caller reads from the registry).
Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the
gateway container's own address) are gathered by the caller and passed in, so
this stays testable without docker.
"""
from __future__ import annotations
import ipaddress
from collections.abc import Iterable
class NoFreeAddressError(RuntimeError):
"""The shared gateway network's subnet is exhausted — every host address
is reserved or already assigned to a bottle."""
def next_free_ip(cidr: str, taken: Iterable[str]) -> str:
"""The lowest host address in `cidr` not in `taken` and not docker's
router (`.1`). `taken` must include the gateway container's own address
and every live bottle's. Raises `NoFreeAddressError` if the subnet is
full."""
net = ipaddress.ip_network(cidr, strict=False)
reserved = {str(a) for a in taken}
# Docker assigns the network's first host (.1) to the bridge router; a
# bottle must never be handed that address.
reserved.add(str(net.network_address + 1))
for host in net.hosts(): # hosts() already excludes network + broadcast
candidate = str(host)
if candidate not in reserved:
return candidate
raise NoFreeAddressError(
f"no free address in {cidr} ({len(reserved)} reserved/assigned)"
)
__all__ = ["next_free_ip", "NoFreeAddressError"]
@@ -1,132 +0,0 @@
"""Provision one bottle's git-gate state into the running shared gateway
(PRD 0070, docker slice).
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
is registered the launcher must place *its* deploy keys + known_hosts into
that per-bottle creds dir and init its bare repos there — so this copies the
credential files into the live gateway container and runs the (namespaced,
init-only) provisioning script produced by `git_gate_render_provision`.
Isolating each bottle's creds dir + repo root by id is what keeps one
bottle's push credentials out of another's repos on the shared gateway.
"""
from __future__ import annotations
import re
from typing import Protocol
from ...docker_cmd import run_docker
from ...git_gate import GitGatePlan, git_gate_render_provision
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
# exec/cp path arguments, so validate before any path is built (a traversal
# id like "../etc" must never reach the gateway). Registry ids are token_hex —
# this is defense in depth at the transport boundary.
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
class GatewayProvisionError(RuntimeError):
"""A git-gate provisioning step against the running gateway failed."""
class GatewayTransport(Protocol):
"""How the launcher stages files + runs commands in the running gateway.
Backend-neutral so the same provisioning logic serves the docker gateway
(exec/cp over the docker socket) and the firecracker gateway VM (over
SSH)."""
def exec(self, argv: list[str]) -> None:
"""Run `argv` in the gateway, raising `GatewayProvisionError` on
failure."""
def cp_into(self, src: str, dest: str) -> None:
"""Copy host file `src` to `dest` in the gateway, raising on
failure."""
class DockerGatewayTransport:
"""`GatewayTransport` for the docker gateway container (exec/cp)."""
def __init__(self, gateway: str) -> None:
self.gateway = gateway
def exec(self, argv: list[str]) -> None:
proc = run_docker(["docker", "exec", self.gateway, *argv])
if proc.returncode != 0:
raise GatewayProvisionError(
f"gateway exec {argv!r} failed: {proc.stderr.strip()}"
)
def cp_into(self, src: str, dest: str) -> None:
proc = run_docker(["docker", "cp", src, f"{self.gateway}:{dest}"])
if proc.returncode != 0:
raise GatewayProvisionError(
f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}"
)
def _require_safe(bottle_id: str) -> None:
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
def _creds_dir(bottle_id: str) -> str:
return f"/git-gate/creds/{bottle_id}"
def provision_git_gate(
transport: GatewayTransport, bottle_id: str, plan: GitGatePlan,
) -> None:
"""Place `bottle_id`'s git-gate credentials into the running gateway and
init its bare repos under `/git/<bottle_id>/`.
Copies each upstream's identity key (and known_hosts, when present) into
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
script. No-op for a bottle with no git upstreams."""
_require_safe(bottle_id)
if not plan.upstreams:
return
# The pre-receive + access hooks are bottle-agnostic and shared by every
# bottle's repos; install them into the gateway (idempotent — same content
# each time). The per-bottle model cp'd these into each bundle at start.
transport.exec(["mkdir", "-p", "/etc/git-gate"])
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
# The access-hook is exec'd directly (not via `sh`), so it needs the x bit.
# Set it here rather than trusting the copy to carry the staged 0o700:
# `docker cp` preserves source mode, but the Apple `container cp` does not,
# landing the hook 0o644 → EACCES when the git-http handler tries to exec it.
# chmod on the gateway side is backend-neutral and fixes every transport.
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
creds = _creds_dir(bottle_id)
transport.exec(["mkdir", "-p", creds])
for u in plan.upstreams:
if u.identity_file:
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
known_hosts = str(u.known_hosts_file)
if known_hosts and known_hosts != ".":
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
# Init the bare repos + per-repo credential config for this namespace.
script = git_gate_render_provision(bottle_id, plan.upstreams)
transport.exec(["sh", "-c", script])
def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None:
"""Remove a bottle's repos + creds from the gateway on teardown. Idempotent
— an already-absent namespace is a clean no-op (best effort; a stray dir
can't leak, since attribution is by source IP and the bottle is gone)."""
_require_safe(bottle_id)
try:
transport.exec([
"rm", "-rf", f"/git/{bottle_id}", _creds_dir(bottle_id),
])
except GatewayProvisionError:
pass # best-effort teardown; absent namespace is success
__all__ = [
"provision_git_gate", "deprovision_git_gate",
"GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport",
]
+1 -1
View File
@@ -2,7 +2,7 @@
bind-mounts target + the listening port. The prepare-time entrypoint bind-mounts target + the listening port. The prepare-time entrypoint
/ hook render lives on the platform-neutral `GitGate` ABC — backends / hook render lives on the platform-neutral `GitGate` ABC — backends
instantiate it directly. The git-gate daemon's container lifecycle instantiate it directly. The git-gate daemon's container lifecycle
is owned by the gateway (PRD 0024).""" is owned by the sidecar bundle (PRD 0024)."""
from __future__ import annotations from __future__ import annotations
+62 -66
View File
@@ -5,7 +5,7 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
The flow is: The flow is:
1. Build the agent image from the provider Dockerfile (compose 1. Build the agent image from the provider Dockerfile (compose
builds the gateway image on first up). builds the sidecar images via the `build:` directive on first up).
2. Mint the per-bottle egress CA (chunk 2 writes it under 2. Mint the per-bottle egress CA (chunk 2 writes it under
state/<slug>/egress/). state/<slug>/egress/).
3. Populate the inner plans with launch-time fields so the 3. Populate the inner plans with launch-time fields so the
@@ -36,13 +36,13 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...egress import egress_resolve_token_values from ...egress import egress_resolve_token_values
from ...git_gate import ( from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...log import info, warn
from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -53,6 +53,7 @@ from ...bottle_state import (
read_committed_image, read_committed_image,
) )
from .compose import ( from .compose import (
bottle_plan_to_compose,
compose_down, compose_down,
compose_dump_logs, compose_dump_logs,
compose_file_path, compose_file_path,
@@ -61,10 +62,7 @@ from .compose import (
compose_up, compose_up,
write_compose_file, write_compose_file,
) )
from .consolidated_compose import consolidated_agent_compose from .egress import egress_tls_init
from ...orchestrator.config_store import resolve_teardown_timeout
from .consolidated_launch import launch_consolidated, teardown_consolidated
from ...orchestrator.gateway import DockerGateway
# Where the repo root lives, for `docker build` context. Computed once. # Where the repo root lives, for `docker build` context. Computed once.
@@ -99,7 +97,8 @@ def launch(
try: try:
# Step 1: agent image. Use a committed snapshot when one exists # Step 1: agent image. Use a committed snapshot when one exists
# and is present in the local daemon; otherwise build from the # and is present in the local daemon; otherwise build from the
# Dockerfile. (The gateway image is built by the orchestrator.) # Dockerfile. Sidecar images get built lazily by `docker compose
# up` via the renderer's `build:` directives.
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed): if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}") info(f"using committed image {committed!r}")
@@ -112,90 +111,83 @@ def launch(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before internal_network = network_mod.network_name_for_slug(plan.slug)
# provisioning the bottle's repos into the shared gateway. egress_network = network_mod.network_egress_name_for_slug(plan.slug)
egress_ca_host, egress_ca_cert_only = egress_tls_init(
egress_state_dir(plan.slug),
)
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys( git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug), plan.manifest.bottle,
git_gate_plan,
git_gate_state_dir(plan.slug),
)
git_gate_plan = dataclasses.replace(
git_gate_plan,
internal_network=internal_network,
egress_network=egress_network,
) )
# Step 3: register on the orchestrator + provision this bottle's
# git-gate state into the shared gateway; get the agent's attach
# context (pinned source IP, gateway address, shared network). The
# per-bottle egress auth tokens are resolved from the host env now and
# handed to the orchestrator (in memory) for the gateway to inject —
# the agent never sees them.
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
ctx = launch_consolidated(
plan.egress_plan, git_gate_plan, image_ref=plan.image, tokens=token_values,
)
stack.callback(
teardown_consolidated, ctx.bottle_id,
orchestrator_url=ctx.orchestrator_url,
timeout=teardown_timeout,
)
# Step 4: install the SHARED gateway CA into the agent (replaces the
# per-bottle CA) — read it out of the running gateway.
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem"
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
egress_plan = dataclasses.replace( egress_plan = dataclasses.replace(
plan.egress_plan, plan.egress_plan,
mitmproxy_ca_host_path=ca_file, internal_network=internal_network,
mitmproxy_ca_cert_only_host_path=ca_file, egress_network=egress_network,
) mitmproxy_ca_host_path=egress_ca_host,
# Point the agent's git-gate insteadOf rewrites at the shared gateway's mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
# HTTP git endpoint (9420) instead of the dead per-bottle `git-gate`
# alias. git-http + supervise on the gateway bypass the egress proxy
# (NO_PROXY includes the gateway address).
git_gate_url = (
f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else ""
)
supervise_url = (
f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else ""
) )
supervise_plan = plan.supervise_plan
if supervise_plan is not None:
supervise_plan = dataclasses.replace(
supervise_plan,
internal_network=internal_network,
)
plan = dataclasses.replace( plan = dataclasses.replace(
plan, plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
agent_git_gate_url=git_gate_url, supervise_plan=supervise_plan,
agent_supervise_url=supervise_url,
identity_token=ctx.identity_token,
) )
# Step 5: render + up the agent-only compose, pinned on the shared # Step 6: render + write the compose file. metadata.json
# gateway network and proxied through the gateway's address. # was written at prepare time and already carries
# compose_project; nothing to update here.
state_dir = bottle_state_dir(plan.slug) state_dir = bottle_state_dir(plan.slug)
spec = consolidated_agent_compose( spec = bottle_plan_to_compose(plan)
plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network,
)
compose_file = write_compose_file(spec, compose_file_path(state_dir)) compose_file = write_compose_file(spec, compose_file_path(state_dir))
project = compose_project_name(plan.slug) project = compose_project_name(plan.slug)
# Forwarded vars (OAuth token, host interpolations) flow through the
# subprocess env as bare names so values never land in the file. # Step 7: compose up. Token values + the OAuth placeholder
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env} # flow through subprocess env; the compose file holds only
# bare names for the secret-carrying entries.
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
compose_env: dict[str, str] = {
**os.environ,
**plan.forwarded_env,
**token_values,
}
info( info(
f"docker compose up -d (project {project}, agent on shared " f"docker compose up -d (project {project}, "
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})" f"{len(spec['services'])} services)"
) )
compose_up(project, compose_file, env=compose_env) compose_up(project, compose_file, env=compose_env)
# Register teardown in reverse order: log dump first, then
# `compose down`. Networks come down last via callbacks
# registered in step 2.
stack.callback(compose_down, project, compose_file) stack.callback(compose_down, project, compose_file)
stack.callback( stack.callback(
compose_dump_logs, project, compose_file, compose_log_path(state_dir), compose_dump_logs, project, compose_file, compose_log_path(state_dir),
) )
# Step 6: provision (CA install now uses the gateway CA) + yield. # Step 8: provision. Create the bottle first so provisioners
# can use bottle.exec / bottle.cp_in; set the prompt path
# returned by provision_prompt after the fact.
bottle = DockerBottle( bottle = DockerBottle(
plan.container_name, plan.container_name,
teardown, teardown,
@@ -208,6 +200,10 @@ def launch(
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
bottle.prompt_path = provision(plan, bottle) bottle.prompt_path = provision(plan, bottle)
# Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's
# service spec.
yield bottle yield bottle
finally: finally:
teardown() teardown()
+1 -1
View File
@@ -76,7 +76,7 @@ def network_create_internal(slug: str) -> str:
def network_create_egress(slug: str) -> str: def network_create_egress(slug: str) -> str:
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`) """Create a per-agent user-defined bridge (NOT the legacy `bridge`)
so the egress daemon has working DNS for upstream hostnames.""" so the egress sidecar has working DNS for upstream hostnames."""
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
+7 -11
View File
@@ -1,7 +1,7 @@
"""Host setup + status for the Docker backend. """Host setup + status for the Docker backend.
Unlike Firecracker, the Docker backend needs no privileged one-time Unlike Firecracker, the Docker backend needs no privileged one-time
host provisioning (no TAP pool / nft table) — networks and the gateway host provisioning (no TAP pool / nft table) — networks and the sidecar
bundle are created per-launch. So `setup()` is mostly an install/daemon bundle are created per-launch. So `setup()` is mostly an install/daemon
pointer, and `status()` reports whether docker is usable. pointer, and `status()` reports whether docker is usable.
@@ -27,14 +27,10 @@ def _docker_on_path() -> bool:
def _daemon_reachable() -> bool: def _daemon_reachable() -> bool:
if not _docker_on_path(): if not _docker_on_path():
return False return False
try: return subprocess.run(
return subprocess.run( ["docker", "info"],
["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode == 0
check=False, timeout=5,
).returncode == 0
except subprocess.TimeoutExpired:
return False
def _print_install_pointer() -> None: def _print_install_pointer() -> None:
@@ -49,7 +45,7 @@ def setup() -> int:
return 1 return 1
sys.stderr.write( sys.stderr.write(
"Docker backend: no privileged host setup required — networks and " "Docker backend: no privileged host setup required — networks and "
"the gateway are created per-launch.\n" "the sidecar bundle are created per-launch.\n"
) )
if not _daemon_reachable(): if not _daemon_reachable():
sys.stderr.write( sys.stderr.write(
@@ -68,7 +64,7 @@ def setup() -> int:
def teardown() -> int: def teardown() -> int:
sys.stderr.write( sys.stderr.write(
"Docker backend: nothing to undo — it provisions no privileged host " "Docker backend: nothing to undo — it provisions no privileged host "
"state (networks and the gateway are per-launch and are " "state (networks and the sidecar bundle are per-launch and are "
"removed by `./cli.py cleanup`). Docker itself is left installed.\n" "removed by `./cli.py cleanup`). Docker itself is left installed.\n"
) )
return 0 return 0
@@ -0,0 +1,30 @@
"""Sidecar bundle constants + helpers for the Docker backend
(PRD 0024).
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs egress + git-gate + supervise as one container per bottle
under a small Python init supervisor. As of chunk 5 the bundle
is the only shape — the legacy four-sidecar topology and its
`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
from __future__ import annotations
import os
# Bundle image. Defaults to a built-locally tag (built from the
# repo's Dockerfile.sidecars via compose `build:`). Operators
# pinning to a published digest can override via env.
SIDECAR_BUNDLE_IMAGE = os.environ.get(
"BOT_BOTTLE_SIDECAR_IMAGE",
"bot-bottle-sidecars:latest",
)
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
def sidecar_bundle_container_name(slug: str) -> str:
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
per-sidecar containers it replaces, so the dashboard's
discovery-by-prefix logic keeps working."""
return f"bot-bottle-sidecars-{slug}"
+35 -58
View File
@@ -4,13 +4,11 @@ existence, and building images."""
from __future__ import annotations from __future__ import annotations
import os
import re import re
import shutil import shutil
import subprocess import subprocess
from typing import Iterator from typing import Iterable, Iterator
from ...docker_cmd import run_docker
from ...log import die, info from ...log import die, info
# from ...workspace import WorkspacePlan # from ...workspace import WorkspacePlan
@@ -32,7 +30,12 @@ def container_name_candidates(base: str) -> Iterator[str]:
def runsc_available() -> bool: def runsc_available() -> bool:
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime """Return True if the Docker daemon has the gVisor (`runsc`) runtime
registered. Called once per prepare; the result lives on the plan.""" registered. Called once per prepare; the result lives on the plan."""
r = run_docker(["docker", "info", "--format", "{{json .Runtimes}}"]) r = subprocess.run(
["docker", "info", "--format", "{{json .Runtimes}}"],
capture_output=True,
text=True,
check=False,
)
return r.returncode == 0 and "runsc" in r.stdout return r.returncode == 0 and "runsc" in r.stdout
@@ -46,15 +49,20 @@ def require_docker() -> None:
def image_exists(ref: str) -> bool: def image_exists(ref: str) -> bool:
return run_docker(["docker", "image", "inspect", ref]).returncode == 0 return _silent_run(["docker", "image", "inspect", ref]) == 0
def container_exists(name: str) -> bool: def container_exists(name: str) -> bool:
"""Returns True if a container (running or stopped) with the given """Returns True if a container (running or stopped) with the given
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
matches don't false-positive.""" matches don't false-positive."""
result = run_docker(["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"]) result = subprocess.run(
return result.returncode == 0 and bool(result.stdout.strip()) ["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
capture_output=True,
text=True,
check=True,
)
return bool(result.stdout.strip())
def force_remove_container(name: str) -> None: def force_remove_container(name: str) -> None:
@@ -62,7 +70,12 @@ def force_remove_container(name: str) -> None:
doesn't — and the rm itself is best-effort (errors swallowed) so doesn't — and the rm itself is best-effort (errors swallowed) so
this is safe to register as a teardown callback.""" this is safe to register as a teardown callback."""
if container_exists(name): if container_exists(name):
run_docker(["docker", "rm", "-f", name]) subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
def docker_exec_root(container: str, argv: list[str]) -> None: def docker_exec_root(container: str, argv: list[str]) -> None:
@@ -75,29 +88,6 @@ def docker_exec_root(container: str, argv: list[str]) -> None:
) )
def docker_exec(container: str, argv: list[str], *, user: str = "") -> None:
"""Run `docker exec` in the named container, dying with the
command's own stderr on failure. Pass `user=\"0\"` to run as root."""
cmd = ["docker", "exec"]
if user:
cmd += ["-u", user]
cmd += [container, *argv]
result = run_docker(cmd)
if result.returncode != 0:
die(
f"docker exec in {container} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def docker_cp(src: str, dest: str) -> None:
"""Run `docker cp`, dying with the command's own stderr on failure."""
result = run_docker(["docker", "cp", src, dest])
if result.returncode != 0:
die(f"docker cp {src} -> {dest} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}")
_SLUG_RE = re.compile(r"[^a-z0-9]+") _SLUG_RE = re.compile(r"[^a-z0-9]+")
@@ -118,40 +108,15 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
`dockerfile` is an optional path (relative to `context`, or `dockerfile` is an optional path (relative to `context`, or
absolute) for callers that need to build from a non-default absolute) for callers that need to build from a non-default
Dockerfile in the same context — e.g. `Dockerfile.git-gate`. Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
`--no-cache`. The npm/curl installers some provider Dockerfiles
shell out to can silently no-op on a transient network failure —
e.g. an `optionalDependencies` fetch for a platform-native binary —
and Docker will then cache that broken layer indefinitely."""
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)") info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
args = ["docker", "build", "-t", ref] args = ["docker", "build", "-t", ref]
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
args.append("--no-cache")
if dockerfile: if dockerfile:
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
args.append(context) args.append(context)
subprocess.run(args, check=True) subprocess.run(args, check=True)
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
whose CLI only breaks at first real use. No-op when the provider
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
if not argv:
return
result = run_docker(["docker", "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]])
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(
f"agent image {image!r} failed its post-build smoke test "
f"({' '.join(argv)}): {detail}\n"
f"Try rebuilding from scratch: bot-bottle start --no-cache"
)
# def build_image_with_cwd( # def build_image_with_cwd(
# derived: str, # derived: str,
# base: str, # base: str,
@@ -190,10 +155,22 @@ def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
def commit_container(container_name: str, image_tag: str) -> None: def commit_container(container_name: str, image_tag: str) -> None:
"""Run `docker commit <container_name> <image_tag>` to snapshot the """Run `docker commit <container_name> <image_tag>` to snapshot the
running container's filesystem state as a local Docker image.""" running container's filesystem state as a local Docker image."""
result = run_docker(["docker", "commit", container_name, image_tag]) result = subprocess.run(
["docker", "commit", container_name, image_tag],
capture_output=True, text=True, check=False,
)
if result.returncode != 0: if result.returncode != 0:
die( die(
f"docker commit {container_name!r}{image_tag!r} failed: " f"docker commit {container_name!r}{image_tag!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}" f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
info(f"committed {container_name!r}{image_tag!r}") info(f"committed {container_name!r}{image_tag!r}")
def _silent_run(cmd: Iterable[str]) -> int:
return subprocess.run(
list(cmd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode
@@ -110,7 +110,3 @@ class FirecrackerBottleBackend(
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str: def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
def ensure_orchestrator(self) -> str:
from . import infra_vm
return infra_vm.ensure_running().control_plane_url
+6 -13
View File
@@ -7,7 +7,7 @@ session. `ssh -t` forwards the host terminal's SIGWINCH to the remote
PTY natively, so no separate resize bridge is needed. PTY natively, so no separate resize bridge is needed.
Commands run as the image's `node` user via `runuser`, with HOME/USER/ Commands run as the image's `node` user via `runuser`, with HOME/USER/
PATH and the bottle env (HTTPS_PROXY at the gateway, CA paths, …) set PATH and the bottle env (HTTPS_PROXY at the sidecar, CA paths, …) set
per-invocation through `env` (the VM itself just runs the init; it has per-invocation through `env` (the VM itself just runs the init; it has
no baked-in process env like a `docker run` container would). no baked-in process env like a `docker run` container would).
""" """
@@ -105,9 +105,10 @@ class FirecrackerBottle(Bottle):
# root-owned and unreadable by node, which breaks Node's # root-owned and unreadable by node, which breaks Node's
# process.cwd(), the shell-snapshot machinery, and `/doctor`. # process.cwd(), the shell-snapshot machinery, and `/doctor`.
# Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'` # Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'`
# wrapper: it keeps the guest command a flat argv that `agent_argv` # wrapper: ssh space-joins everything after the host into one
# can quote token-by-token for the ssh→guest-shell round trip, # string for the guest shell, so a quoted script + $@ would be
# avoiding a fragile nested-quoting `"$@"` script. # re-split and mangled (exec'ing the $0 placeholder). All-simple
# words survive that join.
workdir = self.agent_workdir or _HOME_FOR["node"] workdir = self.agent_workdir or _HOME_FOR["node"]
remote = ["runuser", "-u", "node", "--", remote = ["runuser", "-u", "node", "--",
"env", f"--chdir={workdir}", "env", f"--chdir={workdir}",
@@ -116,15 +117,7 @@ class FirecrackerBottle(Bottle):
return remote return remote
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]: def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
# ssh space-joins everything after the host into one line the guest return [*self._ssh(tty=tty), "--", *self._agent_remote_argv(argv)]
# shell re-parses, so pre-quote each remote token for that shell.
# Simple words are unchanged (existing behaviour); an arg containing
# spaces — e.g. codex's `read_prompt_file` positional "Read and follow
# the instructions in <path>." — is quoted so it survives as ONE
# argument instead of being re-split (which made codex parse "and" as
# a subcommand).
remote = self._agent_remote_argv(argv)
return [*self._ssh(tty=tty), "--", *(shlex.quote(t) for t in remote)]
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
agent_argv = self.agent_argv(argv, tty=tty) agent_argv = self.agent_argv(argv, tty=tty)
@@ -10,9 +10,10 @@ from .. import BottleCleanupPlan
@dataclass(frozen=True) @dataclass(frozen=True)
class FirecrackerBottleCleanupPlan(BottleCleanupPlan): class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
# PIDs of orphaned firecracker VMM processes and the per-bottle run # PIDs of orphaned firecracker VMM processes and the sidecar
# dirs left behind by previous bottles. # containers left behind by previous bottles.
vm_pids: tuple[int, ...] = () vm_pids: tuple[int, ...] = ()
containers: tuple[str, ...] = ()
run_dirs: tuple[str, ...] = () run_dirs: tuple[str, ...] = ()
def print(self) -> None: def print(self) -> None:
@@ -21,9 +22,11 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
return return
for pid in self.vm_pids: for pid in self.vm_pids:
info(f"firecracker VM process: pid {pid}") info(f"firecracker VM process: pid {pid}")
for name in self.containers:
info(f"firecracker sidecar container: {name}")
for path in self.run_dirs: for path in self.run_dirs:
info(f"firecracker run dir: {path}") info(f"firecracker run dir: {path}")
@property @property
def empty(self) -> bool: def empty(self) -> bool:
return not (self.vm_pids or self.run_dirs) return not (self.vm_pids or self.containers or self.run_dirs)
@@ -13,19 +13,15 @@ from .. import BottlePlan
class FirecrackerBottlePlan(BottlePlan): class FirecrackerBottlePlan(BottlePlan):
slug: str slug: str
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
# Stamped by launch once the gateway is up and its ports are # Stamped by launch once the sidecar is up and its ports are
# published on the host-side TAP IP (empty at prepare time). # published on the host-side TAP IP (empty at prepare time).
agent_proxy_url: str = "" agent_proxy_url: str = ""
agent_git_gate_url: str = "" agent_git_gate_url: str = ""
agent_supervise_url: str = "" agent_supervise_url: str = ""
# Per-bottle identity token the agent presents on every attributed request
# (egress proxy credentials, git-gate/supervise headers); set by launch
# from the orchestrator registration. Empty pre-registration.
identity_token: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
"""Instance name, reused for the gateway container + VM run dir. """Instance name, reused for the sidecar container + VM run dir.
Matches the `bot-bottle-<slug>` convention the other backends Matches the `bot-bottle-<slug>` convention the other backends
use so cleanup/enumerate discovery-by-prefix keeps working.""" use so cleanup/enumerate discovery-by-prefix keeps working."""
return self.agent_provision.instance_name return self.agent_provision.instance_name
+23 -2
View File
@@ -1,8 +1,9 @@
"""Cleanup for the Firecracker backend. """Cleanup for the Firecracker backend.
Orphans are: firecracker VMM processes whose config lives under our run Orphans are: firecracker VMM processes whose config lives under our run
dir, and the per-bottle run dirs. TAP slots free themselves (the flock dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run
drops when the launcher exits), so there is nothing to reclaim there. dirs. TAP slots free themselves (the flock drops when the launcher
exits), so there is nothing to reclaim there.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +18,8 @@ from ...log import info
from . import util from . import util
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
def _run_root() -> Path: def _run_root() -> Path:
return util.cache_dir() / "run" return util.cache_dir() / "run"
@@ -43,6 +46,17 @@ def _orphan_vm_pids() -> list[int]:
return pids return pids
def _sidecar_containers() -> list[str]:
result = subprocess.run(
["docker", "ps", "-a", "--format", "{{.Names}}",
"--filter", f"name={_SIDECAR_PREFIX}"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return []
return sorted(n.strip() for n in result.stdout.splitlines() if n.strip())
def _run_dirs() -> list[str]: def _run_dirs() -> list[str]:
run_root = _run_root() run_root = _run_root()
if not run_root.is_dir(): if not run_root.is_dir():
@@ -53,6 +67,7 @@ def _run_dirs() -> list[str]:
def prepare_cleanup() -> FirecrackerBottleCleanupPlan: def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
return FirecrackerBottleCleanupPlan( return FirecrackerBottleCleanupPlan(
vm_pids=tuple(_orphan_vm_pids()), vm_pids=tuple(_orphan_vm_pids()),
containers=tuple(_sidecar_containers()),
run_dirs=tuple(_run_dirs()), run_dirs=tuple(_run_dirs()),
) )
@@ -64,6 +79,12 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
except ProcessLookupError: except ProcessLookupError:
pass pass
for name in plan.containers:
info(f"docker rm -f {name}")
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
for path in plan.run_dirs: for path in plan.run_dirs:
info(f"rm -rf {path}") info(f"rm -rf {path}")
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
@@ -1,103 +0,0 @@
"""Consolidated bottle launch sequence for the Firecracker backend
(PRD 0070, Stage B).
The shared gateway + orchestrator control plane run in a single persistent
per-host **infra VM** (`infra_vm.py`), not Docker containers. Agent VMs reach
the gateway's egress / supervise / git-http ports at the infra VM via a
PREROUTING DNAT on their own host-side TAP IP (see
`scripts/firecracker-netpool.sh`), and the host CLI reaches the control plane
over HTTP at the infra VM's guest IP.
Attribution is by the agent VM's guest IP, unspoofable by construction: the
/31 point-to-point TAP + the `bot_bottle_fc` nft table ensure only the
expected VM can source-IP that address.
Sequence:
1. ensure the infra VM (control plane + gateway) is up (a singleton — a
prior launcher may already have booted it);
2. register the bottle by its guest IP (attribution key) → bottle id +
identity token;
3. provision its git-gate repos/creds into the gateway VM (over SSH);
4. fetch the shared gateway CA for the provisioner to install in the rootfs.
The TAP slot allocation, rootfs build, and VM boot are the caller's job.
"""
from __future__ import annotations
from dataclasses import dataclass
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...orchestrator.client import OrchestratorClient
from ...orchestrator.lifecycle import (
OrchestratorStartError, # re-exported so callers can catch it
)
from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
from . import infra_vm
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class LaunchContext:
"""What the Firecracker launch needs from the consolidated sequence."""
bottle_id: str
identity_token: str
source_ip: str # the VM's guest IP — the attribution key
gateway_ca_pem: str # the shared gateway CA the provisioner installs
orchestrator_url: str
def launch_consolidated(
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
*,
guest_ip: str,
image_ref: str = "",
tokens: dict[str, str] | None = None,
) -> LaunchContext:
"""Ensure the infra VM is up, register the bottle by its guest IP, and
provision its git-gate state into the gateway VM. Returns the context the
agent-VM launch needs. Raises on failure — the caller tears down."""
infra = infra_vm.ensure_running()
url = infra.control_plane_url
client = OrchestratorClient(url)
transport = infra_vm.gateway_transport()
reg = provision_bottle(
client, guest_ip, egress_plan, git_gate_plan, transport,
image_ref=image_ref, tokens=tokens,
)
# The shared gateway CA every agent on this host trusts for TLS
# interception — fetched from the infra VM over SSH.
return LaunchContext(
bottle_id=reg.bottle_id,
identity_token=reg.identity_token,
source_ip=guest_ip,
gateway_ca_pem=infra.gateway_ca_pem(),
orchestrator_url=url,
)
def teardown_consolidated(
bottle_id: str, *, orchestrator_url: str, timeout: float | None = None,
) -> None:
"""Deregister the bottle and remove its git-gate state from the gateway
VM. Both steps are idempotent so this is safe from a cleanup trap. Does
NOT stop the infra VM — it's a persistent per-host singleton shared by
every bottle."""
_teardown_util(bottle_id, infra_vm.gateway_transport(),
orchestrator_url=orchestrator_url, timeout=timeout)
__all__ = [
"LaunchContext",
"launch_consolidated",
"teardown_consolidated",
"ConsolidatedLaunchError",
"OrchestratorStartError",
]
+33 -4
View File
@@ -1,14 +1,43 @@
"""Active-agent enumeration for the Firecracker backend. """Active-agent enumeration for the Firecracker backend.
The backend is disabled during the companion-container removal (#385) — it can't The agent runs in a VM (no container to list), so a live bottle is
launch bottles, so there are none to enumerate. Real enumeration returns identified by its running sidecar container `bot-bottle-sidecars-<slug>`
with the backend's consolidated relaunch (#354). the same discovery-by-prefix the other backends use.
""" """
from __future__ import annotations from __future__ import annotations
import subprocess
from ...bottle_state import read_metadata
from .. import ActiveAgent from .. import ActiveAgent
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
def enumerate_active() -> list[ActiveAgent]: def enumerate_active() -> list[ActiveAgent]:
return [] result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}",
"--filter", f"name={_SIDECAR_PREFIX}"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return []
out: list[ActiveAgent] = []
for name in sorted(n.strip() for n in result.stdout.splitlines() if n.strip()):
slug = name[len(_SIDECAR_PREFIX):]
metadata = read_metadata(slug)
if metadata is None or metadata.backend != "firecracker":
# Skip sidecars owned by another backend (docker shares the
# container-name prefix).
continue
out.append(ActiveAgent(
backend_name="firecracker",
slug=slug,
agent_name=metadata.agent_name,
started_at=metadata.started_at,
services=(),
label=metadata.label,
color=metadata.color,
))
return out
@@ -78,32 +78,20 @@ def _config(
vcpus: int, vcpus: int,
mem_mib: int, mem_mib: int,
guest_mac: str, guest_mac: str,
data_drive: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
drives: list[dict[str, object]] = [
{
"drive_id": "rootfs",
"path_on_host": str(rootfs),
"is_root_device": True,
"is_read_only": False,
}
]
# A second virtio-block device (guest /dev/vdb) — the infra VM's
# persistent registry "volume", a host-side ext4 file that outlives the
# ephemeral rootfs across VM restarts.
if data_drive is not None:
drives.append({
"drive_id": "data",
"path_on_host": str(data_drive),
"is_root_device": False,
"is_read_only": False,
})
return { return {
"boot-source": { "boot-source": {
"kernel_image_path": str(util.kernel_path()), "kernel_image_path": str(util.kernel_path()),
"boot_args": _boot_args(guest_ip, host_ip, pubkey), "boot_args": _boot_args(guest_ip, host_ip, pubkey),
}, },
"drives": drives, "drives": [
{
"drive_id": "rootfs",
"path_on_host": str(rootfs),
"is_root_device": True,
"is_read_only": False,
}
],
"network-interfaces": [ "network-interfaces": [
{ {
"iface_id": "eth0", "iface_id": "eth0",
@@ -130,16 +118,9 @@ def boot(
vcpus: int = 2, vcpus: int = 2,
mem_mib: int = 2048, mem_mib: int = 2048,
guest_mac: str = "06:00:AC:10:00:02", guest_mac: str = "06:00:AC:10:00:02",
detached: bool = False,
data_drive: Path | None = None,
) -> VmHandle: ) -> VmHandle:
"""Write the config and launch the VMM. Returns once the process is """Write the config and launch the VMM. Returns once the process is
spawned; callers wait for SSH readiness separately. spawned; callers wait for SSH readiness separately."""
`detached` starts the VMM in its own session (`start_new_session`) so it
survives the launcher exiting — used for the persistent per-host infra
VM, which must outlive the short-lived `start` process (agent VMs stay
attached and are torn down with the launcher)."""
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
config_path = run_dir / "config.json" config_path = run_dir / "config.json"
console_log = run_dir / "console.log" console_log = run_dir / "console.log"
@@ -147,7 +128,6 @@ def boot(
_config( _config(
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip, rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac, pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
data_drive=data_drive,
), ),
indent=2, indent=2,
)) ))
@@ -157,7 +137,6 @@ def boot(
process = subprocess.Popen( process = subprocess.Popen(
["firecracker", "--no-api", "--config-file", str(config_path)], ["firecracker", "--no-api", "--config-file", str(config_path)],
stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=detached,
) )
return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log) return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log)
+30 -44
View File
@@ -1,12 +1,9 @@
"""FirecrackerFreezer — snapshot a running microVM to a rootfs tar. """FirecrackerFreezer — snapshot a running microVM to a Docker image.
The VM is live and can't be block-copied safely, so — like the macOS The VM is live and can't be block-copied safely, so — like the macOS
backend — we stream the guest root filesystem out over the control backend — we stream the guest root filesystem out over the control
channel (SSH here). Unlike the other backends this needs no Docker: the channel (SSH here) and rebuild an image from it. The bottle keeps
tar *is* the resumable artifact. `resume` extracts it and rebuilds a running after the snapshot.
fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir`
and `launch._build_agent_base`). The bottle keeps running after the
snapshot.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,9 +11,9 @@ from __future__ import annotations
import json import json
import os import os
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from ...bottle_state import committed_rootfs_path
from ...log import die, info from ...log import die, info
from .. import ActiveAgent from .. import ActiveAgent
from ..freeze import Freezer from ..freeze import Freezer
@@ -33,13 +30,14 @@ class FirecrackerFreezer(Freezer):
if not private_key.is_file() or not guest_ip: if not private_key.is_file() or not guest_ip:
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the " die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
f"SSH key or VM config (is the bottle still running?)") f"SSH key or VM config (is the bottle still running?)")
tar_path = committed_rootfs_path(agent.slug) image_tag = f"bot-bottle-committed-{agent.slug}:latest"
_commit_rootfs_via_ssh(private_key, guest_ip, tar_path) _commit_via_ssh(private_key, guest_ip, image_tag)
info(f"committed {agent.slug} -> {tar_path}") info(f"committed {agent.slug} -> {image_tag!r}")
return str(tar_path) return image_tag
def _export_hint(self, slug: str, image_ref: str) -> None: def _export_hint(self, slug: str, image_ref: str) -> None:
info(f"to export for migration: cp {image_ref} {slug}.tar") info(f"to export for migration: docker image save {image_ref} "
f"-o {slug}.tar")
def _guest_ip_from_config(config_path: Path) -> str: def _guest_ip_from_config(config_path: Path) -> str:
@@ -55,36 +53,24 @@ def _guest_ip_from_config(config_path: Path) -> str:
return "" return ""
def _commit_rootfs_via_ssh(private_key: Path, guest_ip: str, tar_path: Path) -> None: def _commit_via_ssh(private_key: Path, guest_ip: str, image_tag: str) -> None:
"""Stream the guest rootfs out over SSH into `tar_path`. Excludes the with tempfile.TemporaryDirectory(prefix="bot-bottle-fc-commit.") as tmp:
virtual/live mounts (proc/sys/dev/run) — resume recreates those empty rootfs_tar = os.path.join(tmp, "rootfs.tar")
mount points. Written to a `.partial` sibling and renamed on success so ssh = util.ssh_base_argv(private_key, guest_ip)
a failed freeze never leaves a truncated artifact in its place.""" with open(rootfs_tar, "wb") as tar_out:
tar_path.parent.mkdir(parents=True, exist_ok=True) result = subprocess.run(
partial = tar_path.with_name(tar_path.name + ".partial") [*ssh, "--", "tar", "--create", "--one-file-system",
ssh = util.ssh_base_argv(private_key, guest_ip) "--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
# The snapshot can contain the bottle's private workspace, so keep it "--exclude=./run", "--file=-", "--directory=/", "."],
# owner-only (0600) for the whole stream. The `os.open` mode only applies stdout=tar_out, stderr=subprocess.PIPE, check=False,
# on *creation*, so unlink any leftover partial (a prior interrupted run )
# could have left it world-readable, or something could swap in a symlink if result.returncode != 0:
# at this predictable name) and exclusively recreate it — O_EXCL|O_NOFOLLOW die(f"ssh tar for {guest_ip} failed: "
# — then fchmod immediately so umask can't loosen it. Re-assert after the f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
# rename too (os.replace carries the source mode, but be explicit). with open(os.path.join(tmp, "Dockerfile"), "w", encoding="utf-8") as f:
partial.unlink(missing_ok=True) f.write("FROM scratch\nADD rootfs.tar /\nUSER node\nWORKDIR /home/node\n")
fd = os.open( build = subprocess.run(
partial, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 ["docker", "build", "-t", image_tag, tmp], check=False,
)
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as tar_out:
result = subprocess.run(
[*ssh, "--", "tar", "--create", "--one-file-system",
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
"--exclude=./run", "--file=-", "--directory=/", "."],
stdout=tar_out, stderr=subprocess.PIPE, check=False,
) )
if result.returncode != 0: if build.returncode != 0:
partial.unlink(missing_ok=True) die(f"docker build for {image_tag!r} failed")
die(f"ssh tar for {guest_ip} failed: "
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
os.replace(partial, tar_path)
os.chmod(tar_path, 0o600)
@@ -1,238 +0,0 @@
"""Docker-free agent-image builds for the Firecracker backend (PRD 0069 Stage 3).
Agent Dockerfiles build **inside the persistent per-host infra VM**
(`infra_vm.py`), which carries buildah (rootless, daemonless): no host Docker
daemon, no root-equivalent `docker` group. The build runs over SSH against the
infra VM and its rootfs streams back to the host, where the existing
`mke2fs -d` path (`util.build_rootfs_ext4`) turns it into a bootable ext4.
Building in the infra VM — rather than a throwaway builder VM — means there is
one buildah image (`bot-bottle-infra`) and no contention for the orchestrator
TAP. Tradeoff: an untrusted Dockerfile's `RUN` steps share the VM with the
control plane + gateway (buildah `--isolation chroot` isn't a hard boundary) —
the accepted single-VM blast-radius tradeoff, re-splittable into a disposable
builder (booted from this same image on its own TAP) later.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import shutil
import subprocess
from contextlib import contextmanager
from pathlib import Path
from typing import Generator
from ...log import die, info
from . import infra_vm, util
# vfs + chroot: buildah works as root in the microVM (no fuse-overlayfs /
# overlay module / subuid maps). `--isolation` is a build/run-only flag;
# `from`/`mount` take just the store.
_BUILD_FLAGS = "--isolation chroot --storage-driver vfs"
_STORE_FLAG = "--storage-driver vfs"
_BUILD_TIMEOUT_SECONDS = 900.0
def _dockerfile_hash(dockerfile: Path) -> str:
"""The Dockerfile's content hash. The shipped agent Dockerfiles COPY
nothing from the build context (see .dockerignore), so their content fully
determines the built image; a Dockerfile that adds COPY will want the
context folded in here too."""
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
def _rootfs_digest(dockerfile: Path) -> str:
"""Cache key for the built AND boot-injected agent rootfs. Two inputs
determine the on-disk rootfs: the Dockerfile (the image) and the guest init
injected into it (`util._GUEST_INIT`). Folding the init in means a fix to
it — e.g. making /tmp world-writable — busts the cache instead of silently
reusing a stale rootfs built with the old init."""
h = hashlib.sha256()
h.update(_dockerfile_hash(dockerfile).encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16]
def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path:
"""Build `dockerfile` in the infra VM (buildah, no host docker), export its
rootfs, inject the guest boot bits, and return the cached base dir — the
same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content
+ injected guest init, so a repeat launch skips the rebuild but an init or
Dockerfile change rebuilds.
`smoke_test` (the provider's declared argv, e.g. `("claude","--version")`)
is run in the freshly built image before export, catching an npm
silent-failure image at build time rather than at first agent use."""
digest = _rootfs_digest(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}"
if (base / ".bb-ready").is_file():
info(f"using cached agent rootfs {base.name}")
return base
# Serialize builds: the infra VM's buildah store + this cache dir are
# shared, so concurrent `start`s must not build into them at once. The
# lock covers the cache lookup + build + atomic publish; the ready
# fast-path above takes no lock.
with _build_lock():
if (base / ".bb-ready").is_file(): # another build finished while we waited
info(f"using cached agent rootfs {base.name}")
return base
# Build into a temp dir and publish by atomic rename, so a partial
# build is never visible as `agent-<digest>`.
staging = util.cache_dir() / "rootfs" / f".building-{digest}"
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
info(f"building agent image {image_tag!r} in the infra VM")
_build_in_infra(dockerfile, staging, smoke_test, digest)
util.inject_guest_boot(staging)
(staging / ".bb-ready").write_text("ok\n")
shutil.rmtree(base, ignore_errors=True)
os.rename(staging, base)
return base
@contextmanager
def _build_lock() -> Generator[None, None, None]:
"""Host-level exclusive lock serializing agent-image builds (shared infra
buildah store + cache dir). flock auto-releases on a crash."""
lock_path = util.cache_dir() / "rootfs" / ".build.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "w", encoding="utf-8")
try:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
handle.close()
def _build_in_infra(
dockerfile: Path, base: Path, smoke_test: tuple[str, ...], digest: str,
) -> None:
"""Ensure the infra VM is up, `buildah build` the Dockerfile in it, smoke
test the image, and stream its rootfs into `base`. The infra VM persists;
only the per-build container/image/context are cleaned up."""
infra = infra_vm.ensure_running()
key, ip = infra.private_key, infra.guest_ip
tag = f"bot-bottle-agent-build-{digest}"
ctx = f"/tmp/agent-build-{digest}"
smoke_ctr, export_ctr = f"{tag}-smoke", f"{tag}-export"
def _cleanup() -> None:
# Remove only THIS build's working containers/image/context — never
# `buildah rm -a`, which would nuke a concurrent build's container.
_ssh(key, ip,
f"buildah rm {smoke_ctr} {export_ctr} >/dev/null 2>&1; "
f"buildah rmi {_STORE_FLAG} {tag} >/dev/null 2>&1; rm -rf {ctx}",
timeout=60)
_cleanup() # clear leftovers from a crashed prior build of this digest
try:
prep = _ssh(key, ip, f"mkdir -p {ctx}/ctx")
if prep.returncode != 0:
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
_send_dockerfile(key, ip, dockerfile, ctx)
_buildah_build(key, ip, ctx, tag)
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base)
finally:
_cleanup()
def _ssh(private_key: Path, guest_ip: str, script: str,
*, timeout: float = 60.0) -> subprocess.CompletedProcess[str]:
return subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [script],
capture_output=True, text=True, timeout=timeout, check=False,
)
def _ssh_streamed(private_key: Path, guest_ip: str, script: str,
*, timeout: float) -> int:
"""Run an SSH command letting the remote's stdout/stderr flow straight to
ours (no capture), for long chatty steps where live progress beats a
silent wait. Returns the exit code."""
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [script],
timeout=timeout, check=False,
)
return proc.returncode
def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: str) -> None:
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [f"cat > {ctx}/Dockerfile"],
input=dockerfile.read_bytes(), capture_output=True, timeout=30, check=False,
)
if proc.returncode != 0:
die(f"sending Dockerfile to the infra VM failed: "
f"{proc.stderr.decode(errors='replace').strip()}")
def _buildah_build(private_key: Path, guest_ip: str, ctx: str, tag: str) -> None:
# Stream buildah's step-by-step output straight to our stderr (like the
# docker backend's `docker build`), so a long first build (base pull +
# apt/npm installs) shows live progress instead of a silent wait. The
# remote stderr is where buildah writes its `STEP i/n` lines.
info(f"buildah build {tag} in the infra VM (streaming output)")
rc = _ssh_streamed(
private_key, guest_ip,
f"buildah build {_BUILD_FLAGS} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
timeout=_BUILD_TIMEOUT_SECONDS,
)
if rc != 0:
die(f"buildah build in the infra VM failed (exit {rc}); "
"see the build output above.")
def _smoke_test(private_key: Path, guest_ip: str, tag: str, ctr: str,
argv: tuple[str, ...]) -> None:
"""Run the provider's smoke argv inside the freshly built image
(`buildah run`, which uses the image's own PATH), failing the build
loudly if the CLI is a broken stub. No-op without a declared test. Uses a
named working container (`ctr`) so cleanup is scoped to this build."""
if not argv:
return
cmd = (
f"set -e; buildah from {_STORE_FLAG} --name {ctr} {tag} >/dev/null; "
f"buildah run {_BUILD_FLAGS} {ctr} -- {' '.join(argv)}; rc=$?; "
f"buildah rm {ctr} >/dev/null 2>&1 || true; exit $rc"
)
result = _ssh(private_key, guest_ip, cmd, timeout=120)
if result.returncode != 0:
detail = (result.stdout + result.stderr).strip().splitlines()[-10:]
die(f"agent image failed its post-build smoke test "
f"({' '.join(argv)}):\n" + "\n".join(detail))
def _stream_rootfs(private_key: Path, guest_ip: str, tag: str, ctr: str, base: Path) -> None:
"""`buildah mount` the built image in the infra VM and pipe its rootfs tar
straight into `base` on the host (extracted as the non-root host user, so
uid 0 isn't preserved — the guest init restores /root ownership). Uses a
named working container so cleanup is scoped to this build."""
export = (
f"set -e; buildah from {_STORE_FLAG} --name {ctr} {tag} >/dev/null; "
f"mnt=$(buildah mount {_STORE_FLAG} {ctr}); "
f"tar -C \"$mnt\" -cf - ."
)
ssh_proc = subprocess.Popen(
util.ssh_base_argv(private_key, guest_ip) + [export],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
assert ssh_proc.stdout is not None
untar = subprocess.run(
["tar", "-x", "-C", str(base)], stdin=ssh_proc.stdout, check=False,
)
ssh_proc.stdout.close()
ssh_err = (ssh_proc.stderr.read().decode(errors="replace")
if ssh_proc.stderr else "")
rc = ssh_proc.wait()
if rc != 0 or untar.returncode != 0:
die(f"exporting the built rootfs from the infra VM failed: "
f"{ssh_err.strip() or '<no stderr>'}")
@@ -1,232 +0,0 @@
"""Prebuilt infra-VM rootfs, pulled as an artifact (PRD 0069 Stage 2).
The Firecracker infra VM boots a fixed rootfs (orchestrator control plane +
gateway + buildah, control-plane init as PID 1) that does not vary per launch —
the per-boot bits (authorized_keys, guest IP) ride the kernel cmdline, so one
rootfs boots on any host. Instead of building that rootfs on the launch host
with Docker, we build it **off-host** and publish it as a versioned, ready-to-
boot ext4 (gzip-compressed) to a Gitea **generic package**; the launch host
downloads + verifies + boots it. No Docker, no image tooling on the launch
host — just an HTTP fetch and gunzip.
publish (off-host, see publish_infra.py):
docker build -> rootfs dir -> mke2fs -> gzip -> PUT generic package
pull (this module, launch host):
GET .../rootfs.ext4.gz (+ .sha256) -> verify -> gunzip -> boot
The artifact **version** is a content hash of everything baked into the rootfs
(the shipped bot_bottle package, the three Dockerfiles, and the init), so a
launch host always pulls the artifact matching its code and a content change
can't silently boot a stale rootfs. A checksum mismatch fails closed.
Set `BOT_BOTTLE_INFRA_BUILD=local` to skip the pull and build the rootfs
locally with Docker (dev iteration on the Dockerfiles) — see `infra_vm`.
"""
from __future__ import annotations
import gzip
import hashlib
import os
import shutil
import urllib.error
import urllib.request
from pathlib import Path
from ...log import die, info
from . import util
# Bump if the on-disk artifact *format* changes (compression, layout) so a new
# scheme can't collide with a cached/published artifact of the old one.
_ARTIFACT_FORMAT = "1"
_REPO_ROOT = Path(__file__).resolve().parents[3]
_DOCKERFILES = ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra", "Dockerfile.infra.fc")
_DEFAULT_BASE = "https://gitea.dideric.is"
_DEFAULT_OWNER = "didericis"
_PACKAGE = "bot-bottle-firecracker-infra"
# Streaming copy chunk for the (hundreds-of-MB) download.
_CHUNK = 1 << 20
def local_build_requested() -> bool:
"""True when the operator opted into the dev Docker-build path instead of
pulling the published artifact (`BOT_BOTTLE_INFRA_BUILD=local`)."""
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
def infra_artifact_version(init_script: str, *, repo_root: Path = _REPO_ROOT) -> str:
"""Content hash (16 hex) of everything baked into the infra rootfs: the
whole shipped `bot_bottle` package, the three fixed Dockerfiles, and the
guest init. Deterministic across the publish host and the launch host when
both run the same checkout, so the tag the launch host pulls is exactly the
tag publish produced.
The package is `COPY bot_bottle /app/bot_bottle`'d wholesale into the image,
so hash *every* regular file under it — not just `*.py`. Non-Python inputs
(e.g. `egress_entrypoint.sh`, `netpool.defaults.env`) are baked in too, and
a change to one must bump the version or a launch host could boot a stale
rootfs whose code differs from its checkout. `__pycache__`/`.pyc` are the
only exclusions — build artifacts, never copied."""
h = hashlib.sha256()
h.update(f"format={_ARTIFACT_FORMAT}\n".encode())
pkg = repo_root / "bot_bottle"
for path in sorted(pkg.rglob("*")):
if not path.is_file():
continue
if "__pycache__" in path.parts or path.suffix == ".pyc":
continue
h.update(str(path.relative_to(repo_root)).encode())
h.update(b"\0")
h.update(path.read_bytes())
for name in _DOCKERFILES:
h.update(name.encode())
h.update(b"\0")
h.update((repo_root / name).read_bytes())
h.update(b"pyproject.toml\0")
h.update((repo_root / "pyproject.toml").read_bytes())
h.update(b"dropbear\0")
dropbear = util.dropbear_path()
h.update(dropbear.read_bytes() if dropbear.is_file() else b"<missing>")
h.update(b"init\0")
h.update(init_script.encode())
return h.hexdigest()[:16]
def _config() -> tuple[str, str, str]:
"""(base_url, owner, token) for the generic-package endpoint. Base + owner
are overridable for other deployments / mirrors; the token comes solely from
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN` (a dedicated package-scoped token, kept
separate from the general-purpose Gitea token) and is optional — a public
package needs none to pull."""
base = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_BASE", _DEFAULT_BASE).rstrip("/")
owner = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_OWNER", _DEFAULT_OWNER)
token = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_TOKEN", "")
return base, owner, token
def artifact_url(version: str, filename: str) -> str:
"""The generic-package download URL for one file of this version's
artifact (`rootfs.ext4.gz` / `rootfs.ext4.gz.sha256`)."""
base, owner, _ = _config()
return f"{base}/api/packages/{owner}/generic/{_PACKAGE}/{version}/{filename}"
_GZ_NAME = "rootfs.ext4.gz"
_SHA_NAME = "rootfs.ext4.gz.sha256"
_CANDIDATE_DIR_ENV = "BOT_BOTTLE_INFRA_ARTIFACT_DIR"
def _cache_root(version: str) -> Path:
return util.cache_dir() / "infra-artifact" / version
def _open(url: str) -> urllib.request.Request:
_, _, token = _config()
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
return req
def _download(url: str, dest: Path) -> None:
"""Stream `url` to `dest` (atomic via a `.part` sibling)."""
tmp = dest.with_suffix(dest.suffix + ".part")
try:
with urllib.request.urlopen(_open(url)) as resp, open(tmp, "wb") as out:
shutil.copyfileobj(resp, out, _CHUNK)
except urllib.error.HTTPError as e:
tmp.unlink(missing_ok=True)
if e.code == 404:
die(
f"infra artifact not published for this code version.\n"
f" missing: {url}\n"
f" publish it from a build host (Docker):\n"
f" python3 -m bot_bottle.backend.firecracker.publish_infra\n"
f" or build the rootfs locally: BOT_BOTTLE_INFRA_BUILD=local"
)
die(f"downloading infra artifact failed (HTTP {e.code}): {url}")
except urllib.error.URLError as e:
tmp.unlink(missing_ok=True)
die(f"infra artifact registry unreachable: {url} ({e.reason})")
tmp.replace(dest)
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def ensure_artifact_gz(version: str) -> Path:
"""The verified, cached `rootfs.ext4.gz` for `version` — downloading it (and
its `.sha256`) once, then reusing it. Fail-closed on a checksum mismatch:
the partial is removed and we die rather than boot an unverified rootfs."""
candidate_dir = os.environ.get(_CANDIDATE_DIR_ENV, "").strip()
if candidate_dir:
root = Path(candidate_dir)
version_file = root / "version.txt"
# Guard the read so a missing version.txt is a clean error, not a raw
# FileNotFoundError.
if not version_file.is_file():
die(f"infra candidate bundle is incomplete: {root}")
declared = version_file.read_text(encoding="utf-8").strip()
if declared != version:
die(
f"infra candidate version mismatch: expected {version}, "
f"bundle contains {declared or '<empty>'}"
)
gz = root / _GZ_NAME
sha = root / _SHA_NAME
if not gz.is_file() or not sha.is_file():
die(f"infra candidate bundle is incomplete: {root}")
expected = sha.read_text().split()[0].strip().lower()
actual = _sha256_file(gz)
if actual != expected:
die(
f"infra candidate checksum mismatch for {version}:\n"
f" expected {expected}\n actual {actual}"
)
return gz
root = _cache_root(version)
root.mkdir(parents=True, exist_ok=True)
gz = root / _GZ_NAME
ok = root / ".verified"
if gz.is_file() and ok.is_file():
return gz
info(f"pulling infra rootfs artifact {_PACKAGE}/{version}")
_download(artifact_url(version, _GZ_NAME), gz)
sha = root / _SHA_NAME
_download(artifact_url(version, _SHA_NAME), sha)
expected = sha.read_text().split()[0].strip().lower()
actual = _sha256_file(gz)
if actual != expected:
gz.unlink(missing_ok=True)
sha.unlink(missing_ok=True)
die(
f"infra artifact checksum mismatch for {version}:\n"
f" expected {expected}\n"
f" actual {actual}\n"
f" refusing to boot an unverified rootfs."
)
ok.write_text("ok\n")
return gz
def materialize_ext4(version: str, dest: Path) -> None:
"""Ensure the verified artifact is cached, then gunzip it to `dest` — a
fresh, writable per-boot rootfs (the VM mutates it; the cached `.gz` stays
pristine). Atomic via a `.part` sibling."""
gz = ensure_artifact_gz(version)
tmp = dest.with_suffix(dest.suffix + ".part")
info(f"expanding infra rootfs -> {dest}")
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
shutil.copyfileobj(src, out, _CHUNK)
tmp.replace(dest)
-504
View File
@@ -1,504 +0,0 @@
"""The per-host infra VM for the Firecracker backend (PRD 0070 Stage B).
A single persistent microVM that runs the orchestrator **control plane** (and,
in a following step, the gateway **data plane**) — the trusted per-host service
the docker backend runs as containers. It boots on the NAT'd orchestrator link
(`netpool.orch_slot()`): the host CLI reaches its control plane over HTTP at the
guest IP, and agent VMs reach its gateway ports over VM-to-VM routing.
Build-from-source (the default while the design churns): the rootfs is exported
from the locally built orchestrator image, which bakes the stdlib-only
control-plane source. A pull-from-registry mode (Gitea's OCI registry) becomes
the default later.
SSH is left enabled for debugging; the control plane is the load-bearing
surface.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import shlex
import signal
import stat
import subprocess
import time
import urllib.error
import urllib.request
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Generator
from ...log import die, info
from .. import util as backend_util
from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError
from . import firecracker_vm, infra_artifact, netpool, util
# The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default;
# a pull-from-registry mode lands later.
_INFRA_IMAGE = "bot-bottle-infra:latest"
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
_REPO_ROOT = Path(__file__).resolve().parents[3]
CONTROL_PLANE_PORT = 8099
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
# git-http. Reached by agent VMs over VM-to-VM routing (added next).
EGRESS_PORT = 9099
SUPERVISE_PORT = 9100
GIT_HTTP_PORT = 9420
# mitmproxy writes its CA here a beat after start; agents install it to trust
# the gateway's TLS interception.
_GATEWAY_CA_PATH = "/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem"
# The infra VM makes direct upstream connections (gateway egress, and buildah
# during builds), and the kernel `ip=` cmdline sets no resolver. Public for
# now; routing DNS through a filtered path is a later refinement.
_INFRA_RESOLVER = "1.1.1.1"
_HEALTH_TIMEOUT_SECONDS = 45.0
_HEALTH_POLL_SECONDS = 0.5
_CA_TIMEOUT_SECONDS = 30.0
@dataclass
class InfraVm:
"""A handle to the per-host infra VM: its guest IP and the stable SSH key
used to fetch the gateway CA / provision git-gate. `vm` is the live VMM
handle when this process booted it, and None when adopting a singleton a
prior launcher started (teardown then goes through the PID file)."""
guest_ip: str
private_key: Path
vm: firecracker_vm.VmHandle | None = None
@property
def control_plane_url(self) -> str:
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}"
def terminate(self) -> None:
"""Stop the infra VM — via the live handle if we booted it, else the
PID file (adopting-process case)."""
if self.vm is not None:
self.vm.terminate()
else:
_kill_pidfile()
_pid_file().unlink(missing_ok=True)
def gateway_ca_pem(self, *, timeout: float = _CA_TIMEOUT_SECONDS) -> str:
"""The gateway's mitmproxy CA (PEM) that agents install to trust its
TLS interception. Generated a moment after boot, so this polls over
SSH until it appears (mirrors DockerGateway.ca_cert_pem)."""
def _fetch() -> str | None:
proc = subprocess.run(
util.ssh_base_argv(self.private_key, self.guest_ip)
+ [f"cat {_GATEWAY_CA_PATH}"],
capture_output=True, text=True, timeout=15, check=False,
)
ok = proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout
return proc.stdout if ok else None
try:
return backend_util.poll_ca_cert(_fetch, timeout=timeout)
except TimeoutError as exc:
die(str(exc))
def ensure_built() -> None:
"""Ensure the infra rootfs is available before boot.
Default (docker-free, PRD 0069 Stage 2): download + verify the prebuilt
rootfs artifact matching this code version (see `infra_artifact`); the
launch host needs no Docker. `BOT_BOTTLE_INFRA_BUILD=local` instead builds
the three fixed images from source with host Docker — the infra image
`COPY --from`s the orchestrator image and is `FROM` the gateway image, so
both must exist first — for iterating on the Dockerfiles."""
if infra_artifact.local_build_requested():
build_infra_images_with_docker()
return
infra_artifact.ensure_artifact_gz(
infra_artifact.infra_artifact_version(_infra_init()))
def build_infra_images_with_docker() -> None:
"""Build the four fixed images from source with host Docker: orchestrator,
gateway, the shared infra base (Dockerfile.infra), then the Firecracker
infra image (Dockerfile.infra.fc: FROM infra + buildah). The launch host
uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode; `publish_infra`
uses it off-host to produce the published artifact."""
docker_mod.build_image(
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
docker_mod.build_image(
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
docker_mod.build_image(
"bot-bottle-infra:latest", str(_REPO_ROOT), dockerfile="Dockerfile.infra")
docker_mod.build_image(
_INFRA_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.infra.fc")
def build_infra_rootfs_dir() -> Path:
"""The infra VM's base rootfs: the infra image prepared with the
control-plane + gateway init as PID 1. The init's content is folded into
the cache key so an init change rebuilds the rootfs (the base image digest
alone wouldn't catch it)."""
init = _infra_init()
tag = hashlib.sha256(init.encode()).hexdigest()[:8]
return util.build_base_rootfs_dir(
_INFRA_IMAGE, variant=f"-infra-{tag}", init_script=init,
)
def ensure_running() -> InfraVm:
"""Idempotent per-host singleton. Adopt the infra VM if its control plane
is already healthy (a prior launcher booted it — it outlives short-lived
`start` processes); otherwise clear any stale VM and boot a fresh one.
Returns a handle usable for CA fetch / git-gate provisioning.
Concurrency-safe: the cold stop/build/boot path is serialized by a host
flock, so two simultaneous first launches don't both boot on the same
rootfs/PID. The healthy fast-path takes no lock."""
slot = netpool.orch_slot()
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
key = _infra_dir() / "id_ed25519"
want = _expected_version()
if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
with _singleton_lock():
# Re-check under the lock: another launcher may have booted it while
# we waited for the lock (double-checked, so we adopt not re-boot).
if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
# Clear a stale/hung/OUTDATED VM holding the link before booting fresh.
stop()
ensure_built()
infra = boot()
wait_for_health(infra)
_record_booted_version(want)
return infra
@contextmanager
def _singleton_lock() -> Generator[None, None, None]:
"""Host-level exclusive lock serializing the infra VM's cold create path
(`stop`/`ensure_built`/`boot`). flock auto-releases if the launcher
crashes, so the lock is never leaked."""
lock_path = _infra_dir() / "singleton.lock"
handle = open(lock_path, "w", encoding="utf-8")
try:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
handle.close()
def stop() -> None:
"""Stop the infra VM singleton (idempotent — absent is success). Reaps the
recorded VMM AND any orphaned firecracker still bound to the infra config —
the PID file drifts after crashes / out-of-band kills, and a survivor would
hold the orchestrator TAP so the next boot dies with "tap … Resource busy".
Drops the version marker so a stopped VM is never treated as adoptable."""
_kill_pidfile()
_kill_infra_firecrackers()
_pid_file().unlink(missing_ok=True)
_version_file().unlink(missing_ok=True)
def boot() -> InfraVm:
"""Boot the infra VM (detached, so it outlives the launcher) on the
orchestrator link, recording its PID. Prefer `ensure_running`."""
slot = netpool.orch_slot()
if not netpool.tap_present(slot.iface):
die(f"orchestrator link {slot.iface} not present.\n"
f" ./cli.py backend setup --backend=firecracker")
run_dir = _infra_dir()
rootfs = run_dir / "rootfs.ext4"
if infra_artifact.local_build_requested():
util.build_rootfs_ext4(build_infra_rootfs_dir(), rootfs, slack_mib=8192)
else:
# Prebuilt artifact already carries the buildah build slack; expand it
# to a fresh writable rootfs for this boot.
infra_artifact.materialize_ext4(
infra_artifact.infra_artifact_version(_infra_init()), rootfs)
private_key, pubkey = _stable_keypair()
info(f"booting infra VM on {slot.iface} (guest {slot.guest_ip})")
vm = firecracker_vm.boot(
name="bot-bottle-infra", rootfs=rootfs, tap=slot.iface,
guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey,
run_dir=run_dir, mem_mib=4096, detached=True,
data_drive=_ensure_registry_volume(),
)
_pid_file().write_text(str(vm.process.pid))
return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
def _infra_dir() -> Path:
d = util.cache_dir() / "infra"
d.mkdir(parents=True, exist_ok=True)
return d
def _pid_file() -> Path:
return _infra_dir() / "vm.pid"
def _version_file() -> Path:
"""Records the infra-artifact version the *running* VM booted from, so a
later launcher can tell whether the singleton it found is the current code.
Without it, a healthy VM built from an older image gets adopted forever and
the new code never boots — every infra change would need an out-of-band
kill to dislodge the stale VM (and races whatever launched next)."""
return _infra_dir() / "booted-version"
def _expected_version() -> str:
return infra_artifact.infra_artifact_version(_infra_init())
def _adoptable(key: Path, url: str, want: str) -> bool:
"""Adopt a running infra VM only if it booted from the CURRENT version and
its control plane is healthy. A missing/mismatched marker means a prior
launcher booted an older infra image — reboot rather than reuse stale code."""
if not key.exists():
return False
try:
booted = _version_file().read_text(encoding="utf-8").strip()
except OSError:
return False
return booted == want and _health_ok(url)
def _record_booted_version(version: str) -> None:
_version_file().write_text(version + "\n", encoding="utf-8")
# The registry "volume": a host-side ext4 file attached to the infra VM as a
# second virtio-block device (guest /dev/vdb), mounted at the control plane's
# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an
# infra-VM restart — the firecracker analogue of a docker volume. It is a
# plain ext4 file: `sudo mount -o loop <path>` on the host (with the VM
# stopped) to inspect bot-bottle.db directly.
_REGISTRY_SIZE = "512M"
def registry_volume_path() -> Path:
return _infra_dir() / "registry.ext4"
def _ensure_registry_volume() -> Path:
"""Create the empty ext4 registry volume on first use; reuse it after."""
vol = registry_volume_path()
if vol.exists():
return vol
info(f"creating infra registry volume {vol} ({_REGISTRY_SIZE})")
proc = subprocess.run(
["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _REGISTRY_SIZE],
capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
vol.unlink(missing_ok=True)
die(f"creating registry volume failed: {proc.stderr.strip()}")
return vol
def _stable_keypair() -> tuple[Path, str]:
"""The infra VM's SSH keypair — generated once and reused, so any later
launcher can SSH in (fetch CA / provision) even though a different process
booted the VM. The pubkey is re-injected on every boot via the cmdline."""
d = _infra_dir()
key, pub = d / "id_ed25519", d / "id_ed25519.pub"
if key.exists() and pub.exists():
return key, pub.read_text().strip()
key.unlink(missing_ok=True)
pub.unlink(missing_ok=True)
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", str(key),
"-C", "bot-bottle-infra"],
check=True,
)
return key, pub.read_text().strip()
def _kill_pidfile() -> None:
"""SIGTERM (then SIGKILL) the recorded infra VMM, if it's still ours.
Guards against a recycled PID by checking the process is firecracker."""
try:
pid = int(_pid_file().read_text().strip())
except (OSError, ValueError):
return
try:
comm = Path(f"/proc/{pid}/comm").read_text().strip()
except OSError:
return # already gone
if comm != "firecracker":
return # PID recycled by an unrelated process
try:
os.kill(pid, signal.SIGTERM)
for _ in range(50):
if not Path(f"/proc/{pid}").exists():
return
time.sleep(0.1)
os.kill(pid, signal.SIGKILL)
except OSError:
pass
def _kill_infra_firecrackers(proc_root: Path = Path("/proc")) -> None:
"""SIGKILL any firecracker VMM whose `--config-file` is this host's infra
config, independent of the PID file — reaps orphans it lost track of so the
orchestrator TAP is free to rebind. Scoped to the infra config path, so the
interactive pool's agent/infra VMs (other config paths) are untouched."""
cfg = str(_infra_dir() / "config.json")
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
if (entry / "comm").read_text().strip() != "firecracker":
continue
args = (entry / "cmdline").read_bytes().split(b"\0")
except OSError:
continue # process vanished / not ours
if any(a.decode("utf-8", "replace") == cfg for a in args):
try:
os.kill(int(entry.name), signal.SIGKILL)
except (OSError, ValueError):
pass
def _health_ok(url: str) -> bool:
try:
with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
class SshGatewayTransport:
"""`GatewayTransport` for the gateway running in the infra VM — the docker
exec/cp equivalents over SSH (dropbear + the stable infra key)."""
def __init__(self, private_key: Path, guest_ip: str) -> None:
self._key = private_key
self._ip = guest_ip
def exec(self, argv: list[str]) -> None:
proc = subprocess.run(
util.ssh_base_argv(self._key, self._ip) + [shlex.join(argv)],
capture_output=True, text=True, timeout=60, check=False,
)
if proc.returncode != 0:
raise GatewayProvisionError(
f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}")
def cp_into(self, src: str, dest: str) -> None:
# Preserve the source mode (docker cp does): the access-hook is staged
# 0700 and git-http execs it directly — a plain `cat >` would land it
# 0644 and the exec fails with EACCES; keys stay 0600.
mode = stat.S_IMODE(os.stat(src).st_mode)
q = shlex.quote(dest)
proc = subprocess.run(
util.ssh_base_argv(self._key, self._ip)
+ [f"cat > {q} && chmod {mode:o} {q}"],
input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False,
)
if proc.returncode != 0:
raise GatewayProvisionError(
f"infra gateway cp {src} -> {dest} failed: "
f"{proc.stderr.decode(errors='replace').strip()}")
def gateway_transport() -> SshGatewayTransport:
"""git-gate provisioning transport for the gateway in the infra VM, built
from the stable key + the orchestrator link's guest IP. Needs no live VM
handle, so teardown can use it too."""
return SshGatewayTransport(
_infra_dir() / "id_ed25519", netpool.orch_slot().guest_ip)
def wait_for_health(
infra: InfraVm, *, timeout: float = _HEALTH_TIMEOUT_SECONDS,
) -> None:
"""Poll the control plane's /health until it answers 200 or the deadline
passes. Dies (with the console tail) if the VMM exits early."""
url = f"{infra.control_plane_url}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if infra.vm is not None and not infra.vm.is_alive():
die(f"infra VM exited during boot (rc={infra.vm.process.returncode}).\n"
f"{firecracker_vm._console_tail(infra.vm.console_log)}")
try:
with urllib.request.urlopen(url, timeout=1.0) as resp:
if resp.status == 200:
info(f"infra control plane healthy at {infra.control_plane_url}")
return
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(_HEALTH_POLL_SECONDS)
tail = (firecracker_vm._console_tail(infra.vm.console_log)
if infra.vm is not None else "")
die(f"infra control plane at {url} did not become healthy within "
f"{timeout:.0f}s.\n{tail}")
def _infra_init() -> str:
"""PID-1 init for the infra VM: mount the pseudo-filesystems, wire a
resolver, start dropbear (debug SSH), then launch the control plane and
the gateway data plane (multi-tenant against the local control plane)."""
return f"""#!/bin/sh
# bot-bottle Firecracker infra VM init (PID 1).
mount -t proc proc /proc 2>/dev/null
mount -t sysfs sys /sys 2>/dev/null
mount -t devtmpfs dev /dev 2>/dev/null
mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null
mount -o remount,rw / 2>/dev/null
# Export a real 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 subprocess daemons (spawned as `python3 ...`) would
# inherit no PATH and fail to find python3. Export it for all children.
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Direct upstream resolver (control-plane / gateway egress + buildah).
printf 'nameserver {_INFRA_RESOLVER}\\n' > /etc/resolv.conf 2>/dev/null
# Debug SSH: install the per-boot pubkey from the kernel cmdline.
KEY=$(sed -n 's/.*bb_pubkey=\\([^ ]*\\).*/\\1/p' /proc/cmdline | base64 -d 2>/dev/null)
if [ -n "$KEY" ]; then
mkdir -p /root/.ssh
printf '%s\\n' "$KEY" > /root/.ssh/authorized_keys
chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys
fi
chown -R 0:0 /root 2>/dev/null || true
mkdir -p /etc/dropbear /run /var/lib/bot-bottle
# Persistent registry volume (second virtio-block device, /dev/vdb) mounted
# at the control plane's DB dir, so bot-bottle.db survives infra-VM restarts.
mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true
/bb-dropbear -R -E -p 22 &
# Control plane. Source is baked at /app; the package is stdlib-only.
cd /app
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
# Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
python3 -m bot_bottle.gateway_init &
# Reap as PID 1; children are backgrounded, so `wait` blocks.
while : ; do wait ; done
"""
+265 -124
View File
@@ -1,65 +1,75 @@
"""Launch flow for the Firecracker backend (PRD 0070, consolidated). """Launch flow for the Firecracker backend.
Per bottle: Per bottle:
1. build the agent rootfs in a builder VM (buildah, no host docker), or 1. mint the egress CA, build the agent image (docker), export it to a
resume a frozen bottle from its committed rootfs tar; cache the ext4; cached ext4 rootfs;
2. ensure the per-host orchestrator + shared gateway are up; 2. claim a free TAP pool slot (rootless flock);
3. claim a free TAP pool slot (rootless flock); 3. bring up the Docker sidecar bundle, publishing egress / git-gate /
4. register the bottle on the orchestrator by the VM's guest IP (the supervise on the slot's host-side TAP IP at fixed ports;
attribution key) and provision its git-gate state into the gateway; 4. boot the microVM on that TAP; wait for SSH;
5. boot the microVM on that TAP; wait for SSH; 5. provision (CA, prompt, skills, workspace, git, supervise) over SSH.
6. provision (shared gateway CA, prompt, skills, workspace, git, supervise)
over SSH.
The per-bottle Docker sidecar bundle is gone. The shared gateway handles
egress / git-gate / supervise for every VM; Docker's PREROUTING DNAT routes
the VMs' traffic to it, and the nft table's `ct status dnat accept` rule
in the forward chain lets it pass. The VM still sends to `host_tap_ip:PORT`
— the address its world is, by nft design, limited to.
Isolation is enforced by the operator-provisioned nft table (checked Isolation is enforced by the operator-provisioned nft table (checked
fail-closed in preflight): a VM reaches only the sidecar (DNAT'd from fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from
the host TAP IP) and nothing else. the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore
points at `http://<host_tap_ip>:9099`, its only route to the world.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import os import os
import subprocess
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import ( from ...bottle_state import (
committed_rootfs_path,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
read_committed_image, read_committed_image,
) )
from ...egress import ( from ...egress import (
EGRESS_ROUTES_IN_CONTAINER,
egress_agent_env_entries, egress_agent_env_entries,
egress_resolve_token_values, egress_resolve_token_values,
egress_sidecar_env_entries,
) )
from ...git_gate import ( from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...log import die, info, warn
from ...supervise import SUPERVISE_PORT from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ...util import expand_tilde
from ..docker.egress import (
EGRESS_CA_IN_CONTAINER,
EGRESS_PORT,
egress_tls_init,
)
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ..docker.sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
)
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from . import firecracker_vm, image_builder, isolation_probe, netpool, util from . import firecracker_vm, isolation_probe, netpool, util
from .bottle import FirecrackerBottle from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout
from .consolidated_launch import (
launch_consolidated,
teardown_consolidated,
)
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_GIT_HTTP_PORT = 9420 _GIT_HTTP_PORT = 9420
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
def sidecar_container_name(slug: str) -> str:
return f"bot-bottle-sidecars-{slug}"
@contextmanager @contextmanager
@@ -68,8 +78,6 @@ def launch(
*, *,
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None], provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
) -> Generator[FirecrackerBottle, None, None]: ) -> Generator[FirecrackerBottle, None, None]:
"""Build, launch, and provision a Firecracker bottle via the consolidated
orchestrator. Teardown on exit."""
stack = ExitStack() stack = ExitStack()
bottle_for_revoke = plan.manifest.bottle bottle_for_revoke = plan.manifest.bottle
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
@@ -86,89 +94,31 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
# Step 1: agent rootfs. Built from the Dockerfile inside a Firecracker plan = _mint_certs(plan)
# builder VM (buildah, no host docker); a committed snapshot is reused plan = _build_agent_image(plan)
# when present. Returns the base dir the per-bottle ext4 is made from.
plan, agent_base = _build_agent_base(plan)
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any. # Claim a TAP slot; the flock is held until teardown closes it.
git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
)
# Step 3: claim a TAP slot; the flock is held until teardown.
slot, lock = netpool.allocate(plan.slug) slot, lock = netpool.allocate(plan.slug)
stack.callback(lock.close) stack.callback(lock.close)
info(f"firecracker slot {slot.iface}: host={slot.host_ip} " info(f"firecracker slot {slot.iface}: host={slot.host_ip} "
f"guest={slot.guest_ip}") f"guest={slot.guest_ip}")
# Step 4: register on the orchestrator + provision this bottle's plan = _provision_git_gate_keys(plan)
# git-gate state into the shared gateway. The per-bottle egress tokens
# are resolved from the host env now and handed to the orchestrator
# (in memory) for the gateway to inject — the agent never sees them.
# Attribution is by the VM's guest IP (unspoofable via /31 TAP + nft).
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
ctx = launch_consolidated(
plan.egress_plan, git_gate_plan,
guest_ip=slot.guest_ip,
image_ref=plan.image,
tokens=token_values,
)
stack.callback(
teardown_consolidated, ctx.bottle_id,
orchestrator_url=ctx.orchestrator_url,
timeout=teardown_timeout,
)
# Step 5: install the SHARED gateway CA (replaces the per-bottle CA). sidecar_name = sidecar_container_name(plan.slug)
# Write it to a stable host path so the provisioner can copy it over SSH. _force_remove_container(sidecar_name)
ca_dir = egress_state_dir(plan.slug) / "gateway-ca" _start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
ca_dir.mkdir(parents=True, exist_ok=True) stack.callback(_force_remove_container, sidecar_name)
ca_file = ca_dir / "gateway-ca.pem" _stage_git_gate(plan, sidecar_name)
ca_file.write_text(ctx.gateway_ca_pem)
egress_plan = dataclasses.replace(
plan.egress_plan,
mitmproxy_ca_host_path=ca_file,
mitmproxy_ca_cert_only_host_path=ca_file,
)
# Point the agent's git-gate insteadOf rewrites and supervise MCP URL
# at the shared gateway (reached at the slot's host TAP IP — the VM
# sends there and Docker DNAT routes to the gateway container).
git_gate_url = (
f"http://{slot.host_ip}:{_GIT_HTTP_PORT}" if git_gate_plan.upstreams else ""
)
supervise_url = (
f"http://{slot.host_ip}:{SUPERVISE_PORT}/"
if plan.supervise_plan is not None else ""
)
plan = dataclasses.replace(
plan,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
identity_token=ctx.identity_token,
# Deliver the identity token as egress proxy credentials — clients
# honor `HTTPS_PROXY=http://id:token@gw` without app changes; the
# gateway reads Proxy-Authorization, validates the (source_ip,
# token) pair, and strips it before upstream.
agent_proxy_url=(
f"http://bottle:{ctx.identity_token}"
f"@{slot.host_ip}:{EGRESS_PORT}"
),
agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url,
)
# Step 6: build the per-bottle rootfs + SSH key, then boot. plan = _stamp_agent_urls(plan, slot.host_ip)
# Build the per-bottle rootfs + SSH key, then boot.
base_dir = util.build_base_rootfs_dir(plan.image)
run_dir = util.cache_dir() / "run" / plan.slug run_dir = util.cache_dir() / "run" / plan.slug
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
rootfs = run_dir / "rootfs.ext4" rootfs = run_dir / "rootfs.ext4"
util.build_rootfs_ext4(agent_base, rootfs) util.build_rootfs_ext4(base_dir, rootfs)
private_key, pubkey = util.generate_keypair(run_dir) private_key, pubkey = util.generate_keypair(run_dir)
vm = firecracker_vm.boot( vm = firecracker_vm.boot(
@@ -183,8 +133,8 @@ def launch(
stack.callback(vm.terminate) stack.callback(vm.terminate)
firecracker_vm.wait_for_ssh(vm, private_key) firecracker_vm.wait_for_ssh(vm, private_key)
# Authoritative fail-closed egress-boundary check, before the agent # Authoritative fail-closed egress-boundary check, before the
# runs: prove the VM cannot reach the host directly. # agent runs: prove the VM cannot reach the host directly.
isolation_probe.verify_isolation(private_key, slot.guest_ip) isolation_probe.verify_isolation(private_key, slot.guest_ip)
bottle = FirecrackerBottle( bottle = FirecrackerBottle(
@@ -209,24 +159,169 @@ def launch(
teardown() teardown()
def _build_agent_base( def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
plan: FirecrackerBottlePlan, egress_ca_host, egress_ca_cert_only = egress_tls_init(egress_state_dir(plan.slug))
) -> tuple[FirecrackerBottlePlan, Path]: egress_plan = dataclasses.replace(
"""Produce the agent's base rootfs dir. Primary path: build the Dockerfile plan.egress_plan,
inside a Firecracker builder VM (buildah, no host docker), smoke-testing mitmproxy_ca_host_path=egress_ca_host,
the image before export. A committed snapshot (freeze/migrate) is resumed mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
directly from the rootfs tar the freezer wrote — no host docker either."""
committed = read_committed_image(plan.slug)
committed_tar = committed_rootfs_path(plan.slug)
if committed and committed_tar.is_file():
info(f"resuming from committed rootfs {committed_tar}")
return plan, util.build_committed_rootfs_dir(committed_tar)
base = image_builder.build_agent_rootfs_dir(
Path(plan.dockerfile_path),
image_tag=plan.image,
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
) )
return plan, base return dataclasses.replace(plan, egress_plan=egress_plan)
def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
_docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
committed = read_committed_image(plan.slug)
if committed and _image_exists(committed):
info(f"using committed image {committed!r}")
return dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
_docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
return plan
def _provision_git_gate_keys(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
if not plan.git_gate_plan.upstreams:
return plan
git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug),
)
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
def _stamp_agent_urls(
plan: FirecrackerBottlePlan, host_ip: str,
) -> FirecrackerBottlePlan:
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
supervise_url = (
f"http://{host_ip}:{SUPERVISE_PORT}/" if plan.supervise_plan is not None else ""
)
git_gate_url = (
f"http://{host_ip}:{_GIT_HTTP_PORT}" if plan.git_gate_plan.upstreams else ""
)
return dataclasses.replace(
plan,
agent_proxy_url=proxy_url,
agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url,
)
# --- sidecar bundle (Docker) ----------------------------------------
def _start_sidecar_bundle(
plan: FirecrackerBottlePlan, sidecar_name: str, host_ip: str,
) -> None:
argv = ["docker", "run", "--name", sidecar_name, "--detach", "--rm",
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}"]
for entry in _sidecar_env_entries(plan):
argv += ["-e", entry]
for host_path, container_path, read_only in _sidecar_mounts(plan):
argv += ["-v", f"{host_path}:{container_path}{':ro' if read_only else ''}"]
# Publish on the slot's host TAP IP at fixed ports — each bottle
# has a distinct host_ip, so fixed ports never collide, and the VM
# reaches them at a stable, well-known address (its only route out).
for port in _sidecar_ports(plan):
argv += ["-p", f"{host_ip}:{port}:{port}"]
argv.append(SIDECAR_BUNDLE_IMAGE)
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
env = {**os.environ, **token_values}
info(f"docker run sidecar bundle {sidecar_name} (published on {host_ip})")
result = subprocess.run(argv, capture_output=True, text=True, env=env, check=False)
if result.returncode != 0:
die(f"docker run for sidecar bundle {sidecar_name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}")
def _sidecar_daemons(plan: FirecrackerBottlePlan) -> tuple[str, ...]:
daemons = ["egress"]
if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None:
daemons.append("supervise")
return tuple(daemons)
def _sidecar_ports(plan: FirecrackerBottlePlan) -> tuple[int, ...]:
ports = [EGRESS_PORT]
if plan.git_gate_plan.upstreams:
ports.append(_GIT_HTTP_PORT)
if plan.supervise_plan is not None:
ports.append(SUPERVISE_PORT)
return tuple(ports)
def _sidecar_env_entries(plan: FirecrackerBottlePlan) -> tuple[str, ...]:
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
if plan.git_gate_plan.upstreams:
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
if plan.supervise_plan is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
return tuple(env)
def _sidecar_mounts(
plan: FirecrackerBottlePlan,
) -> tuple[tuple[str, str, bool], ...]:
mounts: list[tuple[str, str, bool]] = []
ep = plan.egress_plan
mounts.append((str(ep.mitmproxy_ca_host_path.parent),
str(Path(EGRESS_CA_IN_CONTAINER).parent), False))
if ep.routes:
mounts.append((str(ep.routes_path.parent),
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
sp = plan.supervise_plan
if sp is not None:
mounts.append((str(sp.db_path.parent),
str(Path(DB_PATH_IN_CONTAINER).parent), False))
return tuple(mounts)
def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
gp = plan.git_gate_plan
if not gp.upstreams:
return
_docker_exec(sidecar_name, [
"mkdir", "-p",
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git",
str(Path(_GIT_GATE_READY_FILE).parent),
])
for host_path, container_path in _git_gate_files(plan):
_docker_cp(host_path, f"{sidecar_name}:{container_path}")
_docker_exec(sidecar_name, [
"sh", "-c",
f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
f"touch {_GIT_GATE_READY_FILE}",
])
def _git_gate_files(plan: FirecrackerBottlePlan) -> tuple[tuple[str, str], ...]:
gp = plan.git_gate_plan
files: list[tuple[str, str]] = [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
]
for upstream in gp.upstreams:
files.append((expand_tilde(upstream.identity_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key"))
if upstream.known_hosts_file:
files.append((str(upstream.known_hosts_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts"))
return tuple(files)
# --- agent guest env ------------------------------------------------- # --- agent guest env -------------------------------------------------
@@ -235,8 +330,7 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
"""Env injected into every agent/exec call over SSH. The VM has no """Env injected into every agent/exec call over SSH. The VM has no
baked process env (it just runs init), so the proxy/CA/git/supervise baked process env (it just runs init), so the proxy/CA/git/supervise
wiring is applied per-invocation.""" wiring is applied per-invocation."""
# Carries the identity token as proxy credentials (set in `launch`). proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
proxy_url = plan.agent_proxy_url or f"http://{host_ip}:{EGRESS_PORT}"
no_proxy = f"localhost,127.0.0.1,{host_ip}" no_proxy = f"localhost,127.0.0.1,{host_ip}"
env: dict[str, str] = { env: dict[str, str] = {
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
@@ -261,3 +355,50 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
if value is not None: if value is not None:
env[name] = value env[name] = value
return env return env
# --- docker helpers --------------------------------------------------
def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None:
info(f"docker build {ref}")
args = ["docker", "build", "-t", ref]
if dockerfile:
if not os.path.isabs(dockerfile):
dockerfile = os.path.join(context, dockerfile)
args += ["-f", dockerfile]
args.append(context)
result = subprocess.run(args, check=False)
if result.returncode != 0:
die(f"docker build for {ref!r} failed")
def _image_exists(ref: str) -> bool:
return subprocess.run(
["docker", "image", "inspect", ref],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
).returncode == 0
def _force_remove_container(name: str) -> None:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
def _docker_exec(name: str, argv: list[str]) -> None:
result = subprocess.run(
["docker", "exec", name, *argv], capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"docker exec in {name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}")
def _docker_cp(host_path: str, dest: str) -> None:
result = subprocess.run(
["docker", "cp", host_path, dest], capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"docker cp {host_path} -> {dest} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}")
@@ -15,11 +15,3 @@ BOT_BOTTLE_FC_POOL_SIZE=8
BOT_BOTTLE_FC_IP_BASE=10.243.0.0 BOT_BOTTLE_FC_IP_BASE=10.243.0.0
BOT_BOTTLE_FC_IFACE_PREFIX=bbfc BOT_BOTTLE_FC_IFACE_PREFIX=bbfc
BOT_BOTTLE_FC_NFT_TABLE=bot_bottle_fc BOT_BOTTLE_FC_NFT_TABLE=bot_bottle_fc
# The orchestrator/gateway VM's own TAP — a dedicated link OUTSIDE the
# bbfc* agent pool. Unlike agent VMs (which reach only their gateway),
# the orchestrator is trusted infra that needs real NAT'd internet
# egress: to FROM-pull + apt/npm during in-VM agent-image builds
# (buildah) and to forward agent egress upstream (Stage B gateway). Its
# /31 is the top of the IP_BASE /16 (host x.y.255.0, guest x.y.255.1),
# clear of the pool near the bottom of the block.
BOT_BOTTLE_FC_ORCH_IFACE=bborch0
+3 -28
View File
@@ -18,7 +18,7 @@ Topology (per slot i):
* a /31 host<->guest link: host = base + 2i (the gateway the VM * a /31 host<->guest link: host = base + 2i (the gateway the VM
routes through), guest = base + 2i + 1 (the VM's address). routes through), guest = base + 2i + 1 (the VM's address).
* isolation via ``table inet bot_bottle_fc``: a VM reaches only its * isolation via ``table inet bot_bottle_fc``: a VM reaches only its
own gateway (DNAT'd from the host TAP IP) and nothing else. own sidecar (DNAT'd from the host TAP IP) and nothing else.
The default IP block is ``10.243.0.0/16`` — an intentionally obscure The default IP block is ``10.243.0.0/16`` — an intentionally obscure
corner of RFC-1918 private space. RFC-1918 is the range *designated* corner of RFC-1918 private space. RFC-1918 is the range *designated*
@@ -79,12 +79,6 @@ def _cfg(key: str) -> str:
IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX") IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX")
NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE") NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE")
# The orchestrator/gateway VM's dedicated TAP — outside the bbfc* agent
# pool and, unlike it, NAT'd to the internet (see `orch_slot`). The
# orchestrator is trusted infra: it builds agent images in-VM (buildah
# needs to FROM-pull + apt/npm) and forwards agent egress upstream.
ORCH_IFACE = _cfg("BOT_BOTTLE_FC_ORCH_IFACE")
def pool_size() -> int: def pool_size() -> int:
return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE")) return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE"))
@@ -94,10 +88,10 @@ def ip_base() -> str:
return _cfg("BOT_BOTTLE_FC_IP_BASE") return _cfg("BOT_BOTTLE_FC_IP_BASE")
# Gateway ports the VM reaches at its host-side TAP IP. Kept in sync # Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
# with the backend constants (egress 9099, supervise 9100, git-http # with the backend constants (egress 9099, supervise 9100, git-http
# 9420); rendered into the setup output for operator visibility. # 9420); rendered into the setup output for operator visibility.
GATEWAY_PORTS = (9099, 9100, 9420) SIDECAR_PORTS = (9099, 9100, 9420)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -129,25 +123,6 @@ def all_slots() -> list[Slot]:
return [slot(i) for i in range(pool_size())] return [slot(i) for i in range(pool_size())]
def orch_slot() -> Slot:
"""The orchestrator/gateway VM's dedicated link — its own TAP
(`ORCH_IFACE`) on a /31 at the TOP of the IP_BASE /16 (host
x.y.255.0, guest x.y.255.1), well clear of the agent pool near the
bottom of the block. Unlike a pool `Slot`, this link is NAT'd out to
the internet by the setup (the orchestrator is trusted infra), so it
is deliberately *not* one of the isolated `bbfc*` slots.
`index` is -1 (sentinel: not a pool index)."""
base16 = int(ipaddress.IPv4Address(ip_base())) & 0xFFFF0000
host = base16 + 0xFF00
return Slot(
index=-1,
iface=ORCH_IFACE,
host_ip=str(ipaddress.IPv4Address(host)),
guest_ip=str(ipaddress.IPv4Address(host + 1)),
)
# --- fail-closed verification --------------------------------------- # --- fail-closed verification ---------------------------------------
def _run_ok(argv: list[str]) -> bool: def _run_ok(argv: list[str]) -> bool:
@@ -1,212 +0,0 @@
"""Build the infra rootfs and publish it as a Gitea generic package.
The off-host (build / CI) half of PRD 0069 Stage 2: this DOES use Docker, but
never on the launch host. It runs the same pipeline the launch host used to run
locally — `docker build` the three fixed images, export to a rootfs dir, inject
the guest boot, `mke2fs` to an ext4 with the buildah build slack — then gzips
the ext4 and PUTs it (plus a `.sha256`) to
`…/api/packages/<owner>/generic/bot-bottle-firecracker-infra/<version>/`.
The `<version>` is `infra_artifact.infra_artifact_version(...)`, the content
hash of the rootfs inputs, so a launch host at the same code checkout resolves
the exact artifact this produced.
python3 -m bot_bottle.backend.firecracker.publish_infra --output DIR
python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir DIR
Auth: a token with `write:package` on the target owner, from
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import shutil
import sys
import urllib.error
import urllib.request
from pathlib import Path
from . import infra_artifact, infra_vm, util
_CHUNK = 1 << 20
# A human-readable description shipped alongside the artifact — generic packages
# have no description field, so this file *is* the description on the package
# page. Uploaded on every publish so it never goes stale.
_ABOUT_NAME = "about.txt"
_ABOUT_TEXT = (
"bot-bottle infra rootfs for the Firecracker backend (PRD 0069 Stage 2, "
"#348): the per-host infra VM (orchestrator control plane + gateway + "
"buildah). Prebuilt off-host, gzip ext4; the launch host downloads + "
"sha256-verifies + boots it, no host Docker. The version tag is a content "
"hash of the rootfs inputs. Files: rootfs.ext4.gz + rootfs.ext4.gz.sha256.\n"
)
def _gzip(src: Path, dest: Path) -> None:
with open(src, "rb") as fh, gzip.open(dest, "wb") as out:
shutil.copyfileobj(fh, out, _CHUNK)
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def _put(url: str, body: "bytes | Path", token: str) -> None:
"""PUT `body` (raw bytes, or a Path streamed from disk) to `url`. The rootfs
is hundreds of MB, so it is passed as a Path and streamed — `urlopen` reads
the open file in blocks rather than materializing it in memory (with an
explicit Content-Length, which Gitea requires and which also stops urllib
from `len()`-ing a non-bytes body)."""
handle = None
if isinstance(body, Path):
length = body.stat().st_size
handle = open(body, "rb")
data: object = handle
else:
length = len(body)
data = body
req = urllib.request.Request(url, data=data, method="PUT") # type: ignore[arg-type]
req.add_header("Content-Length", str(length))
if token:
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req) as resp:
print(f" uploaded {url} (HTTP {resp.status})")
except urllib.error.HTTPError as e:
if e.code == 409:
raise SystemExit(
f"artifact already published at {url} (HTTP 409); "
f"bump the code version or pass --force to overwrite"
)
raise SystemExit(f"upload failed (HTTP {e.code}): {url}\n{e.read().decode(errors='replace')}")
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
finally:
if handle is not None:
handle.close()
def _delete(url: str, token: str) -> None:
req = urllib.request.Request(url, method="DELETE")
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req):
pass
except urllib.error.HTTPError as e:
if e.code != 404:
raise SystemExit(f"could not overwrite existing artifact (HTTP {e.code}): {url}")
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
def build_artifact(out_dir: Path) -> tuple[str, Path, Path]:
"""Build the infra rootfs ext4, gzip it, and write the checksum. Returns
`(version, gz_path, sha_path)`. Uses host Docker (off-host / CI)."""
version = infra_artifact.infra_artifact_version(infra_vm._infra_init())
print(f"building infra rootfs artifact {version} (docker)")
infra_vm.build_infra_images_with_docker()
base = infra_vm.build_infra_rootfs_dir()
ext4 = out_dir / "rootfs.ext4"
util.build_rootfs_ext4(base, ext4, slack_mib=8192)
gz = out_dir / "rootfs.ext4.gz"
print("compressing rootfs")
_gzip(ext4, gz)
ext4.unlink(missing_ok=True)
sha = out_dir / "rootfs.ext4.gz.sha256"
digest = _sha256(gz)
sha.write_text(f"{digest} rootfs.ext4.gz\n")
print(f" {gz.name}: {gz.stat().st_size / 1e6:.0f} MB sha256={digest}")
return version, gz, sha
def _publish_bundle(root: Path, token: str) -> str:
version_file = root / "version.txt"
# Guard the read so a missing version.txt is a clean error, not a raw
# FileNotFoundError.
if not version_file.is_file():
raise SystemExit(f"incomplete artifact bundle: {root}")
version = version_file.read_text(encoding="utf-8").strip()
expected = infra_artifact.infra_artifact_version(infra_vm._infra_init())
if version != expected:
raise SystemExit(
f"artifact bundle version {version!r} does not match checkout {expected!r}"
)
gz = root / "rootfs.ext4.gz"
sha = root / "rootfs.ext4.gz.sha256"
if not gz.is_file() or not sha.is_file():
raise SystemExit(f"incomplete artifact bundle: {root}")
expected_sha = sha.read_text().split()[0].strip().lower()
if _sha256(gz) != expected_sha:
raise SystemExit("artifact bundle checksum mismatch")
gz_url = infra_artifact.artifact_url(version, gz.name)
sha_url = infra_artifact.artifact_url(version, sha.name)
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME)
# Publishing is idempotent. If this exact complete artifact is already
# present, a test-only main commit is a no-op. Otherwise clear any partial
# upload left by an interrupted prior attempt and upload the complete set.
try:
with urllib.request.urlopen(infra_artifact._open(sha_url)) as resp:
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
except urllib.error.HTTPError as e:
if e.code != 404:
raise SystemExit(f"checking existing artifact failed (HTTP {e.code})")
remote_sha = ""
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {sha_url} ({e.reason})")
if remote_sha == expected_sha:
print(f"infra rootfs {version} already published")
return version
for url in (gz_url, sha_url, about_url):
_delete(url, token)
_put(gz_url, gz, token)
_put(sha_url, sha.read_bytes(), token)
_put(about_url, _ABOUT_TEXT.encode(), token)
return version
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="publish_infra", description="Build + publish the infra rootfs artifact.")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--output", type=Path,
help="build a candidate bundle in DIR without publishing")
mode.add_argument("--publish-dir", type=Path,
help="publish an already-built and tested candidate bundle")
args = parser.parse_args(argv)
_, _, token = infra_artifact._config()
if args.publish_dir is not None and not token:
raise SystemExit(
"no publish token: set BOT_BOTTLE_INFRA_ARTIFACT_TOKEN to a token "
"with write:package")
if args.output is not None:
args.output.mkdir(parents=True, exist_ok=True)
version, _gz, _sha = build_artifact(args.output)
(args.output / "version.txt").write_text(version + "\n", encoding="utf-8")
print(f"built infra rootfs candidate {version}")
return 0
assert args.publish_dir is not None
version = _publish_bundle(args.publish_dir, token)
print(f"published infra rootfs {version}")
return 0
if __name__ == "__main__":
sys.exit(main())
+13 -93
View File
@@ -14,7 +14,6 @@ and `./cli.py backend setup --backend=firecracker`.
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
import platform import platform
import shutil import shutil
@@ -88,7 +87,7 @@ def require_firecracker() -> None:
booting a VM without it.""" booting a VM without it."""
if not is_linux(): if not is_linux():
die("firecracker backend is only supported on Linux (KVM). " die("firecracker backend is only supported on Linux (KVM). "
"On macOS use the macos-container backend.") "On macOS use --backend=macos-container.")
if shutil.which("firecracker") is None: if shutil.which("firecracker") is None:
info("Firecracker is required but was not found on PATH.") info("Firecracker is required but was not found on PATH.")
info("Install: https://github.com/firecracker-microvm/firecracker/releases") info("Install: https://github.com/firecracker-microvm/firecracker/releases")
@@ -160,22 +159,15 @@ def docker_image_id(ref: str) -> str:
return result.stdout.strip().replace("sha256:", "")[:16] return result.stdout.strip().replace("sha256:", "")[:16]
def build_base_rootfs_dir( def build_base_rootfs_dir(image_ref: str) -> Path:
image_ref: str, *, variant: str = "", init_script: str | None = None, """Export the agent image's filesystem and inject the guest init +
) -> Path: static dropbear. Cached by image digest — the per-bottle bits
"""Export the image's filesystem and inject the guest init + static
dropbear. Cached by image digest — the per-bottle bits
(authorized_keys, IP) are passed at boot via the kernel cmdline, so (authorized_keys, IP) are passed at boot via the kernel cmdline, so
this tree carries nothing bottle-specific and is safely shared. this tree carries nothing bottle-specific and is safely shared.
`variant` suffixes the cache key so the same image can be prepared
with a different `init_script` (e.g. the infra VM boots the same
orchestrator image as the builder but runs the control plane as
PID 1, not the SSH-only agent init) without a cache collision.
Returns the prepared directory (read as the `mke2fs -d` source).""" Returns the prepared directory (read as the `mke2fs -d` source)."""
digest = docker_image_id(image_ref) digest = docker_image_id(image_ref)
base = cache_dir() / "rootfs" / f"{digest}{variant}" base = cache_dir() / "rootfs" / digest
ready = base / ".bb-ready" ready = base / ".bb-ready"
if ready.is_file(): if ready.is_file():
return base return base
@@ -208,85 +200,18 @@ def build_base_rootfs_dir(
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
inject_guest_boot(base, init_script=init_script) _inject_guest_boot(base)
ready.write_text("ok\n") ready.write_text("ok\n")
return base return base
def build_committed_rootfs_dir(tar_path: Path) -> Path: def _inject_guest_boot(rootfs: Path) -> None:
"""Prepare a base rootfs dir from a frozen-bottle snapshot tar (the """Drop the static dropbear and the PID-1 init into the rootfs."""
freeze/resume path — no Docker). Extracts the snapshot, recreates the shutil.copy2(dropbear_path(), rootfs / "bb-dropbear")
virtual mount points the freezer excluded, and injects the guest init + os.chmod(rootfs / "bb-dropbear", 0o755)
static dropbear, mirroring `build_base_rootfs_dir` but sourced from a tar init = rootfs / "bb-init"
we control rather than a Docker image. init.write_text(_GUEST_INIT)
os.chmod(init, 0o755)
Cached under the rootfs cache, keyed by the tar's size+mtime so a
re-freeze re-extracts but repeated resumes of the same snapshot don't.
Returns the prepared directory (read as the `mke2fs -d` source)."""
st = tar_path.stat()
fingerprint = hashlib.sha256(
f"{tar_path}:{st.st_size}:{st.st_mtime_ns}".encode()
).hexdigest()[:16]
base = cache_dir() / "rootfs" / f"committed-{fingerprint}"
ready = base / ".bb-ready"
if ready.is_file():
return base
if base.exists():
shutil.rmtree(base, ignore_errors=True)
base.mkdir(parents=True)
info(f"extracting committed rootfs {tar_path} -> {base}")
result = subprocess.run(
["tar", "-x", "-f", str(tar_path), "-C", str(base)],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"extracting committed rootfs {tar_path} failed: "
f"{result.stderr.strip() or '<no stderr>'}")
# The freezer excludes the live/virtual filesystems from the snapshot;
# recreate them as empty mount points so the guest init can mount
# proc/sys/dev and dropbear has a writable /run.
for mount_point in ("proc", "sys", "dev", "run"):
(base / mount_point).mkdir(mode=0o755, exist_ok=True)
inject_guest_boot(base)
ready.write_text("ok\n")
return base
def inject_guest_boot(rootfs: Path, init_script: str | None = None) -> None:
"""Drop the static dropbear and the PID-1 init into the rootfs.
`init_script` defaults to the SSH-only agent init; the infra VM
passes its own (control plane + gateway) init.
A committed snapshot is guest-controlled, so `bb-dropbear`/`bb-init`
may already exist as symlinks aimed at a host file (e.g. bb-init ->
~/.bashrc). Replace whatever is there and create the files with
O_EXCL|O_NOFOLLOW so the write always lands a fresh regular file in
the staging tree and never follows a planted symlink out of it."""
_write_staged_file(rootfs / "bb-dropbear", dropbear_path().read_bytes())
_write_staged_file(rootfs / "bb-init", (init_script or _GUEST_INIT).encode())
def _write_staged_file(path: Path, data: bytes) -> None:
"""Write `data` to `path` (mode 0755) as a fresh regular file inside a
staging rootfs, replacing any pre-existing entry without following a
symlink at `path`. Fails closed on anything unexpected there."""
if path.is_symlink() or path.exists():
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
fd = os.open(
path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755
)
try:
os.write(fd, data)
finally:
os.close(fd)
os.chmod(path, 0o755)
def build_rootfs_ext4(base_dir: Path, out_path: Path, *, slack_mib: int = 1024) -> None: def build_rootfs_ext4(base_dir: Path, out_path: Path, *, slack_mib: int = 1024) -> None:
@@ -368,11 +293,6 @@ mount -t devtmpfs dev /dev 2>/dev/null
mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null
mount -o remount,rw / 2>/dev/null mount -o remount,rw / 2>/dev/null
# /tmp must be world-writable + sticky. The rootless rootfs build can land
# it 0755/root-owned, leaving the agent (uid 1000 node) unable to create
# scratch dirs there — git worktrees, build temp, `git init /tmp/...`, etc.
mkdir -p /tmp && chmod 1777 /tmp
# Install the per-bottle SSH pubkey from the kernel cmdline. # Install the per-bottle SSH pubkey from the kernel cmdline.
KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null) KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null)
if [ -n "$KEY" ]; then if [ -n "$KEY" ]; then
@@ -2,7 +2,7 @@
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
the Apple `container` CLI integration; launch remains gated until the the Apple `container` CLI integration; launch remains gated until the
gateway network enforcement shape is implemented. sidecar network enforcement shape is implemented.
""" """
from .backend import MacosContainerBottleBackend from .backend import MacosContainerBottleBackend
@@ -89,14 +89,6 @@ class MacosContainerBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
def ensure_orchestrator(self) -> str:
"""Bring up the per-host infra container (control plane + gateway) and
return its control-plane URL the on-demand entry point operator tools
(`supervise`) call when no control plane is running yet. Mirrors
firecracker's infra-VM bring-up."""
from .infra import MacosInfraService
return MacosInfraService().ensure_running().control_plane_url
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan: def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
return _cleanup.prepare_cleanup() return _cleanup.prepare_cleanup()
+4 -37
View File
@@ -52,7 +52,6 @@ class MacosContainerBottle(Bottle):
terminal_title: str = "", terminal_title: str = "",
terminal_color: str = "", terminal_color: str = "",
agent_workdir: str = "/home/node", agent_workdir: str = "/home/node",
exec_env: dict[str, str] | None = None,
): ):
self.name = container self.name = container
self._teardown = teardown self._teardown = teardown
@@ -63,20 +62,6 @@ class MacosContainerBottle(Bottle):
self.terminal_color = terminal_color self.terminal_color = terminal_color
self.agent_provider_template = agent_provider_template self.agent_provider_template = agent_provider_template
self.agent_workdir = agent_workdir self.agent_workdir = agent_workdir
# Env applied to the agent process at `container exec` time, on top of
# what the container was run with. This is how the identity token
# reaches the agent (PRD 0070): registration mints it *after* the
# container exists — its source IP is the registration key and Apple
# Container assigns that by DHCP — so it cannot be in the run-time env
# the way docker's compose spec does it.
#
# `container exec --env` does NOT override a run-time value — it
# appends, leaving duplicate entries in the agent's `environ` whose
# resolution is runtime-specific (Node last-wins, Rust first-wins). So
# nothing here may rely on superseding: the proxy vars are supplied
# *only* at exec time and are deliberately absent from the run-time
# env. See `launch._agent_env_entries`.
self._exec_env = dict(exec_env or {})
self._closed = False self._closed = False
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]: def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
@@ -89,12 +74,6 @@ class MacosContainerBottle(Bottle):
) )
) )
container_exec = ["container", "exec"] container_exec = ["container", "exec"]
# Bare env names, same rule as the terminal hints below: the value
# stays in the child env `exec_agent` builds and never reaches argv —
# the proxy URL here carries the identity token, which `ps` would
# otherwise expose to every process on the host.
for name in sorted(self._exec_env):
container_exec.extend(["--env", name])
if tty: if tty:
container_exec.extend(["--interactive", "--tty"]) container_exec.extend(["--interactive", "--tty"])
# Forward terminal capability hints so TUIs can enable modified-key # Forward terminal capability hints so TUIs can enable modified-key
@@ -115,33 +94,21 @@ class MacosContainerBottle(Bottle):
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
agent_argv = self.agent_argv(argv, tty=tty) agent_argv = self.agent_argv(argv, tty=tty)
# The values behind the bare `--env` names in `agent_argv`. `sh -lc`
# below is in this process tree, so the child env reaches `container
# exec` either way.
env = {**os.environ, **self._exec_env} if self._exec_env else None
script = ( script = (
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
if tty else None if tty else None
) )
if script is None: if script is None:
return subprocess.run(agent_argv, env=env, check=False).returncode return subprocess.run(agent_argv, check=False).returncode
return subprocess.run(["sh", "-lc", script], env=env, check=False).returncode return subprocess.run(["sh", "-lc", script], check=False).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
# Carry the same exec env the agent gets: provisioning steps run
# through here, and a provider whose provision step fetches anything
# would egress without the identity token and be denied by /resolve.
# Bare `--env NAME` again, so the token stays off argv.
argv = ["container", "exec", "--user", user, "--interactive"]
for name in sorted(self._exec_env):
argv.extend(["--env", name])
argv.extend([self.name, "sh", "-s"])
result = subprocess.run( result = subprocess.run(
argv, ["container", "exec", "--user", user, "--interactive",
self.name, "sh", "-s"],
input=script, input=script,
capture_output=True, capture_output=True,
text=True, text=True,
env={**os.environ, **self._exec_env} if self._exec_env else None,
check=False, check=False,
) )
return ExecResult( return ExecResult(
@@ -13,13 +13,9 @@ from .. import BottlePlan
class MacosContainerBottlePlan(BottlePlan): class MacosContainerBottlePlan(BottlePlan):
slug: str slug: str
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
agent_proxy_url: str = ""
agent_git_gate_url: str = "" agent_git_gate_url: str = ""
agent_supervise_url: str = "" agent_supervise_url: str = ""
# Read by provision-time consumers (git extraHeader, supervise MCP header)
# via getattr(plan, "identity_token", ""); stamped in launch after the
# bottle is registered. See launch.py's stamp for why it lives here and not
# only in the exec-time proxy env.
identity_token: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
@@ -9,6 +9,7 @@ from . import util as container_mod
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
_PREFIX = "bot-bottle-" _PREFIX = "bot-bottle-"
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
def _list_prefixed_containers() -> list[str]: def _list_prefixed_containers() -> list[str]:
@@ -23,7 +24,7 @@ def _list_prefixed_containers() -> list[str]:
return [] return []
return sorted( return sorted(
name for name in (line.strip() for line in result.stdout.splitlines()) name for name in (line.strip() for line in result.stdout.splitlines())
if name.startswith(_PREFIX) if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
) )
@@ -1,178 +0,0 @@
"""Consolidated bottle launch sequence for the macOS backend (PRD 0070).
The docker backend allocates a free address, pins the agent to it with
`--ip`, registers it, *then* starts the agent registration precedes launch
because the pinned address is known up front.
**Apple Container 1.0.0 has no `--ip`.** The `--network` flag takes only
`<name>[,mac=][,mtu=]`; the address is assigned by vmnet's DHCP and is
knowable only once the container is running. So the macOS order inverts:
ensure_gateway() -> caller starts the agent -> register_agent(source_ip)
That is why this module exposes two functions where docker has one the
caller has to start the agent in between. `ensure_gateway` runs first because
the agent's proxy env needs the gateway's address at `container run` time; the
agent's *own* address (the attribution key) only exists afterwards.
The control plane and the gateway are one **infra container** here (see
`infra`), so `gateway_ip` and the control-plane host are the same address.
The consequence for the identity token: it is minted by registration, i.e.
*after* the agent container exists, so it cannot be baked into the run-time
env the way docker's compose spec does. It is delivered at `container exec`
time instead see `bottle.MacosContainerBottle`.
That delivery is load-bearing, not a nicety: `/resolve` requires a matching
`(source_ip, identity_token)` pair and fail-closes with no source-IP-only
fallback (#366). So egress that does not carry the token is denied — which is
the safe direction, and is why the agent's init process is a bare `sleep` and
every real command arrives through `container exec`.
"""
from __future__ import annotations
from dataclasses import dataclass
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK
from .gateway_provision import AppleGatewayTransport
from .infra import MacosInfraService, OrchestratorStartError
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class GatewayEndpoint:
"""What the agent `container run` needs to reach the shared gateway (the
infra container). `gateway_ip` is that container's host-only address, the
same host the control-plane URL points at."""
orchestrator_url: str
gateway_ip: str # the gateway's address — the agent's proxy target
gateway_ca_pem: str # the shared CA the provisioner installs
network: str # the shared host-only network to attach to
@dataclass(frozen=True)
class LaunchContext:
"""What the running agent needs once it has been registered."""
bottle_id: str
identity_token: str
source_ip: str # the agent's DHCP-assigned address (attribution key)
gateway_ip: str
network: str
orchestrator_url: str
def ensure_gateway(
*, service: MacosInfraService | None = None,
) -> GatewayEndpoint:
"""Ensure the per-host infra container (control plane + gateway) is up and
report how to reach it. Idempotent one singleton, so N bottle launches
share it. Call before starting the agent container: the agent's proxy env
needs `gateway_ip` at run time."""
service = service or MacosInfraService()
infra = service.ensure_running()
return GatewayEndpoint(
orchestrator_url=infra.control_plane_url,
gateway_ip=infra.gateway_ip,
gateway_ca_pem=service.ca_cert_pem(),
network=service.network,
)
def live_source_ips(network: str) -> list[str]:
"""Every running agent container's address on `network`.
The reconciliation input: the orchestrator lives inside the infra
container and cannot enumerate the host's containers, so the host has to
tell it which bottles are actually up. Containers that have not been
assigned an address yet contribute nothing the reap's grace window, not
this list, is what protects an in-flight launch.
Raises `EnumerationError` when the live set cannot be determined
authoritatively: either the container listing fails or any individual
inspect fails. Callers must skip reconciliation in that case to avoid
unregistering healthy bottles."""
ips: list[str] = []
for agent in enumerate_active():
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
ip = container_mod.inspect_container_network_ip(name, network)
if ip is None:
raise EnumerationError(
f"container inspect {name!r} failed; live set is not authoritative"
)
if ip:
ips.append(ip)
return ips
def register_agent(
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
*,
source_ip: str,
endpoint: GatewayEndpoint,
image_ref: str = "",
tokens: dict[str, str] | None = None,
) -> LaunchContext:
"""Register the (already running) agent by its address and provision its
git-gate state into the gateway. `source_ip` must be read from the live
container it is the attribution key the gateway resolves policy by.
Raises on failure; the caller tears down."""
client = OrchestratorClient(endpoint.orchestrator_url)
# Self-heal before registering: a launcher that died hard (SIGKILL, closed
# terminal, host sleep) never ran its teardown callback, leaving an active
# row with no container. vmnet recycles addresses, so such a row can
# collide with this bottle's — and `by_source_ip` fail-closes on ambiguity,
# which would resolve no policy at all and deny every host. Best-effort: a
# reconciliation failure must not block an otherwise-fine launch.
try:
client.reconcile(live_source_ips(endpoint.network))
except (OrchestratorClientError, EnumerationError) as e:
info(f"registry reconciliation skipped: {e}")
reg = provision_bottle(
client, source_ip, egress_plan, git_gate_plan, AppleGatewayTransport(),
image_ref=image_ref, tokens=tokens,
)
return LaunchContext(
bottle_id=reg.bottle_id,
identity_token=reg.identity_token,
source_ip=source_ip,
gateway_ip=endpoint.gateway_ip,
network=endpoint.network,
orchestrator_url=endpoint.orchestrator_url,
)
def teardown_consolidated(
bottle_id: str, *, orchestrator_url: str, timeout: float | None = None,
) -> None:
"""Deregister the bottle and remove its git-gate state from the gateway.
Both steps are idempotent so this is safe from a cleanup trap. Does NOT
stop the gateway it's a persistent per-host singleton."""
_teardown_util(bottle_id, AppleGatewayTransport(),
orchestrator_url=orchestrator_url, timeout=timeout)
__all__ = [
"GatewayEndpoint",
"LaunchContext",
"ensure_gateway",
"live_source_ips",
"register_agent",
"teardown_consolidated",
"ConsolidatedLaunchError",
"OrchestratorStartError",
"GATEWAY_NETWORK",
]
@@ -1,26 +1,36 @@
"""Host-side egress route-apply for the macos-container backend. """Host-side egress apply for the macos-container backend.
The per-bottle companion container this used to signal (`container kill Uses `container kill --signal HUP` (Apple Container framework) instead
--signal HUP <container>`) was removed in the companion-container removal of `docker kill` to signal the sidecar bundle.
(#385). In the consolidated model the shared gateway resolves egress policy
per-request against the orchestrator rather than reloading a per-bottle routes
file, so the live per-bottle reload is not supported here and fails closed
until the gateway-side apply lands same posture as the docker backend.
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess
from ...log import warn
from ..egress_apply import EgressApplicator, EgressApplyError from ..egress_apply import EgressApplicator, EgressApplyError
from .launch import sidecar_container_name
class MacOSContainerEgressApplicator(EgressApplicator): class MacOSContainerEgressApplicator(EgressApplicator):
def _signal_bundle_reload(self, slug: str) -> None: def _signal_bundle_reload(self, slug: str) -> None:
del slug container = sidecar_container_name(slug)
raise EgressApplyError( result = subprocess.run(
"live egress route-apply was removed with the per-bottle " ["container", "kill", "--signal", "HUP", container],
"companion container (#385); route changes will flow through " capture_output=True, text=True, check=False, env=os.environ,
"the consolidated gateway in a follow-up."
) )
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'container kill failed'}"
)
raise EgressApplyError(
f"could not reload egress bundle {container}: "
f"{last_error or 'container kill failed'}"
)
applicator = MacOSContainerEgressApplicator() applicator = MacOSContainerEgressApplicator()
@@ -6,21 +6,9 @@ import subprocess
from ...bottle_state import read_metadata from ...bottle_state import read_metadata
from .. import ActiveAgent from .. import ActiveAgent
from .infra import INFRA_NAME
# The name every agent container carries: `bot-bottle-<slug>`. Exported _PREFIX = "bot-bottle-"
# because callers that act on a running bottle (gateway-host rewrites, _SIDECAR_PREFIX = "bot-bottle-sidecars-"
# registry reconciliation) have to map an enumerated slug back to a
# container name.
CONTAINER_NAME_PREFIX = "bot-bottle-"
# The shared per-host infra container carries the same prefix as agent
# containers but is infrastructure, not a bottle — one control plane + gateway
# serves every agent, so listing it as an agent would invent one per host.
_INFRA_NAMES = frozenset({INFRA_NAME})
class EnumerationError(RuntimeError):
"""container list failed; the resulting live set is not authoritative."""
def enumerate_active() -> list[ActiveAgent]: def enumerate_active() -> list[ActiveAgent]:
@@ -31,15 +19,14 @@ def enumerate_active() -> list[ActiveAgent]:
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
raise EnumerationError( return []
f"container list failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
out: list[ActiveAgent] = [] out: list[ActiveAgent] = []
for name in sorted(line.strip() for line in result.stdout.splitlines()): for name in sorted(line.strip() for line in result.stdout.splitlines()):
if not name.startswith(CONTAINER_NAME_PREFIX) or name in _INFRA_NAMES: if not name.startswith(_PREFIX):
continue continue
slug = name[len(CONTAINER_NAME_PREFIX):] if name.startswith(_SIDECAR_PREFIX):
continue
slug = name[len(_PREFIX):]
metadata = read_metadata(slug) metadata = read_metadata(slug)
out.append(ActiveAgent( out.append(ActiveAgent(
backend_name="macos-container", backend_name="macos-container",
@@ -1,46 +0,0 @@
"""Shared network/image constants for the macOS consolidated infra container.
The gateway data plane no longer runs as its own Apple container it shares a
single per-host **infra container** with the control plane (see `infra`),
because two Apple-Container guests writing one `bot-bottle.db` over virtiofs
would race incoherent `fcntl` locks. This module holds the pieces both the
infra service and the launch/provision glue need: the network names, the
gateway image, and the network-creation helper.
"""
from __future__ import annotations
import os
from ...orchestrator.gateway import GatewayError
from . import util as container_mod
# The shared host-only network the infra container and every agent bottle sit
# on. The agent's address here is the attribution key. Distinct from the docker
# names so both backends can coexist on one host.
GATEWAY_NETWORK = "bot-bottle-mac-gateway"
# The NAT network that gives the infra container (and only it) a route out.
GATEWAY_EGRESS_NETWORK = "bot-bottle-mac-egress"
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
def ensure_networks(
network: str = GATEWAY_NETWORK, egress_network: str = GATEWAY_EGRESS_NETWORK,
) -> None:
"""Create the shared host-only network + the NAT egress network. Idempotent
`create_network` tolerates 'already exists'."""
container_mod.create_network(egress_network)
container_mod.create_network(network, internal=True)
__all__ = [
"GATEWAY_NETWORK",
"GATEWAY_EGRESS_NETWORK",
"GATEWAY_IMAGE",
"GatewayError",
"DEFAULT_CA_TIMEOUT_SECONDS",
"ensure_networks",
]
@@ -1,96 +0,0 @@
"""Stable gateway name for macOS agents, via each bottle's `/etc/hosts`.
The shared gateway's address is assigned by vmnet's DHCP and changes whenever
the infra container is recreated a source-hash bump, an image upgrade, a
crash. Every agent-facing URL (egress proxy, git-http, supervise) embeds 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 used to strand every running bottle permanently:
not degraded, unreachable, until the bottle was relaunched and its session
thrown away.
So the agent never learns the address. It is given a stable *name*
(`GATEWAY_HOSTNAME`) in every URL, resolved through its own `/etc/hosts`.
Unlike `environ`, that is a file it can be rewritten inside a container that
is already running, so a gateway that comes back at a new address is picked up
by live bottles instead of orphaning them.
Apple Container 1.0 offers no container-name DNS on a user network (the only
nameserver an agent sees is vmnet's, which does not know container names) and
`container run` has no `--add-host`, so the entry is written by exec after the
container starts.
Writing it needs root, and the agent runs as `node`: the agent therefore
cannot repoint its own gateway name, while the host (which drives `container
exec --user root`) can. That asymmetry is deliberate keep it.
"""
from __future__ import annotations
from ...log import warn
from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, enumerate_active
# The name every agent-facing gateway URL uses. Must not collide with a real
# DNS name the agent might resolve; it is bottle-local by construction.
GATEWAY_HOSTNAME = "bot-bottle-gateway"
# Marker so the rewrite is idempotent and only ever touches our own line —
# the rest of /etc/hosts (localhost, the container's own name) is preserved.
_MARKER = "# bot-bottle gateway"
def _rewrite_script(gateway_ip: str) -> str:
"""A shell one-liner that replaces our managed line in `/etc/hosts`.
Rewrites in place via a temp file + `cat` rather than `mv`, so the file
keeps its original inode, ownership, and mode a bind-mounted or
pre-created `/etc/hosts` must not be replaced by a root-owned 0644 copy
that the runtime then refuses to update.
"""
return (
"set -e; "
f"grep -v '{_MARKER}' /etc/hosts > /tmp/.bb-hosts || true; "
f"printf '%s %s %s\\n' '{gateway_ip}' '{GATEWAY_HOSTNAME}' "
f"'{_MARKER}' >> /tmp/.bb-hosts; "
"cat /tmp/.bb-hosts > /etc/hosts; "
"rm -f /tmp/.bb-hosts"
)
def set_gateway_host(container_name: str, gateway_ip: str) -> None:
"""Point `GATEWAY_HOSTNAME` at `gateway_ip` inside one running container.
Must run before the agent is exec'd: the agent's proxy URL names the
gateway, so the entry has to exist for its first connection. Idempotent
re-running with the same address is a no-op in effect.
"""
container_mod.exec_container_as_root(
container_name, ["sh", "-c", _rewrite_script(gateway_ip)],
)
def refresh_gateway_host(gateway_ip: str) -> list[str]:
"""Re-point every running bottle at the current gateway address.
Called once the shared gateway is known to be up, so a bottle stranded by
an earlier gateway restart re-attaches instead of needing a relaunch.
Returns the containers updated.
Best-effort per bottle: one container that refuses the write (already
exiting, say) must not stop the others from being repaired, and must not
fail the launch that triggered the sweep.
"""
updated: list[str] = []
for agent in enumerate_active():
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
try:
set_gateway_host(name, gateway_ip)
updated.append(name)
# One bad bottle must not stop the sweep, so this is deliberately broad.
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
warn(f"could not re-point {name} at the gateway: {e}")
return updated
__all__ = ["GATEWAY_HOSTNAME", "set_gateway_host", "refresh_gateway_host"]
@@ -1,44 +0,0 @@
"""`GatewayTransport` for the Apple infra container (PRD 0070).
The provisioning *logic* (per-bottle creds dirs, namespaced repo init) is
backend-neutral and lives in `backend.docker.gateway_provision`; this is only
the transport how files and commands reach the running gateway. Docker uses
`docker exec`/`docker cp` and Firecracker uses SSH; Apple uses the `container`
CLI's equivalents against the infra container that hosts the gateway daemons.
"""
from __future__ import annotations
from ..docker.gateway_provision import GatewayProvisionError
from . import util as container_mod
from .infra import INFRA_NAME
class AppleGatewayTransport:
"""`GatewayTransport` for the gateway daemons in the Apple infra container."""
def __init__(self, gateway: str = INFRA_NAME) -> None:
self.gateway = gateway
def exec(self, argv: list[str]) -> None:
result = container_mod.run_container_argv(
["container", "exec", self.gateway, *argv]
)
if result.returncode != 0:
raise GatewayProvisionError(
f"gateway exec {argv!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def cp_into(self, src: str, dest: str) -> None:
result = container_mod.run_container_argv(
["container", "cp", src, f"{self.gateway}:{dest}"]
)
if result.returncode != 0:
raise GatewayProvisionError(
f"gateway cp {src} -> {dest} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
__all__ = ["AppleGatewayTransport", "GatewayProvisionError"]
-304
View File
@@ -1,304 +0,0 @@
"""The per-host infra container for the macOS backend (PRD 0070).
A single persistent Apple container that runs BOTH the orchestrator control
plane and the gateway data plane the macOS analogue of the Firecracker infra
VM (`backend/firecracker/infra_vm.py`), not the docker backend's two separate
containers.
Why one container, not two: Apple Containers are lightweight VMs, each with its
own kernel. The docker backend runs the orchestrator and gateway as two
containers safely because they share the host kernel, so their concurrent
writes to the one `bot-bottle.db` (the orchestrator's registry + the gateway
supervise daemon's queue) are serialized by coherent `fcntl` locks. Across two
*guest* kernels sharing a virtiofs-mounted DB those locks are not coherent, and
concurrent writers can corrupt the file. Firecracker solved this by putting
both services in one guest with the DB on a device only that guest mounts; this
does the same with Apple primitives.
Two consequences fall out of the single container, both simplifications:
- **No DNS dance.** The control plane and the gateway daemons reach each other
over `127.0.0.1`, so nothing depends on Apple's (absent) container DNS and
there is no orchestrator-before-gateway ordering to get right.
- **The DB is never host-shared.** It lives on a container-only volume, so no
host process opens the live file. The host CLI reaches registry + supervise
state through the control-plane HTTP surface (`cli/supervise.py` already uses
`OrchestratorClient`), exactly as it does for firecracker.
The control-plane source is bind-mounted (like the docker orchestrator), so a
code change takes effect on the next launch without an image rebuild; the
gateway daemons are baked in the gateway image and rebuild through its own
digest check.
"""
from __future__ import annotations
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from ... import log
from ...orchestrator.gateway import GATEWAY_CA_CERT
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
OrchestratorStartError,
source_hash,
)
from ...paths import (
CONTROL_PLANE_TOKEN_ENV,
HOST_DB_FILENAME,
host_control_plane_token,
)
from .. import util as backend_util
from . import util as container_mod
from .gateway import (
DEFAULT_CA_TIMEOUT_SECONDS,
GATEWAY_EGRESS_NETWORK,
GATEWAY_IMAGE,
GATEWAY_NETWORK,
GatewayError,
ensure_networks,
)
# The one per-host infra container: control plane + gateway data plane.
INFRA_NAME = "bot-bottle-mac-infra"
INFRA_LABEL = "bot-bottle-mac-infra=1"
# Container-only volume holding bot-bottle.db. No host bind-mount, so the DB is
# written by exactly one kernel (this container's). Survives recreation.
INFRA_DB_VOLUME = "bot-bottle-mac-db"
# BOT_BOTTLE_ROOT inside the container; host_db_path() resolves the DB to
# <root>/db/<filename> and the supervise daemon writes the same file.
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
_DB_PATH_IN_CONTAINER = f"{_DB_ROOT_IN_CONTAINER}/db/{HOST_DB_FILENAME}"
_SRC_IN_CONTAINER = "/bot-bottle-src"
_REPO_ROOT = Path(__file__).resolve().parents[3]
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
_CA_POLL_SECONDS = 0.5
# The gateway subset the consolidated model runs (no per-bottle git:// daemon).
_GATEWAY_DAEMONS = "egress,git-http,supervise"
def _init_script(port: int) -> str:
"""PID-1 init: start the control plane and the gateway daemons, both in
this container, reaching each other over loopback. Backgrounded so `wait`
reaps as PID 1. No `set -e` a transient daemon failure must not kill the
whole container (gateway_init applies the same 'stay up' policy)."""
return (
"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
f"mkdir -p $(dirname {_DB_PATH_IN_CONTAINER})\n"
# Control plane, from the bind-mounted source (stdlib-only package).
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
"--broker stub ) &\n"
# Gateway data plane, multi-tenant against the local control plane.
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
"while : ; do wait ; done\n"
)
@dataclass(frozen=True)
class InfraEndpoint:
"""How to reach the running infra container. The control plane and the
gateway are the same container, so one address serves both."""
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
gateway_ip: str # same container; agents' proxy / git-http / MCP target
class MacosInfraService:
"""Manages the single per-host infra container. Callers use
`ensure_running()` (returns the endpoint) and `ca_cert_pem()`."""
def __init__(
self,
*,
port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK,
egress_network: str = GATEWAY_EGRESS_NETWORK,
image: str = GATEWAY_IMAGE,
repo_root: Path = _REPO_ROOT,
name: str = INFRA_NAME,
db_volume: str = INFRA_DB_VOLUME,
) -> None:
self.port = port
self.network = network
self.egress_network = egress_network
self.image = image
self._repo_root = repo_root
self._name = name
self._db_volume = db_volume
def _resolve_url(self) -> str:
"""The control-plane URL, or "" while the container has no address."""
ip = container_mod.try_container_ipv4_on_network(self._name, self.network)
return f"http://{ip}:{self.port}" if ip else ""
def is_healthy(
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
) -> bool:
if not url:
return False
try:
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _source_current(self, current_hash: str) -> bool:
"""True iff the running infra container was created from the current
bind-mounted control-plane source. The control-plane process loads that
code at startup and won't reload it, so a stale container keeps serving
OLD code."""
if not container_mod.container_is_running(self._name):
return False
env = container_mod.container_env(self._name)
if not env:
return True # can't compare → don't churn a working container
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
def _running_healthy_endpoint(self, current_hash: str) -> InfraEndpoint | None:
"""The endpoint if the running container is BOTH source-current and
answering /health, else None ( recreate). Health, not just the source
label, is what lets a wedged-but-current container self-heal instead of
being polled to death forever."""
if not self._source_current(current_hash):
return None
url = self._resolve_url()
if url and self.is_healthy(url):
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
return None
def ensure_built(self) -> None:
"""Ensure the gateway data-plane image exists. The control-plane source
is bind-mounted, not baked, so only the gateway image needs building."""
container_mod.build_image(
self.image, str(self._repo_root), dockerfile="Dockerfile.gateway",
)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> InfraEndpoint:
"""Ensure the single infra container is up; return how to reach it.
Idempotent per-host singleton a healthy container on current source
is left untouched, so N launches share the one control plane + gateway.
Raises `OrchestratorStartError` on startup timeout."""
current_hash = source_hash(self._repo_root)
endpoint = self._running_healthy_endpoint(current_hash)
if endpoint is not None:
return endpoint
self.ensure_built()
log.info("starting infra container", context={"name": self._name})
self._run_container(current_hash)
return self._wait_healthy(startup_timeout)
def _run_container(self, current_hash: str) -> None:
ensure_networks(self.network, self.egress_network)
container_mod.force_remove_container(self._name)
argv = [
"container", "run", "--detach",
"--name", self._name,
"--label", "bot-bottle.backend=macos-container",
"--label", INFRA_LABEL,
# NAT network FIRST so the gateway's egress has a default route;
# the host-only network is where agents (and the host CLI) reach it.
"--network", self.egress_network,
"--network", self.network,
"--dns", container_mod.dns_server(),
# Container-only DB volume: one kernel writes bot-bottle.db, never
# shared with the host or another guest.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
# Bind-mount the control-plane source (read-only); a code change
# takes effect on relaunch with no image rebuild.
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
# Baked onto the container so `_source_current` can detect a real
# control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
# The control-plane secret, for BOTH the control plane (to require
# it) and the gateway's PolicyResolver (to present it) — they share
# this one container. Bare `--env NAME` inherits the value from the
# run process below, so the secret never lands on argv or in
# `container inspect`'s command line. The agent runs in a SEPARATE
# container that is never given this var, which is the whole point.
"--env", CONTROL_PLANE_TOKEN_ENV,
"--entrypoint", "sh",
self.image,
"-c", _init_script(self.port),
]
run_env = {**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()}
result = container_mod.run_container_argv(argv, env=run_env)
if result.returncode != 0:
raise OrchestratorStartError(
f"infra container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _wait_healthy(self, startup_timeout: float) -> InfraEndpoint:
deadline = time.monotonic() + startup_timeout
while True:
url = self._resolve_url()
if url and self.is_healthy(url):
log.info("infra container healthy", context={"url": url})
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
if time.monotonic() >= deadline:
raise OrchestratorStartError(
f"infra container did not become healthy within "
f"{startup_timeout:g}s"
)
time.sleep(_HEALTH_POLL_SECONDS)
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
interception. Read out of the container (the CA lives on a
container-internal path, not a host mount); polls because mitmproxy
writes it a beat after start."""
def _fetch() -> str | None:
result = container_mod.run_container_argv(
["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
return result.stdout if result.returncode == 0 and result.stdout.strip() else None
try:
return backend_util.poll_ca_cert(_fetch, timeout=timeout)
except TimeoutError as exc:
raise GatewayError(
f"gateway CA not available in {self._name} after {timeout:g}s"
) from exc
def stop(self) -> None:
"""Remove the infra container (idempotent). The DB volume persists."""
container_mod.force_remove_container(self._name)
def _ip_of(url: str) -> str:
"""The host from an http://host:port URL."""
return url.split("://", 1)[-1].rsplit(":", 1)[0]
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
"""The running infra container's control-plane URL, or "" if it isn't up.
Used by host-side control-plane discovery (`discover_orchestrator_url`);
safe to call on any host returns "" when the container or the `container`
CLI isn't present."""
ip = container_mod.try_container_ipv4_on_network(INFRA_NAME, GATEWAY_NETWORK)
return f"http://{ip}:{port}" if ip else ""
__all__ = [
"MacosInfraService",
"InfraEndpoint",
"OrchestratorStartError",
"GatewayError",
"INFRA_NAME",
"INFRA_DB_VOLUME",
]
+310 -245
View File
@@ -1,33 +1,11 @@
"""Launch flow for the macOS Apple Container backend (PRD 0070). """Launch flow for the macOS Apple Container backend.
The agent container attaches to the **shared host-only gateway network** and This backend keeps the explicit proxy-env enforcement model for v1:
proxies egress through the one per-host gateway, replacing the per-bottle the agent container is attached only to a host-only Apple Container
companion container removed in #385. network, while the sidecar bundle is attached to a NAT network first
and the host-only network second. The sidecar's host-only IP is
The order differs from docker's, forced by Apple Container 1.0.0 having no discovered from `container inspect` and stamped into the agent's
`--ip` (see `consolidated_launch`): the agent is started *before* it is HTTP_PROXY / HTTPS_PROXY env vars.
registered, because its DHCP-assigned address the attribution key does not
exist until then.
gateway up -> run agent -> read its IP -> register it -> provision
Two things follow from that inversion:
- The **identity token** is minted by registration and so cannot be in the
agent's run-time env; it rides the proxy URL applied at `container exec`
time (`bottle.MacosContainerBottle`). `/resolve` requires it (#366), so
egress without it is denied hence the bare `sleep` init: every real agent
command goes through exec and therefore carries the token.
- The agent is run with `--cap-drop CAP_NET_RAW`. Apple Container grants
NET_RAW by default, which would let an agent open a raw socket and forge a
neighbour's source address on the shared segment. NET_ADMIN is already
absent (the agent cannot change its own address or route), so dropping
NET_RAW is what closes the source-address half of PRD 0070's invariant:
"a packet's source address, as seen by the orchestrator, provably identifies
the originating bottle." The identity token is the other half — an attacker
would need to forge the address *and* steal the token but the invariant is
a stated precondition of consolidation, so it is enforced on its own terms
rather than left to the token.
""" """
from __future__ import annotations from __future__ import annotations
@@ -45,36 +23,52 @@ from ...bottle_state import (
read_committed_image, read_committed_image,
) )
from ...egress import ( from ...egress import (
EGRESS_ROUTES_IN_CONTAINER,
egress_agent_env_entries, egress_agent_env_entries,
egress_resolve_token_values, egress_resolve_token_values,
egress_sidecar_env_entries,
) )
from ...git_gate import ( from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...log import die, info, warn from ...log import die, info, warn
from ...supervise import SUPERVISE_PORT from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ...util import expand_tilde
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ..docker.sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
)
from ..docker.egress import egress_tls_init
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from . import util as container_mod from . import util as container_mod
from .bottle import MacosContainerBottle from .bottle import MacosContainerBottle
from .gateway_hosts import (
GATEWAY_HOSTNAME,
refresh_gateway_host,
set_gateway_host,
)
from .bottle_plan import MacosContainerBottlePlan from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout
from .consolidated_launch import (
GatewayEndpoint,
ensure_gateway,
register_agent,
teardown_consolidated,
)
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_AGENT_SLEEP_SECONDS = "2147483647" _AGENT_SLEEP_SECONDS = "2147483647"
_GIT_HTTP_PORT = 9420
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
def internal_network_name(slug: str) -> str:
return f"bot-bottle-net-{slug}"
def egress_network_name(slug: str) -> str:
return f"bot-bottle-egress-{slug}"
def sidecar_container_name(slug: str) -> str:
return f"bot-bottle-sidecars-{slug}"
@contextmanager @contextmanager
@@ -83,8 +77,7 @@ def launch(
*, *,
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
) -> Generator[MacosContainerBottle, None, None]: ) -> Generator[MacosContainerBottle, None, None]:
"""Build, run, register, provision, and yield an Apple Container bottle on """Build, run, provision, and yield an Apple Container bottle."""
the shared per-host gateway."""
stack = ExitStack() stack = ExitStack()
bottle_for_revoke = plan.manifest.bottle bottle_for_revoke = plan.manifest.bottle
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
@@ -101,76 +94,30 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
plan = _mint_certs(plan)
plan = _build_images(plan) plan = _build_images(plan)
# Step 1: the per-host singletons. Must precede the agent run — its internal_network = internal_network_name(plan.slug)
# proxy env needs the gateway's address at `container run` time. egress_network = egress_network_name(plan.slug)
endpoint = ensure_gateway() _create_networks(internal_network, egress_network, stack)
# The gateway's address may have changed since these bottles launched
# (any infra recreate re-runs DHCP). They name the gateway rather than
# address it, so re-pointing /etc/hosts re-attaches them in place
# instead of leaving them stranded until relaunch.
refresh_gateway_host(endpoint.gateway_ip)
# Step 2: mint this bottle's deploy keys, then point it at the SHARED
# gateway's CA + git-http/supervise ports.
plan = _provision_git_gate_keys(plan) plan = _provision_git_gate_keys(plan)
plan = _install_gateway_ca(plan, endpoint)
plan = _stamp_agent_urls(plan, endpoint)
# Step 3: run the agent. It has no identity token yet — registration sidecar_name = sidecar_container_name(plan.slug)
# needs the address this run assigns. container_mod.force_remove_container(sidecar_name)
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
stack.callback(container_mod.force_remove_container, sidecar_name)
_stage_git_gate(plan, sidecar_name)
sidecar_ip = container_mod.container_ipv4_on_network(
sidecar_name, internal_network,
)
plan = _stamp_agent_urls(plan, sidecar_ip)
container_mod.force_remove_container(plan.container_name) container_mod.force_remove_container(plan.container_name)
_start_agent(plan, endpoint) _start_agent(plan, internal_network, sidecar_ip)
stack.callback(container_mod.force_remove_container, plan.container_name) stack.callback(container_mod.force_remove_container, plan.container_name)
# Step 4: read the assigned address and register by it. This is the
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
# unforgeable. Poll: `container run --detach` can return before vmnet's
# DHCP has assigned the address.
# Resolve the gateway name before anything execs: every agent-facing
# URL uses it, so the entry must exist for the first connection.
set_gateway_host(plan.container_name, endpoint.gateway_ip)
source_ip = container_mod.wait_container_ipv4_on_network(
plan.container_name, endpoint.network,
)
if not source_ip:
die(
f"agent {plan.container_name} never got an address on "
f"{endpoint.network}"
)
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
ctx = register_agent(
plan.egress_plan,
plan.git_gate_plan,
source_ip=source_ip,
endpoint=endpoint,
image_ref=plan.image,
tokens=token_values,
)
stack.callback(
teardown_consolidated, ctx.bottle_id,
orchestrator_url=ctx.orchestrator_url,
timeout=teardown_timeout,
)
info(
f"agent {plan.container_name} registered "
f"(gateway {endpoint.gateway_ip}, ip {source_ip})"
)
# Stamp the token onto the plan so provision-time consumers can read it,
# not only the exec-time egress proxy. git-gate's gitconfig extraHeader
# and the supervise MCP --header both reach the gateway on NO_PROXY (they
# bypass the egress proxy that carries the token), so without this the
# gateway's /resolve fail-closes and every git fetch/push and supervise
# call from the bottle is denied. Registration already produced the
# token above, so — unlike the run-time env — the plan CAN carry it.
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
bottle = MacosContainerBottle( bottle = MacosContainerBottle(
plan.container_name, plan.container_name,
teardown, teardown,
@@ -178,13 +125,9 @@ def launch(
agent_command=plan.agent_command, agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode, agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template, agent_provider_template=plan.agent_provider_template,
terminal_title=( terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
f"{plan.spec.label} ({plan.spec.agent_name})"
if plan.spec.label else plan.spec.agent_name
),
terminal_color=plan.spec.color, terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
exec_env=_identity_proxy_env(endpoint, ctx.identity_token),
) )
bottle.prompt_path = provision(plan, bottle) bottle.prompt_path = provision(plan, bottle)
@@ -193,24 +136,116 @@ def launch(
teardown() teardown()
def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
egress_ca_host, egress_ca_cert_only = egress_tls_init(
egress_state_dir(plan.slug),
)
egress_plan = dataclasses.replace(
plan.egress_plan,
mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
)
return dataclasses.replace(plan, egress_plan=egress_plan)
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
"""Build the agent image. The gateway's own image is built by container_mod.build_image(
`ensure_gateway` it belongs to the shared singleton, not to a bottle.""" SIDECAR_BUNDLE_IMAGE,
_REPO_DIR,
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
)
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed): if committed and container_mod.image_exists(committed):
info(f"using committed image {committed!r}") info(f"using committed image {committed!r}")
return dataclasses.replace( return dataclasses.replace(
plan, plan,
agent_provision=dataclasses.replace( agent_provision=dataclasses.replace(
plan.agent_provision, image=committed, plan.agent_provision,
image=committed,
), ),
) )
container_mod.build_image( container_mod.build_image(
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path, plan.image,
_REPO_DIR,
dockerfile=plan.dockerfile_path,
) )
return plan return plan
def _create_networks(
internal_network: str,
egress_network: str,
stack: ExitStack,
) -> None:
container_mod.create_network(internal_network, internal=True)
stack.callback(container_mod.remove_network, internal_network)
container_mod.create_network(egress_network)
stack.callback(container_mod.remove_network, egress_network)
def _start_sidecar_bundle(
plan: MacosContainerBottlePlan,
sidecar_name: str,
internal_network: str,
egress_network: str,
) -> None:
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
env = {**os.environ, **token_values}
info(f"container run sidecar bundle {sidecar_name}")
result = subprocess.run(
argv, capture_output=True, text=True, env=env, check=False,
)
if result.returncode != 0:
die(
f"container run for sidecar bundle {sidecar_name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _start_agent(
plan: MacosContainerBottlePlan,
internal_network: str,
sidecar_ip: str,
) -> None:
argv = _agent_run_argv(plan, internal_network, sidecar_ip)
env = {
**os.environ,
**plan.forwarded_env,
}
info(f"container run agent {plan.container_name}")
result = subprocess.run(
argv, capture_output=True, text=True, env=env, check=False,
)
if result.returncode != 0:
die(
f"container run for agent {plan.container_name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _stamp_agent_urls(
plan: MacosContainerBottlePlan,
sidecar_ip: str,
) -> MacosContainerBottlePlan:
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
supervise_url = ""
if plan.supervise_plan is not None:
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
git_gate_url = ""
if plan.git_gate_plan.upstreams:
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
return dataclasses.replace(
plan,
agent_proxy_url=proxy_url,
agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url,
)
def _provision_git_gate_keys( def _provision_git_gate_keys(
plan: MacosContainerBottlePlan, plan: MacosContainerBottlePlan,
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
@@ -224,152 +259,182 @@ def _provision_git_gate_keys(
return dataclasses.replace(plan, git_gate_plan=git_gate_plan) return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
def _install_gateway_ca( def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint, gp = plan.git_gate_plan
) -> MacosContainerBottlePlan: if not gp.upstreams:
"""Stage the SHARED gateway's CA for the provisioner to install, replacing return
the per-bottle CA the companion container used to mint. Every bottle on
this host trusts this one CA."""
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem"
ca_file.write_text(endpoint.gateway_ca_pem)
egress_plan = dataclasses.replace(
plan.egress_plan,
mitmproxy_ca_host_path=ca_file,
mitmproxy_ca_cert_only_host_path=ca_file,
)
return dataclasses.replace(plan, egress_plan=egress_plan)
container_mod.exec_container(
def _stamp_agent_urls( sidecar_name,
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint, [
) -> MacosContainerBottlePlan: "mkdir",
"""Point the agent's git-gate insteadOf rewrites + supervise MCP at the "-p",
shared gateway's ports. Both bypass the egress proxy (NO_PROXY covers the str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
gateway name). GIT_GATE_CREDS_DIR_IN_CONTAINER,
"/git",
Addressed by `GATEWAY_HOSTNAME`, never by IP: these URLs are baked into str(Path(_GIT_GATE_READY_FILE).parent),
the agent's gitconfig and MCP config at provision time, so an address here ],
would strand the bottle the moment the gateway moved. The name is resolved
per connection through `/etc/hosts`, which stays rewritable while the
bottle runs."""
del endpoint # addressed by name; the address reaches the bottle via /etc/hosts
git_gate_url = (
f"http://{GATEWAY_HOSTNAME}:{_GIT_HTTP_PORT}"
if plan.git_gate_plan.upstreams else ""
)
supervise_url = (
f"http://{GATEWAY_HOSTNAME}:{SUPERVISE_PORT}/"
if plan.supervise_plan is not None else ""
)
return dataclasses.replace(
plan,
agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url,
) )
for host_path, container_path in _git_gate_files(plan):
def _proxy_url(identity_token: str = "") -> str: container_mod.copy_into_container(
"""The agent's egress proxy URL. The identity token rides as proxy sidecar_name, host_path, container_path,
credentials the gateway reads Proxy-Authorization, resolves the
(source_ip, token) pair against the control plane, and strips it before
upstream. Without a valid pair `/resolve` denies the request (#366).
Names the gateway rather than addressing it: this URL reaches the agent as
process environment, which cannot be rewritten once the agent is running,
so an address baked here is unfixable if the gateway moves."""
cred = f"bottle:{identity_token}@" if identity_token else ""
return f"http://{cred}{GATEWAY_HOSTNAME}:{EGRESS_PORT}"
def _no_proxy() -> str:
# git-http + supervise live on the gateway and must NOT go through the
# egress proxy — the agent reaches them directly by name. Deliberately
# address-free: NO_PROXY is baked into the run-time env and is therefore
# just as unfixable as the proxy URL if the gateway moves.
return f"localhost,127.0.0.1,{GATEWAY_HOSTNAME}"
def _identity_proxy_env(
endpoint: GatewayEndpoint, identity_token: str,
) -> dict[str, str]:
"""The token-bearing proxy env applied at `container exec` — the only way
to get the token in, since it does not exist until after the container
runs (registration keys on the DHCP-assigned address).
This is the *sole* source of `*_PROXY` for the agent. It deliberately does
not rely on overriding a run-time value: `container exec --env` appends
rather than replaces, so a run-time `HTTPS_PROXY` would survive alongside
this one and first-wins runtimes would read the wrong entry. See
`_agent_env_entries`."""
if not identity_token:
return {}
del endpoint # the gateway is named, not addressed
url = _proxy_url(identity_token)
return {
"HTTPS_PROXY": url, "HTTP_PROXY": url,
"https_proxy": url, "http_proxy": url,
}
def _start_agent(plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint) -> None:
argv = _agent_run_argv(plan, endpoint)
env = {**os.environ, **plan.forwarded_env}
info(f"container run agent {plan.container_name}")
result = subprocess.run(
argv, capture_output=True, text=True, env=env, check=False,
)
if result.returncode != 0:
die(
f"container run for agent {plan.container_name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
container_mod.exec_container(
sidecar_name,
[
"sh",
"-c",
"chmod 755 "
f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
f"{GIT_GATE_HOOK_IN_CONTAINER} "
f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
f"touch {_GIT_GATE_READY_FILE}",
],
)
def _git_gate_files(
plan: MacosContainerBottlePlan,
) -> tuple[tuple[str, str], ...]:
gp = plan.git_gate_plan
files: list[tuple[str, str]] = [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
]
for upstream in gp.upstreams:
files.append((
expand_tilde(upstream.identity_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key",
))
if upstream.known_hosts_file:
files.append((
str(upstream.known_hosts_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts",
))
return tuple(files)
def _sidecar_run_argv(
plan: MacosContainerBottlePlan,
sidecar_name: str,
internal_network: str,
egress_network: str,
) -> list[str]:
argv = [
"container", "run",
"--name", sidecar_name,
"--detach",
"--rm",
"--network", egress_network,
"--network", internal_network,
"--dns", _sidecar_dns(),
"--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}",
]
for entry in _sidecar_env_entries(plan):
argv += ["--env", entry]
for host_path, container_path, read_only in _sidecar_mounts(plan):
argv += ["--mount", _mount_spec(host_path, container_path, read_only)]
argv.append(SIDECAR_BUNDLE_IMAGE)
return argv
def _agent_run_argv( def _agent_run_argv(
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint, plan: MacosContainerBottlePlan,
internal_network: str,
sidecar_ip: str,
) -> list[str]: ) -> list[str]:
argv = [ argv = [
"container", "run", "container", "run",
"--name", plan.container_name, "--name", plan.container_name,
"--detach", "--detach",
"--label", "bot-bottle.backend=macos-container", "--network", internal_network,
"--network", endpoint.network,
# The attribution invariant: without NET_RAW the agent cannot open a
# raw socket, so it cannot source-IP-spoof its neighbours on the shared
# segment. NET_ADMIN is not granted by default, so its address and
# route are already fixed. See the module docstring.
"--cap-drop", "CAP_NET_RAW",
] ]
for entry in _agent_env_entries(plan, endpoint): for entry in _agent_env_entries(plan, sidecar_ip):
argv += ["--env", entry] argv += ["--env", entry]
# The init process is a no-op: every agent command arrives via
# `container exec`, which is also how the identity token gets in.
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS] argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
return argv return argv
def _sidecar_dns() -> str:
return container_mod.dns_server()
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
daemons = ["egress"]
if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None:
daemons.append("supervise")
return tuple(daemons)
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
if plan.git_gate_plan.upstreams:
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
if plan.supervise_plan is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
return tuple(env)
def _sidecar_mounts(
plan: MacosContainerBottlePlan,
) -> tuple[tuple[str, str, bool], ...]:
mounts: list[tuple[str, str, bool]] = []
ep = plan.egress_plan
mounts.append((
str(ep.mitmproxy_ca_host_path.parent),
str(Path(EGRESS_CA_IN_CONTAINER).parent),
False,
))
if ep.routes:
mounts.append((
str(ep.routes_path.parent),
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
True,
))
sp = plan.supervise_plan
if sp is not None:
# `container run --mount type=bind` only accepts directory
# sources (a file source fails with "is not a directory") —
# mount db_path's dedicated parent dir instead of the file
# itself, same as the CA/routes mounts above.
mounts.append((
str(sp.db_path.parent),
str(Path(DB_PATH_IN_CONTAINER).parent),
False,
))
return tuple(mounts)
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
spec = f"type=bind,source={host_path},target={container_path}"
if read_only:
spec += ",readonly"
return spec
def _agent_env_entries( def _agent_env_entries(
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint, plan: MacosContainerBottlePlan,
sidecar_ip: str,
) -> tuple[str, ...]: ) -> tuple[str, ...]:
# No `*_PROXY` here on purpose. The token-bearing URL is applied at proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
# `container exec` (`_identity_proxy_env`), and Apple's `container exec no_proxy = _agent_no_proxy(plan, sidecar_ip)
# --env` **appends** to the run-time environment rather than replacing it:
# setting a token-less value here leaves two `HTTPS_PROXY` entries in the
# agent's `environ`, token-less first. Which one a runtime reads is then
# pure luck — Node takes the last (and worked), Rust's `std::env::var`
# takes the first, so Codex proxied without its identity token and
# `/resolve` fail-closed on every request.
#
# A token-less proxy URL has no legitimate consumer anyway: the init
# process is `sleep` and everything that egresses arrives via exec. Its
# only value was a tidy 403 for unattributed callers, which is not worth
# silently dropping attribution for. Without it a process that egresses
# before the exec-time env still fails closed — the agent network is
# host-only, so there is no route off it except the gateway.
no_proxy = _no_proxy()
env = [ env = [
f"HTTPS_PROXY={proxy_url}",
f"HTTP_PROXY={proxy_url}",
f"https_proxy={proxy_url}",
f"http_proxy={proxy_url}",
f"NO_PROXY={no_proxy}", f"NO_PROXY={no_proxy}",
f"no_proxy={no_proxy}", f"no_proxy={no_proxy}",
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
@@ -382,12 +447,12 @@ def _agent_env_entries(
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
for name, value in sorted(plan.agent_provision.guest_env.items()): for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}") env.append(f"{name}={value}")
# Forwarded vars: bare name → inherits from the `container run` process env
# so the secret value never lands on argv.
for name in sorted(plan.forwarded_env.keys()): for name in sorted(plan.forwarded_env.keys()):
env.append(name) env.append(name)
env.extend(egress_agent_env_entries(plan.egress_plan)) env.extend(egress_agent_env_entries(plan.egress_plan))
return tuple(env) return tuple(env)
__all__ = ["launch"] def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
hosts = ["localhost", "127.0.0.1", sidecar_ip]
return ",".join(hosts)
+11 -214
View File
@@ -60,21 +60,13 @@ def dns_server() -> str:
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
"""Build an OCI image with Apple's BuildKit-backed `container build`. """Build an OCI image with Apple's BuildKit-backed `container build`."""
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
`--no-cache`. The npm/curl installers some provider Dockerfiles
shell out to can silently no-op on a transient network failure
e.g. an `optionalDependencies` fetch for a platform-native binary
and the builder will then cache that broken layer indefinitely."""
info( info(
f"building image {ref} from {context} with Apple Container " f"building image {ref} from {context} with Apple Container "
"(layer cache keeps repeat builds fast)" "(layer cache keeps repeat builds fast)"
) )
_ensure_builder_dns() _ensure_builder_dns()
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()] args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
args.append("--no-cache")
if dockerfile: if dockerfile:
# `container build` resolves -f relative to the current working # `container build` resolves -f relative to the current working
# directory, not the build context. Anchor a relative Dockerfile to # directory, not the build context. Anchor a relative Dockerfile to
@@ -86,28 +78,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True) subprocess.run(args, check=True)
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
whose CLI only breaks at first real use. No-op when the provider
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
if not argv:
return
result = subprocess.run(
[_CONTAINER, "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(
f"agent image {image!r} failed its post-build smoke test "
f"({' '.join(argv)}): {detail}\n"
f"Try rebuilding from scratch: bot-bottle start --no-cache"
)
def commit_container(container_name: str, image_tag: str) -> None: def commit_container(container_name: str, image_tag: str) -> None:
"""Snapshot a running Apple Container as a local image. """Snapshot a running Apple Container as a local image.
@@ -360,21 +330,6 @@ def exec_container(name: str, argv: list[str]) -> None:
) )
def exec_container_as_root(name: str, argv: list[str]) -> None:
"""`exec_container`, but as uid 0 inside the container.
For host-driven maintenance the agent itself must not be able to perform
rewriting `/etc/hosts` to point the gateway name at an address. The agent
runs as `node`, so it cannot repoint its own gateway; the host can.
"""
result = _run_container_op([_CONTAINER, "exec", "--user", "root", name, *argv])
if result.returncode != 0:
die(
f"container exec (root) in {name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]: def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
@@ -452,180 +407,22 @@ def inspect_container(name: str) -> dict[str, object]:
def container_ipv4_on_network(name: str, network: str) -> str: def container_ipv4_on_network(name: str, network: str) -> str:
"""The container's IPv4 address on `network`. Fatal if absent — callers data = inspect_container(name)
that can tolerate "not yet" want `try_container_ipv4_on_network`."""
ip = try_container_ipv4_on_network(name, network)
if not ip:
die(f"container {name} has no IPv4 address on {network}")
return ip
def run_container_argv(
argv: list[str], *, env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a `container` command, returning the result for the caller to
interpret. Unlike the `die`-on-failure helpers above, this lets callers
that raise their own typed errors (the gateway / orchestrator lifecycle)
keep control of the failure path.
`env` sets the child process environment used to hand a secret to a bare
`--env NAME` flag (Apple's "just key → inherit from host" form) so the
value is inherited from this process, never written onto argv or into
`container inspect`'s recorded command line."""
return subprocess.run(
argv, capture_output=True, text=True, check=False, env=env)
def bind_mount_spec(source: str, target: str, *, readonly: bool = False) -> str:
"""A `container run --mount` bind spec. One definition so the gateway and
orchestrator emit an identical string a divergence here would silently
break one backend's mounts while the other kept working."""
spec = f"type=bind,source={source},target={target}"
if readonly:
spec += ",readonly"
return spec
def _normalize_digest(value: str) -> str:
return value.split(":", 1)[1] if ":" in value else value
def _inspect_first(argv: list[str]) -> dict[str, object]:
"""Run an inspect command and return its first JSON object, or {} on any
failure (non-zero exit, malformed JSON, unexpected shape). {} is the shared
'don't know' signal all the non-fatal inspect readers below build on — a
caller comparing against it treats it as 'leave the working container
alone', never as a mismatch."""
result = run_container_argv(argv)
if result.returncode != 0:
return {}
try:
data = json.loads(result.stdout or "[]")
except json.JSONDecodeError:
return {}
if isinstance(data, list):
data = data[0] if data else {}
return data if isinstance(data, dict) else {}
def _descriptor_digest(node: object) -> str:
"""The normalized digest under a `{... "descriptor": {"digest": ...}}`
node, or "". Both the image and container inspect shapes nest the image's
identity this way, so the digest readers stay symmetric a difference
between them is what would spuriously recreate a container."""
if not isinstance(node, dict):
return ""
descriptor = node.get("descriptor")
if isinstance(descriptor, dict) and descriptor.get("digest"):
return _normalize_digest(str(descriptor["digest"]))
return ""
def image_digest(ref: str) -> str:
"""The digest of image `ref`, or "" if it can't be read. Reads exactly the
field `container_image_digest` reads (`configuration.descriptor.digest`) so
the two are comparable; "" means 'don't know' → callers don't churn."""
data = _inspect_first([_CONTAINER, "image", "inspect", ref])
return _descriptor_digest(data.get("configuration"))
def container_image_digest(name: str) -> str:
"""The digest of the image container `name` was created from, or "" if it
can't be read. Compare with `image_digest(ref)` to tell whether a running
container predates an image rebuild."""
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
image = config.get("image") if isinstance(config, dict) else None
return _descriptor_digest(image)
def container_env(name: str) -> dict[str, str]:
"""The env container `name` was started with, or {} if unreadable. Lets a
caller tell whether a running container's baked-in configuration still
matches what it would pass today."""
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
init = config.get("initProcess") if isinstance(config, dict) else None
entries = init.get("environment") if isinstance(init, dict) else None
if not isinstance(entries, list):
return {}
env: dict[str, str] = {}
for entry in entries:
if isinstance(entry, str) and "=" in entry:
key, value = entry.split("=", 1)
env[key] = value
return env
def try_container_ipv4_on_network(name: str, network: str) -> str:
"""`container_ipv4_on_network` without the fatal exit: "" when the address
isn't readable yet. For pollers — a container is created before it has an
address, so "not yet" is an expected state there, not an error."""
status = _inspect_first([_CONTAINER, "inspect", name]).get("status")
networks = status.get("networks") if isinstance(status, dict) else None
if not isinstance(networks, list):
return ""
for entry in networks:
if not isinstance(entry, dict) or entry.get("network") != network:
continue
raw = entry.get("ipv4Address")
if isinstance(raw, str) and raw:
return raw.split("/", 1)[0]
return ""
def inspect_container_network_ip(name: str, network: str) -> str | None:
"""IP of `name` on `network`, distinguishing inspect failure from "not yet".
Returns:
- the IP string when the container has one on `network`
- "" when inspect succeeds but no address is assigned yet (in-flight DHCP)
- None when the inspect command itself fails (authoritative list impossible)
"""
result = subprocess.run(
[_CONTAINER, "inspect", name],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return None
try:
data = json.loads(result.stdout or "[]")
except json.JSONDecodeError:
return None
if isinstance(data, list):
data = data[0] if data else {}
if not isinstance(data, dict):
return None
status = data.get("status") status = data.get("status")
networks = status.get("networks") if isinstance(status, dict) else None networks = status.get("networks") if isinstance(status, dict) else None
if not isinstance(networks, list): if not isinstance(networks, list):
return "" die(f"container inspect {name} did not include status.networks")
for entry in networks: for entry in networks:
if not isinstance(entry, dict) or entry.get("network") != network: if not isinstance(entry, dict):
continue
if entry.get("network") != network:
continue continue
raw = entry.get("ipv4Address") raw = entry.get("ipv4Address")
if isinstance(raw, str) and raw: if not isinstance(raw, str) or not raw:
return raw.split("/", 1)[0] die(f"container {name} has no IPv4 address on {network}")
return "" return raw.split("/", 1)[0]
die(f"container {name} is not attached to network {network}")
raise AssertionError("unreachable")
def wait_container_ipv4_on_network(
name: str, network: str, *, timeout: float = 15.0, poll: float = 0.25,
) -> str:
"""Poll for the container's DHCP-assigned address on `network`, returning
it once available or "" on timeout.
Apple Container has no `--ip`: `container run --detach` can return before
vmnet's DHCP has populated `status.networks[].ipv4Address`, so a bare read
right after start races the assignment. Callers that need the address (the
attribution key, the gateway's proxy target) poll through here instead of
the fatal `container_ipv4_on_network`."""
deadline = time.monotonic() + timeout
while True:
ip = try_container_ipv4_on_network(name, network)
if ip:
return ip
if time.monotonic() >= deadline:
return ""
time.sleep(poll)
def image_id(ref: str) -> str: def image_id(ref: str) -> str:
+1 -1
View File
@@ -94,7 +94,7 @@ def prepare_egress(
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
"""Prepare the supervise daemon state dir. Returns None when """Prepare the supervise sidecar state dir. Returns None when
bottle.supervise is falsy.""" bottle.supervise is falsy."""
if not bottle.supervise: if not bottle.supervise:
return None return None
-20
View File
@@ -7,8 +7,6 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import ssl import ssl
import time
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -17,24 +15,6 @@ from ..log import die, info
if TYPE_CHECKING: if TYPE_CHECKING:
from ..egress import EgressPlan from ..egress import EgressPlan
_CA_POLL_INTERVAL = 0.5
def poll_ca_cert(fetch: Callable[[], str | None], *, timeout: float) -> str:
"""Poll `fetch` until it returns a non-empty PEM string or `timeout` expires.
`fetch` should return the PEM on success and `None` (or empty string) when
the cert is not yet available. Raises `TimeoutError` if the cert never
appears within `timeout` seconds."""
deadline = time.monotonic() + timeout
while True:
result = fetch()
if result:
return result
if time.monotonic() >= deadline:
raise TimeoutError(f"CA cert not available after {timeout:g}s")
time.sleep(_CA_POLL_INTERVAL)
# Debian-family CA layout, shared by every backend (all guest images # Debian-family CA layout, shared by every backend (all guest images
# are Debian-family). AGENT_CA_PATH is the source path that # are Debian-family). AGENT_CA_PATH is the source path that
+9 -30
View File
@@ -31,7 +31,6 @@ from __future__ import annotations
import dataclasses import dataclasses
import json import json
import secrets import secrets
import socket
import string import string
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -44,18 +43,17 @@ from .paths import bot_bottle_root
_STATE_SUBDIR = "state" _STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" _PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_COMMITTED_IMAGE_NAME = "committed-image" _COMMITTED_IMAGE_NAME = "committed-image"
_COMMITTED_ROOTFS_NAME = "committed-rootfs.tar"
_TRANSCRIPT_SUBDIR = "transcript" _TRANSCRIPT_SUBDIR = "transcript"
# Per-daemon scratch subdirs. PRD 0018 chunk 2: bind-mount sources # Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
# live here so chunk 3's `docker compose up` can find them at stable # live here so chunk 3's `docker compose up` can find them at stable
# paths. Each daemon's `prepare()` writes config + CAs into its own # paths. Each sidecar's `prepare()` writes config + CAs into its own
# subdir; the launch step is unchanged today (still `docker cp`). # subdir; the launch step is unchanged today (still `docker cp`).
_EGRESS_SUBDIR = "egress" _EGRESS_SUBDIR = "egress"
_GIT_GATE_SUBDIR = "git-gate" _GIT_GATE_SUBDIR = "git-gate"
_SUPERVISE_SUBDIR = "supervise" _SUPERVISE_SUBDIR = "supervise"
_AGENT_SUBDIR = "agent" _AGENT_SUBDIR = "agent"
_METADATA_NAME = "metadata.json" _METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise daemon (read-only). # Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's # Host's apply paths keep these files fresh so supervise's
# `list-egress-routes` MCP tool returns the current state — # `list-egress-routes` MCP tool returns the current state —
# not a snapshot from launch time. # not a snapshot from launch time.
@@ -89,14 +87,6 @@ def bottle_identity(agent_name: str) -> str:
return f"{slug}-{suffix}" return f"{slug}-{suffix}"
def globalize_slug(slug: str) -> str:
"""Return a globally-unique slug qualified with the current hostname.
Assumes slug is a value returned from mint_slug. Use wherever a slug
must be unique across hosts (e.g. deploy-key titles)."""
return f"{socket.gethostname()}-{slug}"
@dataclass(frozen=True) @dataclass(frozen=True)
class BottleMetadata: class BottleMetadata:
"""Persistent record of how a bottle was launched, written at """Persistent record of how a bottle was launched, written at
@@ -201,15 +191,6 @@ def committed_image_path(identity: str) -> Path:
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
def committed_rootfs_path(identity: str) -> Path:
"""Where the Firecracker freezer stores a snapshot of the bottle's
guest rootfs (a plain tar). This is the resumable/migratable artifact
the Firecracker backend boots from no Docker image involved. The
matching `committed-image` state file records that a snapshot exists
(and its path); `resume` boots from this tar when both are present."""
return bottle_state_dir(identity) / _COMMITTED_ROOTFS_NAME
def write_committed_image(identity: str, image_tag: str) -> Path: def write_committed_image(identity: str, image_tag: str) -> Path:
"""Persist the committed image tag for `identity`. The next """Persist the committed image tag for `identity`. The next
`cli.py resume <identity>` will boot from this image instead of `cli.py resume <identity>` will boot from this image instead of
@@ -241,7 +222,7 @@ def per_bottle_image_tag(identity: str) -> str:
def live_config_dir(identity: str) -> Path: def live_config_dir(identity: str) -> Path:
"""Per-bottle live-config dir. Bind-mounted read-only into the """Per-bottle live-config dir. Bind-mounted read-only into the
supervise daemon; the host's apply paths refresh the files on supervise sidecar; the host's apply paths refresh the files on
every operator approval so the agent's `list-*` MCP tools always every operator approval so the agent's `list-*` MCP tools always
return current state.""" return current state."""
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
@@ -279,9 +260,9 @@ def transcript_snapshot_dir(identity: str) -> Path:
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
# --- Per-daemon scratch subdirs (PRD 0018 chunk 2) ------------------------ # --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
# #
# Each daemon gets its own subdir under the bottle's state dir for # Each sidecar gets its own subdir under the bottle's state dir for
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes # bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
# land here; the state dir's normal cleanup (`cleanup_state`) reaps # land here; the state dir's normal cleanup (`cleanup_state`) reaps
# them along with everything else when the bottle session ends and # them along with everything else when the bottle session ends and
@@ -289,20 +270,20 @@ def transcript_snapshot_dir(identity: str) -> Path:
def egress_state_dir(identity: str) -> Path: def egress_state_dir(identity: str) -> Path:
"""State subdir for the egress daemon: routes.yaml + the """State subdir for the egress sidecar: routes.yaml + the
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
return bottle_state_dir(identity) / _EGRESS_SUBDIR return bottle_state_dir(identity) / _EGRESS_SUBDIR
def git_gate_state_dir(identity: str) -> Path: def git_gate_state_dir(identity: str) -> Path:
"""State subdir for the git-gate daemon: entrypoint + hooks + """State subdir for the git-gate sidecar: entrypoint + hooks +
per-upstream known_hosts. Bind-mount source from chunk 3 per-upstream known_hosts. Bind-mount source from chunk 3
onward.""" onward."""
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
def supervise_state_dir(identity: str) -> Path: def supervise_state_dir(identity: str) -> Path:
"""State subdir reserved for supervise daemon bind-mount sources. """State subdir reserved for supervise sidecar bind-mount sources.
Runtime queue/audit rows live in the host-level bot-bottle SQLite Runtime queue/audit rows live in the host-level bot-bottle SQLite
database, so they survive state-dir cleanup.""" database, so they survive state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
@@ -359,12 +340,10 @@ __all__ = [
"BottleMetadata", "BottleMetadata",
"agent_state_dir", "agent_state_dir",
"bottle_identity", "bottle_identity",
"globalize_slug",
"bottle_state_dir", "bottle_state_dir",
"cleanup_state", "cleanup_state",
"clear_preserve_marker", "clear_preserve_marker",
"committed_image_path", "committed_image_path",
"committed_rootfs_path",
"egress_state_dir", "egress_state_dir",
"git_gate_state_dir", "git_gate_state_dir",
"is_preserved", "is_preserved",
+1 -8
View File
@@ -38,13 +38,6 @@ COMMANDS = {
"supervise": cmd_supervise, "supervise": cmd_supervise,
} }
# Commands that manage host prerequisites (or are otherwise store-free) and
# must run before — or without — a migrated DB. `backend` provisions/probes
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
# gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered.
NO_MIGRATION_COMMANDS = frozenset({"backend"})
def usage() -> None: def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n") sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
@@ -87,7 +80,7 @@ def main(argv: list[str] | None = None) -> int:
usage() usage()
die(f"unknown command: {command}") die(f"unknown command: {command}")
mgr = StoreManager.instance() mgr = StoreManager.instance()
if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated(): if not mgr.is_migrated():
sys.stderr.write("bot-bottle: database schema is out of date\n") sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ") sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush() sys.stderr.flush()
+10 -2
View File
@@ -3,10 +3,18 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from ..util import read_tty_line as read_tty_line
PROG = "cli.py" PROG = "cli.py"
USER_CWD = os.getcwd() USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent) REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
def read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r", encoding="utf-8") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
+5 -9
View File
@@ -2,8 +2,8 @@
Walks every registered backend (docker, firecracker, macos-container) Walks every registered backend (docker, firecracker, macos-container)
so a single `./cli.py cleanup` reaps every backend's leftovers — a so a single `./cli.py cleanup` reaps every backend's leftovers — a
firecracker bottle's VM processes and run dirs won't survive a firecracker bottle's sidecars won't survive a docker-only cleanup pass
docker-only cleanup pass (issue addressed alongside #77). (issue addressed alongside #77).
Each backend's `prepare_cleanup` enumerates its own resources; Each backend's `prepare_cleanup` enumerates its own resources;
docker's `_list_orphan_state_dirs` consults docker's `_list_orphan_state_dirs` consults
@@ -21,20 +21,16 @@ from __future__ import annotations
import sys import sys
from ..backend import get_bottle_backend, has_backend, known_backend_names from ..backend import get_bottle_backend, known_backend_names
from ..log import info from ..log import info
from ._common import read_tty_line from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int: def cmd_cleanup(_argv: list[str]) -> int:
# Order: stable backend iteration so the y/N output is # Order: stable backend iteration so the y/N output is
# deterministic across runs. Skip backends whose runtime # deterministic across runs.
# isn't available on this host so e.g. macos-container
# doesn't error on Linux.
plans = [ plans = [
(name, get_bottle_backend(name)) (name, get_bottle_backend(name)) for name in known_backend_names()
for name in known_backend_names()
if has_backend(name)
] ]
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans] prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
+14 -19
View File
@@ -27,6 +27,7 @@ from ..backend import (
BottleSpec, BottleSpec,
enumerate_active_agents, enumerate_active_agents,
get_bottle_backend, get_bottle_backend,
known_backend_names,
) )
from ..backend.docker import util as docker_mod from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
@@ -46,14 +47,12 @@ def cmd_start(argv: list[str]) -> int:
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle") parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
parser.add_argument( parser.add_argument(
"--no-cache", "--backend",
action="store_true", choices=known_backend_names(),
default=None,
help=( help=(
"rebuild agent/sidecar images from scratch, bypassing the " "backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
"build layer cache. Use when an image looks broken after a " "or host auto-selection). Overrides the env var when set."
"dependency bump — e.g. an installer's optionalDependencies "
"fetch silently no-op'd on a transient failure and got baked "
"into a cached layer."
), ),
) )
parser.add_argument( parser.add_argument(
@@ -98,17 +97,13 @@ def cmd_start(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
# Read by build_image() in each backend's util module — set here
# so both the interactive and --headless paths pick it up without
# threading a no_cache field through every backend's plan dataclass.
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
manifest = ManifestIndex.resolve(USER_CWD) manifest = ManifestIndex.resolve(USER_CWD)
backend_name: str | None = args.backend
if args.headless: if args.headless:
return _start_headless( return _start_headless(
manifest, args, dry_run=dry_run manifest, args, dry_run=dry_run, backend_name=backend_name
) )
agent_name: str | None = args.name agent_name: str | None = args.name
@@ -159,6 +154,7 @@ def cmd_start(argv: list[str]) -> int:
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=dry_run, dry_run=dry_run,
backend_name=backend_name,
) )
@@ -170,6 +166,7 @@ def _start_headless(
args: argparse.Namespace, args: argparse.Namespace,
*, *,
dry_run: bool, dry_run: bool,
backend_name: str | None,
) -> int: ) -> int:
"""Non-interactive launch path for orchestrators / CI / webhooks. """Non-interactive launch path for orchestrators / CI / webhooks.
@@ -217,6 +214,7 @@ def _start_headless(
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=dry_run, dry_run=dry_run,
backend_name=backend_name,
assume_yes=True, assume_yes=True,
headless_prompt_text=prompt, headless_prompt_text=prompt,
) )
@@ -254,18 +252,15 @@ def prepare_with_preflight(
injected callable, prompt y/N via the injected callable. injected callable, prompt y/N via the injected callable.
`backend_name` selects which backend prepares the plan `backend_name` selects which backend prepares the plan
(`None` `$BOT_BOTTLE_BACKEND` host auto-selection). (`None` `$BOT_BOTTLE_BACKEND` host auto-selection). The CLI
passes whatever `--backend` resolved to.
When `spec.headless` is True the docker-fallback prompt is suppressed:
auto-selection dies with an actionable message rather than blocking
on a TTY read (which would hang CI, webhook dispatch, and orchestrators).
Returns `(plan, identity)`. `plan` is None on dry-run or Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare` operator-N, but `identity` is set as soon as `backend.prepare`
returns so callers can reap the prepare-time state dir via returns so callers can reap the prepare-time state dir via
`settle_state(identity)` in their finally exactly the existing `settle_state(identity)` in their finally exactly the existing
semantics.""" semantics."""
backend = get_bottle_backend(backend_name, prompt=not spec.headless) backend = get_bottle_backend(backend_name)
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan) identity = _identity_from_plan(plan)
+87 -84
View File
@@ -20,19 +20,32 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ..paths import bot_bottle_root from ..paths import bot_bottle_root
from ..log import Die, error, info from ..bottle_state import read_metadata
from ..orchestrator.client import ( from ..backend.docker.egress_apply import (
OrchestratorClient, EgressApplyError,
OrchestratorClientError, applicator as _docker_applicator,
discover_orchestrator_url,
) )
from ..backend.macos_container.egress_apply import (
applicator as _macos_applicator,
)
from ..log import Die, error, info
from ..supervise import ( from ..supervise import (
COMPONENT_FOR_TOOL,
AuditEntry,
Proposal, Proposal,
Response,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
list_all_pending_proposals,
render_diff,
write_audit_entry,
write_response,
) )
from ._common import PROG from ._common import PROG
@@ -47,61 +60,30 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
@dataclass(frozen=True) @dataclass(frozen=True)
class QueuedProposal: class QueuedProposal:
"""A pending proposal from the supervise queue. """A pending proposal from the supervise queue."""
`label` is the operator-facing bottle name (the human slug the
orchestrator resolved from the registry); `proposal.bottle_slug` is the
opaque bottle_id every operator action is keyed by. Display uses `label`;
respond calls use `proposal.bottle_slug`."""
proposal: Proposal proposal: Proposal
label: str = ""
# A failed operator action (orchestrator unreachable, bottle torn down, # Errors any remediation engine may raise. Caught by the TUI key
# 409) is caught by the TUI key handlers and surfaced in the status line so # handlers and surfaced in the status line so a failed apply keeps
# the proposal stays pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (OrchestratorClientError,) ApplyError = (EgressApplyError,)
# The one per-host orchestrator, discovered lazily on first use. Every def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
# operator action — list, approve, reject — goes through its HTTP control meta = read_metadata(slug)
# plane (the orchestrator owns the single DB + live policy); there is no backend = meta.backend if meta is not None else ""
# direct-DB path and no backend branching here. if backend == "macos-container":
_client_instance: OrchestratorClient | None = None return _macos_applicator.apply_routes_change(slug, content)
return _docker_applicator.apply_routes_change(slug, content)
def _resolve_orchestrator_url() -> str:
"""URL of the running orchestrator control plane, starting one on demand.
Supervise is often the first thing an operator runs before any bottle
has booted the control plane. So when discovery finds nothing, bring up
the selected backend's orchestrator + gateway (idempotent) rather than
failing with "launch a bottle first"."""
try:
return discover_orchestrator_url()
except OrchestratorClientError:
from ..backend import get_bottle_backend
backend = get_bottle_backend()
info(f"no orchestrator control plane running; starting one ({backend.name})…")
return backend.ensure_orchestrator()
def _client() -> OrchestratorClient:
global _client_instance # noqa: PLW0603 — CLI-session singleton
if _client_instance is None:
_client_instance = OrchestratorClient(_resolve_orchestrator_url())
return _client_instance
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
"""Collect pending proposals across bottles from the orchestrator.""" """Collect pending proposals across bottles."""
out = [ out = [
QueuedProposal( QueuedProposal(proposal=proposal)
proposal=Proposal.from_dict(d), for proposal in list_all_pending_proposals()
label=str(d.get("bottle_label") or d.get("bottle_slug") or ""),
)
for d in _client().supervise_pending()
] ]
out.sort(key=lambda q: q.proposal.arrival_timestamp) out.sort(key=lambda q: q.proposal.arrival_timestamp)
return out return out
@@ -109,8 +91,8 @@ def discover_pending() -> list[QueuedProposal]:
def _approval_status(qp: QueuedProposal, verb: str) -> str: def _approval_status(qp: QueuedProposal, verb: str) -> str:
"""Status-line text after a successful approval.""" """Status-line text after a successful approval."""
base = f"{verb} {qp.proposal.tool} for [{qp.label}]" base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
return f"{base}; resume: ./cli.py resume {qp.label}" return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
def _detail_lines( def _detail_lines(
@@ -121,7 +103,7 @@ def _detail_lines(
"""Return the detail-view body as (text, curses-attr) tuples.""" """Return the detail-view body as (text, curses-attr) tuples."""
p = qp.proposal p = qp.proposal
out: list[tuple[str, int]] = [ out: list[tuple[str, int]] = [
(f"bottle: {qp.label}", 0), (f"bottle: {p.bottle_slug}", 0),
(f"tool: {p.tool}", 0), (f"tool: {p.tool}", 0),
(f"id: {p.id}", 0), (f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0), (f"arrived: {p.arrival_timestamp}", 0),
@@ -154,27 +136,39 @@ def approve(
notes: str = "", notes: str = "",
final_file: str | None = None, final_file: str | None = None,
) -> None: ) -> None:
"""Approve (or, with `final_file`, modify-then-approve) via the """Apply the proposal, write the waiting response, and audit it."""
orchestrator: it applies the route change to the bottle's live policy, status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
writes the response that unblocks the agent, and audits it one atomic file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
server-side op. Raises `OrchestratorClientError` on failure."""
_client().supervise_respond( diff_before, diff_after = "", ""
qp.proposal.id, if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
bottle_slug=qp.proposal.bottle_slug, diff_before, diff_after = apply_routes_change(
decision="modify" if final_file is not None else "approve", qp.proposal.bottle_slug,
file_to_apply,
)
response = Response(
proposal_id=qp.proposal.id,
status=status,
notes=notes, notes=notes,
final_file=final_file, final_file=final_file,
) )
write_response(qp.proposal.bottle_slug, response)
_write_audit(
qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after,
)
def reject(qp: QueuedProposal, *, reason: str) -> None: def reject(qp: QueuedProposal, *, reason: str) -> None:
"""Reject via the orchestrator (writes the response + audit).""" """Write a rejection response and an audit entry."""
_client().supervise_respond( response = Response(
qp.proposal.id, proposal_id=qp.proposal.id,
bottle_slug=qp.proposal.bottle_slug, status=STATUS_REJECTED,
decision="reject",
notes=reason, notes=reason,
final_file=None,
) )
write_response(qp.proposal.bottle_slug, response)
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
def _approve_from_tui( def _approve_from_tui(
@@ -194,6 +188,29 @@ def _approve_from_tui(
return _approval_status(qp, verb) return _approval_status(qp, verb)
def _write_audit(
qp: QueuedProposal,
*,
action: str,
notes: str,
diff_before: str,
diff_after: str,
) -> None:
"""Audit log for egress tool."""
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
if component is None:
return
write_audit_entry(AuditEntry(
timestamp=datetime.now(timezone.utc).isoformat(),
bottle_slug=qp.proposal.bottle_slug,
component=component,
operator_action=action,
operator_notes=notes,
justification=qp.proposal.justification,
diff=render_diff(diff_before, diff_after, label=component),
))
# --- $EDITOR integration -------------------------------------------------- # --- $EDITOR integration --------------------------------------------------
@@ -228,20 +245,6 @@ def cmd_supervise(argv: list[str]) -> int:
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Establish the orchestrator connection up front so a missing control
# plane is a clean one-line error, not a curses crash mid-loop. This also
# starts the orchestrator on demand when none is running (see `_client`).
try:
_client()
except OrchestratorClientError as e:
error(str(e))
return 1
except Die as e:
# Backend has no orchestrator to start (e.g. macos-container).
if e.message:
error(e.message)
return e.code if isinstance(e.code, int) else 1
if args.once: if args.once:
return _list_once() return _list_once()
try: try:
@@ -296,7 +299,7 @@ def _list_once() -> int:
for qp in pending: for qp in pending:
sys.stdout.write( sys.stdout.write(
f"{qp.proposal.arrival_timestamp} " f"{qp.proposal.arrival_timestamp} "
f"[{qp.label}] " f"[{qp.proposal.bottle_slug}] "
f"{qp.proposal.tool} " f"{qp.proposal.tool} "
f"{qp.proposal.id}\n" f"{qp.proposal.id}\n"
) )
@@ -393,7 +396,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
reason = _prompt(stdscr, "reject reason: ") reason = _prompt(stdscr, "reject reason: ")
if reason: if reason:
reject(qp, reason=reason) reject(qp, reason=reason)
status_line = f"rejected {qp.proposal.tool} for [{qp.label}]" status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
else: else:
status_line = "reject aborted (empty reason)" status_line = "reject aborted (empty reason)"
@@ -432,7 +435,7 @@ def _render(
cursor = "> " if i == selected else " " cursor = "> " if i == selected else " "
line = ( line = (
f"{cursor}{ts_short} " f"{cursor}{ts_short} "
f"[{qp.label}] {p.tool:<18} {p.id[:8]}" f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}"
) )
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr) stdscr.addnstr(row, 0, line, w - 1, attr)
-17
View File
@@ -1,17 +0,0 @@
"""Shared wire-protocol constants for gateway-bundled modules.
Single source of truth for values that appear across the egress addon,
git-http backend, supervise server, and git-gate renderer. Importing
from this module instead of duplicating the literals means a rename is
a one-line change and is caught by the type checker at the import site."""
# App-layer identity token header. Delivered as proxy credentials
# (HTTPS_PROXY=http://<bottle_id>:<token>@gw) by launch; the egress
# addon reads and strips it, the supervise server and git-http backend
# read it for attribution, and none of them forward it upstream.
IDENTITY_HEADER = "x-bot-bottle-identity"
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
# git_http_backend, and the git http-backend CGI subprocess.
GIT_GATE_TIMEOUT_SECS = 15
+8 -29
View File
@@ -4,7 +4,7 @@ The Claude-specific behavior previously inlined under
`agent_provider.agent_provision_plan` (claude.json trust marker, `agent_provider.agent_provision_plan` (claude.json trust marker,
api.anthropic.com egress route, OAuth-token placeholder), plus api.anthropic.com egress route, OAuth-token placeholder), plus
the `claude mcp add` invocation that registers the supervise the `claude mcp add` invocation that registers the supervise
gateway in claude-code's user config (PRD 0013).""" sidecar in claude-code's user config (PRD 0013)."""
from __future__ import annotations from __future__ import annotations
@@ -23,9 +23,8 @@ from ...agent_provider import (
provider_startup_args, provider_startup_args,
) )
from ...backend.docker import util as docker_mod from ...backend.docker import util as docker_mod
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...egress import EgressRoute
from ...log import die, info, warn from ...log import die, info, warn
from .claude_auth import claude_host_access_token
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -33,8 +32,6 @@ if TYPE_CHECKING:
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
# App-layer identity token header (mirrors egress_addon / git_http_backend).
_IDENTITY_HEADER = "x-bot-bottle-identity"
def _skills_dir(guest_home: str) -> str: def _skills_dir(guest_home: str) -> str:
@@ -94,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
smoke_test=("claude", "--version"),
) )
@@ -119,6 +115,7 @@ class ClaudeAgentProvider(AgentProvider):
color: str = "", color: str = "",
provider_settings: dict[str, object] | None = None, provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings) startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home guest_home = self.guest_home
@@ -180,24 +177,13 @@ class ClaudeAgentProvider(AgentProvider):
claude_settings, claude_settings,
f"{guest_home}/.claude/settings.json", f"{guest_home}/.claude/settings.json",
)) ))
provisioned_env: dict[str, str] = {}
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
claude_host_access_token(_host_env)
)
cred_token_ref = (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
else auth_token
)
egress_routes = (EgressRoute( egress_routes = (EgressRoute(
host="api.anthropic.com", host="api.anthropic.com",
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "", auth_scheme="Bearer" if auth_token else "",
token_ref=cred_token_ref, token_ref=auth_token,
),) ),)
hidden_env_names: frozenset[str] = frozenset() hidden_env_names: frozenset[str] = frozenset()
if auth_token or forward_host_credentials: if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
@@ -219,7 +205,6 @@ class ClaudeAgentProvider(AgentProvider):
files=tuple(files), files=tuple(files),
egress_routes=egress_routes, egress_routes=egress_routes,
hidden_env_names=hidden_env_names, hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
) )
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
@@ -308,22 +293,16 @@ class ClaudeAgentProvider(AgentProvider):
supervise_url: str, supervise_url: str,
) -> None: ) -> None:
"""Run `claude mcp add` inside the agent guest to register the """Run `claude mcp add` inside the agent guest to register the
supervise daemon in claude-code's user config (~/.claude.json). supervise sidecar in claude-code's user config (~/.claude.json).
Failure is logged but not fatal the bottle still works without Failure is logged but not fatal the bottle still works without
the entry; the operator can register it manually.""" the entry; the operator can register it manually."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
info(f"registering supervise MCP server in agent claude config → {supervise_url}") info(f"registering supervise MCP server in agent claude config → {supervise_url}")
# Deliver the identity token as an MCP request header — the supervise
# daemon requires it (mandatory (source_ip, token) attribution).
token = getattr(plan, "identity_token", "")
header = (
f" --header {shlex.quote(f'{_IDENTITY_HEADER}: {token}')}" if token else ""
)
r = bottle.exec( r = bottle.exec(
f"claude mcp add --scope user --transport http " f"claude mcp add --scope user --transport http "
f"{_SUPERVISE_MCP_NAME} {supervise_url}{header}", f"{_SUPERVISE_MCP_NAME} {supervise_url}",
user="node", user="node",
) )
if r.returncode != 0: if r.returncode != 0:
-114
View File
@@ -1,114 +0,0 @@
"""Host Claude auth helpers.
Reads the host's Claude Code credentials and returns only the access
token needed by egress. Does not expose refresh tokens or raw payloads.
Credential storage by platform:
Linux ~/.claude/.credentials.json
macOS macOS Keychain, service "Claude Code-credentials"
(file path is tried first; Keychain is the fallback)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from ...log import die
_KEYCHAIN_SERVICE = "Claude Code-credentials"
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env
home = env.get("HOME")
if home:
return Path(home) / ".claude" / ".credentials.json"
return Path.home() / ".claude" / ".credentials.json"
def _read_keychain() -> dict[str, object] | None:
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
if sys.platform != "darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
capture_output=True,
text=True,
timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0 or not result.stdout.strip():
return None
try:
raw = json.loads(result.stdout.strip())
except json.JSONDecodeError:
return None
return raw if isinstance(raw, dict) else None
def claude_host_access_token(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
path = claude_auth_path(host_env)
raw: dict[str, object] | None = None
if path.is_file():
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"claude host credentials: {path} must contain a JSON object")
else:
raw = _read_keychain()
if raw is None:
die(
f"claude host credentials: auth file missing at {path} and "
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
oauth = raw.get("claudeAiOauth")
if not isinstance(oauth, dict):
die(
"claude host credentials: claudeAiOauth is missing from credentials. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
access_token = oauth.get("accessToken")
if not isinstance(access_token, str) or not access_token:
die(
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
"Run `claude login` on the host and restart the bottle."
)
# expiresAt is in milliseconds
expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)):
check_now = now or datetime.now(timezone.utc)
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
if exp_dt <= check_now:
die(
"claude host credentials: host Claude access token is expired. "
"Run `claude login` on the host and restart the bottle."
)
return access_token
__all__ = [
"claude_auth_path",
"claude_host_access_token",
]
+15 -68
View File
@@ -4,12 +4,11 @@ The Codex-specific behavior previously inlined under
`agent_provider.agent_provision_plan` (config.toml trust marker, `agent_provider.agent_provision_plan` (config.toml trust marker,
chatgpt.com / api.openai.com egress routes, optional host-credential chatgpt.com / api.openai.com egress routes, optional host-credential
forwarding with dummy-auth.json + verify), plus the `codex mcp add` forwarding with dummy-auth.json + verify), plus the `codex mcp add`
invocation that registers the supervise daemon in Codex's invocation that registers the supervise sidecar in Codex's
~/.codex/config.toml (PRD 0050).""" ~/.codex/config.toml (PRD 0050)."""
from __future__ import annotations from __future__ import annotations
import base64
import os import os
import shlex import shlex
from pathlib import Path from pathlib import Path
@@ -27,7 +26,7 @@ from ...agent_provider import (
) )
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from ...log import die, info from ...log import die, info, warn
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -35,8 +34,6 @@ if TYPE_CHECKING:
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
# App-layer identity token header (mirrors egress_addon / git_http_backend).
_IDENTITY_HEADER = "x-bot-bottle-identity"
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex" _CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
_CODEX_CLI_PATH = ( _CODEX_CLI_PATH = (
"/home/node/.local/bin:" "/home/node/.local/bin:"
@@ -45,41 +42,6 @@ _CODEX_CLI_PATH = (
) )
def _toml_basic_string(value: str) -> str:
"""Quote `value` as a TOML basic (double-quoted) string."""
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def _supervise_mcp_config_toml(supervise_url: str, token: str) -> str:
"""Render the `[mcp_servers.supervise]` streamable-HTTP entry for
Codex's `config.toml`.
The Codex CLI has no `mcp add --header` flag; a static request
header on an HTTP MCP server is only expressible via the
`http_headers` config key (see `RawMcpServerConfig` /
`McpServerTransportConfig::StreamableHttp`). We deliver the
mandatory identity token (source_ip, token attribution) that way.
Only Codex-supported streamable-HTTP keys (`url`, `http_headers`)
are emitted."""
lines = [
"",
f"[mcp_servers.{_SUPERVISE_MCP_NAME}]",
f"url = {_toml_basic_string(supervise_url)}",
]
if token:
key = _toml_basic_string(_IDENTITY_HEADER)
val = _toml_basic_string(token)
lines.append(f"http_headers = {{ {key} = {val} }}")
lines.append("")
return "\n".join(lines)
def _skills_dir(guest_home: str) -> str: def _skills_dir(guest_home: str) -> str:
# Codex agents still read skills from the claude-code convention # Codex agents still read skills from the claude-code convention
# (~/.claude/skills/) — the bot-bottle-codex image follows the # (~/.claude/skills/) — the bot-bottle-codex image follows the
@@ -99,7 +61,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
smoke_test=(_CODEX_CLI, "--version"),
) )
@@ -304,39 +265,25 @@ class CodexAgentProvider(AgentProvider):
bottle: "Bottle", bottle: "Bottle",
supervise_url: str, supervise_url: str,
) -> None: ) -> None:
"""Register the supervise daemon as a streamable-HTTP MCP """Run `codex mcp add` inside the agent guest to register the
server in Codex's user config (`~/.codex/config.toml`). supervise sidecar in Codex's user config (~/.codex/config.toml).
We write the `[mcp_servers.supervise]` entry directly rather Mirrors the Claude provider's `claude mcp add` flow — failure
than shelling out to `codex mcp add`: the CLI's `add` has no is logged but not fatal."""
way to attach a static request header, and the identity token
(mandatory (source_ip, token) attribution) MUST ride on the
MCP request as `http_headers`. Failure is FATAL when supervise
is enabled a silently-unregistered server leaves the agent
with no supervise access and, under mandatory attribution, no
way to recover from inside the bottle."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
info(f"registering supervise MCP server in agent codex config → {supervise_url}") info(f"registering supervise MCP server in agent codex config → {supervise_url}")
token = getattr(plan, "identity_token", "") r = bottle.exec(
block = _supervise_mcp_config_toml(supervise_url, token) f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
auth_dir = plan.agent_provision.guest_env.get("CODEX_HOME") \ f"{shlex.quote(supervise_url)}",
or f"{plan.guest_home}/.codex" user="node",
config_path = f"{auth_dir}/config.toml"
# Append via base64 so the TOML payload never has to survive a
# shell-quoting round trip. node owns the config file, so append
# as node to preserve ownership/mode.
payload = base64.b64encode(block.encode()).decode()
script = (
f"printf %s {shlex.quote(payload)} | base64 -d "
f">> {shlex.quote(config_path)}"
) )
r = bottle.exec(script, user="node")
if r.returncode != 0: if r.returncode != 0:
die( warn(
"agent provider provisioning: could not register supervise " f"`codex mcp add supervise` failed (exit {r.returncode}): "
f"MCP server in {config_path}: " f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"{(r.stderr or r.stdout or '').strip()}" f"register manually with: "
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
) )
def headless_prompt(self, prompt: str) -> list[str]: def headless_prompt(self, prompt: str) -> list[str]:
+2 -12
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
try: try:
@@ -29,21 +28,12 @@ class DbStore:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@contextmanager
def _connection(self):
conn = self._connect()
try:
with conn:
yield conn
finally:
conn.close()
def is_migrated(self) -> bool: def is_migrated(self) -> bool:
"""Return True if the DB is fully up-to-date, False if migration is needed.""" """Return True if the DB is fully up-to-date, False if migration is needed."""
if not self.db_path.exists(): if not self.db_path.exists():
return False return False
try: try:
with self._connection() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT version FROM schema_versions WHERE module = ?", "SELECT version FROM schema_versions WHERE module = ?",
(self._migrations.schema_key,), (self._migrations.schema_key,),
@@ -55,7 +45,7 @@ class DbStore:
def migrate(self) -> None: def migrate(self) -> None:
"""Apply any pending migrations and set permissions on the DB file.""" """Apply any pending migrations and set permissions on the DB file."""
with self._connection() as conn: with self._connect() as conn:
self._migrations.apply(conn) self._migrations.apply(conn)
self._chmod() self._chmod()
+7 -3
View File
@@ -3,8 +3,9 @@
Pure Python, no mitmproxy dependency. Each detector is a module-level Pure Python, no mitmproxy dependency. Each detector is a module-level
function returning `ScanResult | None`. function returning `ScanResult | None`.
Available in the gateway via the installed `bot_bottle` package Ships flat into the sidecar bundle image alongside
(see `Dockerfile.gateway`). `egress_addon_core.py` both this file and the package source use
the same try/except import shim pattern.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,7 +20,10 @@ from math import log2
from collections import Counter from collections import Counter
from urllib.parse import quote as url_quote from urllib.parse import quote as url_quote
from .egress_addon_core import ScanResult try:
from egress_addon_core import ScanResult # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from .egress_addon_core import ScanResult
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+3 -10
View File
@@ -14,20 +14,13 @@ from __future__ import annotations
import subprocess import subprocess
def run_docker( def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]:
argv: list[str], *, env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a `docker` command, capturing stdout/stderr as text. Never raises """Run a `docker` command, capturing stdout/stderr as text. Never raises
on a non-zero exit callers inspect `returncode` / `stderr` so they can on a non-zero exit callers inspect `returncode` / `stderr` so they can
stay fail-closed or tolerate idempotent no-ops (e.g. removing an stay fail-closed or tolerate idempotent no-ops (e.g. removing an
already-absent container). already-absent container)."""
`env` sets the child process environment used to hand a secret to a bare
`--env NAME` flag (docker inherits its value from this process) so the
value never lands on argv or in `docker inspect`'s recorded command line."""
return subprocess.run( return subprocess.run(
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
check=False, env=env,
) )
+6 -8
View File
@@ -2,7 +2,7 @@
This module defines the abstract proxy (`Egress`), its plan This module defines the abstract proxy (`Egress`), its plan
dataclass (`EgressPlan`), and the resolved per-route shape dataclass (`EgressPlan`), and the resolved per-route shape
(`EgressRoute`). The gateway's start/stop lifecycle is backend- (`EgressRoute`). The sidecar's start/stop lifecycle is backend-
specific and lives on concrete subclasses (see specific and lives on concrete subclasses (see
`bot_bottle/backend/docker/egress.py`). `bot_bottle/backend/docker/egress.py`).
""" """
@@ -30,7 +30,6 @@ if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
EGRESS_HOSTNAME = "egress" EGRESS_HOSTNAME = "egress"
@@ -64,8 +63,8 @@ def _random_canary_env() -> str:
return f"{first}_{second}_SECRET" return f"{first}_{second}_SECRET"
def egress_gateway_env_entries(plan: "EgressPlan") -> tuple[str, ...]: def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
"""Return gateway env entries needed by egress across all backends.""" """Return sidecar env entries needed by egress across all backends."""
env: list[str] = [] env: list[str] = []
if plan.routes: if plan.routes:
env.extend(sorted(plan.token_env_map.keys())) env.extend(sorted(plan.token_env_map.keys()))
@@ -88,7 +87,7 @@ class EgressRoute(Route):
Inherits `host`, `matches`, `auth_scheme`, and `token_env` Inherits `host`, `matches`, `auth_scheme`, and `token_env`
from `egress_addon_core.Route` those are the fields that cross the from `egress_addon_core.Route` those are the fields that cross the
YAML wire into the gateway. The fields below are host-only and YAML wire into the sidecar. The fields below are host-only and
are never serialised to the addon. are never serialised to the addon.
`token_ref` is the host env var the CLI reads at launch and forwards `token_ref` is the host env var the CLI reads at launch and forwards
@@ -387,7 +386,7 @@ class Egress(ABC):
routes_path.write_text(egress_render_routes(routes, log=log)) routes_path.write_text(egress_render_routes(routes, log=log))
routes_path.chmod(0o600) routes_path.chmod(0o600)
# Generate a per-session fake secret under a plausible random env name. # Generate a per-session fake secret under a plausible random env name.
# The gateway marks that exact env name as sensitive for known-secret # The sidecar marks that exact env name as sensitive for known-secret
# scanning; the agent receives the same name/value as exfil bait. # scanning; the agent receives the same name/value as exfil bait.
canary = secrets.token_urlsafe(32) canary = secrets.token_urlsafe(32)
return EgressPlan( return EgressPlan(
@@ -401,7 +400,6 @@ class Egress(ABC):
) )
__all__ = [ __all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF", "CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME", "EGRESS_ROUTES_FILENAME",
@@ -414,6 +412,6 @@ __all__ = [
"egress_resolve_token_values", "egress_resolve_token_values",
"egress_routes_for_bottle", "egress_routes_for_bottle",
"egress_agent_env_entries", "egress_agent_env_entries",
"egress_gateway_env_entries", "egress_sidecar_env_entries",
"egress_token_env_map", "egress_token_env_map",
] ]
+119 -268
View File
@@ -1,4 +1,4 @@
"""mitmproxy addon entrypoint for the egress gateway (PRD 0017, PRD 0053). """mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053).
Loaded by `mitmdump -s /app/egress_addon.py` inside the Loaded by `mitmdump -s /app/egress_addon.py` inside the
egress container.""" egress container."""
@@ -6,18 +6,15 @@ egress container."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import binascii
import json import json
import os import os
import signal
import sys import sys
import typing from pathlib import Path
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
from bot_bottle.constants import IDENTITY_HEADER from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
from bot_bottle.dlp_detectors import redact_tokens, strip_crlf
from bot_bottle.egress_addon_core import (
LOG_BLOCKS, LOG_BLOCKS,
LOG_FULL, LOG_FULL,
DEFAULT_OUTBOUND_ON_MATCH, DEFAULT_OUTBOUND_ON_MATCH,
@@ -33,48 +30,32 @@ from bot_bottle.egress_addon_core import (
decide_git_fetch, decide_git_fetch,
is_git_fetch_request, is_git_fetch_request,
is_git_push_request, is_git_push_request,
load_config,
match_route, match_route,
resolve_client_context,
outbound_scan_headers, outbound_scan_headers,
route_to_yaml_dict, route_to_yaml_dict,
scan_inbound, scan_inbound,
scan_outbound, scan_outbound,
) )
from bot_bottle import supervise as _sv
from bot_bottle.policy_resolver import PolicyResolver
try:
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
redact_tokens,
strip_crlf,
)
try:
import supervise as _sv # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
INTROSPECT_HOST = "_egress.local" INTROSPECT_HOST = "_egress.local"
# The per-host orchestrator control plane the addon resolves every request's
# Config against, by source IP (PRD 0070). Mandatory: the consolidated gateway
# is the only topology now — there is no static per-bottle routes file to fall
# back to — so an unset value is a fatal misconfiguration (see __init__).
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# Per-flow key under which `request()` stashes the resolved (Config, supervise
# slug, env) so the later `response()` and `websocket_message()` hooks scan
# against the *calling bottle's* policy — the same one the request was decided
# on — without a second `/resolve` per response or per WebSocket frame. A hook
# on a flow that never resolved (no stash) fails closed to deny-all, so it's a
# safe no-op rather than an unscanned pass.
_FLOW_CTX_KEY = "bot_bottle_egress_ctx"
def _token_from_proxy_auth(header: str) -> str:
"""Extract the identity token (the password) from a `Proxy-Authorization:
Basic base64(<bottle_id>:<token>)` header. Empty on any malformed value
the mandatory `/resolve` then fail-closes on the empty token."""
scheme, _, encoded = header.partition(" ")
if scheme.lower() != "basic" or not encoded:
return ""
try:
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
except (binascii.Error, ValueError, UnicodeDecodeError):
return ""
_, _, password = decoded.partition(":")
return password
# Seconds the egress proxy holds a token-blocked request open waiting for the # Seconds the egress proxy holds a token-blocked request open waiting for the
# operator's supervisor decision (PRD 0062), overridable via env. # operator's supervisor decision (PRD 0062), overridable via env.
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
@@ -91,64 +72,55 @@ _TOKEN_ALLOW_JUSTIFICATION = (
class EgressAddon: class EgressAddon:
# Bare annotations (no class value): __init__ sets a live PolicyResolver for
# real runs, and every host-side test builds an addon via __new__ and sets a
# fake resolver. Egress is resolver-only now — the per-request policy always
# comes from the orchestrator's /resolve (PRD 0070); there is no static
# per-bottle routes file, SIGHUP reload, or single-tenant fallback.
_resolver: "PolicyResolver"
# Class default so __new__-built addons have it (real runs get a fresh
# per-instance dict in __init__; only http_connect mutates it, which the
# request-flow tests don't exercise).
_conn_tokens: "dict[str, str]" = {}
def __init__(self) -> None: def __init__(self) -> None:
# Resolver-only: the gateway is always multi-tenant, resolving each self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
# request's policy by source IP against the orchestrator control plane self.config: Config = Config(routes=())
# (PRD 0070). The URL is mandatory — without a policy source the gateway # Tokens the operator has approved this session (PRD 0062). In-memory
# must not come up (fail-closed), rather than silently allowing nothing. # only — a restart re-prompts. Mutated only from the asyncio loop that
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip() # runs the addon hooks, so no lock is needed.
if not orch_url: self.safe_tokens: set[str] = set()
raise RuntimeError( self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
f"{ORCHESTRATOR_URL_ENV} is required: the egress gateway "
"resolves every request's policy from the orchestrator and has "
"no static routes file to fall back to."
)
self._resolver = PolicyResolver(orch_url)
# Tokens the operator has approved this session (PRD 0062), keyed by
# bottle so the shared gateway keeps each bottle's safelist separate —
# a global set would let bottle A's approved secret pass bottle B's DLP
# scan. In-memory only (a restart re-prompts); mutated only from the
# asyncio loop that runs the addon hooks, so no lock is needed.
self._safe_tokens: dict[str, set[str]] = {}
# Per-client-connection identity token captured from the CONNECT's
# `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped
# inner requests). Keyed by client_conn.id; cleared on disconnect.
self._conn_tokens: dict[str, str] = {}
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True)
self._install_sighup()
@staticmethod def _supervise_available(self) -> bool:
def _supervise_available(slug: str) -> bool: return bool(self._supervise_slug)
"""Supervise is reachable for this request iff we resolved a bottle to
attribute its proposals to (the source-IP-attributed bottle id). Empty
fail closed (no queue to write to)."""
return bool(slug)
def _safe_tokens_for(self, slug: str) -> set[str]: def _reload(self, *, initial: bool = False) -> None:
"""This bottle's operator-approved DLP safelist (PRD 0062), created on try:
first use. Keyed by bottle so the shared gateway never leaks one text = Path(self.routes_path).read_text(encoding="utf-8")
bottle's approved token into another's scan.""" new_config = load_config(text)
return self._safe_tokens.setdefault(slug, set()) except (OSError, ValueError) as e:
tag = "boot" if initial else "SIGHUP"
sys.stderr.write(
f"egress: {tag} load failed: {e}\n"
)
if initial:
self.config = Config(routes=())
return
self.config = new_config
log_label = ("off", "blocks", "full")[self.config.log]
sys.stderr.write(
f"egress: loaded {len(self.config.routes)} route(s): "
f"{', '.join(r.host for r in self.config.routes)}"
f" [log={log_label}]\n"
)
def _serve_introspection( def _install_sighup(self) -> None:
self, flow: http.HTTPFlow, path: str, config: Config, if not hasattr(signal, "SIGHUP"):
) -> None: return
"""Serve the calling bottle's own allowlist. `config` is this flow's
resolved policy (the same one every hook uses), so the agent sees the def handler(signum: int, frame: object) -> None:
routes that actually apply to it.""" del signum, frame
self._reload()
signal.signal(signal.SIGHUP, handler)
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
if path == "/allowlist": if path == "/allowlist":
payload = json.dumps( payload = json.dumps(
{"routes": [route_to_yaml_dict(r) for r in config.routes]}, {"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
indent=2, indent=2,
).encode("utf-8") ).encode("utf-8")
flow.response = http.Response.make( flow.response = http.Response.make(
@@ -162,21 +134,11 @@ class EgressAddon:
{"Content-Type": "text/plain; charset=utf-8"}, {"Content-Type": "text/plain; charset=utf-8"},
) )
def _flow_log(self, flow: http.HTTPFlow) -> int:
"""This flow's log level, from the policy `request()` resolved and
stashed. The block/redact log gates were a single global in the static-
config world; they are per bottle now, so they read it from the flow."""
return self._flow_ctx(flow)[0].log
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]: def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
# Redact with this flow's resolved env overlay (process env + the
# bottle's /resolve tokens), so the ctx scrubs the calling bottle's
# provisioned secrets, not just os.environ's.
env = self._flow_ctx(flow)[2]
return { return {
"host": redact_tokens(flow.request.pretty_host, env=env), "host": redact_tokens(flow.request.pretty_host, env=os.environ),
"method": flow.request.method, "method": flow.request.method,
"path": redact_tokens(flow.request.path, env=env), "path": redact_tokens(flow.request.path, env=os.environ),
} }
def _block( def _block(
@@ -185,7 +147,7 @@ class EgressAddon:
reason: str, reason: str,
ctx: dict[str, object] | None = None, ctx: dict[str, object] | None = None,
) -> None: ) -> None:
if self._flow_log(flow) >= LOG_BLOCKS: if self.config.log >= LOG_BLOCKS:
entry: dict[str, object] = {"event": "egress_block", "reason": reason} entry: dict[str, object] = {"event": "egress_block", "reason": reason}
if ctx: if ctx:
entry.update(ctx) entry.update(ctx)
@@ -196,39 +158,31 @@ class EgressAddon:
{"Content-Type": "text/plain; charset=utf-8"}, {"Content-Type": "text/plain; charset=utf-8"},
) )
def _log_request( def _log_request(self, flow: http.HTTPFlow) -> None:
self, flow: http.HTTPFlow, env: "typing.Mapping[str, str]",
) -> None:
# `env` is the per-flow resolved overlay (process env + this bottle's
# /resolve tokens), so the log redaction scrubs the calling bottle's
# provisioned secrets — not just the process-level ones in os.environ.
headers = { headers = {
k: redact_tokens(v, env=env) k: redact_tokens(v, env=os.environ)
for k, v in flow.request.headers.items() for k, v in flow.request.headers.items()
if k.lower() != "authorization" if k.lower() != "authorization"
} }
body = redact_tokens(flow.request.get_text(strict=False) or "", env=env) body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
sys.stderr.write( sys.stderr.write(
json.dumps({ json.dumps({
"event": "egress_request", "event": "egress_request",
"host": redact_tokens(flow.request.pretty_host, env=env), "host": redact_tokens(flow.request.pretty_host, env=os.environ),
"method": flow.request.method, "method": flow.request.method,
"path": redact_tokens(flow.request.path, env=env), "path": redact_tokens(flow.request.path, env=os.environ),
"headers": headers, "headers": headers,
"body": body, "body": body,
}) })
+ "\n" + "\n"
) )
def _log_response( def _log_response(self, flow: http.HTTPFlow) -> None:
self, flow: http.HTTPFlow, env: "typing.Mapping[str, str]",
) -> None:
# Per-flow env overlay (see _log_request): redact this bottle's tokens.
headers = { headers = {
k: redact_tokens(v, env=env) k: redact_tokens(v, env=os.environ)
for k, v in flow.response.headers.items() for k, v in flow.response.headers.items()
} }
body = redact_tokens(flow.response.get_text(strict=False) or "", env=env) body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
sys.stderr.write( sys.stderr.write(
json.dumps({ json.dumps({
"event": "egress_response", "event": "egress_response",
@@ -240,104 +194,19 @@ class EgressAddon:
+ "\n" + "\n"
) )
def _resolve_flow(
self, flow: http.HTTPFlow,
) -> "tuple[Config, str, typing.Mapping[str, str]]":
"""The calling bottle's `(Config, supervise slug, env)`, resolved by
source IP in one round-trip against the orchestrator fail-closed to
deny-all + empty slug if unattributed. `env` is the process env overlaid
with the bottle's `/resolve` tokens, so upstream-auth injection (and DLP)
use *this* bottle's credentials. The identity token, if the agent
injected one, is read then stripped so it never leaks upstream."""
conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else ""
token = self._request_token(flow)
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
env = {**os.environ, **tokens} if tokens else os.environ
return config, slug, env
def _stash_flow_ctx(
self,
flow: http.HTTPFlow,
config: Config,
slug: str,
env: "typing.Mapping[str, str]",
) -> None:
"""Remember the per-flow context `request()` resolved, so the later
`response()` / `websocket_message()` hooks reuse it scanning against
the same bottle's policy the request was decided on, with one `/resolve`
per flow rather than one per frame."""
meta = getattr(flow, "metadata", None)
if isinstance(meta, dict):
meta[_FLOW_CTX_KEY] = (config, slug, env)
def _flow_ctx(
self, flow: http.HTTPFlow,
) -> "tuple[Config, str, typing.Mapping[str, str]]":
"""The `(Config, supervise slug, env)` `request()` resolved for this
flow, so a later hook scans against the calling bottle's policy. Falls
back to deny-all (empty routes, empty slug) for a flow that never passed
through `request()` (or a flow object without metadata) fail-closed, so
a DLP hook on such a flow is a safe no-op rather than an unscanned pass."""
meta = getattr(flow, "metadata", None)
if isinstance(meta, dict):
ctx = meta.get(_FLOW_CTX_KEY)
if ctx is not None:
return ctx
return Config(routes=()), "", os.environ
def _request_token(self, flow: http.HTTPFlow) -> str:
"""The per-bottle identity token for this request, from the proxy
credentials the delivery mechanism (`HTTPS_PROXY=http://id:token@gw`)
that clients honor without app changes. Plain-HTTP requests carry
`Proxy-Authorization` directly; HTTPS bumped requests inherit the token
captured from their tunnel's CONNECT. Read then stripped so it never
leaks upstream (also strips the legacy header, if present)."""
token = _token_from_proxy_auth(
flow.request.headers.get("Proxy-Authorization", ""))
flow.request.headers.pop("Proxy-Authorization", None)
flow.request.headers.pop(IDENTITY_HEADER, None)
conn = flow.client_conn
if not token and conn is not None:
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
return token
def http_connect(self, flow: http.HTTPFlow) -> None:
"""Capture the identity token from an HTTPS tunnel's CONNECT (the inner
bumped requests won't carry `Proxy-Authorization`), keyed by client
connection, and strip it so it never reaches upstream."""
token = _token_from_proxy_auth(
flow.request.headers.get("Proxy-Authorization", ""))
flow.request.headers.pop("Proxy-Authorization", None)
conn = flow.client_conn
if conn is not None and getattr(conn, "id", ""):
self._conn_tokens[conn.id] = token
def client_disconnected(self, client: typing.Any) -> None:
"""Drop the per-connection token when the client goes away."""
self._conn_tokens.pop(getattr(client, "id", ""), None)
async def request(self, flow: http.HTTPFlow) -> None: async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
config, slug, env = self._resolve_flow(flow)
# Stash for the response / websocket hooks so their DLP scans reuse this
# bottle's resolved policy (one /resolve per flow — see _flow_ctx).
self._stash_flow_ctx(flow, config, slug, env)
# Introspection ("_egress.local/allowlist") reports the calling bottle's
# own resolved routes — served after resolution so it reflects this
# bottle's policy, not a stale global.
if flow.request.pretty_host == INTROSPECT_HOST: if flow.request.pretty_host == INTROSPECT_HOST:
self._serve_introspection(flow, request_path, config) self._serve_introspection(flow, request_path)
return return
# DLP outbound scan BEFORE stripping auth — catches tokens the # DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body. # agent tried to smuggle in any header, path, query param, or body.
# Hostname is included to catch DNS-tunnelling exfiltration attempts. # Hostname is included to catch DNS-tunnelling exfiltration attempts.
route = match_route(config.routes, flow.request.pretty_host) route = match_route(self.config.routes, flow.request.pretty_host)
if route is not None: if route is not None:
if not await self._handle_outbound_dlp(flow, route, slug, env): if not await self._handle_outbound_dlp(flow, route):
return return
# The redact policy may have rewritten the request line; recompute # The redact policy may have rewritten the request line; recompute
# the path/query the git checks below rely on. # the path/query the git checks below rely on.
@@ -355,7 +224,7 @@ class EgressAddon:
if is_git_fetch_request(request_path, query): if is_git_fetch_request(request_path, query):
git_decision = decide_git_fetch( git_decision = decide_git_fetch(
config.routes, flow.request.pretty_host, self.config.routes, flow.request.pretty_host,
) )
if git_decision.action == "block": if git_decision.action == "block":
self._block( self._block(
@@ -366,20 +235,19 @@ class EgressAddon:
return return
# Strip agent-set Authorization after DLP scan so smuggled tokens # Strip agent-set Authorization after DLP scan so smuggled tokens
# are caught above; the route may inject gateway-owned auth below. # are caught above; the route may inject sidecar-owned auth below.
flow.request.headers.pop("authorization", None) flow.request.headers.pop("authorization", None)
# Build headers mapping for match evaluation # Build headers mapping for match evaluation
req_headers = {k.lower(): v for k, v in flow.request.headers.items()} req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
decision = decide( decision = decide(
config.routes, self.config.routes,
flow.request.pretty_host, flow.request.pretty_host,
request_path, request_path,
env, os.environ,
request_method=flow.request.method, request_method=flow.request.method,
request_headers=req_headers, request_headers=req_headers,
deny_reason=config.deny_reason,
) )
if decision.action == "block": if decision.action == "block":
@@ -389,8 +257,8 @@ class EgressAddon:
if decision.inject_authorization is not None: if decision.inject_authorization is not None:
flow.request.headers["authorization"] = decision.inject_authorization flow.request.headers["authorization"] = decision.inject_authorization
if config.log >= LOG_FULL: if self.config.log >= LOG_FULL:
self._log_request(flow, env) self._log_request(flow)
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None: def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
ctx = self._req_ctx(flow) ctx = self._req_ctx(flow)
@@ -402,13 +270,10 @@ class EgressAddon:
self, self,
flow: http.HTTPFlow, flow: http.HTTPFlow,
route: Route, route: Route,
slug: str,
env: "typing.Mapping[str, str]",
) -> bool: ) -> bool:
"""Scan the outbound request and apply the route's on-match policy """Scan the outbound request and apply the route's on-match policy
(PRD 0062). `env` is the per-bottle env overlay (process env + this (PRD 0062). Returns True if the request may be forwarded, False if a
bottle's tokens) used for DLP detection. Returns True if the request may 403 response has been written to `flow`.
be forwarded, False if a 403 response has been written to `flow`.
Loops so the supervise policy can re-scan after each approval a Loops so the supervise policy can re-scan after each approval a
second, un-approved token in the same request is still caught.""" second, un-approved token in the same request is still caught."""
@@ -425,8 +290,8 @@ class EgressAddon:
flow.request.pretty_host, request_path, query, headers, "", flow.request.pretty_host, request_path, query, headers, "",
) )
result = scan_outbound( result = scan_outbound(
route, scan_text, env, route, scan_text, os.environ,
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text, safe_tokens=self.safe_tokens, crlf_text=crlf_text,
) )
if result is None or result.severity != "block": if result is None or result.severity != "block":
return True return True
@@ -436,8 +301,8 @@ class EgressAddon:
# redact scrubs every detection (tokens and structural CRLF) and # redact scrubs every detection (tokens and structural CRLF) and
# forwards; it fails closed only if a match survives the scrub. # forwards; it fails closed only if a match survives the scrub.
if policy == ON_MATCH_REDACT: if policy == ON_MATCH_REDACT:
if self._redact_outbound(flow, route, env): if self._redact_outbound(flow, route):
if self._flow_log(flow) >= LOG_BLOCKS: if self.config.log >= LOG_BLOCKS:
sys.stderr.write(json.dumps({ sys.stderr.write(json.dumps({
"event": "egress_redacted", "event": "egress_redacted",
"reason": f"egress DLP: {result.reason}", "reason": f"egress DLP: {result.reason}",
@@ -461,34 +326,32 @@ class EgressAddon:
# supervise (default): hold the request for operator approval. # supervise (default): hold the request for operator approval.
# Fall back to a hard 403 when supervise isn't wired for the bottle. # Fall back to a hard 403 when supervise isn't wired for the bottle.
if not self._supervise_available(slug): if not self._supervise_available():
self._block_dlp(flow, result) self._block_dlp(flow, result)
return False return False
approved = await self._supervise_token_block(flow, request_path, result, slug, env) approved = await self._supervise_token_block(flow, request_path, result)
if not approved: if not approved:
return False # _supervise_token_block wrote the 403 response return False # _supervise_token_block wrote the 403 response
# loop: the approved value is now in safe_tokens; re-scan. # loop: the approved value is now in safe_tokens; re-scan.
def _redact_outbound( def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
) -> bool:
"""Scrub detected tokens (and CRLF injection sequences) from the mutable """Scrub detected tokens (and CRLF injection sequences) from the mutable
request surfaces (body, headers, path/query) and re-scan. `env` is the request surfaces (body, headers, path/query) and re-scan. Returns True
per-bottle env overlay. Returns True if the request is now clean; False if the request is now clean; False if a block-severity match remains on
if a block-severity match remains on a surface redaction cannot rewrite a surface redaction cannot rewrite (the hostname) so the caller fails
(the hostname) so the caller fails closed.""" closed."""
body = flow.request.get_text(strict=False) body = flow.request.get_text(strict=False)
if body: if body:
redacted_body = redact_tokens(body, env=env) redacted_body = redact_tokens(body, env=os.environ)
if redacted_body != body: if redacted_body != body:
flow.request.text = redacted_body flow.request.text = redacted_body
for name, value in list(flow.request.headers.items()): for name, value in list(flow.request.headers.items()):
if name.lower() == "host": if name.lower() == "host":
continue # routing-critical; never a legitimate token continue # routing-critical; never a legitimate token
redacted = strip_crlf(redact_tokens(value, env=env)) redacted = strip_crlf(redact_tokens(value, env=os.environ))
if redacted != value: if redacted != value:
flow.request.headers[name] = redacted flow.request.headers[name] = redacted
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env)) redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
if redacted_path != flow.request.path: if redacted_path != flow.request.path:
flow.request.path = redacted_path flow.request.path = redacted_path
@@ -501,7 +364,7 @@ class EgressAddon:
crlf_text = build_outbound_scan_text( crlf_text = build_outbound_scan_text(
flow.request.pretty_host, request_path, query, headers, "", flow.request.pretty_host, request_path, query, headers, "",
) )
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text) result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
return result is None or result.severity != "block" return result is None or result.severity != "block"
async def _supervise_token_block( async def _supervise_token_block(
@@ -509,27 +372,21 @@ class EgressAddon:
flow: http.HTTPFlow, flow: http.HTTPFlow,
request_path: str, request_path: str,
result: ScanResult, result: ScanResult,
slug: str,
env: "typing.Mapping[str, str]",
) -> bool: ) -> bool:
"""Route a token DLP block to the operator's supervisor queue and wait. """Route a token DLP block to the operator's supervisor queue and wait.
`slug` attributes the proposal to the calling bottle (its own queue + Returns True if the operator approved (the matched value is added to
safelist) in the shared gateway this is what keeps one bottle's `self.safe_tokens` and the caller re-scans); False if the request must
approval from unblocking another's request. `env` is the per-bottle env be blocked (a 403 response has been written to `flow`)."""
overlay used to redact secrets from the proposal text. Returns True if
the operator approved (the matched value is added to that bottle's
safelist and the caller re-scans); False if the request must be blocked
(a 403 response has been written to `flow`)."""
host = flow.request.pretty_host host = flow.request.pretty_host
payload = build_token_allow_payload( payload = build_token_allow_payload(
redact_tokens(host, env=env), redact_tokens(host, env=os.environ),
flow.request.method, flow.request.method,
redact_tokens(request_path, env=env), redact_tokens(request_path, env=os.environ),
result, result,
) )
proposal = _sv.Proposal.new( proposal = _sv.Proposal.new(
bottle_slug=slug, bottle_slug=self._supervise_slug,
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW, tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload, proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION, justification=_TOKEN_ALLOW_JUSTIFICATION,
@@ -552,14 +409,14 @@ class EgressAddon:
**self._req_ctx(flow), **self._req_ctx(flow),
}) + "\n") }) + "\n")
response = await self._await_token_response(proposal.id, slug) response = await self._await_token_response(proposal.id)
_sv.archive_proposal(slug, proposal.id) _sv.archive_proposal(self._supervise_slug, proposal.id)
if response is not None and response.status in ( if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED, _sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
): ):
self._safe_tokens_for(slug).add(result.matched) self.safe_tokens.add(result.matched)
if self._flow_log(flow) >= LOG_BLOCKS: if self.config.log >= LOG_BLOCKS:
sys.stderr.write(json.dumps({ sys.stderr.write(json.dumps({
"event": "egress_token_allowed", "event": "egress_token_allowed",
"reason": f"egress DLP: {result.reason}", "reason": f"egress DLP: {result.reason}",
@@ -581,7 +438,6 @@ class EgressAddon:
async def _await_token_response( async def _await_token_response(
self, self,
proposal_id: str, proposal_id: str,
slug: str,
) -> "_sv.Response | None": ) -> "_sv.Response | None":
"""Poll the DB for the operator's response without blocking the """Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout.""" proxy event loop. Returns the Response, or None on timeout."""
@@ -589,7 +445,7 @@ class EgressAddon:
deadline = loop.time() + self._token_allow_timeout deadline = loop.time() + self._token_allow_timeout
while True: while True:
try: try:
return _sv.read_response(slug, proposal_id) return _sv.read_response(self._supervise_slug, proposal_id)
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until # Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed. # the deadline, then fail closed.
@@ -599,16 +455,14 @@ class EgressAddon:
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS) await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
def response(self, flow: http.HTTPFlow) -> None: def response(self, flow: http.HTTPFlow) -> None:
"""DLP inbound scan on response headers and body, against the calling """DLP inbound scan on response headers and body."""
bottle's resolved config (`request()` stashed it — see `_flow_ctx`).""" route = match_route(self.config.routes, flow.request.pretty_host)
config, _slug, env = self._flow_ctx(flow)
route = match_route(config.routes, flow.request.pretty_host)
if route is None: if route is None:
return return
if flow.response is None: if flow.response is None:
return return
if config.log >= LOG_FULL: if self.config.log >= LOG_FULL:
self._log_response(flow, env) self._log_response(flow)
resp_headers = {k.lower(): v for k, v in flow.response.headers.items()} resp_headers = {k.lower(): v for k, v in flow.response.headers.items()}
body = flow.response.get_text(strict=False) or "" body = flow.response.get_text(strict=False) or ""
scan_text = build_inbound_scan_text(resp_headers, body) scan_text = build_inbound_scan_text(resp_headers, body)
@@ -625,7 +479,7 @@ class EgressAddon:
resp_ctx = {**resp_ctx, "context": result.context} resp_ctx = {**resp_ctx, "context": result.context}
if result.severity == "block": if result.severity == "block":
self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx) self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx)
elif result.severity == "warn" and config.log >= LOG_BLOCKS: elif result.severity == "warn" and self.config.log >= LOG_BLOCKS:
sys.stderr.write( sys.stderr.write(
json.dumps({ json.dumps({
"event": "egress_warn", "event": "egress_warn",
@@ -636,9 +490,7 @@ class EgressAddon:
) )
def websocket_message(self, flow: http.HTTPFlow) -> None: def websocket_message(self, flow: http.HTTPFlow) -> None:
"""DLP scan on WebSocket frames, against the calling bottle's resolved """DLP scan on WebSocket frames.
config (see `_flow_ctx`). `request()` resolves and stashes the per-flow
(config, slug, env) at the upgrade, and every frame reuses it.
Outbound frames (from_client) are scanned for credential leakage; Outbound frames (from_client) are scanned for credential leakage;
inbound frames are scanned for prompt injection. On a block the inbound frames are scanned for prompt injection. On a block the
@@ -647,8 +499,7 @@ class EgressAddon:
""" """
if flow.websocket is None: # type: ignore[union-attr] if flow.websocket is None: # type: ignore[union-attr]
return return
config, slug, env = self._flow_ctx(flow) route = match_route(self.config.routes, flow.request.pretty_host)
route = match_route(config.routes, flow.request.pretty_host)
if route is None: if route is None:
return return
message = flow.websocket.messages[-1] # type: ignore[union-attr] message = flow.websocket.messages[-1] # type: ignore[union-attr]
@@ -657,8 +508,8 @@ class EgressAddon:
# A WebSocket data frame is not an HTTP request line, so CRLF is # A WebSocket data frame is not an HTTP request line, so CRLF is
# not an injection vector here — scan only for credential leakage. # not an injection vector here — scan only for credential leakage.
result = scan_outbound( result = scan_outbound(
route, content, env, route, content, os.environ,
safe_tokens=self._safe_tokens_for(slug), crlf_text="", safe_tokens=self.safe_tokens, crlf_text="",
) )
if result is not None and result.severity == "block": if result is not None and result.severity == "block":
sys.stderr.write(f"egress DLP: {result.reason}\n") sys.stderr.write(f"egress DLP: {result.reason}\n")
+37 -128
View File
@@ -3,12 +3,12 @@
Split out of `egress_addon.py` so the host's unit tests can Split out of `egress_addon.py` so the host's unit tests can
exercise the parse + decision functions without depending on the exercise the parse + decision functions without depending on the
`mitmproxy` package. The companion module wraps these with the `mitmproxy` package. The companion module wraps these with the
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway `mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar
container. container.
Imports: stdlib + sibling package modules (`yaml_subset`, Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
`egress_dlp_config`). Available in the gateway via the installed ships flat into the sidecar bundle image alongside this file
`bot_bottle` package (see `Dockerfile.gateway`).""" see `Dockerfile.sidecars`)."""
from __future__ import annotations from __future__ import annotations
@@ -16,20 +16,36 @@ import re
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from .yaml_subset import YamlSubsetError, parse_yaml_subset try:
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from .yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module. Re-exported below # DLP detector-config parsing lives in a sibling module (also flat-bundled
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working. # into the sidecar — see Dockerfile.sidecars). Re-exported below so existing
from .egress_dlp_config import ( # `from egress_addon_core import ON_MATCH_*` callers keep working.
DEFAULT_OUTBOUND_ON_MATCH, try:
INBOUND_DETECTOR_NAMES, from egress_dlp_config import ( # type: ignore[import-not-found]
ON_MATCH_BLOCK, DEFAULT_OUTBOUND_ON_MATCH,
ON_MATCH_REDACT, INBOUND_DETECTOR_NAMES,
ON_MATCH_SUPERVISE, ON_MATCH_BLOCK,
OUTBOUND_DETECTOR_NAMES, ON_MATCH_REDACT,
OUTBOUND_ON_MATCH_VALUES, ON_MATCH_SUPERVISE,
parse_dlp_block, OUTBOUND_DETECTOR_NAMES,
) OUTBOUND_ON_MATCH_VALUES,
parse_dlp_block,
)
except ImportError: # pragma: no cover - host-side path
from .egress_dlp_config import (
DEFAULT_OUTBOUND_ON_MATCH,
INBOUND_DETECTOR_NAMES,
ON_MATCH_BLOCK,
ON_MATCH_REDACT,
ON_MATCH_SUPERVISE,
OUTBOUND_DETECTOR_NAMES,
OUTBOUND_ON_MATCH_VALUES,
parse_dlp_block,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -89,14 +105,6 @@ LOG_FULL = 2 # log block/warn events + full request and response bodies
class Config: class Config:
routes: tuple[Route, ...] routes: tuple[Route, ...]
log: int = LOG_OFF log: int = LOG_OFF
# Why this Config is a deny-all, when it is one for a reason *other* than
# the bottle's own policy genuinely not listing the host. A deny-all is
# indistinguishable from "policy loaded, host not allowed" at the decision
# point — both are simply "no matching route" — so without this the
# operator sees `host X is not in the allowlist` and goes hunting for a
# missing route that was never the problem. Empty for a normally-parsed
# policy; `decide` prefers it over the allowlist wording when set.
deny_reason: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -112,7 +120,7 @@ class ScanResult:
reason: str reason: str
location: str = "" # where the match was found, e.g. "body", "authorization header" location: str = "" # where the match was found, e.g. "body", "authorization header"
context: str = "" # surrounding text with the match replaced by REDACT context: str = "" # surrounding text with the match replaced by REDACT
# Raw substring the detector matched. Used inside the gateway to key the # Raw substring the detector matched. Used inside the sidecar to key the
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written # supervisor-approved "safe tokens" set (PRD 0062); never logged or written
# to a proposal file. Empty for structural detectors (CRLF) that carry no # to a proposal file. Empty for structural detectors (CRLF) that carry no
# safelist-able value. # safelist-able value.
@@ -405,94 +413,6 @@ def load_config(text: str) -> "Config":
return parse_config(payload) return parse_config(payload)
class PolicyResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` this module needs — kept a
Protocol so egress_addon_core stays free of that import."""
def resolve(self, source_ip: str, identity_token: str = ...) -> "str | None":
...
# Deny-all explanations. Each names the *actual* failure so an operator isn't
# sent looking for a missing egress route when the bottle never had a policy
# to begin with — the failure mode that made a bricked registration read like
# a misconfigured allowlist.
DENY_UNATTRIBUTED = (
"egress: this request was not attributed to any bottle, so no egress "
"policy applies and every host is denied. Either the bottle's registry "
"row is missing/ambiguous (torn down, or another bottle claimed its "
"source IP), or the request carried no matching identity token — check "
"that the caller's proxy URL includes it. This is not an allowlist problem."
)
DENY_UNPARSEABLE = (
"egress: this bottle's egress policy could not be parsed, so it is being "
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
"until it loads."
)
DENY_RESOLVER_ERROR = (
"egress: the orchestrator could not be reached to resolve this bottle's "
"egress policy, so every host is denied (fail-closed). Check that the "
"control plane is up; this is not an allowlist problem."
)
def _config_from_policy(policy: "str | None") -> "Config":
"""Parse a resolved policy blob into a Config, fail-closed: None / empty /
unparseable all become a deny-all Config (no routes every request
blocked). Each deny-all carries the reason it is one, so the block message
names the real fault instead of blaming the allowlist."""
if not policy:
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
try:
return load_config(policy)
except ValueError:
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
def resolve_client_config(
resolver: PolicyResolverLike, client_ip: str, identity_token: str = ""
) -> "Config":
"""The calling client's egress Config, resolved from the orchestrator via
`resolver` and parsed **fail-closed**. An unattributed client (None), a
resolver error, or an unparseable policy all yield a deny-all Config (no
routes every request blocked). A compromised, absent, or confused
orchestrator must never *widen* a bottle's egress."""
try:
policy = resolver.resolve(client_ip, identity_token)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
return _config_from_policy(policy)
class ContextResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
needs one round-trip returning policy, bottle id, and auth tokens."""
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> "tuple[str | None, str | None, dict[str, str]]":
...
def resolve_client_context(
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
) -> "tuple[Config, str, dict[str, str]]":
"""The calling client's `(Config, bottle_id, tokens)` in one round-trip —
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
rules; the bottle id is `""` whenever unattributed or the orchestrator
errored (caller treats as "supervise unavailable", never another bottle's
queue); `tokens` are the per-bottle upstream auth values the addon injects.
One `/resolve` keys the egress policy, the supervise queue + safelist, and
auth injection."""
try:
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
client_ip, identity_token,
)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
return _config_from_policy(policy), (bottle_id or ""), tokens
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Match evaluation # Match evaluation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -604,16 +524,12 @@ def decide(
*, *,
request_method: str = "GET", request_method: str = "GET",
request_headers: typing.Mapping[str, str] | None = None, request_headers: typing.Mapping[str, str] | None = None,
deny_reason: str = "",
) -> Decision: ) -> Decision:
"""`deny_reason` is `Config.deny_reason`: when the deny-all came from a
missing/unparseable policy rather than the bottle's own allowlist, report
that instead of implying a route is merely absent."""
route = match_route(routes, request_host) route = match_route(routes, request_host)
if route is None: if route is None:
return Decision( return Decision(
action="block", action="block",
reason=deny_reason or ( reason=(
f"egress: host {request_host!r} is not in the " f"egress: host {request_host!r} is not in the "
f"bottle's egress.routes allowlist. Declare a " f"bottle's egress.routes allowlist. Declare a "
f"route for it or remove the request." f"route for it or remove the request."
@@ -697,7 +613,7 @@ def outbound_scan_headers(
) -> dict[str, str]: ) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP. """Return request headers that should be included in outbound DLP.
Routes that inject gateway-owned auth always strip the agent's Routes that inject sidecar-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what their own bearer-shaped placeholder, while still not changing what
@@ -748,7 +664,7 @@ def scan_outbound(
crlf_text: str | None = None, crlf_text: str | None = None,
) -> ScanResult | None: ) -> ScanResult | None:
# Lazy import to avoid circular deps and keep dlp_detectors optional # Lazy import to avoid circular deps and keep dlp_detectors optional
# at import time (the gateway copies it flat alongside this file). # at import time (the sidecar copies it flat alongside this file).
try: try:
from dlp_detectors import ( # type: ignore[import-not-found] from dlp_detectors import ( # type: ignore[import-not-found]
scan_crlf_injection, scan_crlf_injection,
@@ -888,13 +804,6 @@ __all__ = [
"is_git_push_request", "is_git_push_request",
"is_git_fetch_request", "is_git_fetch_request",
"load_config", "load_config",
"DENY_UNATTRIBUTED",
"DENY_UNPARSEABLE",
"DENY_RESOLVER_ERROR",
"resolve_client_config",
"resolve_client_context",
"PolicyResolverLike",
"ContextResolverLike",
"match_route", "match_route",
"outbound_scan_headers", "outbound_scan_headers",
"parse_config", "parse_config",
+2 -2
View File
@@ -6,8 +6,8 @@ and what the proxy does when an outbound detector matches a token
kept apart from the request-time scan/decision flow in `egress_addon_core` kept apart from the request-time scan/decision flow in `egress_addon_core`
so each half reads top-to-bottom without scrolling past the other. so each half reads top-to-bottom without scrolling past the other.
Stdlib-only; ships flat into the gateway image alongside Stdlib-only; ships flat into the sidecar bundle image alongside
`egress_addon_core.py` see `Dockerfile.gateway`.""" `egress_addon_core.py` see `Dockerfile.sidecars`."""
from __future__ import annotations from __future__ import annotations
+5 -17
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# Egress daemon entrypoint inside the gateway (PRD 0024). # Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
# #
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c` # Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
# ENTRYPOINT so the supervisor in bot_bottle/gateway_init.py can # ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
# call it as a normal child. Behavior is unchanged: # call it as a normal child. Behavior is unchanged:
# #
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
@@ -15,26 +15,14 @@
# mitmproxy at it. The option REPLACES mitmproxy's default # mitmproxy at it. The option REPLACES mitmproxy's default
# trust store, so passing the upstream CA alone would break # trust store, so passing the upstream CA alone would break
# non-chained hosts. # non-chained hosts.
# * `-s /app/egress_addon.py` loads the addon that resolves each # * `-s /app/egress_addon.py` loads the addon that reads
# request's policy from the orchestrator control plane by source # /etc/egress/routes.yaml.
# IP (PRD 0070). There is no static routes file.
set -e set -e
# Fail closed on a missing policy source. The addon itself raises at
# load when BOT_BOTTLE_ORCHESTRATOR_URL is unset (so mitmdump exits via
# its errorcheck addon), but that leaves the fail-closed guarantee at the
# mercy of a mitmproxy version keeping that behavior. Refuse here too, so
# a misconfigured gateway can never come up as a bare TLS-bumping open
# proxy with no policy — independent of mitmproxy's startup-error handling.
if [ -z "$BOT_BOTTLE_ORCHESTRATOR_URL" ]; then
echo "egress: BOT_BOTTLE_ORCHESTRATOR_URL is required (no static routes fallback)" >&2
exit 1
fi
# Pin mitmproxy's config dir to the bind-mount location of its CA # Pin mitmproxy's config dir to the bind-mount location of its CA
# regardless of which user mitmdump runs as. In the legacy # regardless of which user mitmdump runs as. In the legacy
# four-daemon setup (Dockerfile.egress, USER mitmproxy) this # four-sidecar setup (Dockerfile.egress, USER mitmproxy) this
# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024 # resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024
# bundle (USER root) `~root/.mitmproxy` is empty, so without this # bundle (USER root) `~root/.mitmproxy` is empty, so without this
# flag mitmdump would generate a fresh CA on the wrong path and # flag mitmdump would generate a fresh CA on the wrong path and
+6 -10
View File
@@ -1,6 +1,6 @@
"""Per-agent git-gate (PRD 0008). """Per-agent git-gate (PRD 0008).
A third per-agent daemon that fronts the bottle's declared git A third per-agent sidecar that fronts the bottle's declared git
upstreams as a transparent mirror. Each `bottle.git` entry maps to upstreams as a transparent mirror. Each `bottle.git` entry maps to
a bare repo on the gate; `git daemon` serves the bare repos over a bare repo on the gate; `git daemon` serves the bare repos over
`git://<gate>/<name>.git`. Two hooks make the mirror bidirectional: `git://<gate>/<name>.git`. Two hooks make the mirror bidirectional:
@@ -15,7 +15,7 @@ a bare repo on the gate; `git daemon` serves the bare repos over
The agent never sees the upstream credential under either path. The agent never sees the upstream credential under either path.
Why a separate daemon (not folded into egress or ssh-gate): the Why a separate sidecar (not folded into egress or ssh-gate): the
gate is the only one of the three that holds upstream push gate is the only one of the three that holds upstream push
credentials. Mixing it with egress would put push creds in the credentials. Mixing it with egress would put push creds in the
same blast radius as internet-facing TLS interception; mixing it same blast radius as internet-facing TLS interception; mixing it
@@ -23,7 +23,7 @@ with ssh-gate would force ssh-gate above L4 and into git-protocol
land. See `docs/prds/0008-git-gate.md`. land. See `docs/prds/0008-git-gate.md`.
This module defines the abstract gate (`GitGate`) and its plan This module defines the abstract gate (`GitGate`) and its plan
dataclass (`GitGatePlan`). The gateway's start/stop lifecycle is dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
backend-specific and lives on concrete subclasses (see backend-specific and lives on concrete subclasses (see
`bot_bottle/backend/docker/git_gate.py`).""" `bot_bottle/backend/docker/git_gate.py`)."""
@@ -46,7 +46,6 @@ from .git_gate_render import (
git_gate_known_hosts_line, git_gate_known_hosts_line,
git_gate_render_access_hook, git_gate_render_access_hook,
git_gate_render_entrypoint, git_gate_render_entrypoint,
git_gate_render_provision,
git_gate_render_gitconfig, git_gate_render_gitconfig,
git_gate_render_hook, git_gate_render_hook,
git_gate_upstreams_for_bottle, git_gate_upstreams_for_bottle,
@@ -86,7 +85,7 @@ class GitGatePlan:
class GitGate(ABC): class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare """The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the gateway's (upstream lift + entrypoint/hook render); the sidecar's
start/stop lifecycle is backend-specific and lives on concrete start/stop lifecycle is backend-specific and lives on concrete
subclasses.""" subclasses."""
@@ -112,10 +111,8 @@ class GitGate(ABC):
access_hook = stage_dir / "git_gate_access_hook.sh" access_hook = stage_dir / "git_gate_access_hook.sh"
access_hook.write_text(git_gate_render_access_hook()) access_hook.write_text(git_gate_render_access_hook())
# 0o700 (not 0o600): git daemon execs --access-hook directly, # 0o700 (not 0o600): git daemon execs --access-hook directly,
# not via `sh`, so the script needs the x bit. The gateway copy # not via `sh`, so the script needs the x bit. docker cp
# does not necessarily preserve this mode (`docker cp` does, the # preserves source mode into the container.
# Apple `container cp` does not), so provision_git_gate re-applies
# +x on the gateway side — see backend/docker/gateway_provision.py.
access_hook.chmod(0o700) access_hook.chmod(0o700)
upstreams_with_files: list[GitGateUpstream] = [] upstreams_with_files: list[GitGateUpstream] = []
for u in upstreams: for u in upstreams:
@@ -158,7 +155,6 @@ __all__ = [
"git_gate_render_gitconfig", "git_gate_render_gitconfig",
"git_gate_known_hosts_line", "git_gate_known_hosts_line",
"git_gate_render_entrypoint", "git_gate_render_entrypoint",
"git_gate_render_provision",
"git_gate_render_hook", "git_gate_render_hook",
"git_gate_render_access_hook", "git_gate_render_access_hook",
"provision_git_gate_dynamic_keys", "provision_git_gate_dynamic_keys",
+1 -2
View File
@@ -13,7 +13,6 @@ import dataclasses
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .bottle_state import globalize_slug
from .errors import MissingEnvVarError from .errors import MissingEnvVarError
from .log import info from .log import info
from .manifest import ManifestBottle, ManifestGitEntry from .manifest import ManifestBottle, ManifestGitEntry
@@ -47,7 +46,7 @@ def _provision_dynamic_key(
owner_repo = entry.UpstreamPath owner_repo = entry.UpstreamPath
if owner_repo.endswith(".git"): if owner_repo.endswith(".git"):
owner_repo = owner_repo[:-4] owner_repo = owner_repo[:-4]
title = f"bot-bottle:{globalize_slug(slug)}:{entry.Name}" title = f"bot-bottle:{slug}:{entry.Name}"
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]") info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
key_id, private_key_bytes = provisioner.create(owner_repo, title) key_id, private_key_bytes = provisioner.create(owner_repo, title)
+40 -89
View File
@@ -1,7 +1,7 @@
"""Pure host-side rendering for the per-agent git-gate (PRD 0008). """Pure host-side rendering for the per-agent git-gate (PRD 0008).
Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts
line, and the entrypoint / pre-receive / access-hook scripts the gateway line, and the entrypoint / pre-receive / access-hook scripts the sidecar
runs. No docker or forge calls exposed for tests and reuse across runs. No docker or forge calls exposed for tests and reuse across
backends. Split out of `git_gate.py` so the control surface (`GitGate`) backends. Split out of `git_gate.py` so the control surface (`GitGate`)
and the deploy-key lifecycle (`git_gate_provision`) each read on their and the deploy-key lifecycle (`git_gate_provision`) each read on their
@@ -9,17 +9,19 @@ own; `git_gate` re-exports these names for API stability."""
from __future__ import annotations from __future__ import annotations
import re
import shlex import shlex
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from .manifest import ManifestBottle, ManifestGitEntry from .manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the gateway. The # Short network alias for git-gate inside the sidecar bundle. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name. # agent's `.gitconfig` insteadOf rewrites resolve through this name.
GIT_GATE_HOSTNAME = "git-gate" GIT_GATE_HOSTNAME = "git-gate"
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
# git_http_backend, and the git http-backend CGI subprocess.
GIT_GATE_TIMEOUT_SECS = 15
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -35,7 +37,7 @@ class GitGateUpstream:
KnownHostKey string from the manifest; the gate's start step KnownHostKey string from the manifest; the gate's start step
materialises it into a known_hosts file if non-empty. materialises it into a known_hosts file if non-empty.
the gate credential paths inside the running gateway.""" the gate credential paths inside the running sidecar."""
name: str name: str
upstream_url: str upstream_url: str
@@ -72,7 +74,6 @@ def _gitconfig_validate_value(field: str, value: str) -> None:
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
identity_token: str = "",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / VM; `insteadOf` rewrites. Pure host-side, no docker / VM;
@@ -94,15 +95,6 @@ def git_gate_render_gitconfig(
"# the upstream bidirectionally (gitleaks-scanned push;\n", "# the upstream bidirectionally (gitleaks-scanned push;\n",
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
] ]
# Over the smart-HTTP transport (VM backends), attach the per-bottle
# identity token as a request header on requests to the gate, scoped to
# its URL so it never goes to any other remote. git-http requires it (the
# gateway's mandatory (source_ip, token) attribution). git:// (single-tenant
# docker) carries no header — attribution there is the network alias.
if identity_token and scheme == "http":
_gitconfig_validate_value("identity_token", identity_token)
out.append(f'[http "http://{gate_host}/"]\n')
out.append(f"\textraHeader = {IDENTITY_HEADER}: {identity_token}\n")
for entry in entries: for entry in entries:
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream) _gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n') out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
@@ -133,18 +125,22 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
return f"{target} {key}\n" return f"{target} {key}\n"
def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]: def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
"""The `init_repo` shell function, parameterized by the bare-repo root """Posix-sh entrypoint. One `init_repo` call per upstream, then
and the per-bottle creds dir. Single source of the credential-wiring `exec git daemon`. The function reads
logic, shared by the single-tenant daemon entrypoint (`/git`, `/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
`/git-gate/creds`) and the consolidated per-bottle provisioning the bundle by the renderer) and wires them into each bare repo's
(`/git/<bottle_id>`, `/git-gate/creds/<bottle_id>`).""" config; the access-hook + pre-receive hook pick those paths up
return [ at fetch / push time."""
lines = [
"#!/bin/sh",
"set -eu",
"",
"init_repo() {", "init_repo() {",
" name=$1", " name=$1",
" upstream_url=$2", " upstream_url=$2",
f" keyfile={creds_dir}/${{name}}-key", " keyfile=/git-gate/creds/${name}-key",
f" hostsfile={creds_dir}/${{name}}-known_hosts", " hostsfile=/git-gate/creds/${name}-known_hosts",
"", "",
# `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the # `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the
# host, so chmod-syscalls fail with EROFS. The files already # host, so chmod-syscalls fail with EROFS. The files already
@@ -157,14 +153,14 @@ def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
" chmod 600 \"$hostsfile\" 2>/dev/null || true", " chmod 600 \"$hostsfile\" 2>/dev/null || true",
" fi", " fi",
"", "",
f" repo={repo_root}/${{name}}.git", " repo=/git/${name}.git",
" if [ ! -d \"$repo\" ]; then", " if [ ! -d \"$repo\" ]; then",
" git init --bare \"$repo\" >/dev/null", " git init --bare \"$repo\" >/dev/null",
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later # --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
# `git fetch origin` mirrors the upstream's full ref graph (heads, # a later `git fetch origin` mirrors the upstream's full ref",
# tags, notes) into the bare repo at canonical paths. It does NOT set # graph (heads, tags, notes) into the bare repo at canonical",
# remote.origin.mirror=true, so an explicit `git push origin # paths. It does NOT set remote.origin.mirror=true, so an",
# <ref>:<ref>` still pushes one ref. # explicit `git push origin <ref>:<ref>` still pushes one ref.",
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"", " git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
" fi", " fi",
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
@@ -174,19 +170,9 @@ def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
" git -C \"$repo\" config http.receivepack true", " git -C \"$repo\" config http.receivepack true",
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
"}", "}",
"",
"mkdir -p /git",
] ]
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
`exec git daemon`. The function reads
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
the bundle by the renderer) and wires them into each bare repo's
config; the access-hook + pre-receive hook pick those paths up
at fetch / push time."""
lines = ["#!/bin/sh", "set -eu", ""]
lines += _git_gate_init_repo_fn("/git", "/git-gate/creds")
lines += ["", "mkdir -p /git"]
for u in upstreams: for u in upstreams:
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
lines.extend([ lines.extend([
@@ -204,35 +190,6 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is
# embedded unquoted in the provisioning script, so restrict it to a shell- and
# path-safe alphabet (registry ids are token_hex — this is defense in depth).
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
def git_gate_render_provision(
bottle_id: str, upstreams: tuple[GitGateUpstream, ...],
) -> str:
"""Posix-sh script that provisions ONE bottle's bare repos into the
consolidated gateway (PRD 0070), under `/git/<bottle_id>/` with creds
read from `/git-gate/creds/<bottle_id>/`. Init-only no `git daemon`,
since the shared gateway already serves every bottle; run inside the
running gateway when the bottle is registered.
Isolating each bottle's repo root and creds dir by id is what keeps one
bottle's push credentials out of another's repos on the shared gateway."""
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}")
repo_root = f"/git/{bottle_id}"
creds_dir = f"/git-gate/creds/{bottle_id}"
lines = ["#!/bin/sh", "set -eu", ""]
lines += _git_gate_init_repo_fn(repo_root, creds_dir)
lines += ["", f"mkdir -p {shlex.quote(repo_root)}"]
for u in upstreams:
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
return "\n".join(lines) + "\n"
def git_gate_render_hook() -> str: def git_gate_render_hook() -> str:
"""The shared pre-receive hook: gitleaks-scan all incoming refs, """The shared pre-receive hook: gitleaks-scan all incoming refs,
then forward each accepted ref to the real upstream (`origin`) then forward each accepted ref to the real upstream (`origin`)
@@ -419,24 +376,18 @@ PY
while IFS=' ' read -r old new ref; do while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue [ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue [ "$new" = "$zero" ] && continue
# Scan only the commits this push introduces — those reachable from if [ "$old" = "$zero" ]; then
# $new but not from any ref the gate already has. Everything already # New ref: scan only the commits this push introduces — those
# on the gate arrived via upstream mirror-fetch or a previously # reachable from $new but not from any ref the gate already has.
# gitleaks-scanned push, so it's already-upstream or already-scanned; # Everything already on the gate arrived via upstream mirror-fetch
# re-scanning it only resurfaces historical fixture findings. # or a previously gitleaks-scanned push, so it's already-upstream
# # or already-scanned; re-scanning it (the old `$new` full-ancestry
# Applies to both new refs and updates. The old existing-branch range # range) only resurfaces historical findings and blocks every new
# `$old..$new` walks commits reachable from the new tip but not the # branch. See PRD 0028 / issue #106.
# *old branch tip*: on a rebase/force-push onto a freshly-advanced log_opts="$new --not --all"
# main that pulls in all of main's new history (incl. the deliberate else
# sandbox-escape gitleaks fixtures), blocking the push. `--not --all` log_opts="$old..$new"
# excludes anything already on the gate regardless of ancestry, so it fi
# is also correct for non-fast-forward pushes (a rebase can skip
# commits off the direct path). Security-equivalent per PRD 0028's
# analysis: the bare repo's refs come only from trusted upstream
# mirror-fetch or gitleaks-gated pushes.
# See PRD 0028 (open question) / issues #106, #346.
log_opts="$new --not --all"
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2 echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
echo "git-gate: gitleaks rejected push to $ref" >&2 echo "git-gate: gitleaks rejected push to $ref" >&2
+18 -111
View File
@@ -2,18 +2,10 @@
Used where `git://` push traffic over a host-published Docker port can Used where `git://` push traffic over a host-published Docker port can
hang before receive-pack reaches hooks (e.g. the firecracker backend, hang before receive-pack reaches hooks (e.g. the firecracker backend,
where the guest reaches the gateway over the point-to-point TAP). The where the guest reaches the sidecar over the point-to-point TAP). The
wrapper serves the same `/git/*.git` bare repos through wrapper serves the same `/git/*.git` bare repos through
`git http-backend`, so pre-receive and upstream forwarding remain the `git http-backend`, so pre-receive and upstream forwarding remain the
git-gate enforcement point. git-gate enforcement point.
One shared gateway serves every bottle (PRD 0070): each request is served
from the calling bottle's repo namespace (`<root>/<bottle_id>`), attributed
from the unspoofable source IP via the orchestrator. Per-repo credentials +
hooks scope by repo directory, so isolating the *root* per bottle isolates
its creds too. Unattributed clients and a missing/unreachable orchestrator
fail closed (404). `BOT_BOTTLE_ORCHESTRATOR_URL` is mandatory: there is no
single-tenant flat-root fallback.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,59 +13,19 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
import sys import sys
import typing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
DEFAULT_PORT = 9420 DEFAULT_PORT = 9420
# The per-host orchestrator control plane the backend attributes each request # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
# to, serving from the *calling* bottle's repo namespace selected by source IP. # imported: this module ships as a flat top-level sibling in the sidecar
# Mandatory — the same env the egress addon requires; there is no single flat # bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
# repo-root fallback. # package, so `bot_bottle.git_gate` and its dependency chain aren't
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" # available at runtime.
GIT_GATE_TIMEOUT_SECS = 15
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
DEFAULT_REPO_ROOT = "/git"
class ResolverLike(typing.Protocol):
"""Structural type for the resolver `resolve_sandbox_root` needs — just
`resolve_bottle_id`. A Protocol so tests can pass a fake without
importing PolicyResolver (mirrors egress_addon_core.PolicyResolverLike)."""
def resolve_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> "str | None": ...
def resolve_sandbox_root(
resolver: "ResolverLike",
base_root: Path,
source_ip: str,
identity_token: str = "",
) -> Path | None:
"""The per-sandbox repo root to serve this request from — `base_root/
<bottle_id>`, where the sandbox is attributed from the source IP via the
orchestrator or None to deny (404). Fail-closed: an unattributed client, a
resolver error, or a namespace that would escape `base_root` all deny, so one
sandbox can never reach another's repos."""
try:
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
except PolicyResolveError:
return None # orchestrator unreachable/errored → deny
if not bottle_id:
return None # unattributed → deny
base = base_root.resolve()
namespace = (base / bottle_id).resolve()
if base not in namespace.parents:
return None # bottle_id tried to escape the root → deny
return namespace
# Bound memory use while still allowing ordinary git push packfiles. # Bound memory use while still allowing ordinary git push packfiles.
MAX_BODY_BYTES = 100 * 1024 * 1024 MAX_BODY_BYTES = 100 * 1024 * 1024
@@ -88,26 +40,10 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
self._run_backend() self._run_backend()
def _sandbox_root(self) -> Path | None:
"""This request's per-sandbox repo root (the calling bottle's source-IP-
selected `<base>/<bottle_id>` namespace), or None to deny. `GIT_PROJECT_
ROOT` keeps git's own env-var name."""
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
resolver = getattr(self.server, "policy_resolver", None)
if resolver is None:
return None # server started without a resolver (misconfig) → deny
token = self.headers.get(IDENTITY_HEADER, "")
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
def _run_backend(self) -> None: def _run_backend(self) -> None:
sandbox_root = self._sandbox_root()
if sandbox_root is None:
# Unattributed / resolver error: deny before touching any repo.
self.send_error(404)
return
parsed = urlsplit(self.path) parsed = urlsplit(self.path)
if self._is_upload_pack(parsed.path, parsed.query): if self._is_upload_pack(parsed.path, parsed.query):
repo_dir = self._repo_dir(sandbox_root, parsed.path) repo_dir = self._repo_dir(parsed.path)
if repo_dir is None: if repo_dir is None:
self.send_error(404) self.send_error(404)
return return
@@ -115,24 +51,12 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook", "GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
) )
peer = self.client_address[0] peer = self.client_address[0]
try: hook = subprocess.run(
hook = subprocess.run( [hook_path, "upload-pack", str(repo_dir), peer, peer],
[hook_path, "upload-pack", str(repo_dir), peer, peer], capture_output=True,
capture_output=True, check=False,
check=False, timeout=GIT_GATE_TIMEOUT_SECS,
timeout=GIT_GATE_TIMEOUT_SECS, )
)
except (OSError, subprocess.SubprocessError) as exc:
# The access-hook couldn't be run (missing, not executable,
# timed out, …). Fail closed with a real HTTP error rather
# than letting the exception kill the handler thread — an
# unhandled exception closes the socket with no response, which
# the client sees as an opaque "empty reply from server".
self.log_message(
"access-hook could not run for %s: %s", parsed.path, exc,
)
self.send_error(503, "git-gate access-hook unavailable")
return
if hook.returncode != 0: if hook.returncode != 0:
detail = (hook.stderr or hook.stdout).decode( detail = (hook.stderr or hook.stdout).decode(
"utf-8", errors="replace", "utf-8", errors="replace",
@@ -153,7 +77,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
return return
env = os.environ.copy() env = os.environ.copy()
env.update({ env.update({
"GIT_PROJECT_ROOT": str(sandbox_root), "GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"),
"GIT_HTTP_EXPORT_ALL": "1", "GIT_HTTP_EXPORT_ALL": "1",
"REQUEST_METHOD": self.command, "REQUEST_METHOD": self.command,
"PATH_INFO": parsed.path, "PATH_INFO": parsed.path,
@@ -167,11 +91,6 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"SERVER_PORT": str(self.server.server_port), # type: ignore "SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_PROTOCOL": self.request_version, "SERVER_PROTOCOL": self.request_version,
}) })
# Attribute the gitleaks-allow supervise proposal (written by
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
# the calling bottle. The namespaced root is `<base>/<bottle_id>`, so its
# final component is the bottle id — the same per-bottle key egress uses.
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
for header, variable in ( for header, variable in (
("accept", "HTTP_ACCEPT"), ("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"), ("content-encoding", "HTTP_CONTENT_ENCODING"),
@@ -204,8 +123,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
) )
self._write_cgi_response(proc.stdout) self._write_cgi_response(proc.stdout)
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None: def _repo_dir(self, path: str) -> Path | None:
root = sandbox_root.resolve() root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve()
relative = path.lstrip("/").split(".git", 1)[0] + ".git" relative = path.lstrip("/").split(".git", 1)[0] + ".git"
candidate = (root / relative).resolve() candidate = (root / relative).resolve()
if root not in (candidate, *candidate.parents): if root not in (candidate, *candidate.parents):
@@ -261,20 +180,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def main() -> int: def main() -> int:
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT))) port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
if not orch_url:
# Resolver-only: without an orchestrator the backend can't attribute a
# request to a bottle namespace, so it must not serve (fail-closed).
sys.stderr.write(
f"git-http: {ORCHESTRATOR_URL_ENV} is required "
"(no single-tenant flat-root fallback)\n"
)
return 1
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
# Resolve each request's sandbox namespace by source IP against the sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n")
# orchestrator control plane.
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} (multi-tenant)\n")
sys.stdout.flush() sys.stdout.flush()
server.serve_forever() server.serve_forever()
return 0 return 0
+5 -11
View File
@@ -17,7 +17,7 @@ class ManifestAgentProvider:
`template` selects a built-in launch/runtime contract. `dockerfile` `template` selects a built-in launch/runtime contract. `dockerfile`
optionally points at a custom agent-image Dockerfile while leaving optionally points at a custom agent-image Dockerfile while leaving
bot-bottle's gateway infrastructure intact. bot-bottle's sidecar infrastructure intact.
`auth_token` names the host env var that holds the provider's OAuth `auth_token` names the host env var that holds the provider's OAuth
token (Claude only). The provisioner injects a provider-owned egress token (Claude only). The provisioner injects a provider-owned egress
@@ -25,9 +25,8 @@ class ManifestAgentProvider:
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
so the Claude Code CLI starts. so the Claude Code CLI starts.
`forward_host_credentials` forwards the host provider auth token into `forward_host_credentials` forwards the host Codex auth token into
the egress sidecar (Codex and Claude). For Codex this reads the egress sidecar (Codex only).
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
""" """
template: str = "claude" template: str = "claude"
@@ -93,15 +92,10 @@ class ManifestAgentProvider:
f"is only supported for built-in templates " f"is only supported for built-in templates "
f"({', '.join(sorted(PROVIDER_TEMPLATES))})" f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
) )
if forward_host_credentials and template not in {"codex", "claude"}: if forward_host_credentials and template != "codex":
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials " f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is only supported for templates 'codex' and 'claude'" "is currently only supported for template 'codex'"
)
if forward_host_credentials and auth_token:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"and auth_token both set; use one or the other"
) )
settings = _parse_provider_settings(bottle_name, template, d.get("settings")) settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
return cls( return cls(
+4 -4
View File
@@ -39,10 +39,10 @@ class ManifestBottle:
# identity without any git-gate.repos upstreams, and vice versa. # identity without any git-gate.repos upstreams, and vice versa.
git_user: ManifestGitUser = field(default_factory=ManifestGitUser) git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Per-bottle stuck-recovery daemon (PRD 0013). When true (the # Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
# default, issue #249), the launch step brings up a supervise # default, issue #249), the launch step brings up a supervise
# daemon that exposes egress MCP tools to the agent. Set # sidecar that exposes egress MCP tools to the agent. Set
# `supervise: false` to skip the gateway. # `supervise: false` to skip the sidecar.
supervise: bool = True supervise: bool = True
@classmethod @classmethod
@@ -61,7 +61,7 @@ class ManifestBottle:
raise ManifestError( raise ManifestError(
f"bottle '{name}' has an 'ssh' field, which has been removed " f"bottle '{name}' has an 'ssh' field, which has been removed "
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with " f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
f"url + identity + host_key; the git-gate daemon (PRD 0008) " f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
f"holds the credential and gitleaks-scans pushes." f"holds the credential and gitleaks-scans pushes."
) )
+9 -9
View File
@@ -1,6 +1,6 @@
"""Per-host orchestrator service (PRD 0070). """Per-host orchestrator service (PRD 0070).
A single persistent per-host service that will run the gateway functions A single persistent per-host service that will run the sidecar functions
(egress / git-gate / supervise), coordinate with the console, and broker (egress / git-gate / supervise), coordinate with the console, and broker
agent launches. This package is being built bottom-up, starting with the agent launches. This package is being built bottom-up, starting with the
backend-neutral "consolidation core" that needs no VM packaging: backend-neutral "consolidation core" that needs no VM packaging:
@@ -10,15 +10,15 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `broker` the signed, structured launch-request contract + a * `broker` the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies `LaunchBroker` (stub for the harness) that verifies
provenance before acting. provenance before acting.
* `gateway` the consolidated per-host gateway: a `Gateway` * `sidecar` the consolidated per-host sidecar: a `Sidecar`
lifecycle contract (idempotent singleton) + a lifecycle contract (idempotent singleton) + a
`DockerGateway` impl. One gateway shared by all `DockerSidecar` impl. One sidecar shared by all
bottles instead of one per bottle. bottles instead of one per bottle.
* `service` the `Orchestrator`: owns the registry, brokers the * `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the launch lifecycle (launch/teardown), manages the
shared gateway, attributes. shared sidecar, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown / * `control_plane` the HTTP control-plane RPC (launch / teardown /
list / attribute / gateway / health). list / attribute / sidecar / health).
The actual backend-native launch (a real docker/firecracker broker) and The actual backend-native launch (a real docker/firecracker broker) and
the egress/git/supervise data plane land in later slices once this core is the egress/git/supervise data plane land in later slices once this core is
@@ -38,7 +38,7 @@ from .broker import (
verify_request, verify_request,
) )
from .docker_broker import DockerBroker, DockerBrokerError from .docker_broker import DockerBroker, DockerBrokerError
from .gateway import DockerGateway, Gateway, GatewayError from .sidecar import DockerSidecar, Sidecar, SidecarError
from .service import Orchestrator from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server from .control_plane import ControlPlaneServer, dispatch, make_server
@@ -52,9 +52,9 @@ __all__ = [
"StubBroker", "StubBroker",
"DockerBroker", "DockerBroker",
"DockerBrokerError", "DockerBrokerError",
"Gateway", "Sidecar",
"DockerGateway", "DockerSidecar",
"GatewayError", "SidecarError",
"sign_request", "sign_request",
"verify_request", "verify_request",
"Orchestrator", "Orchestrator",
+9 -21
View File
@@ -16,13 +16,12 @@ import secrets
from pathlib import Path from pathlib import Path
from .. import log from .. import log
from ..store_manager import StoreManager
from .broker import LaunchBroker, StubBroker from .broker import LaunchBroker, StubBroker
from .control_plane import make_server from .control_plane import make_server
from .docker_broker import DockerBroker from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path from .registry import RegistryStore, default_db_path
from .service import Orchestrator from .service import Orchestrator
from .gateway import DockerGateway, Gateway from .sidecar import DockerSidecar, Sidecar
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -39,38 +38,27 @@ def main(argv: list[str] | None = None) -> int:
help="launch broker: 'stub' records requests; 'docker' runs containers", help="launch broker: 'stub' records requests; 'docker' runs containers",
) )
parser.add_argument( parser.add_argument(
"--gateway", action="store_true", "--sidecar", action="store_true",
help="run one consolidated per-host gateway (build-if-missing)", help="run one consolidated per-host sidecar bundle (build-if-missing)",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
registry = RegistryStore(args.db) registry = RegistryStore(args.db)
registry.migrate() registry.migrate()
# One DB per host: the supervise queue + audit tables live in the SAME
# SQLite file the registry owns, so the control plane is the single
# source of truth. The in-VM supervise daemon writes here; the host
# operator reaches it over HTTP (never a second, disconnected DB).
StoreManager(registry.db_path).migrate()
# An ephemeral signing secret ties the orchestrator (signer) to its # An ephemeral signing secret ties the orchestrator (signer) to its
# broker (verifier). 'stub' records launches instead of starting # broker (verifier). 'stub' records launches instead of starting
# anything; 'docker' runs real containers (firecracker drops in later). # anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32) secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret) broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
# Standalone `--gateway` (not the consolidated flow, where the host sidecar: Sidecar | None = DockerSidecar() if args.sidecar else None
# lifecycle runs the gateway). The gateway resolves against this same orchestrator = Orchestrator(registry, broker, secret, sidecar)
# process; the URL is only reachable when they share a docker network.
gateway: Gateway | None = (
DockerGateway(orchestrator_url=f"http://{args.host}:{args.port}")
if args.gateway else None
)
orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host gateway, shared by every bottle: build the # One persistent per-host sidecar, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent). # bundle image if missing, then bring the singleton up (idempotent).
if gateway is not None: if sidecar is not None:
orchestrator.ensure_gateway() orchestrator.ensure_sidecar()
log.info("consolidated gateway ensured", context={"name": gateway.name}) log.info("consolidated sidecar ensured", context={"name": sidecar.name})
server = make_server(orchestrator, host=args.host, port=args.port) server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1] bound_host, bound_port = server.server_address[0], server.server_address[1]
+1 -1
View File
@@ -9,7 +9,7 @@ The request it sends is:
request can't be coerced into launching an arbitrary payload; and request can't be coerced into launching an arbitrary payload; and
* **signed** wrapped as a compact JWS/JWT the broker verifies before * **signed** wrapped as a compact JWS/JWT the broker verifies before
acting, so a *compromised co-located component* (an agent-facing acting, so a *compromised co-located component* (an agent-facing
gateway, say) can't forge a launch. This is the concrete form of the sidecar, say) can't forge a launch. This is the concrete form of the
"structured requests only" rule in the PRD security review. "structured requests only" rule in the PRD security review.
Signing is **HS256** over a secret shared by the orchestrator (signer) and Signing is **HS256** over a secret shared by the orchestrator (signer) and
-255
View File
@@ -1,255 +0,0 @@
"""Host-side control-plane client (PRD 0070).
The launch path talks to the orchestrator over its HTTP control plane to
register, re-policy, and tear down bottles the counterpart to the
gateway-side `PolicyResolver` (which only reads `/resolve`). Where
`PolicyResolver` is fail-closed and lives in the untrusted data plane, this
is the trusted control-plane caller: a non-success response is an error the
launch path must surface, not silently swallow.
Stdlib-only, so the CLI can drive the orchestrator without any dependency.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from ..paths import host_control_plane_token
from .control_plane import CONTROL_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0
def _host_auth_token() -> str:
"""The per-host control-plane secret, or "" if it can't be read. "" means
'send no auth header' correct against an open (unconfigured) control
plane, and harmlessly rejected by a secured one."""
try:
return host_control_plane_token()
except OSError:
return ""
class OrchestratorClientError(RuntimeError):
"""A control-plane call failed (unreachable, or an unexpected status)."""
@dataclass(frozen=True)
class RegisteredBottle:
"""What `POST /bottles` returns: the minted bottle id and the per-bottle
identity token the agent presents for app-layer attribution."""
bottle_id: str
identity_token: str
class OrchestratorClient:
"""Trusted host-side client for the orchestrator control plane.
Presents the per-host control-plane secret on every call (the header the
control plane requires on all routes but `/health`). The secret is read
from the host file this client only ever runs host-side (CLI, launcher,
discovery), so it can read what an agent can't. `auth_token` is overridable
for tests; the default reads the host file, minting it on first use."""
def __init__(
self,
base_url: str,
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
auth_token: str | None = None,
) -> None:
self._base = base_url.rstrip("/")
self._timeout = timeout
self._auth_token = auth_token if auth_token is not None else _host_auth_token()
def _request(
self, method: str, path: str, body: dict[str, object] | None = None,
) -> tuple[int, dict[str, object]]:
"""Send one request; return `(status, payload)`. Raises
`OrchestratorClientError` only when the orchestrator can't be reached
or returns malformed data HTTP *status* codes are returned so
callers can treat 404 as a meaningful "no such bottle"."""
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
if self._auth_token:
headers[CONTROL_AUTH_HEADER] = self._auth_token
req = urllib.request.Request(
f"{self._base}{path}", data=data, method=method, headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
raw = resp.read()
payload = json.loads(raw) if raw else {}
return resp.status, payload if isinstance(payload, dict) else {}
except urllib.error.HTTPError as e:
# A structured error response still carries a usable status.
try:
payload = json.loads(e.read() or b"{}")
except (ValueError, OSError):
payload = {}
return e.code, payload if isinstance(payload, dict) else {}
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise OrchestratorClientError(f"{method} {path}: {e}") from e
def _ok(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
"""`_request` that requires a 2xx, raising otherwise."""
status, payload = self._request(method, path, body)
if not 200 <= status < 300:
detail = payload.get("error", "")
raise OrchestratorClientError(f"{method} {path}: HTTP {status} {detail}".rstrip())
return payload
def health(self) -> bool:
"""True iff the control plane answers `GET /health` with 200."""
try:
status, _ = self._request("GET", "/health")
except OrchestratorClientError:
return False
return status == 200
def register_bottle(
self,
source_ip: str,
*,
image_ref: str = "",
metadata: str = "",
policy: str = "",
tokens: dict[str, str] | None = None,
) -> RegisteredBottle:
"""Register a bottle and broker its launch (`POST /bottles`). `tokens`
are the per-bottle egress auth values (env_name -> value) the
orchestrator holds in memory for the gateway to inject. Returns the
minted id + identity token."""
payload = self._ok("POST", "/bottles", {
"source_ip": source_ip,
"image_ref": image_ref,
"metadata": metadata,
"policy": policy,
"tokens": tokens or {},
})
bottle_id = payload.get("bottle_id")
token = payload.get("identity_token")
if not isinstance(bottle_id, str) or not isinstance(token, str):
raise OrchestratorClientError("register: response missing bottle_id/identity_token")
return RegisteredBottle(bottle_id=bottle_id, identity_token=token)
def teardown_bottle(self, bottle_id: str) -> bool:
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
orchestrator didn't know it (404) — idempotent for cleanup paths."""
status, _ = self._request("DELETE", f"/bottles/{bottle_id}")
if status == 404:
return False
if not 200 <= status < 300:
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
return True
def reconcile(
self, live_source_ips: Iterable[str], *, grace_seconds: float | None = None,
) -> list[str]:
"""Drop registry rows for bottles that are no longer running
(`POST /reconcile`), returning the reaped bottle ids. `live_source_ips`
is the caller's enumeration of its live bottles — the orchestrator
can't see the backend from inside the infra container."""
body: dict[str, object] = {"live_source_ips": list(live_source_ips)}
if grace_seconds is not None:
body["grace_seconds"] = grace_seconds
payload = self._ok("POST", "/reconcile", body)
reaped = payload.get("reaped")
return [r for r in reaped if isinstance(r, str)] if isinstance(reaped, list) else []
def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Live-reload a bottle's policy (`PUT /bottles/<id>/policy`). False on
404 (unknown bottle)."""
status, _ = self._request("PUT", f"/bottles/{bottle_id}/policy", {"policy": policy})
if status == 404:
return False
if not 200 <= status < 300:
raise OrchestratorClientError(f"set_policy {bottle_id}: HTTP {status}")
return True
def list_bottles(self) -> list[dict[str, object]]:
"""Every registered bottle's redacted record (`GET /bottles`)."""
payload = self._ok("GET", "/bottles")
bottles = payload.get("bottles")
return bottles if isinstance(bottles, list) else []
# --- supervise queue (operator TUI) ------------------------------------
def supervise_pending(self) -> list[dict[str, object]]:
"""Pending supervise proposals across all bottles
(`GET /supervise/proposals`)."""
payload = self._ok("GET", "/supervise/proposals")
proposals = payload.get("proposals")
return proposals if isinstance(proposals, list) else []
def supervise_respond(
self,
proposal_id: str,
*,
bottle_slug: str,
decision: str,
notes: str = "",
final_file: str | None = None,
) -> None:
"""Record an operator decision (`POST /supervise/respond`). `decision`
is approve/modify/reject. Raises `OrchestratorClientError` if the
proposal is gone or the bottle can no longer be applied to (409)."""
body: dict[str, object] = {
"proposal_id": proposal_id,
"bottle_slug": bottle_slug,
"decision": decision,
"notes": notes,
}
if final_file is not None:
body["final_file"] = final_file
self._ok("POST", "/supervise/respond", body)
def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
"""The URL of the one running per-host orchestrator control plane, probing
the backends' well-known control-plane addresses (both on port 8099):
docker publishes it on loopback; the firecracker infra VM serves it on the
orchestrator TAP. Returns the first that answers `/health`; raises if none
do (no orchestrator up launch a bottle first)."""
candidates: list[str] = []
try: # docker: loopback-published control plane
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
except Exception: # noqa: BLE001 — backend optional
candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}")
except Exception: # noqa: BLE001 — backend optional / not firecracker
pass
try: # macOS: infra container control plane on its host-only address
from ..backend.macos_container.infra import probe_control_plane_url
url = probe_control_plane_url()
if url:
candidates.append(url)
except Exception: # noqa: BLE001 — backend optional / not macOS
pass
for url in candidates:
if OrchestratorClient(url, timeout=timeout).health():
return url
raise OrchestratorClientError(
"no running orchestrator control plane found (tried "
+ ", ".join(candidates)
+ "); launch a bottle first"
)
__all__ = [
"OrchestratorClient",
"OrchestratorClientError",
"RegisteredBottle",
"DEFAULT_TIMEOUT_SECONDS",
"discover_orchestrator_url",
]
-107
View File
@@ -1,107 +0,0 @@
"""Per-host orchestrator configuration store (settings in bot-bottle.db).
Co-tenants the shared `bot-bottle.db` via the `DbStore` framework. Settings
are readable by the host launch path directly (no HTTP round-trip to the
orchestrator), so they take effect even before the orchestrator is reachable.
"""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..paths import host_db_path
TEARDOWN_TIMEOUT_ENV = "BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS"
DEFAULT_TEARDOWN_TIMEOUT_SECONDS = 30.0
_MIGRATIONS = TableMigrations(
"orchestrator_config",
[
"""
CREATE TABLE IF NOT EXISTS orchestrator_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
teardown_timeout_seconds REAL
)
""",
],
)
class OrchestratorConfigStore(DbStore):
"""Orchestrator settings in the shared host DB."""
def __init__(self, db_path: Path | None = None) -> None:
super().__init__(db_path or host_db_path(), _MIGRATIONS)
def _connect(self) -> sqlite3.Connection:
conn = super()._connect()
conn.execute("PRAGMA busy_timeout=5000")
return conn
def get_teardown_timeout_seconds(self) -> float | None:
"""Return the configured teardown timeout, or None if not set."""
try:
with self._connection() as conn:
row = conn.execute(
"SELECT teardown_timeout_seconds FROM orchestrator_config WHERE id = 1"
).fetchone()
except sqlite3.OperationalError:
return None
return row["teardown_timeout_seconds"] if row else None
def set_teardown_timeout_seconds(self, value: float) -> None:
"""Persist the teardown timeout."""
with self._connection() as conn:
conn.execute(
"INSERT OR REPLACE INTO orchestrator_config"
" (id, teardown_timeout_seconds) VALUES (1, ?)",
(value,),
)
self._chmod()
def delete_teardown_timeout_seconds(self) -> bool:
"""Clear the stored teardown timeout. Returns True if a value existed."""
with self._connection() as conn:
cur = conn.execute(
"UPDATE orchestrator_config SET teardown_timeout_seconds = NULL"
" WHERE id = 1 AND teardown_timeout_seconds IS NOT NULL"
)
return cur.rowcount > 0
def resolve_teardown_timeout(db_path: Path | None = None) -> float:
"""Return the teardown timeout to use, in priority order:
1. ``BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS`` env var
2. ``teardown_timeout_seconds`` in the orchestrator config DB
3. ``DEFAULT_TEARDOWN_TIMEOUT_SECONDS`` (30 s)
"""
raw = os.environ.get(TEARDOWN_TIMEOUT_ENV, "").strip()
if raw:
try:
value = float(raw)
if value > 0:
return value
except ValueError:
pass
store = OrchestratorConfigStore(db_path)
if not store.is_migrated():
store.migrate()
db_value = store.get_teardown_timeout_seconds()
if db_value is not None and db_value > 0:
return db_value
return DEFAULT_TEARDOWN_TIMEOUT_SECONDS
__all__ = [
"OrchestratorConfigStore",
"resolve_teardown_timeout",
"TEARDOWN_TIMEOUT_ENV",
"DEFAULT_TEARDOWN_TIMEOUT_SECONDS",
]
+15 -163
View File
@@ -5,7 +5,7 @@ The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
vsock / unix-socket portability caveats): vsock / unix-socket portability caveats):
GET /health -> 200 {"status": "ok"} GET /health -> 200 {"status": "ok"}
GET /gateway -> 200 {"configured", ["name","running"]} GET /sidecar -> 200 {"configured", ["name","running"]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]} GET /bottles -> 200 {"bottles": [ <redacted record>, ...]}
POST /bottles -> 201 {"bottle_id","identity_token"} (launch) POST /bottles -> 201 {"bottle_id","identity_token"} (launch)
body: {"source_ip", ["image_ref"], body: {"source_ip", ["image_ref"],
@@ -13,16 +13,9 @@ vsock / unix-socket portability caveats):
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload) PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload)
body: {"policy"} body: {"policy"}
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown) DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
POST /reconcile -> 200 {"reaped": [bottle_id, ...]}
body: {"live_source_ips": [...],
["grace_seconds"]}
POST /attribute -> 200 {"bottle_id"} | 403 POST /attribute -> 200 {"bottle_id"} | 403
POST /resolve -> 200 {"bottle_id","policy"} | 403 POST /resolve -> 200 {"bottle_id","policy"} | 403
body: {"source_ip","identity_token"} body: {"source_ip","identity_token"}
GET /supervise/proposals -> 200 {"proposals": [ <proposal>, ...]}
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
body: {"proposal_id","bottle_slug",
"decision", ["notes"],["final_file"]}
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or `POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
tear down) the bottle in the registry AND broker the backend-native launch tear down) the bottle in the registry AND broker the backend-native launch
@@ -37,29 +30,18 @@ returned only once, to the caller that launches the bottle.
from __future__ import annotations from __future__ import annotations
import hmac
import http.server import http.server
import json import json
import os import os
import socketserver import socketserver
import sys
import typing import typing
from urllib.parse import urlsplit from urllib.parse import urlsplit
from ..paths import CONTROL_PLANE_TOKEN_ENV
from .service import Orchestrator from .service import Orchestrator
# JSON body payload type (parsed request / rendered response). # JSON body payload type (parsed request / rendered response).
Json = dict[str, object] Json = dict[str, object]
# The request header carrying the per-host control-plane secret. Every route
# except `GET /health` requires it (see `dispatch`). The trusted callers hold
# the secret (the gateway's PolicyResolver, the host CLI's OrchestratorClient);
# an agent that can merely *reach* the port cannot present it, so it can't
# enumerate bottles, rewrite policy, read injected upstream tokens, or approve
# its own supervise proposals.
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
def _parse_json_object(body: bytes) -> Json: def _parse_json_object(body: bytes) -> Json:
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON.""" """Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
@@ -72,31 +54,17 @@ def _parse_json_object(body: bytes) -> Json:
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
orch: Orchestrator, method: str, path: str, body: bytes, *, authorized: bool = True, orch: Orchestrator, method: str, path: str, body: bytes
) -> tuple[int, Json]: ) -> tuple[int, Json]:
"""Route one control-plane request to a (status, payload) pair. Pure — """Route one control-plane request to a (status, payload) pair. Pure —
no I/O beyond the orchestrator so it is fully testable without a socket. no I/O beyond the orchestrator so it is fully testable without a socket."""
`authorized` is whether the request presented the control-plane secret (or
no secret is configured see `ControlPlaneServer`). Every route except
`GET /health` requires it: the source-IP + identity-token checks inside
`/resolve` and `/attribute` authenticate the *bottle* a request is about,
not the *caller*, so without this gate any agent that can reach the port
could rewrite another bottle's policy, read the injected upstream tokens,
or approve its own supervise proposals. Defaults True so unit tests of the
routing logic don't have to thread it through."""
route = urlsplit(path).path.rstrip("/") or "/" route = urlsplit(path).path.rstrip("/") or "/"
if method == "GET" and route == "/health": if method == "GET" and route == "/health":
return 200, {"status": "ok"} return 200, {"status": "ok"}
if not authorized: if method == "GET" and route == "/sidecar":
# Everything below is a trusted-caller operation. Deny before touching return 200, orch.sidecar_status()
# the registry / broker / supervise store.
return 401, {"error": "control-plane authentication required"}
if method == "GET" and route == "/gateway":
return 200, orch.gateway_status()
if method == "GET" and route == "/bottles": if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]} return 200, {"bottles": [r.redacted() for r in orch.registry.all()]}
@@ -112,16 +80,11 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
image_ref = data.get("image_ref") image_ref = data.get("image_ref")
metadata = data.get("metadata") metadata = data.get("metadata")
policy = data.get("policy") policy = data.get("policy")
raw_tokens = data.get("tokens")
tokens = {
k: v for k, v in raw_tokens.items() if isinstance(k, str) and isinstance(v, str)
} if isinstance(raw_tokens, dict) else {}
rec = orch.launch_bottle( rec = orch.launch_bottle(
source_ip, source_ip,
image_ref=image_ref if isinstance(image_ref, str) else "", image_ref=image_ref if isinstance(image_ref, str) else "",
metadata=metadata if isinstance(metadata, str) else "", metadata=metadata if isinstance(metadata, str) else "",
policy=policy if isinstance(policy, str) else "", policy=policy if isinstance(policy, str) else "",
tokens=tokens,
) )
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token} return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
@@ -144,28 +107,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"torn_down": True} return 200, {"torn_down": True}
return 404, {"error": "no such bottle"} return 404, {"error": "no such bottle"}
if method == "POST" and route == "/reconcile": if method == "POST" and route in ("/attribute", "/resolve"):
# Host-driven self-heal: the caller enumerates its live bottles (only
# the host can see the backend) and the orchestrator drops rows for
# every other active bottle. Trusted-caller only — an agent that could
# reach this would be able to unregister its neighbours.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
raw_ips = data.get("live_source_ips")
if not isinstance(raw_ips, list):
return 400, {"error": "live_source_ips (list of strings) is required"}
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
grace = data.get("grace_seconds")
kwargs = (
{"grace_seconds": float(grace)}
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
else {}
)
return 200, {"reaped": orch.reconcile(live, **kwargs)}
if method == "POST" and route == "/attribute":
try: try:
data = _parse_json_object(body) data = _parse_json_object(body)
except ValueError as e: except ValueError as e:
@@ -177,65 +119,13 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
rec = orch.attribute(source_ip, token) rec = orch.attribute(source_ip, token)
if rec is None: if rec is None:
return 403, {"error": "unattributed"} return 403, {"error": "unattributed"}
# /attribute is the lightweight identity check; /resolve additionally
# returns the bottle's policy — the source-IP-keyed lookup the
# multi-tenant sidecar makes per request.
if route == "/resolve":
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
return 200, {"bottle_id": rec.bottle_id} return 200, {"bottle_id": rec.bottle_id}
if method == "GET" and route == "/supervise/proposals":
# Operator TUI: pending supervise proposals across all bottles.
return 200, {"proposals": orch.supervise_pending()}
if method == "POST" and route == "/supervise/respond":
# Operator decision: apply (approve/modify rewrites egress policy),
# write the queued response, audit — all server-side on the one DB.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
proposal_id = data.get("proposal_id")
bottle_slug = data.get("bottle_slug")
decision = data.get("decision")
if not (isinstance(proposal_id, str) and proposal_id):
return 400, {"error": "proposal_id (string) is required"}
if not (isinstance(bottle_slug, str) and bottle_slug):
return 400, {"error": "bottle_slug (string) is required"}
if not (isinstance(decision, str) and decision):
return 400, {"error": "decision (string) is required"}
notes = data.get("notes")
final_file = data.get("final_file")
ok, err = orch.supervise_respond(
proposal_id,
bottle_slug=bottle_slug,
decision=decision,
notes=notes if isinstance(notes, str) else "",
final_file=final_file if isinstance(final_file, str) else None,
)
if ok:
return 200, {"responded": True}
return 409, {"error": err}
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. Requires a matching (source_ip, identity_token)
# pair — a missing/empty/mismatched token fail-closes (403), no
# source-IP-only fallback.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
# tokens are the in-memory per-bottle egress auth values the gateway
# injects; served here, never persisted.
return 200, {
"bottle_id": rec.bottle_id,
"policy": rec.policy,
"tokens": orch.tokens_for(rec.bottle_id),
}
return 404, {"error": "not found"} return 404, {"error": "not found"}
@@ -249,22 +139,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
super().log_message(format, *args) super().log_message(format, *args)
def _serve(self, method: str) -> None: def _serve(self, method: str) -> None:
"""Read the request body, dispatch it, and write the JSON reply. A """Read the request body, dispatch it, and write the JSON reply."""
dispatch failure (e.g. a broker error) returns a 500 rather than
crashing the connection, so one bad request can't take the control
plane down for the caller."""
server = self.server server = self.server
assert isinstance(server, ControlPlaneServer) assert isinstance(server, ControlPlaneServer)
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
authorized = server.is_authorized(self.headers.get(CONTROL_AUTH_HEADER, "")) status, payload = dispatch(server.orchestrator, method, self.path, body)
try:
status, payload = dispatch(
server.orchestrator, method, self.path, body, authorized=authorized)
except Exception as e: # noqa: BLE001 — the control plane must stay up
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
sys.stderr.flush()
status, payload = 500, {"error": f"internal error: {e}"}
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@@ -286,40 +166,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the orchestrator for its handlers. """Threading HTTP server that carries the orchestrator for its handlers."""
Holds the per-host control-plane secret (from `$BOT_BOTTLE_CONTROL_PLANE_TOKEN`,
injected by the launcher into this container only). When a secret is set,
every route but `/health` requires it; when it is unset the server runs
**open** and says so loudly at startup a fail-visible fallback for tests
and any backend that hasn't wired the secret yet (e.g. Firecracker, whose
nft boundary already blocks agents from the control-plane port)."""
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None: def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator self.orchestrator = orchestrator
self._auth_token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
if not self._auth_token:
sys.stderr.write(
"orchestrator: WARNING — no control-plane secret "
f"(${CONTROL_PLANE_TOKEN_ENV}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n"
)
sys.stderr.flush()
super().__init__(address, Handler) super().__init__(address, Handler)
def is_authorized(self, presented: str) -> bool:
"""True iff the request may proceed past `/health`: either no secret is
configured (open mode) or the presented header matches it. Constant-time
compare so a wrong token leaks nothing timing-wise."""
if not self._auth_token:
return True
return hmac.compare_digest(presented, self._auth_token)
def make_server( def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0 orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
@@ -329,7 +184,4 @@ def make_server(
return ControlPlaneServer((host, port), orchestrator) return ControlPlaneServer((host, port), orchestrator)
__all__ = [ __all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json",
"CONTROL_AUTH_HEADER",
]
+2 -2
View File
@@ -2,12 +2,12 @@
On a verified launch request it starts a Docker container; on teardown it On a verified launch request it starts a Docker container; on teardown it
removes it. This proves the orchestrator -> backend seam on the cheapest removes it. This proves the orchestrator -> backend seam on the cheapest
backend (the gateway is already containers). Only the request's backend (the sidecar bundle is already containers). Only the request's
static ids/flags reach `docker`, so nothing free-form crosses the boundary. static ids/flags reach `docker`, so nothing free-form crosses the boundary.
Slice 3 launches a single container from the request's `image_ref`, named Slice 3 launches a single container from the request's `image_ref`, named
after the bottle id and labelled for cleanup. Wiring the full agent + after the bottle id and labelled for cleanup. Wiring the full agent +
gateway (networks, mounts, the consolidated gateway) is a later sidecar bundle (networks, mounts, the consolidated sidecar) is a later
slice this is the seam, not the finished launcher. slice this is the seam, not the finished launcher.
""" """
-278
View File
@@ -1,278 +0,0 @@
"""The consolidated per-host gateway (PRD 0070).
The core consolidation win: **one** persistent gateway per host, shared by
every bottle, instead of a gateway per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see
`registry`) lets the gateway attribute each request to the right bottle
so per-bottle policy lives in one long-lived process keyed on who's calling.
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerGateway`
is the docker implementation; a firecracker gateway VM slots in later.
The defining behaviour is **idempotent singleton**: `ensure_running` starts
the instance if absent and is a no-op if it's already up, so N bottle
launches never spawn N gateways.
"""
from __future__ import annotations
import abc
import os
import time
from pathlib import Path
from ..docker_cmd import run_docker
from ..paths import (
CONTROL_PLANE_TOKEN_ENV,
host_control_plane_token,
host_db_path,
)
from ..supervise import DB_PATH_IN_CONTAINER
# The host DB dir is bind-mounted here so the gateway's supervise daemon
# writes its queued proposals into the ONE host DB (the same file the
# orchestrator container opens and the operator reaches over HTTP).
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
# The gateway's mitmproxy writes its CA a beat after the container starts, so
# reads poll for it rather than assuming it's there on a fresh launch.
_CA_POLL_SECONDS = 0.5
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
GATEWAY_NAME = "bot-bottle-orch-gateway"
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# The single user-defined network the gateway and every agent bottle share.
# Agents attach here with a pinned IP and reach 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 address on this network.
GATEWAY_NETWORK = "bot-bottle-gateway"
# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the
# gateway's self-generated CA STABLE across container recreation — every agent
# installs this one CA to trust the shared gateway's TLS interception, so it
# must not rotate when the gateway restarts.
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
# The gateway data-plane image + its Dockerfile. Kept as a local constant
# rather than imported from the backend layer, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
_REPO_ROOT = Path(__file__).resolve().parents[2]
def _host_db_dir() -> str:
"""The host DB directory (created if missing), for the gateway's
supervise-DB bind-mount."""
db_dir = host_db_path().parent
db_dir.mkdir(parents=True, exist_ok=True)
return str(db_dir)
class GatewayError(Exception):
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Gateway(abc.ABC):
"""Lifecycle of the single per-host gateway. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return
@abc.abstractmethod
def ensure_running(self) -> None:
"""Start the gateway if it isn't already up. Idempotent: a no-op
when it's already running (that's the whole point one per host).
Assumes the image exists call `ensure_built()` first."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the gateway instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the gateway. Idempotent — absent is success."""
class DockerGateway(Gateway):
"""The consolidated gateway as a single, fixed-name Docker container.
`image_ref` defaults to the gateway data-plane image; `ensure_built`
builds it from `Dockerfile.gateway` when it's missing. (Note: slice 5
builds + launches the bundle container; wiring its per-bottle,
source-IP-keyed config is a later slice see PRD 0070.)"""
def __init__(
self,
image_ref: str = GATEWAY_IMAGE,
*,
name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK,
orchestrator_url: str = "",
build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE,
host_port_bindings: tuple[int, ...] = (),
) -> None:
self.image_ref = image_ref
self.name = name
self.network = network
# The control-plane URL the gateway's data plane resolves per bottle
# against — reached by container name over docker DNS on the shared
# network (container↔container, no host firewall). Mandatory to *run*
# the gateway (see `ensure_running`); empty is tolerated only for the
# construct-then-read-CA path (`ca_cert_pem` on an already-running
# container), which never launches a container.
self._orchestrator_url = orchestrator_url
self._build_context = build_context or _REPO_ROOT
self._dockerfile = dockerfile
# Ports published on the host (0.0.0.0). Used by the Firecracker
# backend's dev-harness gateway so VMs can reach it via their TAP link;
# Docker's DNAT + the nft `ct status dnat accept` rule handle the rest.
self._host_port_bindings = host_port_bindings
def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
def ensure_built(self) -> None:
"""Build the bundle image from its Dockerfile, **cache-aware** — cheap
(a cache check) when nothing changed, a real rebuild when the flat
sources (egress addon / git-http / policy_resolver / supervise) moved.
This deliberately builds every time rather than build-if-missing: the
per-bottle model kept the image fresh via compose's `build:` on up, and
a stale image silently runs the OLD single-tenant daemons. No-op only
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
forces a full rebuild (parity with `start --no-cache`)."""
if self._dockerfile is None:
return
argv = ["docker", "build", "-t", self.image_ref,
"-f", str(self._build_context / self._dockerfile),
str(self._build_context)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
def is_running(self) -> bool:
proc = run_docker([
"docker", "ps",
"--filter", f"name=^{self.name}$",
"--filter", "status=running",
"--format", "{{.Names}}",
])
return self.name in proc.stdout.split()
def _running_image_is_current(self) -> bool:
"""True iff the running gateway was created from the *current*
`image_ref`. When `ensure_built` rebuilds the image (a source change),
the running container is still the OLD image running the OLD flat
daemons so this is how a rebuild actually takes effect: a mismatch
means recreate."""
running = run_docker(["docker", "inspect", "--format", "{{.Image}}", self.name])
current = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", self.image_ref])
if running.returncode != 0 or current.returncode != 0:
return True # can't compare → don't churn a working container
return running.stdout.strip() == current.stdout.strip()
def _ensure_network(self) -> None:
"""Create the shared gateway network if it doesn't exist. Idempotent —
a concurrent create loses harmlessly (the loser sees 'already exists').
Docker picks the subnet; the launcher reads it back to allocate IPs."""
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
return
proc = run_docker(["docker", "network", "create", self.network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def ensure_running(self) -> None:
# Fail closed on a missing policy source. The data-plane daemons are
# resolver-only now (PRD 0070) — without an orchestrator URL egress
# raises, git-http exits 1, and supervise exits 2 — so launching a
# gateway without one would only crash-loop its daemons. Refuse here so
# the misconfiguration surfaces as a clear error, not a broken container.
if not self._orchestrator_url:
raise GatewayError(
"gateway requires an orchestrator URL to run "
"(resolver-only data plane; no single-tenant fallback)"
)
# Recreate when the running container's image is stale (a rebuild),
# so source changes to the gateway's flat daemons take effect — not
# just when the container is absent.
if self.is_running() and self._running_image_is_current():
return
self._ensure_network()
# Clear any stale (stopped OR outdated-image) container holding the
# fixed name, then start fresh. `rm --force` on an absent name is a
# tolerated no-op.
run_docker(["docker", "rm", "--force", self.name])
argv = [
"docker", "run", "--detach",
"--name", self.name,
"--label", GATEWAY_LABEL,
"--network", self.network,
# Persist the self-generated CA so it survives restarts (agents
# trust it) — see GATEWAY_CA_VOLUME.
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
# Share the one host DB: the supervise daemon queues proposals
# into the same file the orchestrator (and the operator, over
# HTTP) reads — no second, disconnected DB in the container.
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
]
for port in self._host_port_bindings:
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
run_env = dict(os.environ)
# The gateway's egress / git / supervise daemons resolve source-IP ->
# policy against the control plane per request (guaranteed non-empty by
# the check above).
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
# ...and present the control-plane secret on those /resolve calls (the
# control plane requires it). Bare `--env NAME` keeps the value off argv
# / `docker inspect`; only the gateway (not the agent) is given it.
argv += ["--env", CONTROL_PLANE_TOKEN_ENV]
run_env[CONTROL_PLANE_TOKEN_ENV] = host_control_plane_token()
argv.append(self.image_ref)
proc = run_docker(argv, env=run_env)
if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its
TLS interception. mitmproxy generates it a moment after the container
starts, so this **polls** for it (up to `timeout`) rather than assuming
it's already there on a fresh gateway — raising only if it never
appears."""
deadline = time.monotonic() + timeout
while True:
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout
if time.monotonic() >= deadline:
raise GatewayError(
f"gateway CA cert not available after {timeout:g}s: "
f"{proc.stderr.strip() or 'empty'}"
)
time.sleep(_CA_POLL_SECONDS)
def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr:
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
__all__ = [
"Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
]
-266
View File
@@ -1,266 +0,0 @@
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
Runs both the orchestrator control plane and the gateway data plane inside
a single `bot-bottle-infra` container on the shared gateway network
matching the structure already used by the macOS and Firecracker backends.
`gateway_init` is PID 1 and supervises both; the infra container is an
idempotent per-host singleton.
The combined container replaces the prior two-container split
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches
the control plane via a published loopback port; gateway daemons reach it
over 127.0.0.1 (same container).
"""
from __future__ import annotations
import hashlib
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from .. import log
from ..docker_cmd import run_docker
from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token
from ..supervise import DB_PATH_IN_CONTAINER
from .gateway import (
GATEWAY_CA_VOLUME,
GATEWAY_DOCKERFILE,
GATEWAY_IMAGE,
GATEWAY_NETWORK,
GatewayError,
MITMPROXY_HOME,
_host_db_dir,
)
DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
INFRA_NAME = "bot-bottle-infra"
INFRA_LABEL = "bot-bottle-infra=1"
# The combined infra image: gateway data plane + orchestrator content.
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
INFRA_DOCKERFILE = "Dockerfile.infra"
# Baked as a container label so `ensure_running` can detect whether the
# running container is executing the current bind-mounted source.
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"
# Orchestrator image: the single canonical definition of the control-plane
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# The gateway daemons + orchestrator the infra container runs.
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
# The bind-mount path for the live control-plane source inside the
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state).
_ROOT_IN_CONTAINER = "/bot-bottle-root"
# The supervise daemon writes proposals into the host DB directory.
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
_REPO_ROOT = Path(__file__).resolve().parents[2]
class OrchestratorStartError(RuntimeError):
"""The infra container did not become healthy within the timeout."""
def source_hash(repo_root: Path) -> str:
"""Content hash of the orchestrator's bind-mounted Python source (the
`bot_bottle` package the control-plane process imports). Changes only
when the code that would actually run changes `ensure_running`
recreates the container on a mismatch so a code change takes effect,
but leaves a healthy up-to-date container alone to preserve in-memory
egress tokens."""
h = hashlib.sha256()
for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
h.update(str(path.relative_to(repo_root)).encode())
h.update(path.read_bytes())
return h.hexdigest()
class OrchestratorService:
"""Manages the single per-host infra container (control plane + gateway).
Callers only need `ensure_running()` + `url`.
`infra_name` / `infra_label` let backends run independent infra containers
on the same host without name collisions (e.g. isolated integration tests
that can't share the production INFRA_NAME singleton)."""
def __init__(
self,
*,
port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK,
image: str = INFRA_IMAGE,
repo_root: Path = _REPO_ROOT,
host_root: Path | None = None,
infra_name: str = INFRA_NAME,
infra_label: str = INFRA_LABEL,
) -> None:
self.port = port
self.network = network
self.image = image
self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root()
self._infra_name = infra_name
self._infra_label = infra_label
@property
def url(self) -> str:
"""Host-side control-plane URL (published loopback port)."""
return f"http://127.0.0.1:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
try:
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _container_running(self, name: str) -> bool:
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
return name in proc.stdout.split()
def _infra_source_current(self, current_hash: str) -> bool:
"""True iff the running infra container was started from the current
bind-mounted source. Mirrors the macOS backend's `_source_current`."""
if not self._container_running(self._infra_name):
return False
proc = run_docker([
"docker", "inspect", "--format",
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
self._infra_name,
])
if proc.returncode != 0:
return True # can't compare → don't churn a working container
return proc.stdout.strip() == current_hash
def _ensure_network(self) -> None:
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
return
proc = run_docker(["docker", "network", "create", self.network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def _build_images(self) -> None:
"""Build the gateway base, the orchestrator intermediate, then the
infra image. All are cache-aware: a no-op when nothing changed."""
for tag, dockerfile in (
(GATEWAY_IMAGE, GATEWAY_DOCKERFILE),
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
(self.image, INFRA_DOCKERFILE),
):
argv = ["docker", "build", "-t", tag,
"-f", str(self._repo_root / dockerfile),
str(self._repo_root)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
def _run_infra_container(self, current_hash: str) -> None:
"""Start the combined infra container (idempotent: clears a stale
fixed-name container first). Labels the container with `current_hash`
so a later `ensure_running` can detect a real code change."""
self._ensure_network()
run_docker(["docker", "rm", "--force", self._infra_name])
proc = run_docker([
"docker", "run", "--detach",
"--name", self._infra_name,
"--label", self._infra_label,
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
"--network", self.network,
# Host CLI reaches the control plane here (loopback only).
# gateway_init always starts the orchestrator on DEFAULT_PORT (8099)
# inside the container; self.port is the host-side published port.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
# Persist the mitmproxy CA so it survives container recreation.
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
# Shared supervise DB (same file the operator reads over HTTP).
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
# Live control-plane source, mounted to a path that does not
# overlay the gateway's baked /app scripts.
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
# PYTHONPATH lets the orchestrator (and other Python daemons)
# import the live source ahead of the installed package.
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
# Orchestrator registry DB on the host (sole writer: control plane).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# Control-plane secret: required by the orchestrator (to enforce)
# and by the gateway daemons (to present on /resolve calls).
"--env", CONTROL_PLANE_TOKEN_ENV,
# Gateway daemons reach the orchestrator over loopback at its
# fixed internal port (DEFAULT_PORT), independent of self.port.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
# Opt the orchestrator into gateway_init's supervise tree.
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
self.image,
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
if proc.returncode != 0:
raise OrchestratorStartError(
f"infra container failed to start: {proc.stderr.strip()}"
)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str:
"""Ensure the infra container (control plane + gateway) is up; return
the host control-plane URL. Idempotent a healthy container on current
source is left untouched. Raises `OrchestratorStartError` on timeout."""
self._build_images()
current_hash = source_hash(self._repo_root)
if self.is_healthy() and self._infra_source_current(current_hash):
return self.url
log.info("starting infra container", context={"name": self._infra_name})
self._run_infra_container(current_hash)
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_healthy():
log.info("infra container healthy", context={"url": self.url})
return self.url
time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError(
f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
)
def stop(self) -> None:
"""Remove the infra container (idempotent)."""
run_docker(["docker", "rm", "--force", self._infra_name])
__all__ = [
"OrchestratorService",
"OrchestratorStartError",
"INFRA_NAME",
"INFRA_IMAGE",
"INFRA_SOURCE_HASH_LABEL",
"ORCHESTRATOR_IMAGE",
"DEFAULT_PORT",
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
"source_hash",
]
-57
View File
@@ -1,57 +0,0 @@
"""Consolidated registration inputs (PRD 0070, docker slice).
Bridges the existing per-bottle `prepare` output to the consolidated
registry: turns a prepared bottle's egress plan into the backend-neutral
inputs `Orchestrator.launch_bottle` takes the egress **policy** blob and
launch **metadata**.
The policy blob is the exact routes YAML the per-bottle egress daemon used
to read from a file; in the consolidated model the multi-tenant gateway's
`PolicyResolver` fetches it from the registry per request (keyed by source
IP) instead. Same render, so consolidated and single-tenant egress apply
byte-identical policy a bottle's allow-list doesn't change when it moves
onto the shared gateway.
Host-side glue (imports `bot_bottle.egress`), used by the launch path not
by the lean orchestrator control-plane process itself.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from ..egress import EgressPlan, egress_render_routes
@dataclass(frozen=True)
class RegistrationInputs:
"""What `Orchestrator.launch_bottle` needs to register a bottle, derived
from its prepared plan. `policy` is served verbatim by the gateway's
`/resolve`; `metadata` is opaque forward-compat state it carries the
human slug so the console / supervise can show a name, not just the
minted bottle id."""
policy: str
metadata: str
def egress_policy(plan: EgressPlan) -> str:
"""The bottle's egress policy blob: the routes YAML the gateway serves
and the addon parses with `load_config`. Identical to the per-bottle
`routes.yaml` render, so the consolidated path applies the same
allow-list."""
return egress_render_routes(plan.routes, log=plan.log)
def registration_inputs(plan: EgressPlan) -> RegistrationInputs:
"""Assemble the orchestrator registration inputs from a prepared egress
plan. `metadata` records the slug so the shared registry can map a minted
bottle id back to its human name."""
return RegistrationInputs(
policy=egress_policy(plan),
metadata=json.dumps({"slug": plan.slug}),
)
__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"]

Some files were not shown because too many files have changed in this diff Show More