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.
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
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>
Completes the de-sidecar cleanup: no live code, test, current doc,
script, or nix file mentions or is named "sidecar" any more. Only the
dated PRD/research docs keep the term as historical record (agreed on
the #385 thread).
- Rename `sidecar_init.py`→`gateway_init.py` was done earlier; this pass
sweeps the remaining descriptive uses: the egress / git-gate / supervise
components are the gateway's *daemons*, the shared container is the
*gateway*, the old per-bottle container was the *companion container*.
- Rename `tests/integration/test_sidecar_bundle_image.py`→`test_gateway_image.py`
and its class; update `docs/ci.md` + `tests/README.md` for the renamed/
removed integration tests.
- `SIDECAR_PORTS` shell var in `scripts/firecracker-netpool.sh`→`GATEWAY_PORTS`.
Full unit suite green (bar the pre-existing `/bin/sleep`-missing env
errors in test_gateway_init); docker integration — gateway singleton,
broker, real two-bottle multitenant isolation, and the gateway-image
build — all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
One image — `bot-bottle-sidecars:latest`, built from `Dockerfile.sidecars` —
served two unrelated roles: the egress/git-gate/supervise *data plane* and
the orchestrator *control plane* (which ran the same image with the entrypoint
overridden to `python3 -m bot_bottle.orchestrator`). The control plane is
stdlib-only, so it needed none of the mitmproxy/git/gitleaks payload it was
riding on — while being the most secret-dense process on the host (PRD 0070's
"secret concentration").
Split into two purpose-built images:
- `Dockerfile.gateway` -> `bot-bottle-gateway:latest` — the data plane
(renamed from Dockerfile.sidecars; identical contents).
- `Dockerfile.orchestrator` -> `bot-bottle-orchestrator:latest` — a lean
`python:3.12-slim` runtime; the bind-mounted `bot_bottle` package supplies
the code (so the #381 source-hash recreate semantics are unchanged).
`OrchestratorService` now takes distinct `image` (control plane, default
`ORCHESTRATOR_IMAGE`) and `gateway_image` (data plane, default `GATEWAY_IMAGE`)
instead of feeding one `self.image` to both, and builds the lean image
(build-if-missing) before starting the container. The per-bottle bundle
constants in `backend/docker/sidecar_bundle.py` now alias the gateway
constants so a bundle and the shared gateway can never drift onto different
images. The `bot-bottle-sidecars` *image* name and `Dockerfile.sidecars` are
gone; the per-bottle *container* name prefix (`bot-bottle-sidecars-<slug>`) is
intentionally left for a separate change.
Verified end-to-end: both images build; the lean image runs the control plane;
`ensure_running` brings up the orchestrator on `bot-bottle-orchestrator:latest`
and the gateway on `bot-bottle-gateway:latest` (distinct images) and reports
healthy.
Closes#384.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Finishes the supervise data-plane writers for the shared gateway. Both
still keyed off a single SUPERVISE_BOTTLE_SLUG env; now each proposal is
attributed to the calling bottle, keeping slices 10/11's per-bottle model.
- git_http_backend: in consolidated mode stamp SUPERVISE_BOTTLE_SLUG=
<bottle_id> into the CGI env. The gitleaks-allow pre-receive hook runs as
a child of the `git http-backend` we spawn, so it inherits the stamp and
queues under the right bottle — no change to the (fragile) embedded hook.
The bottle id is the namespaced root's final component (slice 10), the
same key egress uses. Single-tenant leaves the container-stamped slug.
- supervise_server: resolve the proposal slug per request by source IP when
BOT_BOTTLE_ORCHESTRATOR_URL is set (`_attributed_config`), fail-closed —
an unattributed / unreachable source raises rather than queue under the
wrong or empty slug. `serve`/`main` build + attach the resolver and make
the env slug optional in consolidated mode. Single-tenant unchanged.
Tests: a real git push proving the slug reaches the hook's env; the
supervise attribution matrix (single-tenant slug kept, source-IP bottle
bound, unattributed + resolver-error fail closed).
Remaining supervise gap (noted): websocket DLP still self.config-only.
pyright 0 errors; pylint 9.83/10; unit suite green (1705 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Addresses PR #365 review:
- Type `resolve_sandbox_root`'s resolver param as a `ResolverLike` Protocol
(structural, `resolve_bottle_id` only) — fixes the pyright errors from
passing a duck-typed fake resolver in tests; mirrors
egress_addon_core.PolicyResolverLike.
- Rename `_project_root`/`project_root`/`resolve_repo_root`/
`DEFAULT_PROJECT_ROOT` -> `_sandbox_root`/`sandbox_root`/
`resolve_sandbox_root`/`DEFAULT_REPO_ROOT`: "sandbox" names the per-tenant
scope; "project" was too amorphous. `GIT_PROJECT_ROOT` keeps git's own
env-var name.
- Note the single-tenant path is transitional (stripped once every backend
runs the consolidated gateway).
- policy_resolver: document that the optional identity token is
transitional — the consolidated end state requires it (flips at the
/resolve boundary once token *delivery* lands, a PRD 0070 open question).
- Split the long line in main() (was 105 cols).
pyright 0 errors; pylint 9.95/10; unit suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
First slice of git-gate consolidation: make the smart-HTTP git backend
serve every bottle from one process, selecting each request's repo root by
the calling bottle's source IP — the same attribution invariant + resolver
the multi-tenant egress addon uses. `git daemon` can't source-IP-route per
connection, so the consolidated gateway serves git-gate over this HTTP
backend (the transport firecracker/macOS already use); wiring the docker
path onto it lands with the launch-integration slice.
- policy_resolver: factor out `_post_resolve`; add `resolve_bottle_id`
(source IP -> bottle id, same fail-closed 403->None contract as
`resolve`) — the git-gate has no policy blob to parse, the bottle *is*
the namespace.
- git_http_backend: `resolve_repo_root(resolver, base, source_ip, token)`
— single-tenant passthrough when no resolver; else `<base>/<bottle_id>`,
fail-closed on unattributed / resolver error / namespace escape.
`BOT_BOTTLE_ORCHESTRATOR_URL` (same env as egress) flips the shared
gateway multi-tenant; the identity header is read for attribution and
never forwarded to the CGI. Per-repo creds + hooks scope by repo dir, so
isolating the root per bottle isolates its creds too.
Single-tenant path unchanged (existing real-git-push tests green). New unit
coverage for the resolver + repo-root selection matrix. Full unit suite
green (1690 tests; the 13 test_sidecar_init /bin/sleep errors are the
pre-existing NixOS-local noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Delete the smolmachines backend (the whole bot_bottle/backend/smolmachines
package and its tests). It had fatal Linux issues (TSI networking under
sustained use, exec-channel contention, no SIGWINCH) and is superseded by
the Firecracker backend (issue #342).
Backend selection now:
- default is macos-container on macOS, firecracker on KVM-capable Linux
hosts, and docker as the last resort (was smolmachines).
- firecracker is selected on a KVM host even when the `firecracker`
binary isn't installed, so start routes through its preflight and
prints an install pointer (same UX as require_container), instead of
silently falling back. Split is_host_capable() (Linux + KVM) out of
is_available() (adds the binary check) to drive this.
Retarget the cross-backend tests (parity, print-parity, prepare,
workspace, freezer, selection) from smolmachines to firecracker rather
than dropping the coverage. Remove docker.util.image_id/save, which only
smolmachines used. Update README/AGENTS/example bottles and stale
comments; historical docs/prds are left as a point-in-time record.
BREAKING: BOT_BOTTLE_BACKEND=smolmachines now errors as unknown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
git_http_backend.py is copied flat into the sidecar bundle image as a
standalone script, not as part of the bot_bottle package, and
git_gate.py/git_gate_render.py are never copied in. Its relative
import of GIT_GATE_TIMEOUT_SECS crashed the git-http daemon (port
9420) on every startup, silently leaving the smart-HTTP git-gate
transport down while the other sidecar daemons stayed up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The constant now covers the daemon path, the HTTP backend access-hook,
and the git http-backend CGI subprocess, so 'daemon' in the name was
too narrow. Updated the comment to list all three current uses.
Closes#255. Without timeouts, a hung upstream during the access-hook
or git http-backend CGI call (git_http_backend.py) and a stalled Gitea
API during deploy-key provisioning (contrib/gitea/deploy_key_provisioner.py)
could wedge a sidecar indefinitely. Adds GIT_HTTP_BACKEND_TIMEOUT_SECS
(30s) to both subprocess.run calls in the HTTP backend, mirroring the
existing GIT_GATE_DAEMON_TIMEOUT_SECS on the daemon path. Adds
_API_TIMEOUT_SECS (30s) and _KEYGEN_TIMEOUT_SECS (10s) to the Gitea
provisioner's urlopen and ssh-keygen calls. Tests verify the timeout
values are forwarded in all four call sites.
An empty or non-numeric Status: header from git http-backend raised
ValueError/IndexError that escaped the handler thread. Wrap the parse
in a try/except and fall back to HTTP 500 instead.
Closes#254
Main code fixes:
- Remove unused Iterator import from local_registry.py
- Fix signal handler signature in pty_resize.py (correct parameters for signal.signal)
- Add type annotations for screen parameters in tui.py (use Any for curses types)
- Fix missing tty_fd type annotation in tui.py
- Remove unused old_term variable in tui.py
- Fix tty_fd FileIO wrapping for TextIOWrapper initialization
- Add type: ignore for curses._CursesWindow attributes in supervise.py
- Add type: ignore for BaseServer attributes in git_http_backend.py
- Fix HTTPRequestHandler.log_message parameter name mismatch
- Cast _agent_prompt_mode to PromptMode in bottle.py files
- Fix Popen[bytes] generic type annotations in sidecar_init.py
- Add type: ignore for dynamic prompt_file attribute access in agent_provider.py
Configuration:
- pyrightconfig.json now suppresses third-party library unknowns
- Remaining test errors are mostly in test suites
Fixes 23 errors in main code, reduces total from 985 → 240 (75% reduction from initial ~1,200)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Previously when the access-hook returned non-zero, git-http would pipe
the hook's stderr into the 403 body sent back to the agent's git
client but never log it locally, so docker logs just showed
`"GET ... 403 -"` with no explanation. Operators had to shell into
the sidecar and re-run the hook by hand to find out why a clone was
being refused (e.g. upstream SSH unreachable, missing credentials).
Route the hook's stderr/stdout through the existing log_message
channel before sending the 403, one log line per output line so the
default request-log format stays readable. When the hook exits
non-zero with no output, log the exit code so the line is still
informative.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both remote-addr and peer-addr args to the access hook are the same
TCP peer in this non-proxied stack. Extract a `peer` variable so the
intentional repetition is visible. Closes#148.
Before this change, int() on a non-numeric Content-Length raised an
unhandled ValueError, crashing the request handler. There was also no
upper bound on how much memory a POST body could consume.
After this change:
- Non-numeric or missing Content-Length returns HTTP 400.
- Negative Content-Length returns HTTP 400.
- Bodies declared larger than 1 MiB (_MAX_BODY_BYTES) return HTTP 413,
matching the cap already in supervise_server.py.
Closes#138