# PRD 0077: macOS (Apple Container) CI runner

- **Status:** Active
- **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.
