Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 605146d287 | |||
| 27dea58ae1 | |||
| 5401f036a9 | |||
| 238f5f7614 | |||
| 2644759b0d | |||
| e53104d5c1 |
@@ -212,6 +212,89 @@ jobs:
|
||||
name: firecracker-inputs
|
||||
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
|
||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||
#
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# bot-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](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.
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
||||
|
||||
@@ -59,9 +59,8 @@ class Orchestrator(abc.ABC):
|
||||
|
||||
# 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; the default topology is co-located + fail-closed,
|
||||
# so a backend need not redeclare it (a truly isolated control plane would
|
||||
# override this with a different `Topology`).
|
||||
# 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:
|
||||
|
||||
+11
-38
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import orchestrator_auth
|
||||
from .orchestrator_auth import ROLE_GATEWAY
|
||||
@@ -39,8 +39,8 @@ from .paths import (
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
"""A control-plane auth invariant would be violated (e.g. starting a
|
||||
co-located control plane without its signing key — which would run OPEN)."""
|
||||
"""A control-plane auth invariant would be violated (e.g. starting the
|
||||
orchestrator without its signing key — which would run OPEN)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,31 +99,6 @@ CONTROL_PLANE = TrustDomain(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Topology:
|
||||
"""Where a backend runs the two planes, so the provisioning seam can decide
|
||||
whether an open control plane is dangerous — the backend declares this
|
||||
instead of hardcoding the decision in its launcher.
|
||||
|
||||
`data_plane_shares_control_host` — the gateway runs on the same host/VM as
|
||||
the orchestrator, so an open orchestrator would hand the co-located gateway
|
||||
full `cli`. The default, and what makes the signing key mandatory. Every
|
||||
current backend is co-located (docker/macOS: two containers on one host;
|
||||
firecracker: two VMs on one host, agents L3-isolated); an isolated control
|
||||
plane on a separate trusted host may declare False.
|
||||
|
||||
`combined_guest` — both planes in one guest (the retired combined infra VM).
|
||||
Informational; kept so a future combined backend declares it."""
|
||||
|
||||
data_plane_shares_control_host: bool = True
|
||||
combined_guest: bool = False
|
||||
|
||||
|
||||
# The default posture: data plane co-located with the control plane. Fail-closed
|
||||
# — the signing key is mandatory. A backend opts out only by declaring otherwise.
|
||||
COLOCATED = Topology(data_plane_shares_control_host=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlPlaneProvisioning:
|
||||
"""The one seam every backend launcher uses to provision control-plane auth,
|
||||
@@ -131,22 +106,22 @@ class ControlPlaneProvisioning:
|
||||
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 where its gateway is co-located."""
|
||||
starts open."""
|
||||
|
||||
domain: TrustDomain = CONTROL_PLANE
|
||||
topology: Topology = field(default=COLOCATED)
|
||||
|
||||
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 "" for a
|
||||
co-located topology, since an empty key runs the server open and hands
|
||||
the co-located gateway full `cli`."""
|
||||
`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 and self.topology.data_plane_shares_control_host:
|
||||
if not key:
|
||||
raise ProvisioningError(
|
||||
f"refusing to start the {self.domain.name} orchestrator without "
|
||||
"a signing key: its gateway shares this host/VM, so an open "
|
||||
"orchestrator would grant that gateway full `cli` (#476)"
|
||||
"a signing key: an open orchestrator authenticates no one and "
|
||||
"grants every caller that reaches it full `cli` (#476)"
|
||||
)
|
||||
return key
|
||||
|
||||
@@ -161,7 +136,5 @@ __all__ = [
|
||||
"ProvisioningError",
|
||||
"TrustDomain",
|
||||
"CONTROL_PLANE",
|
||||
"Topology",
|
||||
"COLOCATED",
|
||||
"ControlPlaneProvisioning",
|
||||
]
|
||||
|
||||
+12
-1
@@ -2,11 +2,22 @@
|
||||
|
||||
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
||||
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 `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
|
||||
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
|
||||
prints a clear per-check readiness summary and fails the job when the
|
||||
|
||||
@@ -45,7 +45,9 @@ key.
|
||||
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 control plane never runs open where its data plane shares the host/VM.
|
||||
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
|
||||
|
||||
@@ -72,9 +74,8 @@ signed by one service's key neither carries nor verifies another service's role.
|
||||
|
||||
**`ControlPlaneProvisioning`** is the seam the backends call.
|
||||
`orchestrator_key()` returns the raw key for the control-plane process
|
||||
(fail-closed: it raises rather than hand back an empty key that would run the
|
||||
server open where the data plane is co-located). `gateway_token()` mints the
|
||||
gateway's token. Each backend applies these through its own transport —
|
||||
(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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true"
|
||||
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
||||
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
|
||||
and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
|
||||
"skipped under the containerized act_runner (docker on ubuntu-latest): "
|
||||
"egress_tls_init uses a host bind mount the runner container can't "
|
||||
"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):
|
||||
"""End-to-end attacks against a real bottle. The bottle stays
|
||||
|
||||
@@ -11,7 +11,6 @@ from bot_bottle.trust_domain import (
|
||||
CONTROL_PLANE,
|
||||
ControlPlaneProvisioning,
|
||||
ProvisioningError,
|
||||
Topology,
|
||||
TrustDomain,
|
||||
)
|
||||
|
||||
@@ -78,23 +77,16 @@ class TestControlPlaneProvisioning(unittest.TestCase):
|
||||
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_colocated_and_empty(self) -> None:
|
||||
# Invariant 4: a co-located backend must never start the control plane
|
||||
# without a key (it would run OPEN and hand the co-located data plane
|
||||
# full `cli`).
|
||||
prov = ControlPlaneProvisioning() # default topology: co-located
|
||||
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_orchestrator_key_allows_empty_when_isolated(self) -> None:
|
||||
# A genuinely isolated control plane (no co-located data plane) may run
|
||||
# without a key — the only topology where open mode is permissible.
|
||||
prov = ControlPlaneProvisioning(
|
||||
topology=Topology(data_plane_shares_control_host=False))
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||
self.assertEqual("", 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"):
|
||||
|
||||
Reference in New Issue
Block a user