If resolve_teardown_timeout() raised after launch_consolidated()/register_agent()
returned, the teardown callback was never registered, leaving the bottle
registered with no cleanup path. Resolve the timeout into a local variable
before the registration call so any failure aborts before the bottle exists.
Add tests covering OrchestratorConfigStore, resolve_teardown_timeout priority
ordering (env > db > default), and the source-order invariant that the resolver
runs before registration in all three backends.
Resolves via ENV VAR -> orchestrator DB config -> default (30 s, up from 5 s):
BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS
teardown_timeout_seconds key in new orchestrator_config table (bot-bottle.db)
New OrchestratorConfigStore (same DbStore/TableMigrations pattern as the
registry) stores the DB-level setting. resolve_teardown_timeout() implements
the priority chain and is called at stack.callback registration time in all
three backends (macos_container, docker, firecracker).
The None/empty-string distinction is the invariant that prevents
live_source_ips from treating a failed inspect as a legitimately
no-address container. Add TestInspectContainerNetworkIp to util tests
covering: IP found, CIDR stripping, no address yet, absent network
list, non-zero exit, malformed JSON, and unexpected JSON shape.
A failed container list or a partial per-container inspect were
previously indistinguishable from a legitimately empty/partial live
set, so reconciliation could silently unregister healthy bottles.
- enumerate_active() now raises EnumerationError instead of returning []
when `container list` fails
- Add inspect_container_network_ip() to util, which returns None on
inspect failure (vs "" for "no DHCP address yet"), so live_source_ips
can tell the two apart
- live_source_ips() raises EnumerationError on either failure mode;
register_agent() catches it and skips reconciliation, same as
OrchestratorClientError
- Update tests: rename test_empty_when_the_cli_fails to
test_raises_when_the_cli_fails, update patching to the new function,
add coverage for list failure, per-container inspect failure, and the
launch-not-blocked path
`/resolve` fail-closes on a missing/ambiguous registry row *and* on a
request whose identity token doesn't match. The message named only the
first, so a bottle that was registered correctly but sent no token read as
"not registered" and sent the reader looking for a deregistered bottle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unattributed bottle, an unreachable orchestrator, and an unparseable
policy all become a deny-all Config, and a deny-all is indistinguishable
from "policy loaded, host not allowed" at the decision point — both are just
"no matching route". So every one of them reported `host X is not in the
bottle's egress.routes allowlist`, which reads as a config problem and sends
the operator hunting for a route that was never missing. Diagnosing a
bricked registration cost hours for exactly this reason.
Carry the structural reason on Config and prefer it in decide(). A genuine
allowlist miss — a policy that loaded and simply lacks the host — keeps the
original wording, so the message now tells the two cases apart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A registry row only ever left the registry two ways: an explicit
teardown_bottle (the launcher's cleanup callback) or the same-IP supersede
sweep in register(). Neither runs when the launching CLI dies hard, so the
row outlives its container.
That orphan is not inert. Source IPs are recycled by the backend's DHCP and
by_source_ip fail-closes on ambiguity, so a leftover row at a reused address
resolves no policy at all for the next bottle that lands there — and a
bottle with no policy denies every host, which surfaces to the agent as
"host X is not in the allowlist" for hosts that were never the problem.
Add reap_absent/reconcile and call it from the macOS launch path before
registering, so each launch self-heals the registry. Restores the invariant
the data plane needs: at most one active row per live address, and none for
a dead one. The second half matters as much as the first — when several rows
claim a *live* address the newest wins and the rest are swept, otherwise a
recycled address stays ambiguous, which is exactly the bricked state.
The host supplies the live set because the orchestrator runs inside the
infra container and cannot see the backend. A grace window exempts rows
younger than it, so reconciliation cannot race a bottle still coming up, and
a reconcile failure is logged rather than blocking an otherwise-fine launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared gateway's address is DHCP-assigned and changes whenever the infra
container is recreated — a source-hash bump, an image upgrade, a crash. Every
agent-facing URL embedded that address, and the proxy URL reaches the agent
as process environment at `container exec` time. A running process's environ
cannot be rewritten from outside, so a moved gateway stranded every running
bottle permanently: not degraded, unreachable, until relaunched and its agent
session thrown away.
Give the agent a stable name instead. `GATEWAY_HOSTNAME` replaces the address
in the egress proxy URL, NO_PROXY, git-http, and supervise URLs, and resolves
through the bottle's own /etc/hosts. Unlike environ that is a file, so it can
be rewritten inside a container that is already running — which is the whole
point: a gateway that returns at a new address is picked up by live bottles.
Launch writes the entry before anything execs (every agent URL names the
gateway, so it must resolve for the first connection), and re-points every
running bottle once the gateway is up, so one stranded by an earlier restart
re-attaches instead of needing a relaunch.
The write needs root and the agent runs as `node`: the host can repoint a
bottle's gateway name, the agent cannot repoint its own. Keep that asymmetry.
Apple Container 1.0 has no container-name DNS on a user network and
`container run` has no --add-host, so the entry is written by exec after
start rather than declared at run.
Does not address the other half of #443: per-bottle egress auth tokens are
held in memory by the orchestrator and are still lost across a restart, so a
re-attached bottle resolves its policy but not its injected credentials.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`container exec --env` does not override the run-time environment on Apple
Container — it appends. The launch path baked a token-less `*_PROXY` into
`container run` and relied on the exec-time, token-bearing value superseding
it, so the agent's `environ` ended up with two `HTTPS_PROXY` entries,
token-less first.
Which entry a runtime reads is then luck. Node reads the last, so Claude
bottles picked up the token and worked. Rust's `std::env::var` reads the
first, so Codex proxied with no identity token at all; `/resolve` requires a
matching (source_ip, identity_token) pair and fail-closes, so every request
from a Codex bottle was denied — its model calls, its `wss://` reconnects,
and its MCP servers alike. The registry row was correct the whole time,
which is what made this read as a registration bug.
Drop the run-time proxy vars entirely. A token-less proxy URL has no
legitimate consumer: the init process is `sleep` and everything that
egresses arrives by exec. Its only benefit was a tidy 403 for unattributed
callers, which is not worth silently dropping attribution for — and a
process that egresses before the exec-time env still fails closed, since the
agent network is host-only and the gateway is the only route off it.
Also corrects the two comments that asserted the false "exec --env wins"
invariant, so the next reader doesn't rebuild the same assumption.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pyright reportUnusedImport fired because the bare import is not used
within _common.py itself. The X as X idiom signals an intentional
public re-export without requiring __all__.
_auto_select_backend gains a prompt parameter (default True). When
prompt=False the docker-fallback [i/d/q] menu is skipped and the call
dies immediately with an actionable message ("set
BOT_BOTTLE_BACKEND=docker or install a VM backend"), preventing hangs
in CI, webhook dispatch, and orchestrator launches.
prepare_with_preflight passes prompt=not spec.headless so the headless
start path can never block waiting for TTY input it cannot receive.
Remove the private _read_tty_line duplicate from backend/__init__.py and
the local definition from cli/_common.py. Both now import from the
shared bot_bottle.util module.
- Remove hardcoded --backend=macos-container flag reference in
firecracker/util.py require_firecracker() error message
- Remove --backend flag from cli.py start; backend selection now
driven exclusively by BOT_BOTTLE_BACKEND env var or auto-selection
- Skip unavailable backends in cli.py cleanup (fixes crash on Linux
when macos-container.prepare_cleanup calls require_container())
- Add two-tier auto-selection: VM backend first (macos-container on
macOS, firecracker on Linux+KVM); fall back to docker with a
security warning and interactive i/d/q prompt; exit if docker
also unavailable and print VM install instructions
Closes#344
The macOS infra container's init script still spawned the supervisor as
`python3 /app/gateway_init.py`. 5ad3449 moved bot_bottle from flat files
under /app into a pip-installed package, so that file no longer exists:
the supervisor never started, mitmdump never generated its CA, and the
launch failed downstream with a misleading "gateway CA not available"
error pointing at TLS rather than at the supervisor.
This is the same defect 127ba49 fixed for the firecracker backend. It
survived on macOS because CI has no Apple Container runner — the KVM
runner added in c193b04 covers firecracker, and ubuntu-latest covers
docker, but nothing exercises macos_container.
test_macos_infra asserted the substring "gateway_init.py", which the
broken path satisfied; it now asserts the module form, matching the
assertion 127ba49 already made in test_firecracker_infra_vm.
Verified end to end on the macos-container backend: image builds, CA is
generated, agent registers, and a headless bottle runs a prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_python_imports_supervise_module probed the image with
`import supervise; import supervise_server`, the flat sibling modules that
5ad3449 replaced. They now live in the installed package, so the test fails
with ModuleNotFoundError when actually run.
It went unnoticed because the whole class carries a GITEA_ACTIONS skip — it
is interactive-only, so CI never executes it.
All 4 tests in the class now pass against a freshly built gateway image.
Closes#429.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- agent-sandbox-landscape.md: update agent-safehouse star count (~1.4k →
~1.8k), add HN thread #47301085 link and key discussion notes (creator
framing, Simon Willison observation, top community quote on sandboxing
being THE major challenge), note Apple Container 1.0 stable (Jun 9 2026)
- hn-agent-safety-discourse-july-2026.md: add Agent Safehouse March 12
launch to the sandboxing boom section as context that prefigures the
June–July shift in community tone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review follow-ups for the infra candidate-artifact flow:
1. publish-infra would fail on the first merge to main. The rootfs version now
hashes the dropbear bytes, and build-infra sets BOT_BOTTLE_FC_DROPBEAR to the
staged dropbear — but publish-infra (ubuntu-latest) set none, so _publish_
bundle re-derived the version with a "<missing>" dropbear and rejected the
candidate as "does not match checkout". Download the same firecracker-inputs
dropbear and export BOT_BOTTLE_FC_DROPBEAR in publish-infra (and add
stage-firecracker-inputs to its needs, since it now consumes that artifact).
2. Guard stage-firecracker-inputs with the same fork-PR check as the other
KVM-runner jobs, so a fork PR can't spin the privileged runner (it only
copies a static binary, but keep the posture consistent; it gates the whole
Firecracker chain via needs).
3. ensure_artifact_gz / _publish_bundle read version.txt before checking it
exists — a bundle missing it raised a raw FileNotFoundError instead of the
intended "bundle is incomplete" die. Guard the read (kept before the gz/sha
completeness check so a wrong-version bundle still reports the version
mismatch, not "incomplete").
4. test_infra_artifact.setUp didn't isolate BOT_BOTTLE_INFRA_ARTIFACT_DIR, so
an ambient candidate dir (the coverage job exports one) would send the
registry-pull tests down the local-bundle path. Pin it off in setUp; the
candidate-path cases set it explicitly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The coverage CI job exports BOT_BOTTLE_INFRA_BUILD=local so the integration
suite builds infra/agent images from source. But that job also runs the unit
suite, and the ambient =local leaked into test_default_pulls_artifact_without_
docker — it drove ensure_built() down the local Docker-build branch, so the
"build_image not called / artifact pulled" assertion failed (build_image
called 3 times).
Pin the var off in that test so it exercises the default pull path regardless
of the runner's ambient env (mirrors test_local_mode_builds_deps_before_infra,
which pins it on). Full unit suite verified green under BOT_BOTTLE_INFRA_BUILD
=local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The test jobs were pulling a pre-published infra rootfs artifact from the
registry, which made CI dependent on an out-of-band publish step. Test suites
should be self-contained.
- Set BOT_BOTTLE_INFRA_BUILD=local in integration-firecracker and coverage
so both jobs build agent and infra images from the checked-out source.
- Add publish-infra.yml: a separate workflow that fires after the test
workflow succeeds on main, builds the same rootfs, and uploads it to the
Gitea generic-package registry for operators who want the pull path.
- Registry credentials (BOT_BOTTLE_INFRA_ARTIFACT_TOKEN) are only needed
by the post-merge publish job, never by test jobs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dockerfile.gateway COPYs pyproject.toml into /src and runs pip install
/src, so it is a real input to the baked rootfs. A dependency-only change
previously reused stale artifact versions, potentially booting a rootfs
whose installed packages differed from the current checkout.
Also adds _fake_repo fixture support and a regression test so this input
can't silently drop out of the hash again.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two lint fixes, both test-only (no effect on the infra artifact version):
- annotate the TestAdoptable / TestKillInfraFirecrackers helper params
(reportMissingParameterType).
- test_docker_test_helpers: read __unittest_skip__ via getattr on the
dynamically-built Case type, matching the sibling assertion — pyright can't
see the attribute the skip decorator adds (reportAttributeAccessIssue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
build_agent_rootfs_dir cached the built rootfs by Dockerfile content alone,
but util.inject_guest_boot then writes util._GUEST_INIT into it. So a fix to
the init — making /tmp world-writable (1777) so the agent can create scratch
dirs / git worktrees there — did NOT bust the cache: the KVM runner kept
reusing a stale agent-<dockerfilehash> rootfs built with the old init, and the
sandbox-escape README-push test kept failing at `git init /tmp/...` with
"Permission denied".
Key the cache on Dockerfile content AND the injected init (_rootfs_digest), so
an init change rebuilds. Self-busting: the new key yields a fresh cache dir, so
no manual cache clear on the runner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The KVM CI runner now sets BOT_BOTTLE_FC_* for its isolated pool (distinct
iface prefix / orch iface / nft table / IP base), and that env leaks into the
coverage job's test process. Five netpool tests hardcoded the default names
(bbfc*, bot_bottle_fc) and so failed there with e.g. ['bbfc1'] != ['bbci1'].
Assert against netpool's env-driven config instead — slot(i).iface / the
configured prefix — so the tests check the LOGIC regardless of which pool the
host is configured for. The single-source test now compares the parsed
defaults (netpool._DEFAULTS, env-independent) for the module constants, since
IFACE_PREFIX/NFT_TABLE legitimately layer an env override on top.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The infra VM is a per-host singleton that outlives short-lived launchers, so
`ensure_running` adopts it when its control plane is healthy. But it adopted
ANY healthy VM regardless of the code that built it — so after an infra-code
change the old VM kept being adopted and the new code never booted. The only
way to dislodge it was an out-of-band `kill`, which then raced whatever
launched next. On CI this meant every infra change needed a manual VM kill.
Make adoption version-aware:
* boot records the infra-artifact version it booted from in a `booted-version`
marker beside the singleton; `stop` clears it.
* `ensure_running` adopts only when the marker matches the current version
(`_adoptable`); a missing/mismatched marker falls through to stop + reboot.
So a stale VM is replaced automatically on the next launch — no manual kill,
and it's concurrency-safe (the reboot happens under the singleton flock).
Also harden teardown: the PID file drifts after crashes / out-of-band kills,
so `stop` now also reaps any orphaned firecracker still bound to the infra
config path (scoped to that path, so interactive-pool VMs are untouched) —
otherwise a survivor holds the orchestrator TAP and the fresh boot dies with
"tap … Resource busy".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The rootless agent rootfs build can land /tmp as 0755/root-owned, so the
agent (uid 1000 node) can't create scratch dirs there. The sandbox-escape
suite's README-push test does `cd /tmp && git init sandbox-escape-repo` and
died with "cannot mkdir sandbox-escape-repo: Permission denied" — before the
git-gate gitleaks hook could run — so the test read it as a missing hook.
On docker the agent inherits node:22-slim's 1777 /tmp, which is why only the
Firecracker path was affected (and only now that the suite runs end-to-end).
Set /tmp to 1777 in the guest PID-1 init, so every agent VM boots with a
usable /tmp regardless of rootfs perm drift.
Also update the infra-init unit test to assert the gateway launches via the
`bot_bottle.gateway_init` module (matching the prior fix), not a file path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The infra VM's init boots the orchestrator control plane AND the gateway data
plane (egress/git-http/supervise). Since 5ad3449 moved the daemons into the
installed `bot_bottle` package, there is no `/app/gateway_init.py` file — the
gateway image's entrypoint is `python3 -m bot_bottle.gateway_init`. But the
firecracker init still spawned the old file path, so the guest logged:
python3: can't open file '/app/gateway_init.py': No such file or directory
The control plane came up (it already used `python3 -m bot_bottle.orchestrator`)
but the gateway never started, so mitmproxy never generated its CA and launch
died with "gateway CA not available after 30s". Mirror the orchestrator line:
run the gateway as `python3 -m bot_bottle.gateway_init` (bot_bottle resolves
from the /app CWD, same as the control plane; egress-entrypoint.sh /
egress_addon.py are present from the gateway base image).
Third build/runtime bug from 5ad3449's package refactor that the Firecracker
integration suite never exercised (the artifact publish was broken, so the
job 404'd before boot). Changes the init, so the infra artifact version moves;
the matching rootfs has been rebuilt and published.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
The Firecracker integration + coverage jobs pull a prebuilt infra rootfs
artifact (PRD 0069 Stage 2) versioned by a content hash of the Dockerfiles,
bot_bottle/, and the guest init. Building that artifact (publish_infra ->
docker build Dockerfile.gateway) has been broken since 5ad3449, so the
artifact was never published and the KVM runner's integration test 404'd on
the pull — the failure this branch surfaced once it stopped falsely skipping.
Two build-time bugs, both from 5ad3449, neither exercised since:
- pyproject.toml declared build-backend "setuptools.backends.legacy:build",
which is not an importable module; `pip install /src/` failed with
BackendUnavailable. Use the real backend, "setuptools.build_meta"
(the project has proper [project] metadata + flat-layout autodiscovery).
Not part of the artifact hash, so this alone doesn't move the version.
- Dockerfile.gateway wrote /app/egress_addon.py before /app existed (the
mkdir/WORKDIR came later), so the RUN redirect died with exit 2. Move
WORKDIR /app above the shim write (WORKDIR creates it) and drop the now
redundant later WORKDIR. This changes the gateway Dockerfile, so the infra
artifact version moves 3c9e7b23260992db -> 01e6aaa714756fce; the matching
artifact has been built and published to the generic package registry.
Also add Dockerfile* and pyproject.toml to test.yml's path filters: these
inputs determine what the firecracker jobs build/pull, so a change to them
must re-run the suite (and lets this push trigger a pull_request run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
Two diff-coverage gaps on the ci-kvm-runner branch:
1. bot_bottle/backend/docker/setup.py: the try/except TimeoutExpired
block added in a prior commit had no tests reaching the subprocess
path. Add two tests to TestDockerSetupStatus: one for the success
path (subprocess returns 0) and one for the TimeoutExpired fallback.
2. bot_bottle/db_store.py: the _connection() context manager change in
is_migrated() was never exercised by unit tests (all callers mock
is_migrated() directly). Add test_db_store.py covering the absent-DB,
missing-schema-table, migrated, and behind-schema cases.
On a self-hosted KVM runner the process has a real controlling terminal
so name_color_modal successfully opens /dev/tty and enters a curses
loop waiting for keyboard input, hanging the test indefinitely.
Docker containers (ubuntu-latest runners) don't have a real /dev/tty,
causing an OSError that triggers the existing fallback — this is why
the hang was invisible in ubuntu-latest CI.
Also add timeout=5 to _daemon_reachable() to match the same defensive
fix already applied to docker_available() in tests/_docker.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker integration tests are already covered by the integration-docker
job on ubuntu-latest. On the KVM runner the Firecracker TAP/nftables
pool conflicts with Docker networking, causing those tests to hang
and the coverage job to never complete.
Add SKIP_DOCKER_TESTS env-var support to docker_available() and set
it for the integration phase of coverage.sh so only Firecracker
integration tests run there.
Gitea fires the pull_request push event as 'synchronize' (GitHub spec),
not 'synchronized'. The typo meant the workflow only ran on opened/
edited/reopened, leaving the required check yellow with no details link
after every commit push.
On the self-hosted KVM runner Docker is on PATH but the daemon socket
is unreachable (firewalled/dropped). subprocess.run(["docker", "info"])
with no timeout hangs indefinitely on a dropped connection, stalling the
coverage job for hours — one hang per @skip_unless_docker()-decorated
class, ~8 per integration suite run.
Add timeout=5 with a TimeoutExpired → False fallback so the check
resolves quickly to "unreachable" rather than blocking.
`sqlite3.Connection.__exit__` only commits/rolls back a transaction — it
does not close the connection. Python 3.13 (the Nix env on the KVM
runner) emits `ResourceWarning: unclosed database` for every connection
GC'd without an explicit close, producing noisy output in the coverage job.
Add `DbStore._connection()`, a `contextmanager` that calls `self._connect()`,
wraps it in the existing transaction context manager, and closes the
connection in a `finally` block. Change all `with self._connect() as conn:`
call sites in `db_store.py`, `audit_store.py`, `queue_store.py`, and
`orchestrator/registry.py` to `with self._connection() as conn:`.
`_connect()` remains as the per-subclass hook (RegistryStore overrides
it to set `busy_timeout`); `_connection()` delegates to `self._connect()` so
the override is respected.
The self-hosted runner's Nix python env has no `pip` module, so
`python3 -m pip install -r requirements-dev.txt` failed with "No module
named pip" in both firecracker jobs. Neither job needs that install:
- integration-firecracker runs the stdlib `unittest` suite (no deps);
- coverage needs only `coverage`, which the runner's Nix python env
already ships (7.12.0) — verified `coverage run`/`coverage json` work.
pylint/pyright are lint.yml's concern, not test.yml's. The ubuntu-latest
`unit` job keeps its `--break-system-packages` install unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
Add separate `integration-docker` and `integration-firecracker` jobs,
each with an explicit BOT_BOTTLE_BACKEND env var, so the backend used
is visible in CI output and skipped backends surface as a distinct job
rather than silent unittest.skip lines.
- integration-docker: ubuntu-latest, BOT_BOTTLE_BACKEND=docker
- integration-firecracker: [self-hosted, kvm], BOT_BOTTLE_BACKEND=firecracker,
same-repo PRs + push + workflow_dispatch only (untrusted fork PRs do
not execute on the privileged KVM runner)
- coverage: same same-repo restriction; refs #414 for the planned
follow-up that moves coverage to ubuntu-latest via artifact combination
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sandbox-escape test was unconditionally skipped when GITEA_ACTIONS=true,
which prevented Firecracker orchestration coverage from being measured even
when BOT_BOTTLE_BACKEND=firecracker is set on the KVM runner.
Narrow the skip to: GITEA_ACTIONS=true AND BOT_BOTTLE_BACKEND != firecracker.
When BOT_BOTTLE_BACKEND=firecracker the test is explicitly opted in to run on
the self-hosted KVM runner where the required /dev/kvm + TAP pool exist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Finding 1: set BOT_BOTTLE_BACKEND=firecracker on the coverage step so
the integration suite actually exercises the Firecracker orchestration
paths rather than defaulting to Docker
- Finding 2: restrict the coverage job to push+workflow_dispatch only;
PR-controlled code no longer executes on the privileged KVM runner
automatically — maintainers trigger workflow_dispatch for trusted PRs
- Finding 3: expand path filters to include workflow files, scripts, and
README so changes to CI configuration trigger the workflow itself
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The self-hosted KVM runner is a persistent machine, so
--break-system-packages is inappropriate. Use --user instead so
coverage (and pyright/pylint for future jobs) land in ~/.local
and survive between runs without touching the system Python.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-land the coverage gate deferred from #343. The Firecracker VM/SSH
orchestration (~230 lines) is only exercised by the integration suite,
which needs /dev/kvm + the provisioned TAP/nft pool — a container runner
skips it and those lines read uncovered, so the 90% diff gate can't pass
on ubuntu-latest. Move the `coverage` job to a self-hosted `kvm` runner
with a firecracker-readiness preflight (binary + /dev/kvm + `backend
status`) so the integration test actually runs. Unit/lint stay on
ubuntu-latest. README documents the runner prerequisites.
Depends on a registered self-hosted runner labelled `kvm`; until one is
provisioned this gate will not run. See PRD 0069 / #348.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck