Compare commits

..

15 Commits

Author SHA1 Message Date
Quality Badge Bot 652f14dcb1 chore: update quality badges
- Coverage: 83%
- Core coverage: 94%

[skip ci]
2026-07-26 02:36:35 +00:00
didericis-claude 38c13708c7 Merge pull request 'PRD prd-new: macOS (Apple Container) CI runner' (#479) from prd-macos-container-ci-runner into main
test / integration-macos (push) Has been skipped
prd-number / assign-numbers (push) Failing after 27s
test / integration-docker (push) Successful in 16s
lint / lint (push) Successful in 1m7s
Update Quality Badges / update-badges (push) Successful in 58s
test / unit (push) Successful in 2m11s
test / integration-firecracker (push) Successful in 5m2s
test / coverage (push) Successful in 25s
test / publish-infra (push) Successful in 2m1s
2026-07-25 22:34:47 -04:00
didericis-claude 82669b22d5 fix: doctor probes backend readiness; install.sh resolves user-scripts dir
test / integration-docker (push) Successful in 20s
prd-number / assign-numbers (push) Failing after 24s
test / unit (push) Successful in 57s
lint / lint (push) Successful in 1m1s
Update Quality Badges / update-badges (push) Failing after 54s
test / integration-firecracker (push) Successful in 5m7s
test / coverage (push) Successful in 27s
test / publish-infra (push) Successful in 2m34s
Addresses the third review round on PR #481.

- `bot-bottle doctor` now checks `is_backend_ready()` (a full backend
  status() probe: daemon reachable, network pool present, KVM usable)
  instead of the cheap PATH-only `is_backend_available()`. A host with a
  stopped Docker daemon or half-configured Firecracker no longer reports
  `ok: backend` / exit 0 when `start` can't actually work; each not-ready
  backend prints its own diagnostics, and doctor passes only if at least
  one backend is ready.
- `install.sh` resolves the pip `--user` scripts directory from the
  interpreter (`sysconfig.get_path("scripts", get_preferred_scheme("user"))`)
  instead of hardcoding `~/.local/bin`, which is wrong on a python.org
  macOS interpreter (`~/Library/Python/<X.Y>/bin`). The PATH guidance now
  prints the actual directory.

Tests: doctor tests mock `is_backend_ready` (the readiness contract) and
cover the not-ready → fail path; a new install-script test drives the
macOS `osx_framework_user` scheme and asserts it resolves a
non-~/.local/bin directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:25 -04:00
didericis-claude 1a4b390e8a fix: make installed wheel self-contained + harden install.sh prereqs
Addresses the review on PR #481.

Self-contained wheel (review point 1): the gateway/infra/orchestrator
images build from a context that must hold bot_bottle/, pyproject.toml,
and the root-level Dockerfiles. Modules previously located these by
walking __file__ to the repo root, so an installed wheel (package in
site-packages, no repo root) passed `doctor` but failed `start`.

- Add bot_bottle/resources.py: build_root() returns the repo root in a
  checkout (unchanged) or a staged copy from the wheel's bundled
  _resources/ otherwise; dockerfile()/nix_netpool_module()/
  netpool_script() derive from it.
- setup.py bundles the root Dockerfiles, nix module, netpool script, and
  pyproject.toml into bot_bottle/_resources/ at build; MANIFEST.in ships
  them in the sdist.
- Route every _REPO_ROOT/_REPO_DIR call site (docker/macos launch, macos
  infra, firecracker infra_vm/infra_artifact/setup, orchestrator
  lifecycle/gateway) through resources. Checkout behavior is unchanged.

install.sh prerequisites (review point 2): check for git when installing
a git+ spec, and — before the pip fallback — that pip is usable and the
interpreter isn't externally managed (PEP 668), pointing at pipx.

Tests: test_resources covers checkout + staged-wheel layouts;
test_wheel_install builds the wheel, installs it into an isolated venv,
and asserts `doctor` runs and build_root() yields a valid context.
Running `start` end-to-end still needs a Docker/KVM host (CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:25 -04:00
didericis-claude 955cb3bcbd feat: add quick install script and packaging (#197)
Give bot-bottle a real distribution path so new users can install
without cloning the repo:

- pyproject.toml: full project metadata, a `bot-bottle` console-script
  entry point (bot_bottle.cli:main), and package-data for the runtime
  assets (Dockerfiles, egress entrypoint, netpool defaults, macos init).
  Still zero runtime pip dependencies.
- install.sh: POSIX, sudo-free, idempotent bootstrapper — checks Python
  >= 3.11, creates ~/.bot-bottle/{agents,bottles,contrib}, installs via
  pipx (pip --user fallback), then runs `bot-bottle doctor`.
- `bot-bottle doctor`: new store-free subcommand reporting Python
  version, backend availability (reuses is_backend_available rather than
  hardcoding Docker), and config-dir presence. Exits non-zero when a hard
  prerequisite is unmet.
- PRD prd-new-install-script and unit tests for doctor, the packaging
  contract, and the install script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:25 -04:00
didericis-claude 605146d287 ci(integration-macos): dump infra logs on failure before teardown
test / integration-macos (pull_request) Has been skipped
test / integration-docker (pull_request) Successful in 20s
tracker-policy-pr / check-pr (pull_request) Successful in 23s
test / unit (pull_request) Successful in 40s
test / integration-firecracker (pull_request) Successful in 2m8s
test / coverage (pull_request) Successful in 21s
test / publish-infra (pull_request) Has been skipped
A control-plane failure (e.g. the orchestrator becoming unreachable at
bottle registration) is undiagnosable from CI as-is: the `if: always()`
teardown runs `MacosInfraService().stop()`, which deletes the
orchestrator and gateway containers — and their logs — on every run.

Add an `if: failure()` step that dumps `container ls`/`network ls`, plus
`inspect` and `logs` for `bot-bottle-mac-orchestrator` and
`bot-bottle-mac-infra`, ordered before the teardown so the evidence is
captured while the containers still exist. Best-effort (never fails the
job; tolerates an already-removed container).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:22:52 +00:00
didericis-claude 27dea58ae1 integration-macos: dispatch-only (drop push-to-main trigger)
test / integration-macos (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / integration-docker (pull_request) Successful in 34s
test / unit (pull_request) Successful in 44s
lint / lint (push) Successful in 1m2s
test / integration-firecracker (pull_request) Successful in 2m6s
test / coverage (pull_request) Successful in 17s
test / publish-infra (pull_request) Has been skipped
Make the advisory macOS Apple Container job run on workflow_dispatch
only, removing the push-to-`main` trigger so a single non-redundant
laptop never runs unattended on every push. Updates the job comment,
docs/ci.md, README CI note, and the PRD accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:05:26 -04:00
didericis 5401f036a9 docs: correct git-gate status on macOS backend (not deferred)
git-gate is fully implemented on the macos-container backend via the
gateway's consolidated git-http daemon with dynamic key
provisioning/revocation; only the legacy per-bottle git:// daemon is
unused. Fixes a stale README claim that git-gate is "deferred" and
removes the PRD open-question built on that false premise (sandbox-escape
attack 5 runs the same on macOS as on the other backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:05:26 -04:00
didericis 238f5f7614 ci: add advisory macOS Apple Container integration runner
Adds an `integration-macos` job that runs the integration suite against
BOT_BOTTLE_BACKEND=macos-container on a self-hosted host-mode macOS runner
(label `macos`), plus the PRD and provisioning docs.

The job is advisory (push-to-main + workflow_dispatch only, never PRs, not
in coverage.needs) since it targets a single non-redundant laptop. It
preflights `container`/`backend status` so a misprovisioned runner fails
loudly, serializes on a concurrency group, and tears down the
`bot-bottle-mac-infra` singleton on exit (#425).

Also relaxes the TestSandboxEscape CI skip guard: it skipped every backend
but firecracker under GITEA_ACTIONS, which would also skip on a host-mode
macOS runner. The guard's real target is the containerized act_runner, so
it now allows both host-mode backends (firecracker, macos-container)
through — otherwise the macOS job would go green while skipping the one
end-to-end test that proves the backend launches.

Closes #426

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:05:26 -04:00
Quality Badge Bot 2644759b0d chore: update quality badges
- Coverage: 84%
- Core coverage: 94%

[skip ci]
2026-07-26 01:49:46 +00:00
didericis-claude e53104d5c1 refactor(orchestrator): fail closed unconditionally, drop topology opt-out
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / integration-docker (pull_request) Successful in 15s
test / unit (pull_request) Successful in 39s
test / integration-firecracker (pull_request) Successful in 3m55s
test / coverage (pull_request) Successful in 22s
test / publish-infra (pull_request) Has been skipped
prd-number / assign-numbers (push) Failing after 21s
test / integration-docker (push) Successful in 22s
lint / lint (push) Successful in 56s
Update Quality Badges / update-badges (push) Successful in 53s
test / unit (push) Successful in 1m52s
test / integration-firecracker (push) Successful in 5m2s
test / coverage (push) Successful in 16s
test / publish-infra (push) Successful in 1m56s
Remove the empty-key opt-out codex flagged: ControlPlaneProvisioning
no longer lets the orchestrator start without a signing key when a
backend declares an "isolated" topology. Host separation is not the
safety condition — the gateway (and any other caller) must reach the
control-plane listener for /resolve, so an open orchestrator would
still grant them full `cli`. The signing key is now mandatory for
every backend.

Drops the now-purposeless Topology/COLOCATED machinery (no backend
declared a non-default topology) and the contractual
test_orchestrator_key_allows_empty_when_isolated. Updates the PRD's
invariant 4 and lifecycle docstring accordingly. Docs/behaviour only
otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:40:19 +00:00
didericis-claude 8f6148d571 docs(orchestrator): tighten auth-provisioning wording to concrete services
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 21s
test / integration-firecracker (pull_request) Successful in 3m51s
test / unit (pull_request) Failing after 11m53s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Address review on #482: drop the generic "credential boundary" framing in the
PRD and docstrings and talk about the specific services — the orchestrator holds
the control-plane key; the host controller (#468) gets a separate key the
orchestrator never holds, so the orchestrator can't mint the credentials it uses
to talk to the host controller that owns its lifecycle. Lead with that concrete
win. No code behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:32:53 +00:00
didericis-claude 45f3cefbc5 refactor(orchestrator): uniform control-plane auth provisioning per trust domain
Hoist control-plane auth provisioning out of the per-backend launchers into
one shared contract, parameterized per trust domain (#476). Every blocking
finding in PR #471 was the same integration-bug class: each launcher
re-derived, by hand, how to generate the signing key, scope it to the
orchestrator, mint the gateway JWT, and keep the host key canonical.

Introduces `trust_domain.py`:

  * `TrustDomain` — one credential boundary (host-canonical key file + role
    set + env vars). `mint`/`verify` are scoped to the domain's roles, so a
    future host-controller domain (#468) uses its own key/verifier/roles
    rather than a `host` role on the control plane's frozenset (which the
    orchestrator key could then forge).
  * `ControlPlaneProvisioning` — the single seam answering the four
    invariants: host-canonical key, split key-vs-token credential, CLI token
    valid across co-running backends, and fail-closed (no open mode) for any
    co-located topology.
  * `Topology` — the backend declares what it is; the default is co-located +
    fail-closed, so a backend need not redeclare it.

The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes
`mint_gateway_token()` through the contract; docker/macOS/firecracker
orchestrators, the server (verify), and the host CLI client (mint cli) all go
through the domain instead of reading the host key directly. `orchestrator_auth`
gains an optional `roles=` arg (default unchanged) so a domain scopes its own
role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token.

Adds unit coverage for the domain boundary + provisioning invariants and a PRD
capturing the durable rationale. No change to the auth primitive's HMAC, the
plane split, or the server's documented open-mode fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Closes #476
2026-07-26 01:32:53 +00:00
Quality Badge Bot 0e70d26af4 chore: update quality badges
- Coverage: 83%
- Core coverage: 94%

[skip ci]
2026-07-25 23:45:18 +00:00
didericis-codex f2d8158742 Merge pull request 'fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC)' (#471) from fix/db-off-data-plane-469 into main
test / integration-docker (push) Successful in 35s
test / unit (push) Successful in 49s
Update Quality Badges / update-badges (push) Successful in 50s
lint / lint (push) Successful in 57s
test / integration-firecracker (push) Successful in 5m10s
test / coverage (push) Successful in 16s
test / publish-infra (push) Successful in 2m18s
2026-07-25 19:44:20 -04:00
49 changed files with 1943 additions and 101 deletions
+6
View File
@@ -20,3 +20,9 @@ omit =
bot_bottle/cli/tui.py bot_bottle/cli/tui.py
bot_bottle/cli/init.py bot_bottle/cli/init.py
tests/* tests/*
# Build-time only: setuptools invokes it out-of-process to build the
# wheel/sdist (it's never imported by the running app), so in-process
# coverage can't reach it. Its one job — bundling the root resources into
# bot_bottle/_resources/ — is exercised end-to-end by test_wheel_install,
# which builds and installs a real wheel and checks the result.
setup.py
+83
View File
@@ -212,6 +212,89 @@ jobs:
name: firecracker-inputs name: firecracker-inputs
path: /var/cache/bot-bottle-fc/dropbear path: /var/cache/bot-bottle-fc/dropbear
# Integration tests against the macOS Apple Container backend. Runs on a
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
# Container needs the host `container` CLI + virtualization framework and
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
#
# Advisory only: workflow_dispatch (manual) exclusively — never push or
# pull_request. A single non-redundant laptop that sleeps/roams must not run
# unattended on every push to main, let alone block a PR merge, so this job is
# deliberately NOT in the `coverage` job's `needs` and its coverage never
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
# push) ever executes on the host-mode runner.
#
# The infra container is a singleton (`bot-bottle-mac-infra`); the
# `concurrency` group serializes runs so two never collide on it (#425), and
# the always-run teardown removes it so a crashed run can't wedge the next.
#
# Runner prerequisites (provision once; see README "macOS Apple Container"):
# the `container` CLI on PATH with `container system status` running, and a
# Python >=3.11 with `coverage` importable on the launchd service PATH.
integration-macos:
runs-on: [self-hosted, macos]
if: github.event_name == 'workflow_dispatch'
concurrency:
group: integration-macos-infra
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
# Fail loudly if the backend this job promises isn't actually usable,
# rather than letting every test silently `unittest.skip` and the job go
# green on zero coverage. `backend status` exits non-zero (and prints the
# per-check summary) when the `container` CLI or its system service is
# missing — the same readiness check the skip guards gate on.
- name: Preflight — Apple Container backend is ready
run: |
command -v container >/dev/null || {
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
container system status || {
echo "container system service not running — run 'container system start'"; exit 1; }
python3 cli.py backend status --backend=macos-container
# `coverage` comes from the runner's provisioned Python (no pip install
# into the host interpreter). Advisory job: report coverage in-line for
# visibility but don't upload — it never feeds the combined gate.
- name: Run integration tests (macos-container) with coverage
env:
BOT_BOTTLE_BACKEND: macos-container
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
- name: Report macos coverage
env:
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
run: python3 -m coverage report -m
# On failure, capture the infra containers' state and logs BEFORE the
# teardown below removes them — otherwise a control-plane crash is
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
# logs) on every run. Best-effort: never let the diagnostics themselves
# fail the job, and keep going if a container is already gone.
- name: Dump infra diagnostics (on failure)
if: failure()
run: |
set +e
echo "=== containers ==="
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
echo "=== networks ==="
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
echo "=== inspect $c ==="
container inspect "$c" || echo "($c not found)"
echo "=== logs $c ==="
container logs "$c" || echo "($c logs unavailable)"
done
exit 0
# Remove the singleton infra container so a crashed or cancelled run
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
- name: Teardown infra singleton
if: always()
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each # Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
# test job, then runs the diff-coverage gate (new/changed lines >= 90%). # test job, then runs the diff-coverage gate (new/changed lines >= 90%).
# #
+3
View File
@@ -17,6 +17,9 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.egg-info/ *.egg-info/
# setuptools/build_meta output (wheels, sdists, build tree)
/build/
/dist/
.venv/ .venv/
venv/ venv/
.pytest_cache/ .pytest_cache/
+8
View File
@@ -0,0 +1,8 @@
# Root-level build resources copied into bot_bottle/_resources/ at build time
# (see setup.py). Included in the sdist so `pip install` from an sdist can
# still bundle them into the wheel.
include Dockerfile.gateway
include Dockerfile.orchestrator
include Dockerfile.orchestrator.fc
include nix/firecracker-netpool.nix
include scripts/firecracker-netpool.sh
+4 -2
View File
@@ -5,7 +5,7 @@
# 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-84%25-brightgreen)](https://coverage.readthedocs.io/) [![coverage](https://img.shields.io/badge/coverage-83%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-94%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.
@@ -30,7 +30,7 @@
## 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 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. git-gate runs over the gateway's consolidated `git-http` daemon (the legacy per-bottle `git://` daemon is not used on this backend); keys are provisioned dynamically at launch and revoked on teardown.
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 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.
@@ -75,6 +75,8 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
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.
> **CI (macOS Apple Container):** the `integration-macos` job (`.gitea/workflows/test.yml`) runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container` on a self-hosted macOS runner labelled `macos`, because Apple Container needs the host virtualization framework and cannot run in a Linux container (so it can't reuse the `kvm` runner). Provision an Apple Silicon host with the `container` CLI on `PATH` and `container system status` running, then register the runner in **host mode** (not docker mode) with the `macos` label — `brew install gitea-runner` (the `act_runner` rename). Give it a Python ≥ 3.11 with `coverage` importable on the launchd service's `PATH` (a launchd service doesn't inherit your shell profile, so pin `node` and the Python env explicitly). The job is **advisory** — `workflow_dispatch` (manual) only, never triggered by push or PR — since a single laptop that sleeps/roams must not block merges or churn on every push to main; its coverage doesn't feed the gate. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1.
### Containers inside a bottle ### Containers inside a bottle
A bottle may set `nested_containers: true`. On the macOS backend this starts a A bottle may set `nested_containers: true`. On the macOS backend this starts a
+8 -4
View File
@@ -10,9 +10,10 @@ from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
host_gateway_ca_dir, host_gateway_ca_dir,
) )
from ... import resources
from ...gateway import ( from ...gateway import (
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME, GATEWAY_DOCKERFILE, GATEWAY_LABEL, MITMPROXY_HOME,
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
) )
@@ -50,7 +51,9 @@ class DockerGateway(Gateway):
# `address` / `stop` work on an already-running gateway without it. # `address` / `stop` work on an already-running gateway without it.
self._orchestrator_url = "" self._orchestrator_url = ""
self._gateway_token = "" self._gateway_token = ""
self._build_context = build_context or REPO_ROOT # Resolved lazily in ensure_built() so merely constructing a gateway to
# read its CA never stages a build root from an installed wheel.
self._build_context = build_context
self._dockerfile = dockerfile self._dockerfile = dockerfile
# Ports published on the host (0.0.0.0). Used by the Firecracker # 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; # backend's dev-harness gateway so VMs can reach it via their TAP link;
@@ -72,9 +75,10 @@ class DockerGateway(Gateway):
forces a full rebuild (parity with `start --no-cache`).""" forces a full rebuild (parity with `start --no-cache`)."""
if self._dockerfile is None: if self._dockerfile is None:
return return
context = self._build_context or resources.build_root()
argv = ["docker", "build", "-t", self.image_ref, argv = ["docker", "build", "-t", self.image_ref,
"-f", str(self._build_context / self._dockerfile), "-f", str(context / self._dockerfile),
str(self._build_context)] str(context)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"): if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache") argv.insert(2, "--no-cache")
proc = run_docker(argv) proc = run_docker(argv)
+5 -4
View File
@@ -34,6 +34,7 @@ from .orchestrator import (
ORCHESTRATOR_NETWORK, ORCHESTRATOR_NETWORK,
) )
from ...paths import bot_bottle_root from ...paths import bot_bottle_root
from ... import resources
from ...gateway import ( from ...gateway import (
GATEWAY_IMAGE, GATEWAY_IMAGE,
GATEWAY_NAME, GATEWAY_NAME,
@@ -50,8 +51,6 @@ from ...orchestrator.lifecycle import (
# the pair's public identity. # the pair's public identity.
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
_REPO_ROOT = Path(__file__).resolve().parents[3]
class DockerInfraService(InfraService): class DockerInfraService(InfraService):
"""Composes the per-host control plane + gateway as two containers. """Composes the per-host control plane + gateway as two containers.
@@ -68,7 +67,7 @@ class DockerInfraService(InfraService):
control_network: str = ORCHESTRATOR_NETWORK, control_network: str = ORCHESTRATOR_NETWORK,
orchestrator_image: str = ORCHESTRATOR_IMAGE, orchestrator_image: str = ORCHESTRATOR_IMAGE,
gateway_image: str = GATEWAY_IMAGE, gateway_image: str = GATEWAY_IMAGE,
repo_root: Path = _REPO_ROOT, repo_root: Path | None = None,
host_root: Path | None = None, host_root: Path | None = None,
orchestrator_name: str = ORCHESTRATOR_NAME, orchestrator_name: str = ORCHESTRATOR_NAME,
orchestrator_label: str = ORCHESTRATOR_LABEL, orchestrator_label: str = ORCHESTRATOR_LABEL,
@@ -79,7 +78,9 @@ class DockerInfraService(InfraService):
self.control_network = control_network self.control_network = control_network
self.orchestrator_image = orchestrator_image self.orchestrator_image = orchestrator_image
self.gateway_image = gateway_image self.gateway_image = gateway_image
self._repo_root = repo_root # Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._host_root = host_root or bot_bottle_root() self._host_root = host_root or bot_bottle_root()
self._orchestrator_name = orchestrator_name self._orchestrator_name = orchestrator_name
self._orchestrator_label = orchestrator_label self._orchestrator_label = orchestrator_label
+2 -6
View File
@@ -33,7 +33,6 @@ from __future__ import annotations
import dataclasses import dataclasses
import os import os
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for from ...agent_provider import runtime_for
@@ -65,10 +64,7 @@ from ...orchestrator.store.config_store import resolve_teardown_timeout
from .consolidated_launch import launch_consolidated, deprovision_consolidated from .consolidated_launch import launch_consolidated, deprovision_consolidated
from .infra import INFRA_NAME from .infra import INFRA_NAME
from .gateway import DockerGateway from .gateway import DockerGateway
from ... import resources
# Where the repo root lives, for `docker build` context. Computed once.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages: def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
@@ -88,7 +84,7 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
) )
info(f"using cached agent image {plan.image!r}") info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image) return BottleImages(agent=plan.image)
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path) docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image( docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test, plan.image, runtime_for(plan.agent_provider_template).smoke_test,
) )
+8 -6
View File
@@ -15,11 +15,11 @@ import time
from pathlib import Path from pathlib import Path
from ... import log from ... import log
from ... import resources
from .util import run_docker from .util import run_docker
from ...paths import ( from ...paths import (
ORCHESTRATOR_TOKEN_ENV, ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root, bot_bottle_root,
host_orchestrator_token,
) )
from ...gateway import GatewayError from ...gateway import GatewayError
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
@@ -56,8 +56,6 @@ _ROOT_IN_CONTAINER = "/bot-bottle-root"
_HEALTH_POLL_SECONDS = 0.25 _HEALTH_POLL_SECONDS = 0.25
_REPO_ROOT = Path(__file__).resolve().parents[3]
class DockerOrchestrator(Orchestrator): class DockerOrchestrator(Orchestrator):
"""The control plane as a single fixed-name container. `ensure_built` builds """The control plane as a single fixed-name container. `ensure_built` builds
@@ -72,7 +70,7 @@ class DockerOrchestrator(Orchestrator):
label: str = ORCHESTRATOR_LABEL, label: str = ORCHESTRATOR_LABEL,
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
control_network: str = ORCHESTRATOR_NETWORK, control_network: str = ORCHESTRATOR_NETWORK,
repo_root: Path = _REPO_ROOT, repo_root: Path | None = None,
host_root: Path | None = None, host_root: Path | None = None,
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE, dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
) -> None: ) -> None:
@@ -81,7 +79,9 @@ class DockerOrchestrator(Orchestrator):
self.label = label self.label = label
self.port = port self.port = port
self.control_network = control_network self.control_network = control_network
self._repo_root = repo_root # Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._host_root = host_root or bot_bottle_root() self._host_root = host_root or bot_bottle_root()
self._dockerfile = dockerfile self._dockerfile = dockerfile
@@ -171,7 +171,9 @@ class DockerOrchestrator(Orchestrator):
fixed-name container first).""" fixed-name container first)."""
self._ensure_control_network() self._ensure_control_network()
run_docker(["docker", "rm", "--force", self.name]) run_docker(["docker", "rm", "--force", self.name])
_signing_key = host_orchestrator_token() # The signing key comes through the shared provisioning contract (#476),
# which fail-closes rather than yield an empty key that would run OPEN.
_signing_key = self.control_plane_key()
proc = run_docker([ proc = run_docker([
"docker", "run", "--detach", "docker", "run", "--detach",
"--name", self.name, "--name", self.name,
@@ -37,6 +37,7 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from ... import resources
from ...log import die, info from ...log import die, info
from . import util from . import util
@@ -44,8 +45,6 @@ from . import util
# scheme can't collide with a cached/published artifact of the old one. # scheme can't collide with a cached/published artifact of the old one.
_ARTIFACT_FORMAT = "1" _ARTIFACT_FORMAT = "1"
_REPO_ROOT = Path(__file__).resolve().parents[3]
# The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact # The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact
# from its own generic package; the Dockerfiles baked into each differ (only the # from its own generic package; the Dockerfiles baked into each differ (only the
# orchestrator rootfs carries buildah), so the versions are hashed separately. # orchestrator rootfs carries buildah), so the versions are hashed separately.
@@ -74,7 +73,7 @@ def local_build_requested() -> bool:
def infra_artifact_version( def infra_artifact_version(
init_script: str, role: str, *, repo_root: Path = _REPO_ROOT, init_script: str, role: str, *, repo_root: Path | None = None,
) -> str: ) -> str:
"""Content hash (16 hex) of everything baked into `role`'s infra rootfs: the """Content hash (16 hex) of everything baked into `role`'s infra rootfs: the
whole shipped `bot_bottle` package, that role's Dockerfiles, and its guest whole shipped `bot_bottle` package, that role's Dockerfiles, and its guest
@@ -89,6 +88,8 @@ def infra_artifact_version(
version or a launch host could boot a stale rootfs whose code differs from version or a launch host could boot a stale rootfs whose code differs from
its checkout. `__pycache__`/`.pyc` are the only exclusions — build artifacts, its checkout. `__pycache__`/`.pyc` are the only exclusions — build artifacts,
never copied.""" never copied."""
if repo_root is None:
repo_root = resources.build_root()
h = hashlib.sha256() h = hashlib.sha256()
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode()) h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
pkg = repo_root / "bot_bottle" pkg = repo_root / "bot_bottle"
+5 -4
View File
@@ -42,6 +42,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generator from typing import Generator
from ... import resources
from ...log import die, info from ...log import die, info
from ..docker import util as docker_mod from ..docker import util as docker_mod
from . import firecracker_vm, infra_artifact, netpool, util from . import firecracker_vm, infra_artifact, netpool, util
@@ -65,7 +66,6 @@ _GUEST_GATEWAY_JWT_PATH = "/var/lib/bot-bottle/gateway-jwt"
_GATEWAY_IMAGE = "bot-bottle-gateway:latest" _GATEWAY_IMAGE = "bot-bottle-gateway:latest"
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest" _ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
_ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc:latest" _ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc:latest"
_REPO_ROOT = Path(__file__).resolve().parents[3]
# Per-role rootfs source image + the extra free space `mke2fs` leaves for the # Per-role rootfs source image + the extra free space `mke2fs` leaves for the
# guest to grow into. The orchestrator keeps buildah's large build slack; the # guest to grow into. The orchestrator keeps buildah's large build slack; the
@@ -130,12 +130,13 @@ def build_infra_images_with_docker() -> None:
orchestrator + buildah). The gateway VM boots the gateway image directly. orchestrator + buildah). The gateway VM boots the gateway image directly.
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode; The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
`publish_infra` uses it off-host to produce the published artifacts.""" `publish_infra` uses it off-host to produce the published artifacts."""
root = str(resources.build_root())
docker_mod.build_image( docker_mod.build_image(
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator") _ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
docker_mod.build_image( docker_mod.build_image(
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway") _GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
docker_mod.build_image( docker_mod.build_image(
_ORCHESTRATOR_FC_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator.fc") _ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc")
def build_rootfs_dir(role: str) -> Path: def build_rootfs_dir(role: str) -> Path:
@@ -21,7 +21,6 @@ import time
from pathlib import Path from pathlib import Path
from ...log import die, info from ...log import die, info
from ...paths import host_orchestrator_token
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
DEFAULT_STARTUP_TIMEOUT_SECONDS, DEFAULT_STARTUP_TIMEOUT_SECONDS,
Orchestrator, Orchestrator,
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
data_drive=self._ensure_registry_volume(), data_drive=self._ensure_registry_volume(),
) )
# Push the host-canonical signing key (the init waits for it before # Push the host-canonical signing key (the init waits for it before
# starting the control plane). The host token file stays the single # starting the control plane). It comes through the shared provisioning
# source of truth, so a co-running docker/macOS control plane keeps # contract (#476) — the same host token file every backend uses, so a
# working; the guest verifies tokens with the same key the CLI signs from. # co-running docker/macOS control plane keeps working and the guest
# verifies tokens with the same key the CLI signs from; fail-closed, so
# the guest is never handed an empty key that would run it OPEN.
infra_vm.push_secret( infra_vm.push_secret(
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH, vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
"the control-plane signing key to the orchestrator VM " "the control-plane signing key to the orchestrator VM "
"(its control plane will not start)", "(its control plane will not start)",
) )
+5 -4
View File
@@ -20,6 +20,7 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from ... import resources
from . import netpool from . import netpool
from . import util from . import util
@@ -42,13 +43,13 @@ def _has_systemd() -> bool:
def _module_path() -> str: def _module_path() -> str:
"""Absolute path to the importable NixOS module in this checkout.""" """Absolute path to the importable NixOS module (checkout or wheel)."""
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix") return str(resources.nix_netpool_module())
def _script_path() -> str: def _script_path() -> str:
"""Absolute path to the bundled bring-up script in this checkout.""" """Absolute path to the bundled bring-up script (checkout or wheel)."""
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh") return str(resources.netpool_script())
def _print_prereqs() -> None: def _print_prereqs() -> None:
@@ -28,6 +28,7 @@ from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
host_gateway_ca_dir, host_gateway_ca_dir,
) )
from ... import resources
from .. import util as backend_util from .. import util as backend_util
from . import util as container_mod from . import util as container_mod
@@ -52,8 +53,6 @@ GATEWAY_DAEMONS = "egress,git-http,supervise"
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest") GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
_REPO_ROOT = Path(__file__).resolve().parents[3]
def ensure_networks( def ensure_networks(
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
@@ -84,14 +83,16 @@ class MacosGateway(Gateway):
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
egress_network: str = GATEWAY_EGRESS_NETWORK, egress_network: str = GATEWAY_EGRESS_NETWORK,
control_network: str = CONTROL_NETWORK, control_network: str = CONTROL_NETWORK,
repo_root: Path = _REPO_ROOT, repo_root: Path | None = None,
) -> None: ) -> None:
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
self.network = network self.network = network
self.egress_network = egress_network self.egress_network = egress_network
self.control_network = control_network self.control_network = control_network
self._repo_root = repo_root # Build context: the repo root in a checkout, a staged copy from the
# installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy # Set by `connect_to_orchestrator`: the URL the daemons resolve policy
# against + the pre-minted `gateway` token they present. The gateway # against + the pre-minted `gateway` token they present. The gateway
# never mints, so it never holds the signing key (#469). # never mints, so it never holds the signing key (#469).
+5 -4
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from ... import resources
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS, DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -53,8 +54,6 @@ from .orchestrator import (
# still import it (probe / reprovision attribute against the gateway). # still import it (probe / reprovision attribute against the gateway).
INFRA_NAME = GATEWAY_NAME INFRA_NAME = GATEWAY_NAME
_REPO_ROOT = Path(__file__).resolve().parents[3]
class MacosInfraService(InfraService): class MacosInfraService(InfraService):
"""Composes the per-host orchestrator + gateway containers. Callers use """Composes the per-host orchestrator + gateway containers. Callers use
@@ -70,7 +69,7 @@ class MacosInfraService(InfraService):
control_network: str = CONTROL_NETWORK, control_network: str = CONTROL_NETWORK,
gateway_image: str = GATEWAY_IMAGE, gateway_image: str = GATEWAY_IMAGE,
orchestrator_image: str = ORCHESTRATOR_IMAGE, orchestrator_image: str = ORCHESTRATOR_IMAGE,
repo_root: Path = _REPO_ROOT, repo_root: Path | None = None,
orchestrator_name: str = ORCHESTRATOR_NAME, orchestrator_name: str = ORCHESTRATOR_NAME,
gateway_name: str = INFRA_NAME, gateway_name: str = INFRA_NAME,
db_volume: str = ORCHESTRATOR_DB_VOLUME, db_volume: str = ORCHESTRATOR_DB_VOLUME,
@@ -81,7 +80,9 @@ class MacosInfraService(InfraService):
self.control_network = control_network self.control_network = control_network
self.gateway_image = gateway_image self.gateway_image = gateway_image
self.orchestrator_image = orchestrator_image self.orchestrator_image = orchestrator_image
self._repo_root = repo_root # Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._orchestrator_name = orchestrator_name self._orchestrator_name = orchestrator_name
self._gateway_name = gateway_name self._gateway_name = gateway_name
self._db_volume = db_volume self._db_volume = db_volume
+2 -3
View File
@@ -36,7 +36,6 @@ import dataclasses
import os import os
import subprocess import subprocess
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...bottle_state import ( from ...bottle_state import (
@@ -49,6 +48,7 @@ from ...git_gate import GitGate
from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...image_cache import check_stale from ...image_cache import check_stale
from ...log import die, info, warn from ...log import die, info, warn
from ... import resources
from .. import BottleImages from .. import BottleImages
from ...supervisor.types import SUPERVISE_PORT from ...supervisor.types import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ..docker.egress import EGRESS_PORT
@@ -71,7 +71,6 @@ from .consolidated_launch import (
deprovision_consolidated, deprovision_consolidated,
) )
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_AGENT_SLEEP_SECONDS = "2147483647" _AGENT_SLEEP_SECONDS = "2147483647"
@@ -94,7 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
) )
info(f"using cached agent image {plan.image!r}") info(f"using cached agent image {plan.image!r}")
return plan.image return plan.image
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path) container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
return plan.image return plan.image
@@ -18,10 +18,8 @@ import urllib.request
from pathlib import Path from pathlib import Path
from ... import log from ... import log
from ...paths import ( from ... import resources
ORCHESTRATOR_TOKEN_ENV, from ...paths import ORCHESTRATOR_TOKEN_ENV
host_orchestrator_token,
)
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
DEFAULT_HEALTH_TIMEOUT_SECONDS, DEFAULT_HEALTH_TIMEOUT_SECONDS,
DEFAULT_PORT, DEFAULT_PORT,
@@ -48,7 +46,6 @@ _DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
_SRC_IN_CONTAINER = "/bot-bottle-src" _SRC_IN_CONTAINER = "/bot-bottle-src"
_HEALTH_POLL_SECONDS = 0.25 _HEALTH_POLL_SECONDS = 0.25
_REPO_ROOT = Path(__file__).resolve().parents[3]
class MacosOrchestrator(Orchestrator): class MacosOrchestrator(Orchestrator):
@@ -64,7 +61,7 @@ class MacosOrchestrator(Orchestrator):
label: str = ORCHESTRATOR_LABEL, label: str = ORCHESTRATOR_LABEL,
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
control_network: str = CONTROL_NETWORK, control_network: str = CONTROL_NETWORK,
repo_root: Path = _REPO_ROOT, repo_root: Path | None = None,
db_volume: str = ORCHESTRATOR_DB_VOLUME, db_volume: str = ORCHESTRATOR_DB_VOLUME,
) -> None: ) -> None:
self.image_ref = image_ref self.image_ref = image_ref
@@ -72,7 +69,9 @@ class MacosOrchestrator(Orchestrator):
self.label = label self.label = label
self.port = port self.port = port
self.control_network = control_network self.control_network = control_network
self._repo_root = repo_root # Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._db_volume = db_volume self._db_volume = db_volume
def url(self) -> str: def url(self) -> str:
@@ -134,7 +133,9 @@ class MacosOrchestrator(Orchestrator):
def _run_container(self, current_hash: str) -> None: def _run_container(self, current_hash: str) -> None:
container_mod.force_remove_container(self.name) container_mod.force_remove_container(self.name)
_signing_key = host_orchestrator_token() # The signing key comes through the shared provisioning contract (#476),
# which fail-closes rather than yield an empty key that would run OPEN.
_signing_key = self.control_plane_key()
argv = [ argv = [
"container", "run", "--detach", "container", "run", "--detach",
"--name", self.name, "--name", self.name,
+2 -1
View File
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
"backend": "backend:cmd_backend", "backend": "backend:cmd_backend",
"cleanup": "cleanup:cmd_cleanup", "cleanup": "cleanup:cmd_cleanup",
"commit": "commit:cmd_commit", "commit": "commit:cmd_commit",
"doctor": "doctor:cmd_doctor",
"edit": "edit:cmd_edit", "edit": "edit:cmd_edit",
"help": "help:cmd_help", "help": "help:cmd_help",
"init": "init:cmd_init", "init": "init:cmd_init",
@@ -53,6 +54,6 @@ COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
# gating it on the schema breaks preflight on a fresh CI runner where stdin # 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. `help` and `login` # isn't a TTY and the migration prompt can't be answered. `help` and `login`
# likewise never touch the store. # likewise never touch the store.
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"}) NO_MIGRATION_COMMANDS = frozenset({"backend", "doctor", "help", "login"})
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"] __all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
+88
View File
@@ -0,0 +1,88 @@
"""`doctor` CLI command — validate host prerequisites for running
bot-bottle and report what's ready.
Fails (non-zero exit) only on the two hard requirements: a new-enough
Python and at least one backend that is *ready* (passes its full status
checks, so `start` can actually work). The config directory is a soft
check `install.sh` creates it, but a missing one only warrants a note,
not a failure, since `start` provisions what it needs on first run.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ...backend import is_backend_ready, known_backend_names
from ..constants import PROG
MIN_PYTHON = (3, 11)
CONFIG_DIR = ".bot-bottle"
def _ok(label: str, detail: str) -> None:
print(f"ok: {label}: {detail}")
def _warn(label: str, detail: str) -> None:
print(f"warn: {label}: {detail}")
def _fail(label: str, detail: str) -> None:
print(f"fail: {label}: {detail}")
def _check_python() -> bool:
v = sys.version_info
detail = f"{v.major}.{v.minor}.{v.micro}"
if (v.major, v.minor) >= MIN_PYTHON:
_ok("python", detail)
return True
_fail("python", f"{detail}; need {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
return False
def _check_backends() -> bool:
"""At least one backend must be *ready* to run a bottle — i.e. pass its
full status() checks (daemon reachable, network pool present, KVM usable),
not merely have a binary on PATH. A binary-only check would report `ok`
on a host with a stopped Docker daemon or a half-configured Firecracker,
where `start` still can't work. Each not-ready backend prints its own
diagnostics (quiet=False) so the operator sees exactly what's missing."""
ready = []
for name in known_backend_names():
if is_backend_ready(name, quiet=False):
_ok("backend", f"{name}: ready")
ready.append(name)
else:
_warn("backend", f"{name}: not ready (see diagnostics above)")
if ready:
return True
_fail(
"backend",
"no backend is ready to run a bottle; start Docker, or finish "
"Apple Container (macOS) / Firecracker (Linux) setup",
)
return False
def _check_config_dir() -> None:
config = Path.home() / CONFIG_DIR
if config.is_dir():
_ok("config", str(config))
else:
_warn("config", f"{config} does not exist yet (created on first use)")
def cmd_doctor(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog=f"{PROG} doctor",
description="Check host prerequisites for running bot-bottle.",
)
parser.parse_args(argv)
# Hard requirements gate the exit code; the config note is advisory.
required = [_check_python(), _check_backends()]
_check_config_dir()
return 0 if all(required) else 1
+1
View File
@@ -25,6 +25,7 @@ def cmd_help(argv: list[str] | None = None) -> int:
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n") w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
w(" cleanup stop and remove all active bot-bottle containers\n") w(" cleanup stop and remove all active bot-bottle containers\n")
w(" commit snapshot a running bottle's container state to a Docker image\n") w(" commit snapshot a running bottle's container state to a Docker image\n")
w(" doctor check host prerequisites (Python, backend, config dir)\n")
w(" edit open an agent in vim for editing\n") w(" edit open an agent in vim for editing\n")
w(" help show this command list\n") w(" help show this command list\n")
w(" init interactively create a new agent and add it to bot-bottle.json\n") w(" init interactively create a new agent and add it to bot-bottle.json\n")
-1
View File
@@ -62,7 +62,6 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE. # 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_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
GATEWAY_DOCKERFILE = "Dockerfile.gateway" GATEWAY_DOCKERFILE = "Dockerfile.gateway"
REPO_ROOT = Path(__file__).resolve().parents[2]
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]: def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
+3 -3
View File
@@ -18,8 +18,8 @@ import urllib.request
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from ..orchestrator_auth import ROLE_CLI, mint from ..orchestrator_auth import ROLE_CLI
from ..paths import host_orchestrator_token from ..trust_domain import CONTROL_PLANE
from .server import ORCHESTRATOR_AUTH_HEADER from .server import ORCHESTRATOR_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0 DEFAULT_TIMEOUT_SECONDS = 5.0
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
"" means 'send no auth header' correct against an open (unconfigured) "" means 'send no auth header' correct against an open (unconfigured)
control plane, and harmlessly rejected by a secured one.""" control plane, and harmlessly rejected by a secured one."""
try: try:
return mint(ROLE_CLI, host_orchestrator_token()) return CONTROL_PLANE.mint(ROLE_CLI)
except (OSError, ValueError): except (OSError, ValueError):
return "" return ""
+19 -5
View File
@@ -21,8 +21,7 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from ..orchestrator_auth import ROLE_GATEWAY, mint from ..trust_domain import ControlPlaneProvisioning
from ..paths import host_orchestrator_token
DEFAULT_PORT = 8099 DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0 DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
so it not the gateway mints the gateway's role-scoped token. so it not the gateway mints the gateway's role-scoped token.
Backend-neutral.""" Backend-neutral."""
# The shared control-plane auth provisioning contract (#476). Every backend
# gets its signing key + gateway token through this one seam rather than
# re-deriving the wiring; it is fail-closed for every backend — the
# orchestrator never starts without its signing key.
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
def ensure_built(self) -> None: def ensure_built(self) -> None:
"""Ensure the orchestrator's image / rootfs exists, building it if """Ensure the orchestrator's image / rootfs exists, building it if
needed. Default: nothing to build (e.g. a pre-pulled image). Call before needed. Default: nothing to build (e.g. a pre-pulled image). Call before
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
def mint_gateway_token(self) -> str: def mint_gateway_token(self) -> str:
"""Mint a role-scoped `gateway` JWT from the host signing key for the """Mint a role-scoped `gateway` JWT from the host signing key for the
gateway to present. The orchestrator holds the key; the gateway never gateway to present. The orchestrator holds the key; the gateway never
does (#469). Backend-neutral — the same host token file is the single does (#469). Routed through the shared provisioning contract (#476), so
source of truth across backends.""" the same host token file is the single source of truth across backends."""
return mint(ROLE_GATEWAY, host_orchestrator_token()) return self.provisioning.gateway_token()
def control_plane_key(self) -> str:
"""The raw signing key the control-plane *process* must receive — the ONE
place a backend obtains it (docker/macOS inject it as `key_env`;
firecracker pushes it to the guest). Fail-closed via the provisioning
contract: it raises rather than yield an empty key that would run the
server OPEN (#476)."""
return self.provisioning.orchestrator_key()
__all__ = [ __all__ = [
@@ -117,4 +130,5 @@ __all__ = [
"OrchestratorStartError", "OrchestratorStartError",
"source_hash", "source_hash",
"Orchestrator", "Orchestrator",
"ControlPlaneProvisioning",
] ]
+7 -5
View File
@@ -63,8 +63,8 @@ import sys
import typing import typing
from urllib.parse import urlsplit from urllib.parse import urlsplit
from ..orchestrator_auth import ROLE_CLI, ROLES, verify from ..orchestrator_auth import ROLE_CLI, ROLES
from ..paths import ORCHESTRATOR_TOKEN_ENV from ..trust_domain import CONTROL_PLANE
from ..supervisor.types import TOOLS from ..supervisor.types import TOOLS
from .service import OrchestratorCore from .service import OrchestratorCore
@@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None: def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
self.orchestrator = orchestrator self.orchestrator = orchestrator
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip() # The control-plane trust domain's signing key, as injected into THIS
# (the owning) process by the launcher (#476). Unset → open mode below.
self._signing_key = CONTROL_PLANE.key_from_env()
if not self._signing_key: if not self._signing_key:
sys.stderr.write( sys.stderr.write(
"orchestrator: WARNING — no control-plane signing key " "orchestrator: WARNING — no control-plane signing key "
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller " f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive " "authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable " "it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n" "network MUST set this.\n"
@@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
role ( per-route 401/403 in `dispatch`).""" role ( per-route 401/403 in `dispatch`)."""
if not self._signing_key: if not self._signing_key:
return ROLE_CLI return ROLE_CLI
return verify(presented, self._signing_key) return CONTROL_PLANE.verify(presented, self._signing_key)
def make_server( def make_server(
+14 -8
View File
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
) )
def mint(role: str, secret: str) -> str: def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
"""A compact HS256 token asserting `role`, signed with `secret`. """A compact HS256 token asserting `role`, signed with `secret`.
Raises ValueError for an unknown role (mint only what the control plane will `roles` is the set the signing key is allowed to sign (default: the
accept) or an empty signing key (an unsigned credential is never valid).""" orchestrator's `{gateway, cli}`). A separate service (e.g. the host
if role not in ROLES: controller) passes its own key + role set so its tokens can't be forged with
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
Raises ValueError for a role outside `roles`, or an empty signing key (an
unsigned credential is never valid)."""
if role not in roles:
raise ValueError(f"unknown control-plane role {role!r}") raise ValueError(f"unknown control-plane role {role!r}")
if not secret: if not secret:
raise ValueError("cannot mint a control-plane token without a signing key") raise ValueError("cannot mint a control-plane token without a signing key")
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
return f"{signing_input}.{_sign(secret, signing_input)}" return f"{signing_input}.{_sign(secret, signing_input)}"
def verify(token: str, secret: str) -> str | None: def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
"""The role a valid `token` carries, or None if it is malformed, wrongly """The role a valid `token` carries, or None if it is malformed, wrongly
signed, or names an unknown role. Constant-time signature check; rejects any signed, or names a role outside `roles` (the verifying trust domain's set —
header whose alg isn't HS256 (no alg-confusion / `none`).""" default `{gateway, cli}`). Constant-time signature check; rejects any header
whose alg isn't HS256 (no alg-confusion / `none`)."""
if not token or not secret: if not token or not secret:
return None return None
parts = token.split(".") parts = token.split(".")
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
if not isinstance(header, dict) or header.get("alg") != _ALG: if not isinstance(header, dict) or header.get("alg") != _ALG:
return None return None
role = payload.get("role") if isinstance(payload, dict) else None role = payload.get("role") if isinstance(payload, dict) else None
return role if isinstance(role, str) and role in ROLES else None return role if isinstance(role, str) and role in roles else None
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"] __all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
+19 -9
View File
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
return ca_dir return ca_dir
def host_orchestrator_token() -> str: def host_signing_key(filename: str) -> str:
"""The per-host control-plane secret, minted (256-bit, url-safe) and """A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
persisted 0600 on first use, then reused. and persisted 0600 on first use, then reused.
This is the shared secret the launchers inject into the control-plane and The generic form of `host_orchestrator_token()`: each service names its own
gateway containers and that the host CLI presents on every call. It is a key file (`trust_domain.py`), so the orchestrator and a separate service like
*host* artifact the file lives under the root the agent never mounts, and the host controller (#468) get distinct keys neither can read. It is a *host*
the env var is set only on the trusted containers so reading it here is artifact the file lives under the root the agent never mounts, and its value
safe on the host launch path but the value never reaches a bottle.""" is injected only into the trusted control-plane process so reading it here
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME is safe on the launch path but the value never reaches a bottle."""
path = bot_bottle_root() / filename
try: try:
existing = path.read_text().strip() existing = path.read_text().strip()
if existing: if existing:
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
return token return token
def host_orchestrator_token() -> str:
"""The per-host control-plane signing key — the host-canonical key the
launchers inject into the control-plane process and the host CLI mints its
own `cli` token from. The `control-plane` trust domain's specialization of
`host_signing_key()`."""
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
__all__ = [ __all__ = [
"HOST_DB_FILENAME", "HOST_DB_FILENAME",
"ORCHESTRATOR_TOKEN_FILENAME", "ORCHESTRATOR_TOKEN_FILENAME",
@@ -138,5 +147,6 @@ __all__ = [
"host_db_path", "host_db_path",
"host_db_dir", "host_db_dir",
"host_gateway_ca_dir", "host_gateway_ca_dir",
"host_signing_key",
"host_orchestrator_token", "host_orchestrator_token",
] ]
+159
View File
@@ -0,0 +1,159 @@
"""Locate build-time resources whether bot-bottle runs from a source
checkout or an installed wheel.
The gateway / infra / orchestrator images are built from a Docker (or Apple
`container`) build context that must contain the `bot_bottle` package,
`pyproject.toml`, and the root-level Dockerfiles as siblings. In a source
checkout that context is simply the repo root, one level above the package.
An installed wheel has no repo root: the same root-level files are shipped
inside the package under ``bot_bottle/_resources/`` (see ``setup.py``), and a
repo-root-shaped build context is staged on demand into the app-data dir.
``build_root()`` is the single source of truth it returns a directory laid
out like a repo root (has ``bot_bottle/``, ``pyproject.toml``, the
Dockerfiles, ``nix/``, ``scripts/``). Every caller that needs a build
context, a Dockerfile path, the nix netpool module, or the netpool script
derives from it, so checkout and wheel installs share one downstream path.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import shutil
import tempfile
from pathlib import Path
from .paths import bot_bottle_root
_PKG = Path(__file__).resolve().parent # …/bot_bottle
_CHECKOUT_ROOT = _PKG.parent # repo root in a checkout
_BUNDLED = _PKG / "_resources" # wheel-shipped copies
# Root-level files bundled into the wheel under ``_resources/`` (paths are
# relative to the checkout root, and preserved verbatim under ``_resources/``
# and in the staged build root). ``setup.py`` copies exactly this set; keep
# the two lists in sync (``test_resources`` guards that every entry exists).
BUNDLED_RESOURCES: tuple[str, ...] = (
"pyproject.toml",
"Dockerfile.gateway",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
"nix/firecracker-netpool.nix",
"scripts/firecracker-netpool.sh",
)
# Present at a checkout root, never in a bare installed package — the cheap
# tell for which layout we're in.
_CHECKOUT_MARKER = "Dockerfile.gateway"
class ResourceError(RuntimeError):
"""Build resources are missing from the install (corrupt/partial wheel)."""
def is_source_checkout() -> bool:
"""True when running from a source tree (the root Dockerfiles sit beside
the package); False from an installed wheel."""
return (_CHECKOUT_ROOT / _CHECKOUT_MARKER).is_file()
def build_root() -> Path:
"""A directory shaped like a repo root: ``bot_bottle/``, ``pyproject.toml``,
the root Dockerfiles, ``nix/``, and ``scripts/``.
A checkout returns the repo root itself (no copying). An installed wheel
returns a staged copy under the app-data dir, materialized once and reused.
The stage is keyed by a digest of the installed package + bundled resources
(not the distribution version), so a force-reinstall of a newer commit that
keeps ``version = 0.1.0`` still rebuilds instead of reusing a stale tree."""
if is_source_checkout():
return _CHECKOUT_ROOT
return _stage_build_root()
def dockerfile(name: str) -> Path:
"""Absolute path to a root-level Dockerfile, e.g. ``Dockerfile.gateway``."""
return build_root() / name
def nix_netpool_module() -> Path:
"""Absolute path to the firecracker netpool NixOS module."""
return build_root() / "nix" / "firecracker-netpool.nix"
def netpool_script() -> Path:
"""Absolute path to the firecracker netpool bring-up script."""
return build_root() / "scripts" / "firecracker-netpool.sh"
def _content_digest() -> str:
"""A 16-hex digest of the installed package + bundled resources.
Keys the staged build root by *content*, so a force-reinstall over the same
version string (the installer defaults to a git branch + ``pipx install
--force``, and ``version`` stays ``0.1.0``) yields a different key and
re-stages, rather than reusing an old commit's tree. ``_PKG`` already
contains ``_resources``, so walking it covers both."""
h = hashlib.sha256()
for path in sorted(_PKG.rglob("*")):
if not path.is_file() or "__pycache__" in path.parts or path.suffix == ".pyc":
continue
h.update(str(path.relative_to(_PKG)).encode())
h.update(b"\0")
h.update(path.read_bytes())
return h.hexdigest()[:16]
def _stage_build_root() -> Path:
"""Materialize a repo-root-shaped build context from the installed wheel's
bundled resources, keyed by content digest. Idempotent and concurrency-safe:
a file lock serializes staging, a partial/stale tree is replaced, and the
finished tree is published with an atomic rename."""
if not _BUNDLED.is_dir():
raise ResourceError(
"bot-bottle build resources are missing from this install "
f"(expected {_BUNDLED}). Reinstall the package."
)
base = bot_bottle_root() / "build-root"
base.mkdir(parents=True, exist_ok=True)
dest = base / _content_digest()
if (dest / ".complete").is_file():
return dest
# Serialize staging across processes: a concurrent `start` after an install
# must not race on the shared tree. The lock is held only around stage +
# atomic publish; the fast path above never blocks.
with open(base / ".stage.lock", "w", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if (dest / ".complete").is_file(): # another process staged while we waited
return dest
# Stage into a private temp dir on the same filesystem, then publish by
# rename — never populate a shared path other processes might read.
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=base))
try:
# The package itself, minus caches and the bundled-resource copies,
# so the staged ``bot_bottle/`` matches a checkout's (keeps the
# firecracker infra-artifact hash stable across checkout and wheel).
shutil.copytree(
_PKG,
staging / "bot_bottle",
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "_resources"),
)
# The bundled root files, restored to their checkout-relative layout.
for rel in BUNDLED_RESOURCES:
dst = staging / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(_BUNDLED / rel, dst)
(staging / ".complete").write_text("")
# Replace any partial leftover for this digest (safe: we hold the
# lock), then publish atomically.
if dest.exists():
shutil.rmtree(dest)
os.replace(staging, dest)
staging = None # published; nothing to clean up
finally:
if staging is not None:
shutil.rmtree(staging, ignore_errors=True)
return dest
+140
View File
@@ -0,0 +1,140 @@
"""Per-service control-plane signing keys (issue #476).
A `TrustDomain` is one service's signing material: its host-canonical key file,
the roles that key may sign, and the env vars its key and a pre-minted token ride
in. Scoping `mint`/`verify` to a domain's roles keeps one service's key from
signing (or accepting) another service's tokens.
Today there is one domain, `CONTROL_PLANE` the orchestrator's key (roles
`{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's
tokens. The host controller (#468) will add a **second** domain with its own key
the orchestrator never holds. That is the point: the host controller starts and
stops the orchestrator, so the orchestrator must not be able to mint the
credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE`
instead would defeat that the orchestrator holds that key, so it could forge
`host` tokens.
`ControlPlaneProvisioning` is the one seam every backend launcher uses to get the
orchestrator its key and the gateway its token, instead of re-deriving that
wiring per backend (the bug class behind PR #471 — see
`docs/prds/prd-new-control-plane-auth-provisioning.md`).
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from . import orchestrator_auth
from .orchestrator_auth import ROLE_GATEWAY
from .paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
ORCHESTRATOR_TOKEN_FILENAME,
host_signing_key,
)
class ProvisioningError(RuntimeError):
"""A control-plane auth invariant would be violated (e.g. starting the
orchestrator without its signing key which would run OPEN)."""
@dataclass(frozen=True)
class TrustDomain:
"""One service's signing material: a host-canonical key file, the roles that
key may sign, and the env vars its key and a minted token ride in.
The service that *owns* the domain (e.g. the orchestrator) receives the raw
key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted,
role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are
scoped to `roles`, so this service's key can neither sign nor accept another
service's role."""
name: str
key_filename: str
roles: frozenset[str]
key_env: str
token_env: str
def signing_key(self) -> str:
"""This service's host-canonical signing key (minted 0600 on first use).
Host-side only the value is injected into the owning process."""
return host_signing_key(self.key_filename)
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
"""The signing key as the owning process sees it — read from `key_env`
(default `os.environ`). "" when unset; the caller decides whether that is
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
(`OrchestratorServer`)."""
env = os.environ if environ is None else environ
return env.get(self.key_env, "").strip()
def mint(self, role: str) -> str:
"""A role-scoped token for a delegate, signed with this service's key.
Raises ValueError for a role this service doesn't sign."""
if role not in self.roles:
raise ValueError(f"role {role!r} is not in trust domain {self.name!r}")
return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles)
def verify(self, token: str, key: str) -> str | None:
"""The role `token` carries under `key`, or None. `key` is passed in
(not read from disk) because the verifier the control-plane process
holds it in `key_env`, not on disk in its guest."""
return orchestrator_auth.verify(token, key, roles=self.roles)
# The orchestrator's domain: the key the orchestrator (and host CLI) holds, the
# `gateway` token it mints for the data plane, and the `cli` token the CLI mints
# for itself. #468's host controller will add a second, separate domain.
CONTROL_PLANE = TrustDomain(
name="control-plane",
key_filename=ORCHESTRATOR_TOKEN_FILENAME,
roles=orchestrator_auth.ROLES,
key_env=ORCHESTRATOR_TOKEN_ENV,
token_env=ORCHESTRATOR_AUTH_JWT_ENV,
)
@dataclass(frozen=True)
class ControlPlaneProvisioning:
"""The one seam every backend launcher uses to provision control-plane auth,
instead of re-deriving the four invariants that each cost a PR #471 review
round: the orchestrator gets the raw key (`orchestrator_key`), the gateway
gets a minted `gateway` token (`gateway_token`), the host CLI mints its own
`cli` token from the same host-canonical key, and the orchestrator never
starts open."""
domain: TrustDomain = CONTROL_PLANE
def orchestrator_key(self) -> str:
"""The raw signing key the orchestrator process must receive (carry it in
`domain.key_env`). Fail-closed: raises rather than return "", since an
empty key runs the server open and being on a separate host does not
stop the gateway from reaching the control plane (it must, for
`/resolve`), so an open orchestrator would treat that gateway as `cli`."""
key = self.domain.signing_key()
if not key:
raise ProvisioningError(
f"refusing to start the {self.domain.name} orchestrator without "
"a signing key: an open orchestrator authenticates no one and "
"grants every caller that reaches it full `cli` (#476)"
)
return key
def gateway_token(self) -> str:
"""The `gateway`-role token the gateway receives (carry it in
`domain.token_env`) minted from the key, never the key itself, so a
compromised gateway cannot forge a `cli` token."""
return self.domain.mint(ROLE_GATEWAY)
__all__ = [
"ProvisioningError",
"TrustDomain",
"CONTROL_PLANE",
"ControlPlaneProvisioning",
]
+12 -1
View File
@@ -2,11 +2,22 @@
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml). The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
It runs the unit suite plus one integration job per backend It runs the unit suite plus one integration job per backend
(`integration-docker`, `integration-firecracker`) on: (`integration-docker`, `integration-firecracker`, `integration-macos`) on:
- every push to a branch with an open pull request, and - every push to a branch with an open pull request, and
- every push to `main`. - every push to `main`.
`integration-macos` is the exception: it is **advisory**, running only on
`workflow_dispatch` (manual dispatch), never on push or pull requests. It targets the
Apple Container backend on a self-hosted macOS runner (label `macos`,
registered in host mode — Apple Container can't run in a Linux container, so it
can't reuse the `kvm` runner). A single non-redundant laptop must not be able
to block a PR merge, so the job stays out of the `coverage` job's `needs` and
its coverage never feeds the diff-coverage gate. Because the infra container is
a singleton (`bot-bottle-mac-infra`), the job declares a `concurrency` group
and tears the container down on exit; keep runner concurrency at 1. See the
README "macOS Apple Container" CI note for runner provisioning.
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
runs a **preflight** (`./cli.py backend status --backend=<name>`) that runs a **preflight** (`./cli.py backend status --backend=<name>`) that
prints a clear per-check readiness summary and fails the job when the prints a clear per-check readiness summary and fails the job when the
@@ -0,0 +1,89 @@
# PRD prd-new: Per-service signing keys for control-plane auth
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-26
- **Issue:** #476
## Summary
Provision control-plane signing keys **per service**, through one shared seam, so
no service can mint another's credentials. Concretely: the orchestrator holds the
control-plane key and mints the gateway's and CLI's tokens; the host controller
(#468, next) gets a **separate** key the orchestrator never holds — so the
orchestrator cannot forge the credentials it uses to talk to the host controller
that starts and stops it. Landing this seam also retires the per-backend
provisioning duplication that made PR #471 take three review rounds.
## Problem
**1. The orchestrator could forge host-controller credentials.** The
orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host
controller (#468) is a third role, `host`, on that same key. But then the
orchestrator — which holds the key — can mint `host` tokens, and the host
controller, which owns the orchestrator's lifecycle, must not trust anything the
orchestrator can mint. The two services need separate keys.
**2. Every backend provisioned auth by hand.** Each launcher (docker
gateway/infra, macOS infra, firecracker infra) re-derived how to generate the
signing key, scope it to the orchestrator, mint the gateway JWT, and keep the
host key file canonical. All three PR #471 High-severity findings were this one
integration bug in different launchers: the data plane got the full `cli` token;
the firecracker control plane ran open; the firecracker guest clobbered the host
key.
## Goals / Success Criteria
- The orchestrator and the host controller sign with **different** keys; neither
can mint the other's tokens. (This PR provisions the orchestrator's key and
leaves a drop-in seam for the host controller's.)
- One shared provisioning seam every backend uses — a new backend or daemon
implements it instead of rediscovering these four invariants:
1. the signing key is host-canonical: a guest is handed it, never generates or
overwrites it;
2. only the orchestrator process gets the raw key; the gateway gets a
pre-minted `gateway` token it can't rewrite into `cli`;
3. the host CLI's `cli` token is minted from the same key, so it stays valid
across co-running backends;
4. the orchestrator never runs open — the signing key is mandatory, with no
topology opt-out (a separate host does not stop a caller from reaching the
control-plane listener, so it cannot make open mode safe).
## Non-goals
- The host controller itself (#468) — this only provisions the orchestrator's
key and the seam #468 plugs into.
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
`roles=` arg (default unchanged) so a key can carry a different role set;
nothing else changes.
- Network topology, the plane split (#469), or the server's open-mode fallback
for tests.
## Design
A **`TrustDomain`** is one service's signing material: its host-canonical key
file, the roles that key may sign, and the env vars its key and a pre-minted
token ride in. `mint`/`verify` are scoped to that domain's roles, so a token
signed by one service's key neither carries nor verifies another service's role.
- `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles
`{gateway, cli}`. The orchestrator process holds the key; the gateway holds
only a minted `gateway` token; the host CLI mints its own `cli` token.
- The host controller (#468) will add a second `TrustDomain` — its own key file
and role(s) — that the orchestrator never holds.
**`ControlPlaneProvisioning`** is the seam the backends call.
`orchestrator_key()` returns the raw key for the control-plane process
(fail-closed for every backend: it raises rather than hand back an empty key that
would run the server open). `gateway_token()` mints the gateway's token. Each backend applies these through its own transport —
docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives
*which* key or role.
`paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each
domain names its own key file.
## Open questions
None blocking. #468 adds its `TrustDomain` and a second
`ControlPlaneProvisioning`-shaped consumer; renaming that class to something
service-neutral is a cosmetic call to make then.
+172
View File
@@ -0,0 +1,172 @@
# PRD prd-new: Quick install script
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-25
- **Issue:** #197
## Summary
Add a proper Python package distribution (`pyproject.toml` with a
`bot-bottle` entry point) plus a thin `install.sh` bootstrapper, so users
can install bot-bottle with a single command instead of cloning the repo
and invoking `cli.py` directly. A new `bot-bottle doctor` subcommand
verifies host prerequisites after install.
## Problem
There is currently no install path for new users. The only way to run
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
public demo: readers want `curl | sh` or `pipx install`, not a manual
clone-and-configure flow. There is also no single command that tells a
user whether their host is actually ready to run a bottle.
## Goals / Success Criteria
- `curl -fsSL <raw-url>/install.sh | sh` leaves a working `bot-bottle`
command on PATH.
- Python-native users can install with `pipx install bot-bottle` or
`uv tool install bot-bottle` (once published) — or from a local
checkout today.
- `install.sh` validates prerequisites (Python ≥ 3.11), creates the
`~/.bot-bottle/` config tree, installs the package, and runs
`bot-bottle doctor`. It never installs Docker or a VM backend silently
and never uses `sudo`.
- `install.sh` is idempotent — safe to re-run.
- `bot-bottle doctor` reports Python version, backend *readiness*, and
config-dir presence, exiting non-zero when a hard prerequisite is unmet.
- The package keeps **zero runtime pip dependencies** (stdlib-only,
matching the existing constraint in `AGENTS.md`).
## Non-goals
- Bundling a Python runtime or producing a standalone binary.
- Automatic Docker / VM-backend installation.
- Plugin-architecture changes (issue #197 floats a containerized-plugin
direction; that's a separate feature).
- Publishing to a package index in this PR — the package *structure* is
the deliverable; publishing is a follow-up step.
## Design
### Package structure (`pyproject.toml`)
Fill out the previously-stub `pyproject.toml` with project metadata, a
console-script entry point, and package-data for the non-Python assets the
runtime reads from inside the package:
```toml
[project]
name = "bot-bottle"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
```
`bot_bottle.cli:main` already exists (the `cli.py` shim calls it), so no
refactor of the entry point is needed. `package-data` ships the non-Python
assets that live *inside* the package (`egress_entrypoint.sh`, the contrib
Dockerfiles, the firecracker netpool defaults, the macos-container init
script).
### Self-contained wheel (build resources)
The gateway / infra / orchestrator images are built from a Docker (or Apple
`container`) build context that must contain the `bot_bottle` package,
`pyproject.toml`, and the **root-level** Dockerfiles as siblings. Several
modules used to locate that context by walking `__file__`'s parents to the
repo root (`_REPO_ROOT = Path(__file__)…parents[N]`) and reading
`Dockerfile.gateway`, `nix/firecracker-netpool.nix`, and
`scripts/firecracker-netpool.sh` from it. In an installed wheel the package
lives in `site-packages` with no repo root above it, so those reads fail —
`doctor` passes but `start` / backend setup breaks.
Fix: a single resolver, `bot_bottle/resources.py`.
- `build_root()` returns a directory shaped like a repo root (has
`bot_bottle/`, `pyproject.toml`, the Dockerfiles, `nix/`, `scripts/`).
In a **checkout** it's the repo root itself — unchanged behavior. From an
**installed wheel** it stages a copy under the app-data dir, keyed by a
**content digest** of the installed package + bundled resources (not the
distribution version): the installer defaults to a git branch and
`pipx install --force` while `version` stays `0.1.0`, so a version key
would reuse a previous commit's tree — the digest key re-stages instead.
Staging is concurrency-safe: a file lock serializes it, each writer builds
into a private temp dir, and the finished tree is published with an atomic
rename (never populating a shared path another process might read).
- The root-level resources are shipped inside the wheel under
`bot_bottle/_resources/` by a `setup.py` `build_py` step (kept in sync
with `resources.BUNDLED_RESOURCES`); `MANIFEST.in` includes them in the
sdist.
- Every former `_REPO_ROOT` / `_REPO_DIR` call site now derives from
`resources`: the docker/macos agent-image launch, each backend's
`orchestrator` / `gateway` / `infra` service, firecracker `infra_vm` /
`infra_artifact` / `setup`, and the shared `gateway` build context. So
checkout and wheel installs share one downstream path.
Verification: `test_resources` exercises both layouts — including the staged
wheel context, a re-stage when package content changes at the same version,
and a rebuild of a partial (crashed) stage. `test_wheel_install` builds the
wheel, installs it into an isolated venv, and asserts `bot-bottle doctor`
runs and `build_root()` produces a valid context; `build` is in
`requirements-dev.txt` so it runs in CI, and a build/install failure fails
the test (it does not skip). Running `start` end-to-end still needs a
Docker/KVM host (CI), not a source checkout.
### `install.sh`
A POSIX `sh` bootstrapper that:
1. Checks `python3` is present and ≥ 3.11; exits with a clear message
otherwise.
2. Checks `git` when installing a `git+` spec, and — when falling back to
pip — that pip is usable and the interpreter isn't externally managed
(PEP 668), pointing at pipx otherwise.
3. Creates `~/.bot-bottle/{agents,bottles,contrib}`.
4. Installs via `pipx` if available, else `python3 -m pip install --user`.
The spec defaults to the git URL and is overridable via
`BOT_BOTTLE_INSTALL_SPEC` (used by tests / local installs).
5. Locates the `bot-bottle` entry point: PATH first, else the
interpreter's own user-scheme scripts dir resolved via `sysconfig`
(`~/.local/bin` on Linux, `~/Library/Python/<X.Y>/bin` on a python.org
macOS interpreter — not hardcoded).
6. Runs `bot-bottle doctor` and reports the result.
It is idempotent and never calls `sudo`.
### `bot-bottle doctor`
A new store-free subcommand (no DB migration required) that checks and
reports:
- **python** — interpreter version (hard requirement: ≥ 3.11).
- **backend** — at least one backend *ready* on this host
(macos-container / firecracker / docker), via `is_backend_ready()` — a
full backend `status()` probe (daemon reachable, network pool present,
KVM usable), not a PATH-only check: a stopped daemon or half-configured
backend must not report `ok` when `start` can't work. Each not-ready
backend prints its own diagnostics. Hard requirement.
- **config** — whether `~/.bot-bottle/` exists (advisory only; `start`
provisions on first run).
Exits 0 when both hard requirements pass, non-zero otherwise.
## Testing strategy
- Unit test `bot-bottle doctor` success/failure paths with backend
readiness (`is_backend_ready`) and Python version mocked, including the
available-but-not-ready → fail case.
- Unit test that `pyproject.toml` parses, declares the entry point and an
empty `dependencies` list, and that every `package-data` glob resolves
to a file that exists on disk (guards against drift).
- Unit test that `install.sh` is executable, POSIX-ish (`set -eu`), never
calls `sudo`, and runs `doctor` after install.
## Open questions
- Should `version` be derived from a git tag at build time (e.g.
`hatch-vcs`) or kept static? Static (`0.1.0`) is simpler for now.
- Publishing target (PyPI vs. a self-hosted index) is deferred.
@@ -0,0 +1,142 @@
# PRD prd-new: macOS (Apple Container) CI runner
- **Status:** Draft
- **Author:** Claude
- **Created:** 2026-07-25
- **Issue:** #426
## Summary
CI has no runner for the `macos-container` (Apple Container) backend.
`.gitea/workflows/test.yml` exercises Docker (`ubuntu-latest`) and
Firecracker (self-hosted `kvm`) but never the macOS backend. This PRD adds a
self-hosted macOS runner (label `macos`) and an advisory `integration-macos`
job that runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container`,
so the backend that is the default on macOS stops shipping unexercised.
## Problem
The gap is not theoretical. `5ad3449` moved `bot_bottle` from flat files under
`/app` into a pip-installed package but left init scripts spawning the
supervisor as `python3 /app/gateway_init.py`, which no longer exists. Both the
Firecracker and macOS backends carried the identical bug:
- **firecracker** — caught and fixed in `127ba49` because the KVM runner
(added in `c193b04`, PR #349) runs that backend's integration suite.
- **macos-container** — survived on `main` and only surfaced when a human ran
`bot-bottle start` by hand.
The failure mode is expensive to debug: the supervisor never starts, so
mitmdump never generates its CA, and launch dies downstream with
`GatewayError: gateway CA not available`, which points at TLS rather than at
the supervisor. Unit tests did not help — `test_macos_infra` asserted the
substring `"gateway_init.py"`, which the *broken* path satisfies. (That
specific assertion has since been tightened to the module form
`bot_bottle.gateway_init`, matching its Firecracker twin, so the exact
regression is now covered on `ubuntu-latest`. What remains missing is the
end-to-end runner that would catch the *next* macOS-only launch regression.)
PR #470 (#414) already made the integration suite backend-agnostic:
`skip_unless_selected_backend_available()` gates on the *selected* backend's
own `is_backend_ready()` rather than `docker_available()`, and each
integration job runs `./cli.py backend status --backend=<name>` as a preflight
that fails loudly when the backend is missing. That is the machinery this job
plugs into; this PRD supplies the runner and the job.
## Goals / Success criteria
- A macOS runner is registered and picks up jobs by the `macos` label.
- An `integration-macos` job runs the integration suite against
`BOT_BOTTLE_BACKEND=macos-container`.
- The job **fails, not skips**, when the backend is unavailable on the runner
(via the `backend status` preflight).
- Reverting the `macos_container/infra.py` supervisor fix makes the job fail:
the broken supervisor path throws `GatewayError` at bottle launch, which is
`TestSandboxEscape.setUpClass`, failing the whole class before any individual
attack runs.
## Non-goals
- Making `integration-macos` a **required** PR check. It runs on
`workflow_dispatch` (manual dispatch) only — never on push or PRs. A single
non-redundant laptop that sleeps and roams must never be able to block a PR
merge or churn unattended on every push to main, and it is deliberately kept
out of the `coverage` job's `needs` so the diff-coverage gate never depends on
it.
- Multi-machine or hosted macOS runners. Apple Container needs the host
virtualization framework, so the runner must be a physical/VM macOS host on
Apple Silicon — it cannot reuse the KVM runner or run in a Linux container.
- Coverage aggregation from the macOS job into the combined gate (would couple
the gate to the laptop).
## Design
### Runner (operational, provisioned once)
- Apple Silicon macOS host with Apple's `container` CLI installed and
`container system status` reporting `running`.
- Install the runner: `brew install gitea-runner` (the `act_runner` rename),
registered in **host mode** with label `macos` — not docker mode, because
Apple Container needs the host `container` CLI and virtualization framework,
not a nested container.
- A Python ≥ 3.11 with `coverage` importable on the runner's `PATH`. Because a
launchd service does not inherit an interactive shell's `PATH`, pin `node`
(for the JS `actions/*`) and the Python env explicitly in the service
environment rather than relying on `nvm`/shell profile.
- Concurrency 1. The infra container is a singleton (`bot-bottle-mac-infra`),
so two simultaneous runs on one host collide (#425). The job also declares a
`concurrency` group as belt-and-suspenders and tears the singleton down after
each run.
### `integration-macos` job
Modeled on `integration-firecracker`:
- `runs-on: [self-hosted, macos]`.
- `if:` `workflow_dispatch` only (advisory, manual dispatch; never push or PRs,
so no fork-PR exposure, no merge-blocking, and no unattended runs on push).
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
to serialize runs against the singleton.
- **Preflight**`command -v container`, `container system status`, then
`./cli.py backend status --backend=macos-container`; any failure exits
non-zero so a misprovisioned runner fails loudly instead of silently
skipping.
- Run the integration suite under coverage with
`BOT_BOTTLE_BACKEND=macos-container` and print a `coverage report -m` for
visibility (no upload, not in the gate).
- **Teardown** (`if: always()`) — `MacosInfraService().stop()` removes the
singleton so a crashed run cannot wedge the next one.
### The `test_sandbox_escape` CI guard (the trap #470 left)
`TestSandboxEscape` is the only backend-agnostic integration test that boots a
real bottle, so it is the one that would catch a macOS launch regression. It
still carries a second guard that skips under `GITEA_ACTIONS` for every backend
except `firecracker`:
```python
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true"
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
...,
)
```
The skip exists because the *containerized* `act_runner` (docker on
`ubuntu-latest`) can't see a host bind mount and hides sibling-gateway network
topology. Those constraints do not apply to a **host-mode** runner — neither
the KVM host runner nor a macOS host runner is containerized. This PRD relaxes
the guard to allow both host-mode backends (`firecracker`, `macos-container`)
through while still skipping on the containerized Docker job. Without this
change the macOS job would run green while skipping the exact test that proves
the backend launches — the very false-green this issue is about.
## Open questions
- None known that block the job. git-gate is fully implemented on the macOS
backend (the gateway's consolidated `git-http` daemon plus dynamic key
provisioning/revocation), so `TestSandboxEscape` attack 5 — secret exfil
pushed through git-gate, rejected by the gitleaks hook before the upstream
push — runs the same as on the other backends. Any genuinely
macOS-specific test adjustment would surface at first runner bring-up, but
none is anticipated from the current backend implementation.
Executable
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
# bot-bottle quick installer.
#
# Usage:
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
#
# Python-native users can skip this entirely:
# pipx install bot-bottle # from a checkout or a published index
# uv tool install bot-bottle
#
# This script is a thin bootstrapper: it checks prerequisites, installs the
# package with pipx (falling back to pip --user), creates the config dir, and
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
# what's missing after install.
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
say() {
printf 'bot-bottle install: %s\n' "$*" >&2
}
die() {
say "error: $*"
exit 1
}
# --- prerequisites -----------------------------------------------------------
command -v python3 >/dev/null 2>&1 \
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
import sys
want = (int(sys.argv[1]), int(sys.argv[2]))
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
PY
# Installing a `git+` spec (the default) shells out to git under the hood,
# whether via pipx or pip. Fail early with a clear message rather than deep
# inside the installer's output.
case "${PACKAGE_SPEC}" in
git+*|*.git)
command -v git >/dev/null 2>&1 || die \
"git is required to install from '${PACKAGE_SPEC}'. Install git, or set "\
"BOT_BOTTLE_INSTALL_SPEC to a non-git spec (e.g. a wheel path or a package index name)."
;;
esac
# The pip fallback needs a usable pip. Externally-managed interpreters
# (PEP 668, common on Debian/Ubuntu/Homebrew) reject `pip install --user`;
# pipx sidesteps that, so recommend it when pip can't be used.
if ! command -v pipx >/dev/null 2>&1; then
python3 -m pip --version >/dev/null 2>&1 || die \
"neither pipx nor a usable 'python3 -m pip' was found. Install pipx "\
"(recommended): 'python3 -m pip install --user pipx' or your OS package manager."
if python3 - <<'PY'
import os
import sys
import sysconfig
# PEP 668: an EXTERNALLY-MANAGED marker in the stdlib dir means pip refuses
# to install into this interpreter without --break-system-packages.
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
raise SystemExit(0 if os.path.exists(marker) else 1)
PY
then
die "this Python is externally managed (PEP 668), so 'pip install --user' is "\
"blocked. Install pipx and re-run: 'python3 -m pip install --user --break-system-packages pipx', "\
"then 'pipx ensurepath'."
fi
fi
# --- config directories ------------------------------------------------------
mkdir -p \
"${HOME}/.bot-bottle/agents" \
"${HOME}/.bot-bottle/bottles" \
"${HOME}/.bot-bottle/contrib"
# --- install -----------------------------------------------------------------
if command -v pipx >/dev/null 2>&1; then
say "installing with pipx"
pipx install --force "${PACKAGE_SPEC}"
else
say "pipx not found; installing with 'python3 -m pip install --user'"
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
fi
# --- locate the entry point --------------------------------------------------
# The pip --user scripts directory is platform-specific: ~/.local/bin on Linux,
# but ~/Library/Python/<X.Y>/bin on a python.org macOS interpreter. Ask the
# interpreter for its own user-scheme scripts dir instead of hardcoding.
USER_SCRIPTS="$(python3 - <<'PY'
import sysconfig
print(sysconfig.get_path("scripts", sysconfig.get_preferred_scheme("user")))
PY
)"
if command -v bot-bottle >/dev/null 2>&1; then
BOT_BOTTLE_BIN="bot-bottle"
elif [ -n "${USER_SCRIPTS}" ] && [ -x "${USER_SCRIPTS}/bot-bottle" ]; then
BOT_BOTTLE_BIN="${USER_SCRIPTS}/bot-bottle"
say "note: add ${USER_SCRIPTS} to your PATH to run 'bot-bottle' directly"
else
die "bot-bottle was installed but is not on PATH; add ${USER_SCRIPTS:-your user scripts dir} to PATH and re-run"
fi
# --- verify ------------------------------------------------------------------
say "running '${BOT_BOTTLE_BIN} doctor'"
if "${BOT_BOTTLE_BIN}" doctor; then
say "done. Run '${BOT_BOTTLE_BIN} --help' to get started."
else
say "install completed, but 'doctor' reported unmet prerequisites (see above)."
say "resolve them, then re-run '${BOT_BOTTLE_BIN} doctor'."
fi
+38 -1
View File
@@ -4,5 +4,42 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bot-bottle" name = "bot-bottle"
version = "0.0.0" version = "0.1.0"
description = "Self-hosted sandbox for running AI coding agents with egress controls"
readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [{ name = "didericis" }]
keywords = ["ai", "agents", "sandbox", "security", "egress"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
]
# The package itself has no runtime pip dependencies (stdlib-only); the
# only language runtime is the Python interpreter. Keep this empty.
dependencies = []
[project.urls]
Homepage = "https://gitea.dideric.is/didericis/bot-bottle"
Source = "https://gitea.dideric.is/didericis/bot-bottle"
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
[tool.setuptools.packages.find]
include = ["bot_bottle*"]
# Non-Python assets the runtime reads from inside the package (container
# build contexts, entrypoints, netpool defaults). Keep in sync with the
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
[tool.setuptools.package-data]
bot_bottle = [
"gateway/egress/entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/codex/Dockerfile",
"contrib/pi/Dockerfile",
"backend/firecracker/netpool.defaults.env",
"backend/macos_container/nested-containers-init.sh",
]
+3
View File
@@ -5,3 +5,6 @@
pylint>=3.0.0 pylint>=3.0.0
pyright>=1.1.411 pyright>=1.1.411
coverage>=7.0.0 coverage>=7.0.0
# PEP 517 build front-end used by tests/unit/test_wheel_install.py to build and
# install a real wheel (proves the installed distribution is self-contained).
build>=1.0.0
+46
View File
@@ -0,0 +1,46 @@
"""Build shim. Project metadata lives in ``pyproject.toml``; this only adds a
build step that copies the root-level build resources (the Dockerfiles, the
nix netpool module, the netpool script, and ``pyproject.toml``) into
``bot_bottle/_resources/`` so an installed wheel is self-contained and can
build its gateway/infra/orchestrator images without a source checkout.
Kept in sync with ``bot_bottle.resources.BUNDLED_RESOURCES`` the
``test_resources`` suite guards against drift between the two lists.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from setuptools import setup
from setuptools.command.build_py import build_py
_ROOT = Path(__file__).resolve().parent
# Must match bot_bottle.resources.BUNDLED_RESOURCES (paths relative to root).
_BUNDLED_RESOURCES = (
"pyproject.toml",
"Dockerfile.gateway",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
"nix/firecracker-netpool.nix",
"scripts/firecracker-netpool.sh",
)
class _BundleResources(build_py):
"""Copy the root-level build resources into the built package tree so they
ship inside the wheel under ``bot_bottle/_resources/``."""
def run(self) -> None:
super().run()
pkg_resources = Path(self.build_lib) / "bot_bottle" / "_resources"
for rel in _BUNDLED_RESOURCES:
src = _ROOT / rel
dst = pkg_resources / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
setup(cmdclass={"build_py": _BundleResources})
+14 -3
View File
@@ -67,14 +67,25 @@ _DUMMY_HOST_KEY = (
) )
# Backends whose CI runner is HOST-mode (self-hosted), so the test process
# and the backend share a host. The containerized act_runner (docker on
# ubuntu-latest) is the one that can't see the host bind mount egress_tls_init
# uses and hides sibling-gateway network topology; host-mode runners
# (firecracker/KVM, macos-container) don't have those constraints, so the test
# runs there. Keep this in sync with the `runs-on` labels in
# .gitea/workflows/test.yml.
_HOST_MODE_CI_BACKENDS = frozenset({"firecracker", "macos-container"})
@skip_unless_selected_backend_available() @skip_unless_selected_backend_available()
@unittest.skipIf( @unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true" os.environ.get("GITEA_ACTIONS") == "true"
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker", and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: " "skipped under the containerized act_runner (docker on ubuntu-latest): "
"egress_tls_init uses a host bind mount the runner container can't " "egress_tls_init uses a host bind mount the runner container can't "
"see, and the network topology hides sibling-gateway visibility — " "see, and the network topology hides sibling-gateway visibility — "
"these constraints don't apply on the self-hosted KVM runner", "these constraints don't apply on the self-hosted host-mode runners "
"(firecracker/KVM, macos-container)",
) )
class TestSandboxEscape(unittest.TestCase): class TestSandboxEscape(unittest.TestCase):
"""End-to-end attacks against a real bottle. The bottle stays """End-to-end attacks against a real bottle. The bottle stays
+98
View File
@@ -0,0 +1,98 @@
"""Unit: `bot-bottle doctor` host prerequisite checks (ADR 0004).
`doctor` is a store-free diagnostic it must run on a fresh install
before any DB migration, and its exit code gates only the two hard
prerequisites (Python and at least one *ready* backend). The config-dir
check is advisory and never affects the exit code.
Backend readiness is probed with `is_backend_ready()` (a full status()
check), not the cheap PATH-only `is_backend_available()` a host with a
stopped daemon or half-configured backend must not report `ok`.
"""
from __future__ import annotations
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from bot_bottle.cli.commands import doctor
def _run(argv: list[str] | None = None) -> tuple[int, str]:
buf = io.StringIO()
with redirect_stdout(buf):
code = doctor.cmd_doctor(argv or [])
return code, buf.getvalue()
class TestDoctor(unittest.TestCase):
def test_passes_when_python_and_backend_ready(self):
with patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_ready", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: python", out)
self.assertIn("ok: backend: docker: ready", out)
def test_fails_when_no_backend_ready(self):
# The regression the reviewer flagged: a backend whose binary is on PATH
# but whose daemon/pool isn't ready must NOT pass. is_backend_ready is
# the full status() check, so returning False here means "not ready".
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
patch.object(doctor, "is_backend_ready", return_value=False):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: backend", out)
self.assertIn("warn: backend: docker: not ready", out)
def test_passes_when_at_least_one_backend_ready(self):
# docker not ready, firecracker ready → overall pass, mixed report.
def ready(name: str, *, quiet: bool = False) -> bool:
del quiet
return name == "firecracker"
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
patch.object(doctor, "is_backend_ready", side_effect=ready):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("warn: backend: docker: not ready", out)
self.assertIn("ok: backend: firecracker: ready", out)
def test_fails_when_python_too_old(self):
# Force the version gate to fail without touching the interpreter.
with patch.object(doctor, "MIN_PYTHON", (99, 0)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_ready", return_value=True):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: python", out)
def test_missing_config_dir_is_advisory_not_fatal(self):
# A missing ~/.bot-bottle warns but must not fail. Point home at a
# fresh empty dir so the shared suite HOME (which other tests may
# populate) can't turn this into an "ok: config".
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_ready", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("warn: config", out)
def test_present_config_dir_reports_ok(self):
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_ready", return_value=True):
(Path(tmp) / ".bot-bottle").mkdir()
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: config", out)
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator"
_RUN = f"{_ORCH}.run_docker" _RUN = f"{_ORCH}.run_docker"
_SLEEP = f"{_ORCH}.time.sleep" _SLEEP = f"{_ORCH}.time.sleep"
_MONOTONIC = f"{_ORCH}.time.monotonic" _MONOTONIC = f"{_ORCH}.time.monotonic"
_TOKEN = f"{_ORCH}.host_orchestrator_token" # The signing key is read through the shared provisioning contract (#476); patch
# its host-canonical key file read to keep it off the real host file.
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
# The ABC's is_healthy probes /health via urllib in the lifecycle module. # The ABC's is_healthy probes /health via urllib in the lifecycle module.
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen" _URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
+1 -1
View File
@@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase):
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock()) vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \ with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
patch.object(infra_vm, "push_secret") as push, \ patch.object(infra_vm, "push_secret") as push, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \ patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \
patch.object(orch, "is_running", return_value=False), \ patch.object(orch, "is_running", return_value=False), \
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \ patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
patch.object(orch, "_wait_for_health"): patch.object(orch, "_wait_for_health"):
+101
View File
@@ -0,0 +1,101 @@
"""Unit: install.sh bootstrapper contract.
The installer is a thin, sudo-free, idempotent bootstrapper. These are
static checks on the script text (no network / no real install) so CI can
run them anywhere: it must be executable, fail-fast, never call sudo,
create the config tree, install the package, and verify with `doctor`.
"""
from __future__ import annotations
import os
import sysconfig
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SH = REPO_ROOT / "install.sh"
class TestInstallScript(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.text = INSTALL_SH.read_text()
def test_exists_and_executable(self):
self.assertTrue(INSTALL_SH.is_file())
self.assertTrue(os.access(INSTALL_SH, os.X_OK), "install.sh must be executable")
def test_posix_shebang_and_failfast(self):
first = self.text.splitlines()[0]
self.assertEqual("#!/bin/sh", first)
self.assertIn("set -eu", self.text)
def test_never_uses_sudo(self):
# Only executable lines matter; the header comment may mention sudo.
code = [
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
self.assertNotIn("sudo", "\n".join(code))
def test_creates_config_tree(self):
self.assertIn(".bot-bottle/agents", self.text)
self.assertIn(".bot-bottle/bottles", self.text)
def test_installs_via_pipx_with_pip_fallback(self):
self.assertIn("pipx install", self.text)
self.assertIn("pip install --user", self.text)
def test_runs_doctor_after_install(self):
self.assertIn("doctor", self.text)
def test_install_spec_is_overridable(self):
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
def test_requires_git_for_git_specs(self):
# A git+ / .git spec (the default) shells out to git; the script must
# gate on it rather than failing opaquely inside pipx/pip.
self.assertIn("command -v git", self.text)
self.assertIn("git+*|*.git", self.text)
def test_checks_pip_usable_before_fallback(self):
self.assertIn("python3 -m pip --version", self.text)
def test_detects_externally_managed_python(self):
# PEP 668: 'pip install --user' is blocked on externally-managed
# interpreters; the script must detect this and point at pipx.
self.assertIn("EXTERNALLY-MANAGED", self.text)
self.assertIn("pipx", self.text)
def test_resolves_user_scripts_dir_not_hardcoded(self):
# The pip --user scripts dir differs by platform; the script must ask
# the interpreter (sysconfig + the preferred *user* scheme) rather than
# hardcoding Linux's ~/.local/bin (which is wrong on macOS python.org).
self.assertIn("get_preferred_scheme", self.text)
self.assertIn("sysconfig", self.text)
# No hardcoded Linux path in executable lines (a comment may mention it).
code = "\n".join(
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
)
self.assertNotIn(".local/bin", code)
def test_macos_user_scheme_is_not_dot_local_bin(self):
# The case the fix exists for: a python.org macOS interpreter uses the
# osx_framework_user scheme, whose scripts land under
# ~/Library/Python/<X.Y>/bin — NOT ~/.local/bin. Drive the same
# sysconfig lookup install.sh uses, with a mac-like userbase, to prove
# it resolves a non-~/.local/bin directory.
self.assertIn("osx_framework_user", sysconfig.get_scheme_names())
scripts = sysconfig.get_path(
"scripts", "osx_framework_user",
vars={"userbase": "/Users/dev/Library/Python/3.11"},
)
self.assertEqual("/Users/dev/Library/Python/3.11/bin", scripts)
self.assertNotIn("/.local/bin", scripts)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -283,7 +283,7 @@ class TestBuildOrLoadImages(unittest.TestCase):
images = launch_mod.build_or_load_images(plan) images = launch_mod.build_or_load_images(plan)
build.assert_called_once_with( build.assert_called_once_with(
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access "agent:base", str(launch_mod.resources.build_root()),
dockerfile="/repo/Dockerfile", dockerfile="/repo/Dockerfile",
) )
derived.assert_called_once_with("agent:base", build) derived.assert_called_once_with("agent:base", build)
+2 -2
View File
@@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def _run(self) -> list[str]: def _run(self) -> list[str]:
run = Mock(return_value=_ok()) run = Mock(return_value=_ok())
with patch(f"{_ORCH}.container_mod") as mod, \ with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"): patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1" mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run mod.run_container_argv = run
@@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def test_start_failure_raises(self) -> None: def test_start_failure_raises(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod, \ with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"): patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1" mod.dns_server.return_value = "1.1.1.1"
mod.run_container_argv = Mock(return_value=_fail()) mod.run_container_argv = Mock(return_value=_fail())
with self.assertRaises(OrchestratorStartError): with self.assertRaises(OrchestratorStartError):
+21
View File
@@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase):
mint(ROLE_GATEWAY, "") mint(ROLE_GATEWAY, "")
class TestCustomRoleSet(unittest.TestCase):
"""The `roles=` seam a trust domain other than the control plane uses (#476):
mint/verify are scoped to the passed role set, not the module default."""
_ROLES = frozenset({"host"})
def test_round_trips_a_role_in_the_custom_set(self) -> None:
tok = mint("host", _KEY, roles=self._ROLES)
self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES))
def test_mint_rejects_a_role_outside_the_custom_set(self) -> None:
with self.assertRaises(ValueError):
mint(ROLE_CLI, _KEY, roles=self._ROLES)
def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None:
# A validly signed token whose role isn't in the verifier's set fails —
# this is what keeps one domain's key from asserting another's role.
tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token
self.assertIsNone(verify(tok, _KEY, roles=self._ROLES))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+4 -2
View File
@@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
class TestHostAuthToken(unittest.TestCase): class TestHostAuthToken(unittest.TestCase):
def test_mints_a_cli_token_from_the_host_key(self) -> None: def test_mints_a_cli_token_from_the_host_key(self) -> None:
with patch("bot_bottle.orchestrator.client.host_orchestrator_token", # The CLI mints its `cli` token from the control-plane trust domain's
# host-canonical key (#476) — patch the key file read underneath it.
with patch("bot_bottle.trust_domain.host_signing_key",
return_value="signing-key"): return_value="signing-key"):
tok = _host_auth_token() tok = _host_auth_token()
self.assertEqual(ROLE_CLI, verify(tok, "signing-key")) self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
def test_returns_empty_when_key_unreadable(self) -> None: def test_returns_empty_when_key_unreadable(self) -> None:
with patch("bot_bottle.orchestrator.client.host_orchestrator_token", with patch("bot_bottle.trust_domain.host_signing_key",
side_effect=OSError("no host root")): side_effect=OSError("no host root")):
self.assertEqual("", _host_auth_token()) self.assertEqual("", _host_auth_token())
+45
View File
@@ -0,0 +1,45 @@
"""Unit: pyproject.toml packaging contract.
Guards the install/distribution surface: the console-script entry point,
the stdlib-only (empty) dependency list, and that every package-data glob
still points at a file that exists (so an installed wheel isn't missing a
Dockerfile or entrypoint the runtime reads).
"""
from __future__ import annotations
import tomllib
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = REPO_ROOT / "pyproject.toml"
class TestPyproject(unittest.TestCase):
@classmethod
def setUpClass(cls):
with PYPROJECT.open("rb") as fh:
cls.data = tomllib.load(fh)
def test_entry_point_targets_cli_main(self):
scripts = self.data["project"]["scripts"]
self.assertEqual("bot_bottle.cli:main", scripts["bot-bottle"])
def test_no_runtime_dependencies(self):
# AGENTS.md: the package has no runtime pip dependencies.
self.assertEqual([], self.data["project"]["dependencies"])
def test_requires_python_311(self):
self.assertEqual(">=3.11", self.data["project"]["requires-python"])
def test_package_data_files_exist(self):
pkg_data = self.data["tool"]["setuptools"]["package-data"]["bot_bottle"]
self.assertTrue(pkg_data, "expected package-data entries")
for rel in pkg_data:
path = REPO_ROOT / "bot_bottle" / rel
self.assertTrue(path.is_file(), f"package-data missing: {path}")
if __name__ == "__main__":
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
"""Unit: bot_bottle.resources — build-resource resolution for both a source
checkout and an installed wheel.
The checkout path is what the whole test suite already runs under; the wheel
path is exercised here by faking an installed layout (a package dir with a
bundled ``_resources/`` and no sibling Dockerfiles) and asserting that
``build_root()`` stages a repo-root-shaped context. See
``test_wheel_install.py`` for the end-to-end build+install check.
"""
from __future__ import annotations
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from bot_bottle import resources
from tests.unit import use_bottle_root
class TestCheckoutMode(unittest.TestCase):
"""The environment the suite runs in: a real source checkout."""
def test_is_source_checkout(self):
self.assertTrue(resources.is_source_checkout())
def test_build_root_is_repo_root(self):
root = resources.build_root()
self.assertTrue((root / "bot_bottle").is_dir())
self.assertTrue((root / "pyproject.toml").is_file())
self.assertTrue((root / "Dockerfile.gateway").is_file())
def test_resource_helpers_resolve(self):
self.assertTrue(resources.dockerfile("Dockerfile.gateway").is_file())
self.assertTrue(resources.nix_netpool_module().is_file())
self.assertTrue(resources.netpool_script().is_file())
def test_bundled_resources_all_exist_at_root(self):
# Drift guard: every path setup.py bundles must exist in the checkout.
root = resources.build_root()
for rel in resources.BUNDLED_RESOURCES:
self.assertTrue((root / rel).is_file(), f"missing bundled resource: {rel}")
class TestWheelMode(unittest.TestCase):
"""Fake an installed wheel: a package dir with _resources/ and no
checkout Dockerfiles beside it."""
def _fake_install(self, tmp: Path) -> Path:
pkg = tmp / "site-packages" / "bot_bottle"
(pkg / "cli").mkdir(parents=True)
(pkg / "__init__.py").write_text("")
(pkg / "cli" / "__init__.py").write_text("# module\n")
bundled = pkg / "_resources"
for rel in resources.BUNDLED_RESOURCES:
dst = bundled / rel
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(f"# fake {rel}\n")
return pkg
@contextmanager
def _wheel(self):
"""Point `resources` at a fake installed wheel with an isolated
app-data dir; yields the package dir so a test can mutate it."""
with tempfile.TemporaryDirectory() as tmpname:
tmp = Path(tmpname)
pkg = self._fake_install(tmp)
self.addCleanup(use_bottle_root(tmp / "appdata"))
with patch.object(resources, "_PKG", pkg), \
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
yield pkg
def test_stage_and_resolve(self):
with self._wheel():
self.assertFalse(resources.is_source_checkout())
root = resources.build_root()
# Staged context looks like a repo root.
self.assertTrue((root / "bot_bottle" / "__init__.py").is_file())
self.assertTrue((root / "bot_bottle" / "cli" / "__init__.py").is_file())
self.assertTrue((root / "pyproject.toml").is_file())
self.assertTrue((root / "Dockerfile.gateway").is_file())
self.assertTrue((root / "nix" / "firecracker-netpool.nix").is_file())
self.assertTrue((root / "scripts" / "firecracker-netpool.sh").is_file())
# The bundled-resource copies are NOT re-nested under the staged
# package (keeps it byte-identical to a checkout package).
self.assertFalse((root / "bot_bottle" / "_resources").exists())
# Helpers resolve off the staged root.
self.assertEqual(root / "Dockerfile.gateway",
resources.dockerfile("Dockerfile.gateway"))
# Idempotent: second call returns the same completed dir.
self.assertEqual(root, resources.build_root())
def test_refreshes_when_content_changes_at_same_version(self):
# Regression for the stale-cache bug: `pipx install --force` of a newer
# commit keeps version 0.1.0, so keying on version would reuse the old
# tree. Keying on content must re-stage when a package file changes.
with self._wheel() as pkg:
root1 = resources.build_root()
self.assertTrue((root1 / ".complete").is_file())
(pkg / "cli" / "__init__.py").write_text("# new commit, same version\n")
root2 = resources.build_root()
self.assertNotEqual(root1, root2)
self.assertEqual(
"# new commit, same version\n",
(root2 / "bot_bottle" / "cli" / "__init__.py").read_text(),
)
def test_failed_stage_cleans_up_temp_dir(self):
# A failure mid-stage must not leave a half-written temp dir behind.
with self._wheel():
base = resources.bot_bottle_root() / "build-root"
with patch.object(resources.shutil, "copytree", side_effect=OSError("boom")):
with self.assertRaises(OSError):
resources.build_root()
self.assertEqual([], list(base.glob(".staging-*")))
def test_rebuilds_when_stage_incomplete(self):
# A crash mid-stage can leave a dir without its `.complete` marker; the
# next call must rebuild it rather than trust the partial tree.
with self._wheel():
root = resources.build_root()
(root / ".complete").unlink()
(root / "sentinel").write_text("stale")
again = resources.build_root()
self.assertEqual(root, again) # same content digest → same dir
self.assertTrue((again / ".complete").is_file())
self.assertFalse((again / "sentinel").exists()) # rebuilt clean
def test_reuses_peer_stage_after_lock_wait(self):
# Regression for the staging race: a caller that loses the lock must,
# once it wins, see the peer's completed tree and reuse it — never
# re-clobber a shared path. Drive it deterministically: hold the lock,
# let a worker block after its fast-path miss, publish a complete tree
# as the "peer", then release so the worker takes the reuse path.
import fcntl
import threading
import time
with self._wheel():
base = resources.bot_bottle_root() / "build-root"
base.mkdir(parents=True, exist_ok=True)
dest = base / resources._content_digest() # pylint: disable=protected-access
result: dict[str, Path] = {}
with open(base / ".stage.lock", "w", encoding="utf-8") as held:
fcntl.flock(held, fcntl.LOCK_EX)
def worker() -> None:
result["root"] = resources.build_root()
t = threading.Thread(target=worker)
t.start()
# Let the worker miss the fast path (dest not yet complete) and
# block on the held lock, then publish a complete tree as a peer
# would have and release the lock.
time.sleep(0.3)
dest.mkdir(parents=True)
(dest / ".complete").write_text("")
fcntl.flock(held, fcntl.LOCK_UN)
t.join(timeout=10)
self.assertEqual(dest, result["root"])
self.assertFalse(t.is_alive())
def test_missing_bundle_raises(self):
with tempfile.TemporaryDirectory() as tmpname:
tmp = Path(tmpname)
pkg = tmp / "bot_bottle"
pkg.mkdir()
restore = use_bottle_root(tmp / "appdata")
self.addCleanup(restore)
with patch.object(resources, "_PKG", pkg), \
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
with self.assertRaises(resources.ResourceError):
resources.build_root()
if __name__ == "__main__":
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
"""Unit: trust domains + the shared control-plane provisioning contract (#476)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle import orchestrator_auth
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
from bot_bottle.trust_domain import (
CONTROL_PLANE,
ControlPlaneProvisioning,
ProvisioningError,
TrustDomain,
)
# A second, unrelated domain — the shape #468's host controller would take: its
# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE.
_HOST_CTRL = TrustDomain(
name="host-controller",
key_filename="host-controller-token",
roles=frozenset({"host"}),
key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN",
token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT",
)
class TestTrustDomainMintVerify(unittest.TestCase):
def test_mint_verify_round_trips_within_a_domain(self) -> None:
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = CONTROL_PLANE.mint(ROLE_GATEWAY)
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
def test_mint_rejects_a_role_outside_the_domain(self) -> None:
# `host` is a valid role in _HOST_CTRL but not in the control plane —
# the control-plane key must refuse to mint it.
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
with self.assertRaises(ValueError):
CONTROL_PLANE.mint("host")
def test_a_custom_role_set_verifies_its_own_role(self) -> None:
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = _HOST_CTRL.mint("host")
self.assertEqual("host", _HOST_CTRL.verify(tok, "k"))
def test_key_from_env_reads_the_domains_env_var(self) -> None:
self.assertEqual(
"secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "}))
self.assertEqual("", CONTROL_PLANE.key_from_env({}))
class TestDomainBoundary(unittest.TestCase):
"""The #476/#468 invariant: two domains, two keys, two role sets — one
domain's key can neither mint nor verify the other's tokens."""
def test_a_control_plane_token_does_not_verify_under_another_domains_key(
self,
) -> None:
# Even with an IDENTICAL underlying key, a `cli` token minted under the
# control-plane domain must not verify as a role in the host-controller
# domain — the role isn't in that domain's set.
cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes")
self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes"))
def test_distinct_keys_do_not_cross_verify(self) -> None:
# The realistic case: distinct host-canonical keys per domain. A token
# signed by one key never verifies under the other.
host_tok = orchestrator_auth.mint(
"host", "host-ctrl-key", roles=_HOST_CTRL.roles)
self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key"))
self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key"))
class TestControlPlaneProvisioning(unittest.TestCase):
def test_orchestrator_key_returns_the_canonical_key(self) -> None:
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"):
self.assertEqual("key", prov.orchestrator_key())
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
# Invariant 4: the orchestrator must never start without a key — it would
# run OPEN and grant every caller that reaches it full `cli`. There is no
# topology opt-out: a separate host does not stop the gateway (or any
# other caller) from reaching the control-plane listener.
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
with self.assertRaises(ProvisioningError):
prov.orchestrator_key()
def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None:
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = prov.gateway_token()
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
def test_gateway_token_cannot_be_reused_as_cli(self) -> None:
# The data plane's token is `gateway`-scoped: it never carries `cli`.
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = prov.gateway_token()
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
"""Integration: build the wheel, install it into an isolated venv, and prove
the installed distribution is self-contained.
This is the boundary a source-tree existence test can't reach (issue #197
review): under an installed wheel the package lives in ``site-packages`` with
no repo root above it, so anything resolving Dockerfiles / nix / scripts from
``__file__``'s parents would break. Here we install for real and assert that
``bot-bottle doctor`` runs from the console script and that
``bot_bottle.resources`` stages a valid, repo-root-shaped build context.
It does NOT run `start` building images needs a Docker/KVM host (CI). It
skips cleanly when the build/venv toolchain isn't available.
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
class TestWheelInstall(unittest.TestCase):
"""`build` is a declared dev dependency (requirements-dev.txt), so this runs
in CI. A build/install failure is a real packaging regression and FAILS
only genuinely-unsupported infra (no `venv`/`ensurepip`) skips."""
_tmp: "tempfile.TemporaryDirectory[str]"
venv_py: Path
app_root: Path
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory( # pylint: disable=consider-using-with
prefix="bb-wheel-")
tmp = Path(cls._tmp.name)
dist = tmp / "dist"
# A failed wheel build is exactly the regression this test guards — fail,
# don't skip. `build` is installed via requirements-dev.txt.
built = subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--outdir", str(dist), str(REPO_ROOT)],
capture_output=True, text=True, check=False,
)
if built.returncode != 0:
raise AssertionError(f"wheel build failed:\n{built.stderr[-2000:]}")
wheels = list(dist.glob("*.whl"))
if not wheels:
raise AssertionError(f"no wheel produced:\n{built.stdout[-2000:]}")
# A missing `venv`/`ensurepip` is unsupported optional infra, not a
# packaging bug — skip only here.
venv = tmp / "venv"
made = subprocess.run([sys.executable, "-m", "venv", str(venv)],
capture_output=True, text=True, check=False)
if made.returncode != 0:
raise unittest.SkipTest(f"venv/ensurepip unavailable:\n{made.stderr[-1500:]}")
cls.venv_py = venv / "bin" / "python"
# Installing the freshly-built wheel must succeed — fail if it doesn't.
install = subprocess.run(
[str(cls.venv_py), "-m", "pip", "install", "--quiet", str(wheels[0])],
capture_output=True, text=True, check=False,
)
if install.returncode != 0:
raise AssertionError(f"pip install of the wheel failed:\n{install.stderr[-2000:]}")
# Isolate the staged build root the wheel writes under the app-data dir.
cls.app_root = tmp / "appdata"
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def _run(self, tail: "list[str]") -> "subprocess.CompletedProcess[str]":
"""Run the installed venv's python with `tail` appended, from a neutral
cwd so the source checkout isn't on sys.path — we must import the
*installed* package, not the repo we built from."""
env = {"BOT_BOTTLE_ROOT": str(self.app_root), "PATH": "/usr/bin:/bin"}
return subprocess.run(
[str(self.venv_py), *tail],
capture_output=True, text=True, env=env, cwd=self._tmp.name, check=False,
)
def test_console_entry_point_installed(self):
# The `bot-bottle` script the wheel declares must exist in the venv.
script = self.venv_py.parent / "bot-bottle"
self.assertTrue(script.is_file(), "bot-bottle console script not installed")
def test_doctor_runs_from_installed_package(self):
proc = self._run(["-m", "bot_bottle.cli", "doctor"])
# doctor exits non-zero here (no backend), but it must RUN and report.
self.assertIn("python", proc.stdout)
self.assertIn(proc.returncode, (0, 1))
def test_installed_wheel_is_self_contained(self):
# From the installed layout (not a checkout), resources must resolve
# Dockerfiles and stage a repo-root-shaped build context.
script = (
"import bot_bottle.resources as r\n"
"assert not r.is_source_checkout(), 'should not look like a checkout'\n"
"assert r.dockerfile('Dockerfile.gateway').is_file()\n"
"assert r.nix_netpool_module().is_file()\n"
"assert r.netpool_script().is_file()\n"
"root = r.build_root()\n"
"assert (root / 'bot_bottle' / '__init__.py').is_file(), 'no package in context'\n"
"assert (root / 'pyproject.toml').is_file(), 'no pyproject in context'\n"
"assert (root / 'Dockerfile.gateway').is_file(), 'no Dockerfile in context'\n"
"print('SELF_CONTAINED_OK')\n"
)
proc = self._run(["-c", script])
self.assertIn("SELF_CONTAINED_OK", proc.stdout, msg=proc.stderr)
if __name__ == "__main__":
unittest.main()