d3c4fc0fd4ca66469c3a189acfe705ff3c8427ac
1170 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d3c4fc0fd4 |
ci(test): drop actions/setup-python; install into the container's system Python
test / integration (pull_request) Successful in 7s
test / unit (pull_request) Successful in 44s
test / coverage (pull_request) Successful in 36s
test / integration (push) Successful in 7s
Update Quality Badges / update-badges (push) Failing after 11s
test / unit (push) Successful in 29s
test / coverage (push) Successful in 35s
lint / lint (push) Successful in 2m24s
The old act_runner engine (v0.2.13 on the delphi-ci runner) mishandles actions/setup-python's PATH injection: pip installs coverage into the toolcache interpreter while `python3` in later steps resolves back to the image's system Python, so unit/coverage jobs failed with "No module named coverage". Newer runners (TrueNAS's v0.6.1) don't, which is why it only broke on delphi. The runner-images/act container already ships Python 3.12, and the job container is ephemeral, so drop setup-python entirely and install straight into the system Python with --break-system-packages. Every step now uses one interpreter consistently, on any runner version. Also removes the redundant setup-python step from the integration job (stdlib-only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
232dfdf37a |
refactor(gateway): replace egress_addon.py copy with a one-line shim
mitmdump -s requires a file path, not a module. Instead of copying the full egress_addon.py to /app/, write a one-line shim at image build time that re-exports addons from the installed package. mitmdump finds the addons list in the shim's namespace; all real addon code stays in bot_bottle/egress_addon.py. |
||
|
|
9a0dd821ef |
refactor(gateway): invoke daemons via python3 -m instead of /app/ file copies
supervise_server, git_http_backend, and gateway_init all have __main__ guards, so python3 -m bot_bottle.X replaces the individual COPY lines to /app/. egress_addon.py stays as a file copy because mitmdump -s requires a file path rather than a module reference. |
||
|
|
5ad3449e3b |
refactor(gateway): replace flat-file import shims with installed package
Install bot_bottle via pip in Dockerfile.gateway instead of COPYing individual .py files flat under /app/. This eliminates the try/except import shims in egress_addon_core, dlp_detectors, egress_addon, supervise, supervise_server, and git_http_backend that existed only to support the flat-bundle layout. Adds bot_bottle/constants.py as a single source of truth for IDENTITY_HEADER and GIT_GATE_TIMEOUT_SECS, removing the duplicated literal definitions in egress_addon.py, supervise_server.py, git_http_backend.py, and git_gate_render.py. Test files updated to match: test_supervise_server.py drops the sys.path.insert hack in favour of direct package imports; the egress_addon test shims no longer pre-populate sys.modules with a bare egress_addon_core alias. |
||
|
|
d8e3947bd3 |
test(git-http): wire the resolver into the access-hook-503 regression test
test / unit (pull_request) Successful in 1m9s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m21s
lint / lint (push) Successful in 2m16s
test / unit (push) Successful in 1m9s
test / integration (push) Successful in 31s
test / coverage (push) Successful in 1m33s
Update Quality Badges / update-badges (push) Successful in 1m21s
b1850be's fail-closed-503 test (rebased in from main) built a git-http server with a flat repo.git and no policy_resolver. The resolver-only data plane on this branch denies an unattributed request with 404 before it reaches the access-hook path the test exercises, so it saw 404 != 503. Nest the bare repo under <root>/<_BID>/ and set _FixedResolver(_BID) on the server, matching every other test in this module, so the request is attributed and reaches the access-hook (mocked to raise PermissionError) that the 503 fail-closed behavior guards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
d0b7de119f |
test(egress): set orchestrator URL for entrypoint tests; cover fail-closed guard
The egress_entrypoint.sh fail-closed guard (this branch) exits 1 when BOT_BOTTLE_ORCHESTRATOR_URL is unset, which broke the argv-construction tests that ran the script without it. Set the URL in the shared _run_entrypoint helper (a precondition for reaching mitmdump now, like PATH) and add a test asserting the guard fails closed when it's absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
ea1fbeeaa0 |
refactor(gateway): fail closed without an orchestrator URL; drop stale single-tenant comments
Follow-ups from the #402 review of the single-tenant data-plane teardown. - egress_entrypoint.sh: refuse to launch mitmdump when BOT_BOTTLE_ORCHESTRATOR_URL is unset, so the fail-closed guarantee no longer rests solely on mitmproxy's errorcheck addon exiting on the addon's load-time raise. A misconfigured gateway can never come up as a bare TLS-bumping open proxy with no policy. - orchestrator/gateway.py: ensure_running() raises GatewayError on an empty orchestrator URL — a URL-less launch would only crash-loop the now-resolver-only daemons (egress raises, git-http exits 1, supervise exits 2). The env-injection branch is now unconditional. - Drop stale "single-tenant" / "reads routes.yaml" comments in gateway.py and egress_entrypoint.sh, and the /etc/egress/routes.yaml layout line in Dockerfile.gateway. - Tests: gateway fixtures supply an orchestrator URL; add a refuse-without-URL test and assert the URL env is injected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
b601b663e2 |
refactor(gateway): remove the single-tenant data-plane paths (audit #400 finding 3)
All three backends (docker, firecracker, macos-container) now launch through the consolidated orchestrator, and every production gateway sets BOT_BOTTLE_ORCHESTRATOR_URL — so the legacy single-tenant (`resolver is None`) branches in the shared gateway's data plane were unreachable dead code, a second security-relevant path to keep correct in parallel with the live one. Make the orchestrator resolver mandatory and delete the single-tenant paths from the three data-plane modules. egress_addon.py: drop the static routes file entirely — EGRESS_ROUTES, _reload, the SIGHUP handler, self.config, and the SUPERVISE_BOTTLE_SLUG env slug. The per-request /resolve is the only policy source; __init__ fail-closes if BOT_BOTTLE_ORCHESTRATOR_URL is unset. Introspection (`_egress.local/allowlist`) now reports the calling bottle's *resolved* routes. The block/redact log gates and _req_ctx redaction now read the per-flow config/env from the request-time stash, so they use each bottle's log level and token set (they silently used the empty static config before). Nothing sends `docker kill --signal HUP` to the gateway in the consolidated model (the egress applicators fail closed), so removing the SIGHUP reload is safe. git_http_backend.py: resolver mandatory; no flat-root fallback. main() refuses to start without an orchestrator URL; a request whose source resolves to no bottle 404s. supervise_server.py: resolver mandatory; every proposal is attributed to the source-IP-resolved bottle. Remove handle_list_egress_routes (the proxy-fetch introspection that only worked when the proxy carried one bottle's identity) — list-egress-routes is answered from the resolved policy. main() refuses to start without an orchestrator URL. Tests: a host-side fake resolver serves each test's Config through the real parse path (a small YAML-subset emitter round-trips route_to_yaml_dict); the response/websocket hooks stash it as request() would. Deletes the tests for the removed static-config, SIGHUP-reload, and single-tenant-passthrough paths; adds fail-closed-without-orchestrator coverage. Follow-up: gateway_init still forwards SIGHUP to the egress child (now dormant — no one sends it); the README still describes the docker backend's per-bottle topology. Both are outside the data-plane teardown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
2aec30e501 |
fix(git-gate): install the gitleaks binary for the build's target arch
The gateway Dockerfile hardcoded the linux_x64 gitleaks download, so an image built on/for aarch64 (Apple Silicon) baked in an x86_64 binary. It sat quiet until the git-gate pre-receive hook first invoked it, where the kernel refused the foreign-arch exec — `gitleaks: Exec format error` — failing every push through that gateway. Pick the asset + pinned SHA from the build's target architecture: TARGETARCH (auto-populated by BuildKit) with a `dpkg --print-architecture` fallback for a legacy builder, and hard-fail on any unsupported arch. Each arch keeps its own SHA256 verification, so no supply-chain regression. The existing integration test test_gateway_image.py:: test_gitleaks_binary_present_and_versioned execs `gitleaks version` in the built image, so it now passes on arm64 instead of hitting the same error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b1850be5d1 |
fix(git-gate): make the gateway access-hook executable regardless of copy transport
test / unit (pull_request) Successful in 1m19s
test / integration (pull_request) Successful in 30s
test / coverage (pull_request) Successful in 1m35s
lint / lint (push) Successful in 2m35s
test / unit (push) Successful in 1m21s
test / integration (push) Successful in 29s
test / coverage (push) Successful in 1m31s
Update Quality Badges / update-badges (push) Successful in 1m21s
Cloning/fetching from the git-gate on the Apple-container backend failed with
"empty reply from server" (curl exit 52). Root cause: the git-http handler
crashed on every upload-pack with
PermissionError: [Errno 13] Permission denied: '/etc/git-gate/access-hook'
The access-hook is exec'd directly, so it needs the x bit. prepare() stages it
0o700 and trusted the gateway copy to carry that mode. `docker cp` does; the
Apple `container cp` (AppleGatewayTransport) does not, landing the hook 0o644 →
EACCES. The unhandled exception killed the handler thread, closing the socket
with no HTTP response — which the client sees as the opaque empty reply.
- provision_git_gate now `chmod +x`es the access-hook on the gateway side after
the copy, so it's executable under every transport (docker/apple/firecracker).
- git-http handler wraps the access-hook subprocess.run: an OSError /
SubprocessError (un-execable, timed out) now fails closed with a 503 instead
of crashing the thread into an empty reply — a gate that can't run its hook
should deny, visibly.
- Updates the now-misleading "docker cp preserves source mode" comment in
git_gate.prepare().
Regression tests: provisioning applies +x to the access-hook; the handler
returns 503 (not an empty reply) when the hook can't be exec'd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
fd86e7fa99 |
fix(egress): redact the per-bottle token in the now-active multi-tenant response log
test / unit (pull_request) Successful in 1m9s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m24s
lint / lint (push) Successful in 2m15s
test / unit (push) Successful in 1m8s
test / coverage (push) Successful in 1m20s
test / integration (push) Successful in 24s
Update Quality Badges / update-badges (push) Successful in 1m18s
Self-review of this PR: making `response()` run in the consolidated gateway also activates its `LOG_FULL` `_log_response` call there — previously unreachable, since the empty static config made `response()` return early. That logger redacted with `os.environ`, which in multi-tenant mode does NOT hold the bottle's per-request `/resolve` tokens (only the resolved `env` overlay does), so a non-token-shaped provisioned secret appearing in a response could be logged in the clear. Thread the resolved per-flow `env` into `_log_request` / `_log_response` so the LOG_FULL redaction scrubs the calling bottle's secrets. Adds a regression test (a non-token-shaped `/resolve` secret, absent from os.environ, must not appear in the response log) and updates the redaction-test helpers for the new arg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
3bbd839917 |
fix(egress): scan response + websocket DLP against the resolved per-flow config
In the consolidated (multi-tenant) gateway the addon's static `self.config` is empty — each request's real policy comes from the per-request `/resolve`. `response()` and `websocket_message()` still matched routes against that empty config, so inbound prompt-injection DLP and WebSocket credential/injection DLP silently skipped every scan (fail-open) whenever the gateway ran multi-tenant. This is backend-agnostic: the gateway image (and this addon) is shared by the Firecracker, macOS, and docker consolidated backends. Resolve the per-flow (config, slug, env) once in `request()`, stash it on `flow.metadata`, and have both hooks read it back — falling back to the static single-tenant values for a flow that never passed through `request()`. Reusing the request's one `/resolve` avoids a round-trip per response and per WebSocket frame. Adds multi-tenant regression tests for both hooks that fail against the old fall-open behaviour. Refs: audit issue #400 (finding #2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
5c526860bc |
fix(test): resolve the remaining review findings on the control-plane auth test
test / unit (pull_request) Successful in 1m17s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m21s
lint / lint (push) Successful in 2m23s
test / unit (push) Successful in 1m24s
test / integration (push) Successful in 30s
test / coverage (push) Successful in 1m26s
Update Quality Badges / update-badges (push) Successful in 1m24s
Addresses the 5 lower-priority findings left as follow-up in the earlier review, now that each has a concrete answer: - Add gateway_name: str = GATEWAY_NAME to OrchestratorService.__init__ (mirrors the existing orchestrator_name param) and thread it through _gateway(). Deletes the test's _IsolatedOrchestratorService subclass, which existed only to override a private method for this one kwarg — any caller needing gateway-name isolation can now use the public constructor. Backward compatible: every existing caller constructs OrchestratorService with keyword args and a sensible default is kept. - Give the test its own fixed image tags (bot-bottle-orchestrator:itest, bot-bottle-gateway:itest) instead of the production :latest ones. _running_image_is_current() keys gateway staleness off the image tag's ID, not per-instance identity, so rebuilding the shared :latest tag from whatever's on disk during a test run could make a real host's running production gateway look stale and get force-recreated. Fixed tags (not per-run-suffixed, so they don't accumulate) fully decouple the two. - setUp -> setUpClass/tearDownClass: all 5 tests are read-only checks against the same running control plane, so one shared container lifecycle replaces 5 (each of which paid its own container-start + image-build + health-poll cycle). Cuts the file's wall-clock roughly 4x (11.5s -> 2.9-4.3s) and, combined with the network-rm cleanup from the previous commit, means one cleanup instead of five. - Reuse OrchestratorClient (bot_bottle/orchestrator/client.py) instead of a hand-rolled urllib helper — the test now exercises the same request/response code path the real host CLI uses, rather than a private copy that could silently drift from it. - Add the chown workaround test_multitenant_isolation.py already needed for this exact bind-mount: the orchestrator container has no USER directive, so it writes the registry DB as root into the throwaway host_root; chown it back before tempdir cleanup so that doesn't raise PermissionError on native Linux Docker (no UID remap, unlike Docker Desktop's macOS VM). Verified: ran the suite twice in a row (idempotency — fixed image tags don't accumulate, 5/5 pass both times, 2.99-4.33s each), the real ~/.bot-bottle/control-plane-token is untouched, zero leaked networks or containers after either run, exactly 2 :itest images (not growing), the full orchestrator unit suite (93 tests) and the sibling docker gateway/broker integration tests still pass. pyright clean, pylint 10.00/10 on both changed files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
492669e620 |
fix(test): skip the control-plane auth test under act_runner (CI)
Pushing the previous two fixes surfaced a real, currently-failing CI run (gitea actions run 2164, jobs "integration" and "coverage"): every one of the 5 new tests errored in setUp with docker: Error response from daemon: error while creating mount source path '/workspace/didericis/bot-bottle': mkdir /workspace: read-only file system This is finding #4 from the review of the previous commit, now confirmed live rather than just plausible: act_runner's job-container topology can't satisfy the orchestrator's host-path bind mount, the same constraint test_multitenant_isolation.py, test_gateway_image.py, and test_sandbox_escape.py already skip around. Add the identical skip_unless_docker + GITEA_ACTIONS guard so this test degrades the same way its siblings do instead of failing the job. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
d32f36bb2b |
fix(test): actually isolate the docker control-plane auth test's root + network
Two bugs surfaced by a review of the previous commit: - host_control_plane_token() resolves its path via the ambient BOT_BOTTLE_ROOT env var, not the host_root kwarg passed to OrchestratorService (that kwarg only controls the DB bind-mount destination). The test's isolation claim was false as a result: running it read/wrote the developer's real ~/.bot-bottle/control-plane-token instead of the throwaway temp dir — confirmed directly on disk. Fixed by pointing the env var at the same temp dir for the test's duration and restoring it via addCleanup. - ensure_running() creates a per-bottle Docker network but stop() only ever removes containers, never the network — every run of this test leaked one bridge network permanently (found and removed 5 from prior runs via `docker network ls`). Fixed with an explicit `docker network rm` in addCleanup. Verified: re-ran the suite twice: 5/5 pass both times, the real ~/.bot-bottle/control-plane-token timestamp is unchanged across both runs (proving isolation), and `docker network ls` shows zero leaked bot-bottle-net-itest-* networks afterward. pyright clean, pylint 10.00/10. Remaining findings from the same review (missing GITEA_ACTIONS skip guard, root-owned bind-mount cleanup on native Linux, no setUpClass, reinvented OrchestratorClient, gateway_name should be a constructor param rather than a subclassed private-method override) are left for a follow-up — each is a real, separate design/scope call, not a mechanical fix like these two. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
4f36b919b2 |
test(orchestrator): live Docker coverage for the control-plane auth fix (#400)
|
||
|
|
ca91fc4d91 |
fix(security): authenticate the orchestrator control plane (issue #400 Critical)
The control plane had no caller authentication — dispatch() routed every request straight through, and the only guards anywhere were the (source_ip, identity_token) checks inside /resolve and /attribute, which authenticate the *bottle* a request is about, not the *caller*. On the consolidated backends the agent shares a network with the control-plane port (macOS: the infra container runs 8099 on the agent's --internal network; docker: the orchestrator container sits on the shared gateway network), so a prompt-injected agent could GET /bottles (enumerate siblings + policies), PUT /bottles/<id>/policy (rewrite its own egress allowlist to allow-all), POST /resolve (read the injected upstream credentials it's never meant to see), and POST /supervise/respond (self-approve its own proposals). Every README guarantee collapsed to the agent's discretion. Fix: a per-host control-plane secret required on every route but GET /health, compared with hmac.compare_digest. It is held only by the trusted callers and never handed to an agent: - minted + persisted 0600 at <root>/control-plane-token (paths.host_control_plane_token); - injected as $BOT_BOTTLE_CONTROL_PLANE_TOKEN into the orchestrator + gateway containers via bare `--env NAME` (value inherited from the launch process, so it never lands on argv or in `container/docker inspect`); - presented by the gateway's PolicyResolver (reads the env) on /resolve, and by the host CLI's OrchestratorClient (reads the host file) on every call. The agent container is never given the env var or the host file, so from a bottle every /bottles*, /resolve, /attribute, and /supervise/* call now returns 401 — closing the enumeration, allowlist-rewrite, credential-lift, and self-approval. The existing (source_ip, identity_token) checks stay as defense-in-depth. Enforced when configured: macOS + docker inject the secret (→ enforced). With no secret set the server runs open and warns loudly at startup — a fail-visible fallback for the unit suite and for Firecracker, whose port-scoped nft already blocks agents from 8099 (wiring the secret into its infra-VM init is a clean fast-follow, left out here to avoid churning the prebuilt-artifact hash). Verified end-to-end on real Apple Container: infra comes up healthy, the host CLI (with the secret) lists bottles while an unauthenticated GET /bottles gets 401, all five issue-#400 attacks from inside the agent get 401, and egress policy still works (200 allowed / 403 denied) — proving the gateway authenticates to /resolve with the secret. 1829 unit tests pass, pyright clean, pylint 9.91. Refs #400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4a607ad098 |
refactor(macos): one infra container (control plane + gateway), fixes shared-DB races
Adopts the firecracker infra-VM pattern for macOS: the orchestrator control plane and the gateway data plane now run in a SINGLE Apple container instead of two. Apple Containers are lightweight VMs with separate kernels, so the prior two-container design had both guests writing one bot-bottle.db over virtiofs, where fcntl locks are not coherent across kernels — concurrent writes (the orchestrator's registry vs the gateway supervise daemon's queue) could corrupt it. One container = one kernel = coherent locking. The DB moves onto a container-only Apple volume (bot-bottle-mac-db), never bind-mounted from the host, so no host process opens the live file either. The host CLI already reaches registry + supervise state over the control-plane HTTP surface (cli/supervise.py uses OrchestratorClient), exactly as firecracker's VM-only DB requires. Two simplifications fall out of the single container: - No DNS dance: the control plane and gateway daemons reach each other over 127.0.0.1, so the orchestrator-before-gateway ordering (a workaround for Apple having no container DNS) is gone, along with the moved-IP recreate logic it needed. - Net -243 lines. Mechanics: the infra container runs from the gateway image with the control-plane source bind-mounted read-only (like the docker orchestrator, so a code change needs no rebuild) and a small sh -c init that starts both processes (mirrors firecracker's _infra_init). Also implements the macOS backend's ensure_orchestrator() and adds it to discover_orchestrator_url, so operator tools (supervise) can bring up / find the control plane on demand — previously the macOS backend died with "no orchestrator control plane". Verified end-to-end on real Apple Container 1.0.0: the single infra container comes up healthy (one address for control plane + gateway), both processes run, the DB is written on the container-only volume, host-side supervise works over HTTP, and a registered agent gets 200 for an allowed host / 403 for a denied one. 1824 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.89. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e24b62b6b9 |
fix(macos): review fixes — token on plan, self-heal, symmetric digest, DHCP poll
Addresses findings from a high-effort review of the PRD 0070 macOS backend. Correctness: - Stamp identity_token onto MacosContainerBottlePlan after registration. git's gitconfig extraHeader and the supervise MCP --header read getattr(plan,"identity_token","") at provision time, and both reach the gateway on NO_PROXY (bypassing the egress proxy that carries the token). The plan never carried it, so /resolve fail-closed and every git fetch/push and supervise call from a macOS bottle would have been denied. Registration precedes provision(), so — unlike the run-time env — the plan can carry it. - Self-heal the orchestrator: recreate when it is not (source-current AND answering /health), not on the source-hash label alone. A container running current code but with a wedged HTTP server was left alone and polled to death, failing every launch until manual deletion. - image_digest and container_image_digest now read the same descriptor.digest field; dropped image_digest's id/tag fallback that could yield a value the container side can't produce — a permanent mismatch would have recreated the shared gateway on every launch (severing every live bottle's egress, since the replacement gets a new DHCP address). - Poll for the agent's and gateway's DHCP address instead of a fatal read right after `container run` (there is no --ip; the address can lag start). Cleanup: - One _inspect_first + _descriptor_digest behind the four inspect readers. - Shared bind_mount_spec (util) and host_db_dir (paths) replace per-module copies; _GIT_HTTP_PORT now imports git_http_backend.DEFAULT_PORT. - Drop the dead _url cache / url property and the write-only agent_proxy_url. Deferred (noted on the PR, not fixed here): the gateway image rebuilding on every launch (needs source-hash-labeled build), SQLite shared across VM guests, and the sh -lc profile-override edge — each is design-level or behavior-risk beyond a review fix. Verified: real Apple Container bring-up is green and idempotent; 1826 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.86. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a5910696a5 |
fix(test): stop the macOS unit tests shelling out to the container CLI
CI's unit + coverage jobs failed with `FileNotFoundError: 'container'`: three tests reached the real Apple CLI, which exists on a macOS dev host but not on the Linux runner. They passed locally for that reason alone — and two of them were quietly creating real Apple networks on the dev host as a side effect. - `test_enumerate_active_is_empty_while_disabled` asserted the disabled-era stub and called `enumerate_active()` unmocked. The backend launches bottles again, so it now covers the real enumeration: slug parsing, exclusion of the shared gateway/orchestrator singletons, and the CLI-failure path. - The two orchestrator tests patched `orchestrator_service.container_mod`, but `_run_orchestrator_container` reaches the CLI through `ensure_networks`, which is imported from the gateway module and resolves `container_mod` in *its* namespace. Patch the imported name instead. Adds a test that the networks exist before the orchestrator runs — the ordering the escaped call was hiding. Verified by reproducing the CI environment locally (`PATH` without the `container` binary): 3 failures before, 1818 passing after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c69642e568 |
feat(macos): consolidated per-host gateway for the Apple backend (PRD 0070)
Re-enables the macos-container backend on the shared per-host orchestrator + gateway, replacing the per-bottle companion container removed in #385. This is the last backend in PRD 0070's roadmap. Apple Container 1.0.0 forced three departures from the docker shape, each verified against the live CLI (findings recorded in the networking spike): - No `--ip`. The address is DHCP-assigned and knowable only once the container runs, so the order inverts: gateway up -> run agent -> read its address -> register. The identity token is minted by registration and therefore cannot be in the agent's run-time env; it rides the proxy URL applied at `container exec` time (bare `--env` names keep it off argv). - No container DNS. The gateway can only be handed the control plane's IP, so the orchestrator starts first and the gateway is pointed at its address. - No `network connect`. Networks are fixed at run time, so the shared host-only network is created up front; per-bottle networks would restart the gateway on every launch and defeat the consolidation. The agent runs with `--cap-drop CAP_NET_RAW`: Apple grants NET_RAW by default, which would let an agent forge a neighbour's source address on the shared segment. NET_ADMIN is already absent, so this closes the source-address half of PRD 0070's attribution invariant. Verified end-to-end on real Apple Container 1.0.0: both images build, the control plane comes up healthy, the gateway reaches it by IP, and a registered agent gets 200 for a host in its routes and 403 for one outside them. Bring-up is idempotent — a second launch does not churn the singletons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dfc693e0b6 |
fix(firecracker): keep the snapshot partial private even if one was left behind
test / unit (pull_request) Successful in 1m9s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m14s
lint / lint (push) Successful in 2m21s
test / unit (push) Successful in 1m18s
test / integration (push) Successful in 30s
test / coverage (push) Successful in 1m27s
Update Quality Badges / update-badges (push) Successful in 1m17s
Follow-up to the codex review on #398. os.open's mode arg only applies on creation, so a committed-rootfs.tar.partial left 0644 by an interrupted run would be opened/truncated (not re-moded) and stay world-readable for the whole SSH stream. Unlink any leftover and exclusively recreate it (O_EXCL|O_NOFOLLOW), then fchmod 0600 immediately so umask can't loosen it. Test pre-creates a 0644 partial and asserts the fd is 0600 mid-stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
39d47b8108 |
fix(firecracker): harden committed-snapshot resume against guest-controlled data
Address the codex review on #398: - P1: inject_guest_boot no longer follows a symlink at bb-init/bb-dropbear. A committed snapshot is guest-controlled and could plant those paths as symlinks aimed at a host file (e.g. bb-init -> ~/.bashrc); write_text / copy2 would then overwrite the target as the host user during resume. Replace any pre-existing entry and create the files with O_EXCL|O_NOFOLLOW so the write stays inside the staging tree. - P2: write the snapshot tar owner-only (0600). It can contain the bottle's private workspace; it was being created world-readable (0644). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
d0a0ce8d60 |
test(firecracker): satisfy pyright strict + line length in committed-rootfs tests
Annotate the counting_run subprocess.run wrapper (reportMissingParameterType) and wrap an over-long patch target line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
5c08701983 |
feat(firecracker): port freeze/migrate off host Docker (PRD 0069 / #397)
The last host-Docker dependency in the Firecracker launch path. Freeze and resume no longer touch the docker daemon, so the backend needs firecracker + KVM only — completing #348. Freeze: stream the guest rootfs over SSH straight into a persistent committed-rootfs.tar (the resumable/migratable artifact) instead of round-tripping through `docker build` from a scratch image. Written to a .partial sibling and atomically renamed so a failed freeze leaves no truncated artifact. Resume: extract the snapshot tar into a cached base dir and feed it to the existing rootless `mke2fs -d` pipeline, replacing the `docker create` + `docker export | tar` path. Recreate the proc/sys/dev/run mount points the freezer excludes so the guest init can mount them. `util.build_base_rootfs_dir` / `docker_image_id` stay — they still back the opt-in BOT_BOTTLE_INFRA_BUILD=local dev path and off-host publish_infra, which are out of scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
e3e195f866 |
chore(firecracker): name the artifact package bot-bottle-firecracker-infra
test / unit (pull_request) Successful in 1m19s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m18s
lint / lint (push) Successful in 2m17s
test / unit (push) Successful in 1m18s
test / integration (push) Successful in 32s
test / coverage (push) Successful in 1m21s
Update Quality Badges / update-badges (push) Successful in 1m17s
Rename the Gitea generic package from bot-bottle-infra to bot-bottle-firecracker-infra so it's self-evident in the package list which backend it serves (and leaves room for other artifacts, e.g. a shipped kernel). The version slot stays the content hash — "firecracker" belongs in the package name, not the version. Docker image / VM names are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
e3d24b7e41 |
chore(firecracker): ship an about.txt description with the infra artifact
Generic packages have no description field, so publish_infra now uploads a short about.txt alongside the rootfs on every publish — it's what identifies the package as the Firecracker backend's infra rootfs on the package page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
f2891a1634 |
fix(firecracker): hash all baked-in files + stream the artifact upload
Address PR #395 review (two P1s): - Version hash covered only `bot_bottle/**.py`, but the image `COPY`s the whole package — non-Python inputs baked in (egress_entrypoint.sh, netpool.defaults.env) didn't change the version, so a launch host could boot a stale rootfs whose code differs from its checkout. Hash every regular file under bot_bottle/ (excluding __pycache__/.pyc). Regression tests: a shell-script change bumps the version; .pyc/__pycache__ don't. - publish_infra `_put` read the whole (hundreds-of-MB) gz into memory via read_bytes(). Stream it from disk with an explicit Content-Length; the tiny .sha256 stays in-memory. Test asserts the body is the file object, not bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
8d8a88aeeb |
fix(firecracker): read only BOT_BOTTLE_INFRA_ARTIFACT_TOKEN for the artifact
Drop the fallback to the general-purpose BOT_BOTTLE_CLAUDE_GITEA_TOKEN so the artifact pull/publish uses a dedicated, package-scoped token that can be granted (or revoked) independently of the general Gitea token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
18f190b7e3 |
feat(firecracker): pull the infra rootfs as a prebuilt artifact (PRD 0069 Stage 2)
Stage 2 of the docker-free Firecracker backend (#348): stop building the fixed infra image on the launch host. The infra VM's rootfs is host- and bottle-agnostic (authorized_keys + guest IP ride the kernel cmdline, not the rootfs), so it's built once off-host and published as a versioned, ready-to- boot ext4; the launch host downloads + verifies + boots it — no Docker, no image tooling, just HTTP + gunzip. - infra_artifact.py: version = content hash of the rootfs inputs (the shipped bot_bottle package + the three Dockerfiles + the init), so a launch host pulls the artifact matching its code and a content change can't silently boot a stale rootfs. Pull + sha256-verify (fail-closed) + gunzip from a Gitea generic package; base/owner/token configurable, default this Gitea. - infra_vm.ensure_built/boot default to the pull path; BOT_BOTTLE_INFRA_BUILD= local keeps the docker build-from-source path for iterating on Dockerfiles. - publish_infra.py: the off-host half — builds the images with Docker, mke2fs the rootfs (with buildah slack), gzips, and PUTs it to the generic package. Rollout note: default=pull means a launch 404s until an artifact is published; until the Gitea packages endpoint is enabled + an artifact published, use BOT_BOTTLE_INFRA_BUILD=local. Freeze/migrate's remaining docker use is a separate PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
bbb8913382 |
refactor(git-gate): centralize hostname qualification in globalize_slug
test / unit (pull_request) Successful in 1m19s
test / integration (pull_request) Successful in 24s
test / coverage (pull_request) Successful in 1m24s
lint / lint (push) Successful in 2m37s
test / unit (push) Successful in 1m27s
test / integration (push) Successful in 35s
test / coverage (push) Successful in 1m36s
Update Quality Badges / update-badges (push) Successful in 1m29s
Adds globalize_slug(slug) to bottle_state alongside bottle_identity.
git_gate_provision now calls globalize_slug(slug) instead of inlining
socket.gethostname(), so the hostname-qualification logic has a single,
named home. Assumes slug is a mint_slug output.
Title format changes from bot-bottle:{host}:{slug}:{name}
to bot-bottle:{host}-{slug}:{name} to match the globalize_slug contract.
|
||
|
|
59be808ab1 |
feat(git-gate): include hostname in deploy key title
Closes #388 (part 1 of 3). Deploy key titles now carry the machine hostname so keys provisioned on different hosts don't collide with each other on the forge when a prior bottle was never torn down. Title format: bot-bottle:<hostname>:<slug>:<repo-name> |
||
|
|
eb63bd417d |
chore: update quality badges
- Coverage: 82% - Core coverage: 95% [skip ci] |
||
|
|
943049733e |
test(supervise): stub the client in cmd_supervise crash-logging tests
test / unit (pull_request) Successful in 1m23s
test / integration (pull_request) Successful in 38s
test / coverage (pull_request) Successful in 1m42s
lint / lint (push) Successful in 2m15s
test / unit (push) Successful in 1m17s
test / integration (push) Successful in 31s
test / coverage (push) Successful in 1m31s
Update Quality Badges / update-badges (push) Successful in 1m35s
`cmd_supervise` establishes the orchestrator client up front (since the
HTTP-bridge move in
|
||
|
|
c15eed4f2e |
fix(supervise): list-egress-routes returns the bottle's real routes
In consolidated mode the gateway's static route table is empty (routes are resolved per request by source IP), but `list-egress-routes` was reading that static table via the `_egress.local/allowlist` introspection endpoint — so it always returned an empty allowlist. Agents, told to call it before composing an egress proposal, then sent a routes.yaml with only the newly-needed host; approving it replaced the whole policy and silently dropped base routes like api.anthropic.com, breaking the bottle's egress. Answer `list-egress-routes` from the calling bottle's resolved policy (same (source_ip, identity-token) attribution the proposal path uses, same JSON shape the single-tenant introspection endpoint returns). Fail-closed to an empty list on an unreachable orchestrator; falls back to the introspection endpoint in single-tenant mode (no resolver). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
b1df380ae1 |
feat(supervise): show the bottle's human slug, not its opaque id
Consolidated proposals are keyed by the orchestrator-assigned bottle_id, so the supervise TUI was rendering a hex id (e.g. 3601cbe883c2786d) as the bottle name — and the resume hint printed `./cli.py resume <id>`, which resume can't take (it wants the human identity/slug). `supervise_pending` now tags each dict with `bottle_label` — the human slug resolved from registry metadata, falling back to the id when the bottle is gone. `QueuedProposal` carries the label; every display site (list rows, detail view, status lines, resume hint) shows it, while approve/reject still key on `proposal.bottle_slug` (the id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
5f59df9e10 |
fix(supervise): resolve approval target by bottle_id, not human slug
In consolidated mode the supervise server attributes each proposal to the orchestrator-assigned bottle_id and stores that as the proposal's `bottle_slug` (supervise_server `_attributed_config`). But `_record_for_slug` only scanned registry metadata for a matching human slug, so approving an egress proposal 409'd with "bottle <id> is no longer registered; cannot apply the route change" — the id never matched a human slug. Resolve the record by bottle_id first (the consolidated reality), keeping the metadata-slug scan as a fallback for legacy single-tenant proposals. The prior tests keyed proposals by the human slug matching the metadata, exercising only the fallback path — added a test that mirrors production (proposal keyed by bottle_id, distinct human slug in metadata). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
2641ab70fd |
feat(supervise): start orchestrator on demand via backend-agnostic bring-up
Add BottleBackend.ensure_orchestrator() -> str: the backend-agnostic entry point that brings up the per-host orchestrator + shared gateway (idempotent) and returns its host-reachable control-plane URL. Docker starts the orchestrator + gateway containers (OrchestratorService); firecracker boots the infra VM; macos-container dies with a pointer (no orchestrator). Previously bring-up was reachable only through each backend's consolidated_launch, with no shared handle. Wire it into `bot-bottle supervise`: supervise is often the first thing an operator runs, before any bottle has booted the control plane, so `_resolve_orchestrator_url` now starts the selected backend's orchestrator on demand when discovery finds nothing, instead of failing with "launch a bottle first". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ |
||
|
|
265119d601 |
fix(supervise): docker gateway shares the one host DB (Step 2d)
The docker gateway container ran the supervise daemon but bind-mounted no DB and set no SUPERVISE_DB_PATH — so the daemon wrote proposals to a container-local, ephemeral SQLite file, disconnected from the host DB the orchestrator (and now the operator, over HTTP) uses. Same split-DB bug firecracker had; Dockerfile.gateway even documents the mount (`/run/supervise/bot-bottle.db bind-mounted at run time`) that ensure_running never provided. Bind-mount the host DB dir into the gateway at /run/supervise and set SUPERVISE_DB_PATH, so the daemon queues into the same file the orchestrator container opens (its BOT_BOTTLE_ROOT bind-mount) and the operator reaches over the control plane. One DB per host, shared by bind-mounts — docker now on the exact same supervise path as firecracker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
27fe03b612 |
feat(supervise): host TUI drives approvals over HTTP, not the DB (Step 2b/2c)
The `bot-bottle supervise` operator TUI read and wrote the queue DB directly and tried a backend-specific live "apply" (which was unwired — it raised). It now talks only to the orchestrator control plane: - OrchestratorClient gains supervise_pending() + supervise_respond(). - discover_orchestrator_url() finds the one running per-host control plane by health-probing the backends' well-known :8099 addresses (docker publishes on loopback; the firecracker infra VM serves it on the orchestrator TAP) — no backend branching in the TUI. - discover_pending/approve/reject call the client; the server does the apply + response + audit atomically. The dead direct-DB apply/audit helpers and the docker/macos applicator imports are gone. - A missing control plane is now a clean one-line error up front, not a mid-curses crash. CLI tests move to mocking the client (the DB-write behaviour they used to assert is now server-side, covered by test_orchestrator_service). Docker's orchestrator-container DB wiring lands next so its /supervise endpoints hit the same shared DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
9085d6f713 |
feat(supervise): orchestrator-side operator-approval API (Step 2a)
The orchestrator owns the single DB *and* the live policy, so operator decisions belong there — applied server-side, reached over HTTP. Adds: GET /supervise/proposals -> pending proposals across bottles POST /supervise/respond -> apply + record an operator decision `supervise_respond` is one atomic server-side op on the one DB: approve/ modify on an egress tool rewrites the bottle's policy (so the gateway serves the new routes on its next /resolve — the live "apply" that was a documented TODO), then writes the queued Response (unblocking the agent's MCP call) and an audit entry. reject records the response + audit only. Fails closed (409) when the proposal is unknown or the bottle was torn down before the operator acted (an egress apply would have no target). This is the server half of unifying every backend onto one HTTP path for supervise; the host TUI (direct-DB today) moves onto this client next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
38bc555dbf |
fix(supervise): single migrated DB for firecracker — orchestrator owns supervise tables
The firecracker supervise MCP daemon 500'd (-32603) on egress-allow/block: it ran with no BOT_BOTTLE_ROOT/SUPERVISE_DB_PATH, so it targeted a stray, unmigrated SQLite file and `write_proposal` hit "no such table: supervise_proposals". Meanwhile the in-VM control plane migrated only the registry table (orchestrator_bottles) into its own DB, and the host operator reads a third, disconnected DB — three files, none shared. Consolidate to one DB per host, owned by the control plane on the persisted registry volume (/var/lib/bot-bottle/db/bot-bottle.db): - orchestrator startup now migrates the supervise queue + audit tables into the same file it migrates the registry into (StoreManager), so the control-plane DB carries every table. - the in-VM supervise daemon is pointed at that same file via SUPERVISE_DB_PATH, so daemon and control plane share one queue. `list-egress-routes` already worked (no DB); egress-allow now queues + waits on the single persisted DB instead of erroring. Validated against a live infra VM: migrate + write_proposal succeeds and the proposal is queued. The host-operator HTTP bridge (so approvals complete from the host, unifying docker onto the same path) is the follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
a208bcde08 |
feat(firecracker): stream buildah build output live during agent-image build
The agent-image build ran over ssh with output fully captured, shown only as a 20-line tail on failure — so a successful (or in-progress) first build was a long silent wait through the base pull + apt/npm installs. Stream the `buildah build` step's stdout/stderr straight to our own (like the docker backend's `docker build`) via a non-capturing _ssh_streamed helper, so the operator sees `STEP i/n` progress live. Failure now points at the streamed output above instead of a captured tail. The smoke test and rootfs export stay captured (short / piped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
c0066d2cd2 |
fix(firecracker): quote guest argv tokens so codex's multi-word prompt survives ssh
The interactive agent command is sent to the guest by spreading the remote argv as separate ssh arguments; ssh space-joins everything after the host into one line that the guest login shell re-parses. That only works while every token is a "simple word" — which held for claude (`--append-system-prompt-file <path>`) but not for codex's `read_prompt_file` mode, whose positional is a whole sentence: "Read and follow the instructions in <path>.". The guest shell re-split it on spaces, so codex received `Read` as the prompt and `and`, `follow`, … as extra args — failing with `unrecognized subcommand 'and'` the moment an interactive codex session attached. Pre-quote each remote token with shlex.quote before ssh joins them (the same ssh→guest-shell discipline infra_vm/cp_in already use). Simple words are unchanged, so existing behaviour and the parity/structure tests are untouched; an arg with spaces now survives as a single argument. Regression test round-trips the joined remote command back through shlex.split and asserts codex's prompt comes out as exactly one arg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
7118480d0a |
fix(codex): register supervise MCP via config.toml http_headers, not mcp add --header
The Codex CLI has no `codex mcp add --header` flag (verified against 0.144.5 and the codex-rs `AddMcpStreamableHttpArgs` surface: only `--url`, `--bearer-token-env-var`, `--oauth-*`, `--env`). The old call therefore exited nonzero on every codex bottle; provisioning only warned and continued, so supervise was silently unregistered — and under mandatory (source_ip, token) attribution the suggested manual recovery (`codex mcp add supervise --url ...`, no token) could not restore access either. Write the `[mcp_servers.supervise]` streamable-HTTP entry directly into `~/.codex/config.toml` instead, delivering the identity token via the Codex-supported `http_headers` key (the only way to attach a static request header to an HTTP MCP server). Registration failure is now FATAL when supervise is enabled, rather than a warning. Test validates the generated entry against the real Codex config surface: it must parse as TOML into a streamable-HTTP server carrying the token as `http_headers["x-bot-bottle-identity"]`, and must use only keys accepted by `RawMcpServerConfig` (config.toml is `deny_unknown_fields`). Also covers custom `CODEX_HOME`, the no-token case, and the now-fatal failure path. Refs: PR #354 review (codex P1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
d4b27ebf1f |
feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
Codex review: the /31 TAP doesn't make source IP unspoofable, and the app-layer token was returned by launch but never delivered or enforced, so a spoofed source could select a victim bottle's policy/tokens. Make the token mandatory and deliver it on each attributed plane (anti-spoof landed separately as the network boundary). Enforcement (control plane): - `Orchestrator.resolve` now requires a matching (source_ip, identity_token) pair (constant-time) — no source-IP-only fallback. `/resolve` fail-closes (403) on a missing/empty/mismatched token. Delivery, per plane (the token is `token_urlsafe`, safe in a URL): - egress: proxy credentials (`HTTPS_PROXY=http://bottle:<token>@gw`). The addon reads `Proxy-Authorization` — from the request (HTTP) or captured at the CONNECT for HTTPS tunnels (keyed by client conn, cleared on disconnect) — validates, and strips it (+ the legacy header) before upstream. - git-http: a URL-scoped `http.<gate>/.extraHeader: x-bot-bottle-identity` in the agent's git config (only over the http transport). - supervise: `mcp add --header x-bot-bottle-identity: <token>` (claude + codex); the server reads the header and passes it to resolve. Wiring: thread `ctx.identity_token` onto the firecracker + docker plans and into the agent env/config at launch. Verified on a KVM host: egress with the correct proxy-cred token returns 200 (HTTP and HTTPS/CONNECT), and no-token / wrong-token return 403; a real `cli.py start --backend=firecracker` launch provisions git config + the supervise MCP header and reaches the agent session, all under mandatory enforcement. Fixed a `claude mcp add` arg-order bug (--header must follow the positional name/url) found by that launch. Transparent proxy for tools that ignore proxy env is deferred to a follow-up (see thread); anti-spoof + host firewall remain the fail-closed boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
914f01fa8f |
fix(firecracker): serialize infra-VM create + agent builds (PR #354 review)
Codex flagged that parallel `start`s race on shared state: two cold launches could both stop/build/boot the singleton on the same rootfs/PID, concurrent builds share the infra VM's buildah store, the cleanup did `buildah rm -a` (nuking a peer build's container), and the rootfs cache was populated non-atomically. - `infra_vm.ensure_running`: a host flock (`singleton.lock`) around the cold stop/build/boot path, with a double-checked health re-test under the lock so a second launcher adopts rather than re-boots. The healthy fast-path stays lock-free. - `image_builder.build_agent_rootfs_dir`: a host flock (`.build.lock`) around cache-lookup + build + publish; build into `.building-<digest>` and publish by atomic `os.rename`, so a partial build never appears as `agent-<digest>`. - scoped cleanup: per-build named working containers (`<tag>-smoke`/`-export`) removed by name instead of `buildah rm -a`; also cleared before the build to recover from a crashed prior run. Verified: agent image builds via the locked/atomic path, infra VM stays healthy, agent boots, `claude --version` = 2.1.172. Full unit suite + pyright green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
43c3d4408e |
fix(firecracker-netpool): per-TAP anti-spoof so source-IP attribution is sound (PR #354 review)
The /31 point-to-point TAP does NOT make a guest source address unspoofable: root in an agent VM can source another bottle's guest IP on its own bbfc TAP. The isolation table only matched iifname class + port (DNAT) and never bound iifname to its assigned ip saddr — so a spoofed source was DNAT'd to the gateway and attributed to the *victim* bottle, getting the victim's policy/tokens. Source-IP attribution was therefore not actually sound. Add one anti-spoof rule per slot in the isolation forward chain, before the established/DNAT accepts: `iifname bbfcN ip saddr != <guestN> drop`. Generated in the existing setup loop — no new dependency, ~pool_size lines. Legit traffic (correct saddr) is unchanged; a spoofed saddr on any bbfc TAP is dropped before it can be attributed. Apply with a nixos-rebuild (the systemd unit re-runs this script). Codex review blocker; the app-layer identity token (defense-in-depth) is wired separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
0a26b8795a |
refactor(gateway): install gitleaks by pinned+verified download, drop third-party base image (PR #354 review)
The gateway used `FROM zricethezav/gitleaks AS gitleaks-src` purely to COPY the binary out — a supply-chain surface (a whole third-party image as a build input, tying us to its cadence). Install gitleaks from its official release instead, pinned by version + SHA256 and verified. python (already in the image) does the download, so no curl/wget is added. trixie apt also ships gitleaks but an older 8.16; the pinned download keeps the verified 8.30.1 (byte-identical to what the image provided). Verified: gateway + infra images build, gitleaks 8.30.1 runs in both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
c60e6b7e9f |
refactor(firecracker): single infra VM builds too — buildah in one image (PR #354 review)
Addresses the review finding that buildah lived only in the orchestrator
image, so the persistent infra VM wasn't the builder — a separate throwaway
builder VM contended with it for the orchestrator TAP. Consolidate:
- **Rebase the gateway (and thus infra) on `python:3.12-slim` = Debian
trixie**, pip-installing mitmproxy instead of `FROM mitmproxy/mitmproxy`
(Debian bookworm). trixie ships buildah 1.39, which can build agent
Dockerfiles that use heredocs; bookworm's 1.28 can't (`Unknown
instruction: "{"`). CA path is unchanged (set via `--set confdir=`).
- **buildah lives only in `Dockerfile.infra`** now (removed from the
orchestrator image, which is lean/stdlib-only again).
- **Shared orchestrator content**: `Dockerfile.orchestrator` is the single
definition of the control-plane payload; the infra image `COPY --from`s
it (same trixie base → clean copy, and future deps like iroh are added
once). The docker backend runs the orchestrator image directly.
- **`image_builder` builds inside the infra VM** (which now has buildah)
over SSH — no throwaway builder VM, so the `bborch0` contention is gone.
`ensure_built` builds orchestrator + gateway before infra (FROM gateway,
COPY --from orchestrator).
Verified on a KVM host: images build (buildah 1.39 in infra), the agent
image builds *inside* the infra VM (heredoc Dockerfile and all), the infra
VM stays healthy, the agent boots and `claude --version` = 2.1.172. The
rebased gateway still starts as a docker container and generates its CA
(docker backend unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|