Compare commits

..

35 Commits

Author SHA1 Message Date
didericis eab7d6ccbc feat(orchestrator): slice 13c(viii) — agent-only consolidated compose render
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m18s
lint / lint (push) Successful in 2m8s
The per-bottle model rendered a compose project with the agent + a sidecar
bundle on two per-bottle networks. Consolidated has no sidecars — one shared
gateway serves everyone — so this renders JUST the agent, attached to the
external shared gateway network at the orchestrator-allocated pinned IP, and
pointed at the gateway's address for egress.

- consolidated_agent_compose(plan, *, gateway_ip, source_ip, network):
  agent service only; networks {<gateway>: {ipv4_address: source_ip}} on the
  external gateway network; HTTPS_PROXY -> http://<gateway_ip>:9099; NO_PROXY
  includes the gateway address so git-http + supervise (also on the gateway)
  bypass the proxy and are reached directly. No depends_on/sidecars.
- Pure (takes the launch-time LaunchContext values), so it's unit-testable
  without docker.

pyright 0 errors; pylint 9.83/10; unit suite green (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
2026-07-13 22:17:11 -04:00
didericis 389a2c10ce feat(orchestrator): slice 13c(vii) — stable shared gateway CA
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
The consolidated gateway TLS-intercepts every bottle, so all agents must
trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own
mitmproxy generate its CA into a persistent named volume and extract the
cert for agents — no host openssl, keeps gateway.py lean.

- DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the
  self-generated CA survives container recreation (it must not rotate, or
  already-launched agents would stop trusting the gateway).
- ca_cert_pem(): read the CA cert (PEM) out of the running gateway
  (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its
  trust store to accept the gateway's bumped certs.

pyright 0 errors; pylint 9.83/10; unit suite green (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
2026-07-13 22:12:18 -04:00
didericis 9896986810 feat(orchestrator): slice 13c(vi) — consolidated launch sequence (composition)
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m12s
lint / lint (push) Successful in 2m5s
Composes the orchestrator primitives into the register/teardown sequence
that replaces the per-bottle bundle:

  launch_consolidated(egress_plan, git_gate_plan): ensure orchestrator +
    gateway up -> read the gateway network's subnet + the gateway's own
    address -> allocate the bottle a pinned source IP (skip the gateway +
    live bottles) -> register (egress policy blob + slug metadata) ->
    provision its git-gate repos/creds into the gateway. Returns a
    LaunchContext (bottle_id, identity_token, source_ip, network, gateway_ip,
    orchestrator_url) — everything the agent container needs to attach.
    Rolls the registration back if provisioning fails, so no orphan.

  teardown_consolidated(bottle_id): deregister + deprovision (idempotent).

The agent `docker run` itself stays the backend's job (provider
provisioning); this owns the orchestrator-facing wiring so the sequence is
unit-testable — collaborators mocked, next_free_ip + registration_inputs run
for real (IP allocation + policy blob asserted end to end).

pyright 0 errors; pylint 9.83/10; unit suite green (1755 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
2026-07-13 22:03:21 -04:00
didericis 20e83258bf feat(git-gate+orchestrator): slice 13c(v) — provision git-gate into the running gateway
lint / lint (push) Successful in 2m16s
test / unit (pull_request) Successful in 1m8s
test / integration (pull_request) Successful in 29s
test / coverage (pull_request) Successful in 1m21s
Places a bottle's git-gate credentials + bare repos into the live shared
gateway container when it registers — the execution side of the 13c(i)
provisioning render.

- provision_git_gate(gateway, bottle_id, plan): mkdir the per-bottle creds
  dir, docker cp each upstream's identity key (+ known_hosts when present)
  into /git-gate/creds/<bottle_id>/, then docker exec the namespaced,
  init-only script from git_gate_render_provision. No-op with no upstreams.
- deprovision_git_gate(gateway, bottle_id): rm the bottle's /git/<id> +
  creds on teardown (idempotent).
- bottle_id is validated (shell/path-safe alphabet) BEFORE any docker cp/rm,
  so a traversal id can never reach a path arg — the creds/repo dirs land in
  docker cp/rm arguments. Registry ids are token_hex; defense in depth.

pyright 0 errors; pylint 9.83/10; unit suite green (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
2026-07-13 21:59:12 -04:00
didericis c5ba33bf0a feat(orchestrator): slice 13c(iv) — gateway joins a shared network
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m15s
lint / lint (push) Successful in 2m18s
The consolidated gateway ran on the default bridge; the model needs it on a
dedicated user-defined network that every agent bottle also joins, reaching
the gateway's egress / git-http / supervise ports by its address (no host
port publishing) — and the source IP the gateway attributes by is the
bottle's address on this network.

- GATEWAY_NETWORK = "bot-bottle-gateway"; DockerGateway gains a `network`
  param.
- ensure_running now ensures the network exists (idempotent create,
  tolerating a concurrent 'already exists') then runs with `--network`.
- Docker picks the subnet; the launcher reads it back (13c source-IP
  allocator) to hand each bottle a pinned address.

pyright 0 errors; pylint 9.83/10; unit suite green (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
2026-07-13 21:55:10 -04:00
didericis 72286f188d feat(orchestrator): slice 13c(iii) — host-side control-plane client
test / unit (pull_request) Successful in 1m9s
test / integration (pull_request) Successful in 29s
test / coverage (pull_request) Successful in 1m18s
lint / lint (push) Successful in 2m10s
The launch path's counterpart to the gateway-side PolicyResolver: a trusted
host-side HTTP client for the orchestrator control plane, so the CLI can
register/teardown/re-policy bottles. Stdlib-only.

- OrchestratorClient.register_bottle(source_ip, *, image_ref, metadata,
  policy) -> RegisteredBottle(bottle_id, identity_token)  (POST /bottles)
- teardown_bottle(id) -> bool (DELETE; 404 -> False, idempotent for cleanup)
- set_policy(id, policy) -> bool (PUT; live reload)
- list_bottles() / health()
- Unlike the fail-closed data-plane resolver, this is the trusted caller: a
  non-2xx (other than the meaningful 404s) raises OrchestratorClientError so
  the launch path surfaces failures rather than swallowing them.

pyright 0 errors; pylint 9.82/10; unit suite green (1742 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
2026-07-13 21:51:27 -04:00
didericis 77aaabae63 feat(orchestrator): slice 13c(ii) — shared-gateway source-IP allocator
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 1m4s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m12s
Deterministic per-bottle address assignment on the consolidated docker
gateway's shared network — the address the gateway uses as its attribution
key. Pure ipaddress logic; the docker-specific inputs (subnet CIDR, the
gateway container's own address) are gathered by the caller and passed as
`taken`, keeping it testable without docker.

- next_free_ip(cidr, taken): lowest host address not reserved (network +
  broadcast excluded by hosts(); docker's router .1 excluded here) and not
  already assigned (gateway container + live bottles from the registry).
  NoFreeAddressError when the subnet is exhausted.

pyright 0 errors; pylint 9.83/10; unit suite green (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
2026-07-13 21:20:13 -04:00
didericis d0b35b5506 feat(git-gate+orchestrator): slice 13c(i) — per-bottle git-gate provisioning render
lint / lint (push) Successful in 2m9s
test / unit (pull_request) Successful in 1m8s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m14s
The consolidated gateway serves every bottle's repos under /git/<bottle_id>/
(slice 10), so each bottle's bare repos + per-repo credential config must be
provisioned into that namespace. This adds the renderer for that; placing
the creds + running it inside the gateway is the launch-wiring step.

- Extract the credential-wiring `init_repo` shell into a shared
  `_git_gate_init_repo_fn(repo_root, creds_dir)` — one source for the
  security-sensitive logic. The single-tenant daemon entrypoint is
  byte-unchanged (still /git + /git-gate/creds; existing tests green).
- git_gate_render_provision(bottle_id, upstreams): posix-sh that inits ONE
  bottle's bare repos under /git/<bottle_id>/ reading creds from
  /git-gate/creds/<bottle_id>/. Init-only (no `git daemon` — the shared
  gateway already serves). Isolating each bottle's repo root + creds dir by
  id is what keeps one bottle's push credentials out of another's repos.
- bottle_id is embedded unquoted in the script, so it's restricted to a
  shell/path-safe alphabet (registry ids are token_hex; defense in depth).

pyright 0 errors; pylint 9.83/10; unit suite green (1724 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
2026-07-13 21:15:21 -04:00
didericis ea0c070cfe feat(orchestrator): slice 13b — consolidated registration inputs (egress policy)
lint / lint (push) Successful in 2m10s
test / unit (pull_request) Successful in 1m12s
test / integration (pull_request) Successful in 29s
test / coverage (pull_request) Successful in 1m20s
Bridges the per-bottle `prepare` output to the consolidated registry:
`registration_inputs(plan)` turns a prepared egress plan into the
backend-neutral inputs `Orchestrator.launch_bottle` takes.

- egress_policy(plan): the policy blob = the exact routes YAML the
  per-bottle sidecar read from a file; the multi-tenant gateway's
  PolicyResolver now fetches it from the registry per request instead.
  Same egress_render_routes, so a bottle's allow-list is byte-identical
  moving onto the shared gateway.
- RegistrationInputs bundles policy + metadata; metadata carries the human
  slug so the shared registry can map a minted bottle id back to a name
  (console / supervise display).

Host-side glue (imports bot_bottle.egress) used by the launch path — not
the lean orchestrator control-plane process. Not yet wired into launch
(that's 13c/13d), consistent with the slice-6/10 pattern of landing the
primitive before the cut-over.

Key test: the policy round-trips through load_config back to the same
routes + log level — the resolver-served policy reconstructs the per-bottle
egress config exactly.

pyright 0 errors; pylint 9.83/10; unit suite green (1718 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
2026-07-13 20:40:38 -04:00
didericis 24df322c31 feat(orchestrator): slice 13a — orchestrator process lifecycle (idempotent singleton)
lint / lint (push) Successful in 2m9s
test / unit (pull_request) Successful in 1m4s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m14s
First sub-slice of docker launch integration: the foundation the CLI needs
to ensure exactly one orchestrator control plane + shared gateway is up
before registering/launching bottles. Does NOT touch the real launch path
yet — it's the lifecycle primitive the cut-over slices build on.

- OrchestratorProcess.ensure_running(): idempotent singleton — returns the
  control-plane URL if a healthy one already answers /health, else spawns
  `python -m bot_bottle.orchestrator --broker docker --gateway` detached
  (start_new_session, output tee'd to <root>/orchestrator.log) and polls
  /health until healthy or timeout (OrchestratorStartError).
- The control-plane port is the singleton key: a second orchestrator can't
  bind it, so a stray double-start fails fast rather than forking a rival.
- Host-process (not container) per the PRD's dev-harness sequencing — it
  already has the host user's docker access to broker launches; the
  data-plane gateway it manages is the container.

pyright 0 errors; pylint 9.83/10; unit suite green (1714 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
2026-07-13 20:33:14 -04:00
didericis 80d75de08a feat(supervise+orchestrator): slice 12 — multi-tenant git-gate hook + supervise server
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m11s
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
2026-07-13 20:04:26 -04:00
didericis 8827c64a83 feat(supervise+orchestrator): slice 11 — per-bottle supervise queue + DLP safelist
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 1m6s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m15s
Consolidated egress ran every bottle through one process but keyed the
supervise proposal queue off a single SUPERVISE_BOTTLE_SLUG env and kept
one *global* DLP safelist — so in the shared gateway an operator's token
approval for bottle A would (a) be attributed to the wrong bottle and
(b) leak into bottle B's DLP scan (A's approved secret passes B's egress).
This slice keys both per bottle, resolved by source IP.

- policy_resolver: add `resolve_policy_and_bottle_id` — policy + bottle id
  in one `/resolve`, so egress keys routing *and* the supervise
  queue/safelist from a single round-trip. Fail-closed (403 -> (None,None)).
- egress_addon_core: add `resolve_client_context` (+ `ContextResolverLike`)
  returning `(Config, bottle_id)`, sharing the fail-closed parse with
  `resolve_client_config` via `_config_from_policy`.
- egress_addon: `_active_config` -> `_resolve_flow` returns `(Config, slug)`;
  `safe_tokens` set -> per-bottle `_safe_tokens_for(slug)`; the token-allow
  write/await/archive + the approved-token add all use the resolved slug.
  Single-tenant (no resolver) unchanged — slug = the env SUPERVISE_BOTTLE_SLUG.

New tests cover the resolver, the fail-closed context matrix, and the
cross-tenant isolation (an approval lands only in the calling bottle's
safelist; the proposal is keyed by the source-IP-attributed bottle;
unattributed IPs can't supervise).

Out of scope (noted): the git-gate gitleaks-allow hook + supervise_server
agent-proposal paths, and websocket DLP (still self.config-only, inert in
consolidated mode) — follow-up slices.

pyright 0 errors; pylint 9.83/10; unit suite green (1700 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
2026-07-13 19:48:29 -04:00
didericis 115a64c161 fix(git-gate): review — sandbox naming, ResolverLike Protocol, token-required note
test / unit (pull_request) Successful in 1m8s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m20s
lint / lint (push) Successful in 2m7s
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
2026-07-13 18:47:53 -04:00
didericis 08aef30d87 feat(git-gate): source-IP-keyed multi-tenant repo namespace (PRD 0070)
lint / lint (push) Failing after 2m0s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m19s
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
2026-07-13 18:31:03 -04:00
didericis e0b0429cd1 refactor(orchestrator): rename Sidecar -> Gateway for the consolidated data plane
test / unit (pull_request) Successful in 1m10s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Successful in 1m17s
lint / lint (push) Successful in 2m11s
Retire "sidecar" for the consolidated per-host path (PRD 0070 naming
decision): the orchestrator is the umbrella/control plane, and the
egress/git/supervise data-plane unit it runs is the "gateway".

- git mv sidecar.py -> gateway.py and the two integration + one unit test
  files; DockerSidecar->DockerGateway, Sidecar->Gateway,
  SidecarError->GatewayError, SIDECAR_*->GATEWAY_*, ensure_sidecar->
  ensure_gateway, sidecar_status->gateway_status, container name
  bot-bottle-orch-sidecar->bot-bottle-orch-gateway.
- Prose rename across broker/registry/egress/policy_resolver + PRD 0070.
- Preserved: the image name bot-bottle-sidecars, the
  BOT_BOTTLE_SIDECAR_IMAGE env var, Dockerfile.sidecars, and PRD 0069's
  own stage-name cross-references (that doc still uses "sidecar").

No behavior change. Full unit suite green (1679 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
2026-07-13 18:12:17 -04:00
didericis d3e08cf039 feat(orchestrator+egress): slice 8 — multi-tenant egress via the resolver (#352)
lint / lint (push) Successful in 2m8s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Successful in 1m25s
The egress addon now selects each request's Config by the calling bottle's
source IP, so one shared sidecar serves every bottle. Opt-in and fail-closed;
single-tenant behaviour is unchanged.

Orchestrator side (source-IP-primary attribution, per the PRD invariant):
  * registry: `by_source_ip` (the single active bottle at a source IP —
    network-layer attribution); `attribute` now composes it + the token.
  * service: `resolve(source_ip, token="")` — with a token, strict
    attribution; without, source IP alone.
  * control_plane: `POST /resolve`'s identity_token is now OPTIONAL (absent
    → source-IP-only); split cleanly from the token-required `/attribute`.
  * policy_resolver: `resolve` token now optional.

Egress side:
  * egress_addon_core: `resolve_client_config(resolver, client_ip, token)` —
    fetches + parses the client's Config, **fail-closed**: unattributed, a
    resolver error, or an unparseable policy all yield deny-all (no routes).
    Host-testable; `PolicyResolverLike` Protocol keeps it import-free.
  * egress_addon: consolidated mode when `BOT_BOTTLE_ORCHESTRATOR_URL` is
    set → `_active_config(flow)` resolves per client IP (reads + strips the
    `x-bot-bottle-identity` header); `request()` uses it. Unset → the static
    routes file, exactly as before. `PolicyResolver` added to the bundle.

Security note: source-IP-only resolution is safe where the IP is unspoofable
(Firecracker /31 + nft) AND the control plane is reachable only by the
trusted sidecar; the identity token, when the agent injects it, strengthens
it on weaker backends.

Scope note: the egress data plane is now multi-tenant. Remaining to be fully
live: the network topology routing every bottle's proxy to the one shared
sidecar, git-gate multitenancy, and agent-side identity-token injection.

Tests: registry by_source_ip; orchestrator resolve (with/without token);
control-plane /resolve token-optional; resolver token-optional;
resolve_client_config fail-closed matrix. All 182 egress tests still pass
(single-tenant unchanged). Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:48:46 -04:00
didericis de9da29027 refactor(orchestrator): drop the PolicyResolver cache (#352)
lint / lint (push) Successful in 2m0s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m12s
Review: the resolver is called rarely enough that a round-trip doesn't
matter, and correctness beats speed — always fetching means a revocation,
policy change, or teardown the orchestrator knows about is honored
immediately instead of lingering for a cache TTL. Remove the TTL cache
(and `invalidate`); `resolve` now hits the orchestrator every call. Noted
in the docstring that any future caching should use orchestrator-driven
invalidation, not a blind TTL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:32:05 -04:00
didericis 72ea500342 feat(orchestrator): slice 7 — sidecar-side PolicyResolver (#352)
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m9s
The data-plane bridge that lets the consolidated sidecar apply each
bottle's policy per request. `PolicyResolver` resolves a client's policy
from the orchestrator's `POST /resolve` keyed on (source_ip, identity
token) and caches it briefly (short TTL) so it isn't a round-trip per
request; `invalidate()` drops an entry on teardown / live reload.

Fail-closed: an unattributed client (orchestrator answers 403) resolves to
None so the caller denies; unreachable / unexpected status raises so the
caller can fail closed too rather than serve stale/empty policy. Stdlib
only and free of bot-bottle imports, so it can be COPYed flat into the
sidecar bundle.

Scope note: this is the sidecar-side *client*. Wiring it into the live
egress mitmproxy addon (select `Config` per client IP in the request path)
and git-gate, plus routing all bottles' egress to the one shared sidecar,
are the remaining data-plane pieces — a heavier change to the sidecar
bundle's adversarial-input code, taken next.

Tests: resolve returns/caches/expires/invalidates; 403 -> None (fail
closed); other HTTP status + unreachable raise; missing policy -> empty;
posts source_ip + identity_token. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:21:27 -04:00
didericis f754c575d7 feat(orchestrator): slice 6 — source-IP-keyed multi-tenant policy (#352)
lint / lint (push) Successful in 2m1s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m16s
The orchestrator side of the multi-tenant consolidated sidecar: hold each
bottle's sidecar policy and serve it by verified source IP, with live
reload. One shared sidecar can now get per-bottle config keyed on who's
calling.

  * registry.py — a `policy` column (migration v3, opaque JSON the sidecar
    interprets) on BottleRecord; `register(..., policy=)` stores it,
    `set_policy(bottle_id, policy)` updates it live, and `attribute` returns
    it (the source-IP-keyed resolution).
  * service.py — `launch_bottle(..., policy=)` and `set_policy`.
  * control_plane.py — `POST /bottles` accepts `policy`; `PUT
    /bottles/<id>/policy` live-reloads it; `POST /resolve` returns
    {bottle_id, policy} for a verified (source_ip, token) — the per-request
    call the multi-tenant sidecar makes; `/attribute` stays identity-only.

Scope note: this is the control-plane / state half. The data-plane half —
the egress mitmproxy addon (and git-gate) selecting allowlist / DLP /
token-injection per client IP by calling `/resolve` — is the next slice
(route agent bottles through the shared sidecar). The orchestrator stays
policy-agnostic: it stores and serves the blob verbatim.

Tests: registry policy store/update/persist; Orchestrator launch-with-policy
+ live set_policy; control-plane resolve returns policy (403 on bad token),
PUT policy updates / 404 / 400. Verified live over HTTP (launch -> resolve
-> PUT reload -> resolve reflects). Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:09:32 -04:00
didericis a092d00312 feat(orchestrator): slice 5 — build the consolidated sidecar bundle image (#352)
lint / lint (push) Successful in 2m7s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m12s
Answers "where do we build the consolidated sidecar": nowhere, until now.

  * sidecar.py — `Sidecar.ensure_built()` (default no-op) + `DockerSidecar`
    now defaults its image to the real bundle (`bot-bottle-sidecars`) and
    `ensure_built()` builds it from `Dockerfile.sidecars` when
    `docker image inspect` shows it's missing (no-op when present or when no
    dockerfile is configured, e.g. a pre-pulled image). `image_exists()`
    added.
  * service.py — `ensure_sidecar()` now builds then runs.
  * __main__.py — `--sidecar` runs the consolidated bundle (build-if-missing).

Scope note: this builds + launches the bundle *container*; making the
running instance functional across bottles needs the per-bottle,
source-IP-keyed multi-tenant config + registration/reload, and routing
agent bottles to it — the next slices (added to PRD 0070's roadmap).

Tests: unit (docker mocked) — image_exists, ensure_built builds when
missing / no-op when present / no-op without a dockerfile / raises on build
failure; ensure_sidecar builds-then-runs; integration (gated, no heavy
build) — image_exists reflects real docker state. Full suite green (only
pre-existing /bin/sleep errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:00:18 -04:00
didericis 67652899eb refactor(orchestrator): move run_docker to a lean top-level docker_cmd (#352)
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m7s
Review feedback: don't bury a parallel docker util in the orchestrator.
But reusing backend.docker.util (docker_mod) isn't right either — importing
it runs backend/__init__.py, which eagerly loads all three backends
(docker + firecracker + macos) plus the manifest/egress/git-gate/supervise
framework (~76 modules), so every orchestrator import would drag the whole
backend layer in.

Compromise: promote the helper to a top-level, framework-free
bot_bottle/docker_cmd.py (single stdlib import), a proper shared home the
orchestrator's docker components use now and backend.docker.util can adopt
later. Verified `import bot_bottle.orchestrator` stays lean (12 modules, no
firecracker/macos backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 16:24:37 -04:00
didericis f85cbdeebf feat(orchestrator): slice 4 — consolidated per-host sidecar (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m7s
The core consolidation win: one persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. Safe to share
because the attribution invariant (source IP + identity token) lets the
sidecar map each request to the right bottle.

  * orchestrator/sidecar.py — a backend-neutral `Sidecar` lifecycle
    contract (mirrors LaunchBroker) + a `DockerSidecar` impl. The defining
    behaviour is idempotent singleton: `ensure_running` starts the instance
    if absent and is a no-op if it's already up, so N launches never spawn
    N sidecars; `stop` is idempotent.
  * orchestrator/dockerutil.py — a shared `run_docker` helper; DockerBroker
    now uses it too (DRY with slice 3).
  * service.py — the Orchestrator holds an optional `Sidecar`, exposes
    `ensure_sidecar()` + `sidecar_status()`.
  * control_plane.py — `GET /sidecar` reports it; __main__ gains
    `--sidecar-image` and ensures the single sidecar on startup.

Tests: unit (docker mocked) — is_running, ensure idempotent (no-op when up,
starts when absent), failure raises, stop idempotent; Orchestrator sidecar
wiring/status; control-plane /sidecar; integration (gated) — ensure is a
real idempotent singleton (one container after two ensures), stop removes.
Full suite green (only pre-existing /bin/sleep errors); integration
verified locally against real docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:28:29 -04:00
didericis 44e611d14e fix(orchestrator): pyright — pass explicit LaunchRequest in the docker integration test (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m6s
The **kw unpacking put str into slot: int|None. Pass explicit
LaunchRequests instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:05:55 -04:00
didericis 4fb0f64249 feat(orchestrator): slice 3 — real Docker launch broker (#352)
lint / lint (push) Failing after 1m59s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s
The first concrete LaunchBroker, proving the orchestrator -> backend seam
on the cheapest backend (the sidecar bundle is already containers):

  * orchestrator/docker_broker.py — DockerBroker runs a container on a
    verified launch (`docker run --detach --name <bottle> --label ...
    <image_ref>`) and removes it on teardown (`docker rm --force`,
    idempotent on an already-absent container). The argv is built only from
    the request's static ids/flags, so nothing free-form reaches docker;
    provenance/schema verification is inherited from LaunchBroker.submit.
  * __main__.py gains `--broker {stub,docker}` so the harness can drive real
    containers.

Slice 3 launches a single container from image_ref (the seam); the full
agent + sidecar bundle is a later slice.

Tests: unit (docker mocked) — argv from static fields, launch/teardown call
the right commands, missing-image and docker-failure raise, teardown
idempotent on missing, forged token never touches docker; integration
(gated on a reachable daemon) — launch creates a real container, teardown
removes it. Full suite green (only pre-existing /bin/sleep errors);
integration verified locally against real docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:02:11 -04:00
didericis 9226d45041 feat(orchestrator): slice 2 — launch lifecycle + signed launch-broker (#352)
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m6s
Second slice of PRD 0070, still a backend-neutral dev-harness:

  * orchestrator/broker.py — the launch-broker contract. A LaunchRequest is
    structured (static ids/flags only — bottle id, pool slot, a
    content-addressed image_ref; never a path/argv) and signed as a compact
    HS256 JWT so the broker verifies PROVENANCE before acting: a compromised
    co-located component can't forge a launch without the shared secret.
    verify_request is fail-closed (bad sig / malformed / off-schema -> raise).
    Stdlib only (no runtime deps). Ships a StubBroker that records verified
    requests for the harness/tests.
  * orchestrator/service.py — the Orchestrator: owns the registry and brokers
    the lifecycle. launch_bottle mints the bottle + sends a signed launch,
    rolling the registry entry back if the launch fails (no orphans);
    teardown_bottle brokers teardown then deregisters; attribute delegates.
  * control_plane.py — POST /bottles now launches, DELETE tears down (both go
    through the Orchestrator + broker). dispatch/server take an Orchestrator.
  * __main__.py wires an ephemeral secret + StubBroker for the harness.

Tests: broker sign/verify round-trip, tamper/wrong-secret/malformed/off-schema
rejection, StubBroker fail-closed; Orchestrator launch->registry->attribute,
teardown, rollback-on-broker-failure; control-plane updated for launch/teardown.
Full suite green (only the pre-existing /bin/sleep errors); harness does
launch -> attribute -> teardown over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:42:35 -04:00
didericis 6847fdf0ab refactor: drop the vestigial bot_bottle_root/host_db_path re-exports (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Successful in 1m6s
paths is the single home now, so stop re-exporting the path helpers from
supervise: remove host_db_path from supervise's imports + __all__ (it was
re-export-only) and drop bot_bottle_root from __all__ (kept as an import,
still used by audit_dir). supervise_types was already clean. Repoint the
last readers (test_supervise imports host_db_path from paths;
test_supervise_edge calls paths.bot_bottle_root) and refresh the doc
mentions. No supervise.bot_bottle_root / supervise.host_db_path references
remain.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:30:13 -04:00
didericis 421d31c32f refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 15s
test / coverage (pull_request) Successful in 1m1s
Create bot_bottle/paths.py as the canonical home for the app-root path
helpers (bot_bottle_root, host_db_path, HOST_DB_FILENAME) — foundational,
not supervise- or db-specific. `bot_bottle_root()` now honours a
BOT_BOTTLE_ROOT env override.

Repoint every consumer (supervise, supervise_types, db_store, queue_store,
audit_store, store_manager, bottle_state, cli/supervise, docker/cleanup,
orchestrator/registry) at paths; remove the definitions (and supervise's
duplicate host_db_path) and the now-dead `import sys`. Add paths.py to the
sidecar bundle (Dockerfile.sidecars) for the flat-import copies.

Tests: replace ~12 files' monkeypatching of supervise.bot_bottle_root (and
the flat/pkg/supervise_types triple-patch dance) with a single
`use_bottle_root()` helper that sets BOT_BOTTLE_ROOT — every module and
flat/package copy reads the same env var, so one override covers them all.
Net -97 lines. Behaviour-preserving: full unit suite unchanged (only the
pre-existing /bin/sleep sidecar-init errors remain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 14:04:23 -04:00
didericis b4b4e08f62 refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m0s
host_db_path is shared DB infrastructure, not supervise-specific, so its
canonical home is db_store (alongside DbStore). It resolves bot_bottle_root
from supervise_types lazily inside the function — no load-time cycle, and a
monkey-patch of supervise_types.bot_bottle_root still propagates.
supervise_types re-exports both names for the historical import path
(queue_store/audit_store unchanged); the orchestrator registry now imports
from db_store. Drops the now-unused `import sys` from supervise_types.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors remain); monkeypatch propagation verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:36:19 -04:00
didericis b174442c60 feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m5s
Per review: use one shared bot-bottle.db for all runtime state including
the registry (DbStore namespaces by schema_key), so it's one queryable
file for backup/console. default_db_path() -> host_db_path(). Drop the
unilateral WAL flip — WAL on the shared DB affects supervise/audit and is
finicky over guest shares, so it's a deliberate future change; keep a
busy_timeout for lock contention.

PRD State section updated: integrity now by SOLE ownership (only the
orchestrator opens bot-bottle.db; data plane + console reach state via the
control-plane RPC, never a file handle) rather than ro/rw mount-splitting,
which one shared file can't do. Notes the transitional caveat that the
supervise sidecar currently rw-mounts bot-bottle.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:26:03 -04:00
didericis ed7307e0e3 docs(prd): 0070 registry DB is host-resident with rw/ro access split (#352)
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m1s
Per review: the SQLite runtime-state DB lives on the host, not owned
inside the orchestrator unit. Durability (re-adoption must survive an
orchestrator restart) + integrity via access-scoping (control-plane rw,
data-plane ro) rather than location. Note the WAL-over-guest-share wrinkle
for the VM slices (may want a host-side DB owner reached over the RPC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:17:05 -04:00
didericis 1702664b81 feat(orchestrator): slice 1 — registry + attribution + HTTP control plane (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m2s
First implementation slice of PRD 0070, the backend-neutral consolidation
core as a plain-process dev-harness (no VM packaging yet):

  * orchestrator/registry.py — SQLite (WAL) runtime-state store on the
    existing DbStore/TableMigrations base. Live bottle registry keyed by
    source IP + per-bottle identity token, with fail-closed attribution:
    a request resolves to a bottle only when its source IP AND identity
    token both match exactly one active record (unknown/ambiguous IP,
    empty token, or token mismatch all deny). Tokens are 256-bit urandom.
  * orchestrator/control_plane.py — the HTTP control plane (the universal
    transport chosen in 0070): register / deregister / list / attribute /
    health. Routing is a pure dispatch() so it is socket-free testable;
    Handler/ControlPlaneServer/make_server are a thin stdlib adapter.
    register/deregister are the live-reload path; listing redacts tokens.
  * orchestrator/__main__.py — `python -m bot_bottle.orchestrator` harness.

Launch/teardown, the launch broker, and the egress/git/supervise data
plane come in later slices. 24 unit tests (attribution matrix, persistence,
dispatch, one real-socket round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 13:00:04 -04:00
didericis 8d54fc38ea docs(prd): 0070 VM-to-VM routing is not a blocker (#352)
Per review: resolvable at implementation time (host tweak acceptable, as
the pool setup already needs on NixOS); the earlier sidecars-in-VMs spike
showed feasibility. No need to hunt the spike now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:46:08 -04:00
didericis 73a7582abe docs(prd): 0070 fold in review decisions (#352)
Resolve open questions from review: consolidate egress (hardened minimal
surface + identity token + vault mitigations); HTTP control-plane
transport; broker schema = signed-JWT JSON of static flags+ids (provenance
+ un-coercible); state re-adoption procedure (singleton orchestrator →
wait-healthy → adopt via SQLite + process inspection before serving, with
write-ahead intent to close the in-flight-launch race). Add a per-bottle
identity-token defense-in-depth layer on the attribution invariant.
Remaining open: VM-to-VM routing (per-backend wire(), pending spike link),
live-reload protocol, identity-token delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:39:06 -04:00
didericis 095896817c docs(prd): 0070 secret-handling as a future pattern (SecretProvider, #355)
lint / lint (push) Successful in 2m3s
Add a "Secret handling — FUTURE pattern (not v1)" subsection: vault as a
separate trust domain holding long-lived roots, deriving short-lived
scoped creds where the upstream allows (with the honest limit that a
compromised proxy can still abuse currently-authorized access). The
mechanism is a generic, user-extensible SecretProvider that generalizes
PRD 0048's DeployKeyProvisioner and drops into the manifest wherever a raw
token is accepted — discovered from ~/.bot-bottle/contrib like user
AgentProviders. Marked explicitly as not required for the initial
orchestrator; tracked as #355.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
didericis a30dd49967 docs(prd): 0070 per-host orchestrator service
Fold the per-bottle sidecar bundle into a single persistent per-host
orchestrator: runs the sidecar functions (egress/git-gate/supervise),
coordinates with the console, and brokers agent launches. Virtualized
from the start with backend-native isolation (fc VM / apple ctr / docker
ctr), fronted by a single backend-agnostic contract; per-backend
variation lives on BottleBackend, not a parallel Orchestrator hierarchy.

Leads with the security review (secret concentration, shared fate, the
launch-broker-as-new-privileged-core, and the source-IP attribution
invariant each backend must enforce). Proposes one SQLite DB owned by the
orchestrator for runtime state (slot leases, approvals, registry) —
distinct from build-time constants (flat .env) and user config
(declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos,
developing the service as a plain-process dev-harness before the VM.

Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built
fixed images. Tracking issue #351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:25 -04:00
34 changed files with 562 additions and 1143 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-83%25-brightgreen)](https://coverage.readthedocs.io/) [![coverage](https://img.shields.io/badge/coverage-82%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md) [![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
+31 -73
View File
@@ -40,7 +40,7 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan from ..egress import EgressPlan
@@ -54,9 +54,6 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
if TYPE_CHECKING:
from .freeze import CommitCancelled, Freezer, get_freezer
@dataclass(frozen=True) @dataclass(frozen=True)
class BottleSpec: class BottleSpec:
@@ -575,63 +572,28 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
Not called by the launch path or the test suite.""" Not called by the launch path or the test suite."""
# _backends is None until the first call to _get_backends(), at which # Import concrete backend classes AFTER the base types are defined, so
# point all three concrete backend classes are imported and instantiated. # each backend module can pull BottleSpec / BottlePlan / BottleBackend
# Keeping the imports out of module scope means that importing any # via `from . import ...` without hitting a partially-initialized module.
# backend sub-module (e.g. `backend.docker.util`) no longer drags the from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# firecracker and macos-container implementations into memory. from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it # Freezer is imported after the backend classes for the same reason:
# is not None, so test fakes take effect without triggering real imports. # Freezer.commit_slug constructs ActiveAgent, which must be fully
_backends: dict[str, BottleBackend[Any, Any]] | None = None # defined first.
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
def _get_backends() -> dict[str, BottleBackend[Any, Any]]: # The dict is heterogeneous: each value is a BottleBackend specialized
"""Return the registry of all backend instances, loading lazily on first call.""" # over its own plan type. Concrete plan types are erased here because
global _backends # pylint: disable=global-statement # the registry is selected at runtime and the CLI only needs the
if _backends is None: # unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
from .docker import DockerBottleBackend _BACKENDS: dict[str, BottleBackend[Any, Any]] = {
from .firecracker import FirecrackerBottleBackend "docker": DockerBottleBackend(),
from .macos_container import MacosContainerBottleBackend "firecracker": FirecrackerBottleBackend(),
_backends = { "macos-container": MacosContainerBottleBackend(),
"docker": DockerBottleBackend(), }
"firecracker": FirecrackerBottleBackend(),
"macos-container": MacosContainerBottleBackend(),
}
return _backends
def __getattr__(name: str) -> Any:
"""Lazily surface concrete backend classes and freeze symbols at the
package level so existing `from bot_bottle.backend import X` and
`patch.object(backend_mod, X, ...)` call-sites keep working without
forcing an import of every backend at module-init time."""
if name == "DockerBottleBackend":
from .docker import DockerBottleBackend
globals()[name] = DockerBottleBackend
return DockerBottleBackend
if name == "FirecrackerBottleBackend":
from .firecracker import FirecrackerBottleBackend
globals()[name] = FirecrackerBottleBackend
return FirecrackerBottleBackend
if name == "MacosContainerBottleBackend":
from .macos_container import MacosContainerBottleBackend
globals()[name] = MacosContainerBottleBackend
return MacosContainerBottleBackend
if name == "CommitCancelled":
from .freeze import CommitCancelled
globals()[name] = CommitCancelled
return CommitCancelled
if name == "Freezer":
from .freeze import Freezer
globals()[name] = Freezer
return Freezer
if name == "get_freezer":
from .freeze import get_freezer
globals()[name] = get_freezer
return get_freezer
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def get_bottle_backend( def get_bottle_backend(
@@ -649,11 +611,10 @@ def get_bottle_backend(
Dies with a pointer at the known backends if the chosen name Dies with a pointer at the known backends if the chosen name
isn't implemented.""" isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name() resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
backends = _get_backends() if resolved not in _BACKENDS:
if resolved not in backends: known = ", ".join(sorted(_BACKENDS))
known = ", ".join(sorted(backends))
die(f"unknown backend {resolved!r}; known backends: {known}") die(f"unknown backend {resolved!r}; known backends: {known}")
return backends[resolved] return _BACKENDS[resolved]
def _default_backend_name() -> str: def _default_backend_name() -> str:
@@ -663,17 +624,16 @@ def _default_backend_name() -> str:
# `firecracker` binary isn't installed yet: selecting it here routes # `firecracker` binary isn't installed yet: selecting it here routes
# start through firecracker's preflight, which prints an install # start through firecracker's preflight, which prints an install
# pointer, instead of silently falling back to docker. # pointer, instead of silently falling back to docker.
from .firecracker import FirecrackerBottleBackend
if FirecrackerBottleBackend.is_host_capable(): if FirecrackerBottleBackend.is_host_capable():
return "firecracker" return "firecracker"
return "docker" return "docker"
def known_backend_names() -> tuple[str, ...]: def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_get_backends()`. Used by """Sorted tuple of all backend keys in `_BACKENDS`. Used by
argparse (`--backend` choices) and the dashboard's backend argparse (`--backend` choices) and the dashboard's backend
picker.""" picker."""
return tuple(sorted(_get_backends())) return tuple(sorted(_BACKENDS))
def has_backend(name: str) -> bool: def has_backend(name: str) -> bool:
@@ -685,10 +645,9 @@ def has_backend(name: str) -> bool:
Returns False for unknown names so callers can pass Returns False for unknown names so callers can pass
arbitrary input without separate validation.""" arbitrary input without separate validation."""
backends = _get_backends() if name not in _BACKENDS:
if name not in backends:
return False return False
return backends[name].is_available() return _BACKENDS[name].is_available()
def enumerate_active_agents() -> list[ActiveAgent]: def enumerate_active_agents() -> list[ActiveAgent]:
@@ -704,11 +663,10 @@ def enumerate_active_agents() -> list[ActiveAgent]:
deterministic tiebreaker. Agents with missing metadata deterministic tiebreaker. Agents with missing metadata
(`started_at == ""`) sort first.""" (`started_at == ""`) sort first."""
out: list[ActiveAgent] = [] out: list[ActiveAgent] = []
backends = _get_backends() for name in known_backend_names():
for name in sorted(backends): if not has_backend(name):
if not backends[name].is_available():
continue continue
out.extend(backends[name].enumerate_active()) out.extend(_BACKENDS[name].enumerate_active())
out.sort(key=lambda a: (a.started_at, a.slug)) out.sort(key=lambda a: (a.started_at, a.slug))
return out return out
-5
View File
@@ -111,11 +111,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
plumbing needed; the alias resolves inside the bridge.""" plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return "" return ""
# Consolidated: the supervise daemon lives on the shared gateway, so
# the agent registers the gateway address (NO_PROXY bypasses egress),
# not the per-bottle `supervise` alias.
if plan.agent_supervise_url:
return plan.agent_supervise_url
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan: def prepare_cleanup(self) -> DockerBottleCleanupPlan:
-19
View File
@@ -28,30 +28,11 @@ class DockerBottlePlan(BottlePlan):
# accidental log of the plan dataclass. # accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
use_runsc: bool use_runsc: bool
# Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the
# shared gateway's address (`http://<gateway_ip>:9420`), set at launch.
# Empty → single-tenant defaults (the `git-gate` alias over git://).
agent_git_gate_url: str = ""
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
# empty → the single-tenant `supervise` alias.
agent_supervise_url: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
return self.agent_provision.instance_name return self.agent_provision.instance_name
@property
def git_gate_insteadof_host(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
return super().git_gate_insteadof_host
@property
def git_gate_insteadof_scheme(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return "http"
return super().git_gate_insteadof_scheme
@property @property
def image(self) -> str: def image(self) -> str:
return self.agent_provision.image return self.agent_provision.image
@@ -26,7 +26,7 @@ from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...orchestrator.client import OrchestratorClient from ...orchestrator.client import OrchestratorClient
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
from ...orchestrator.lifecycle import OrchestratorService from ...orchestrator.lifecycle import OrchestratorProcess
from ...orchestrator.registration import registration_inputs from ...orchestrator.registration import registration_inputs
from .gateway_net import next_free_ip from .gateway_net import next_free_ip
from .gateway_provision import deprovision_git_gate, provision_git_gate from .gateway_provision import deprovision_git_gate, provision_git_gate
@@ -76,21 +76,15 @@ def _container_ip(name: str, network: str) -> str:
return ip return ip
def _network_container_ips(network: str) -> list[str]: def _taken_ips(client: OrchestratorClient, gateway_ip: str) -> list[str]:
"""Every address currently assigned on the gateway network the ground """Every address already in use on the gateway network: the gateway
truth for "in use": the gateway + orchestrator infrastructure containers container plus every live bottle the registry knows."""
and every live agent. Read from the network so a new bottle can't collide taken = [gateway_ip]
with anything actually attached (a registry-only view would miss the for rec in client.list_bottles():
orchestrator/gateway containers).""" src = rec.get("source_ip")
proc = run_docker([ if isinstance(src, str) and src:
"docker", "network", "inspect", "--format", taken.append(src)
"{{range .Containers}}{{.IPv4Address}} {{end}}", network, return taken
])
ips: list[str] = []
for entry in proc.stdout.split():
# entries look like "172.20.0.2/16" — keep the address.
ips.append(entry.split("/", 1)[0])
return ips
def launch_consolidated( def launch_consolidated(
@@ -98,8 +92,7 @@ def launch_consolidated(
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
*, *,
image_ref: str = "", image_ref: str = "",
tokens: dict[str, str] | None = None, process: OrchestratorProcess | None = None,
service: OrchestratorService | None = None,
gateway_name: str = GATEWAY_NAME, gateway_name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
) -> LaunchContext: ) -> LaunchContext:
@@ -107,18 +100,17 @@ def launch_consolidated(
bottle, and provision its git-gate state. Returns the agent's attach bottle, and provision its git-gate state. Returns the agent's attach
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors) context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
if any step fails the caller tears down on failure.""" if any step fails the caller tears down on failure."""
service = service or OrchestratorService() process = process or OrchestratorProcess()
url = service.ensure_running() url = process.ensure_running()
client = OrchestratorClient(url) client = OrchestratorClient(url)
cidr = _network_cidr(network) cidr = _network_cidr(network)
gateway_ip = _container_ip(gateway_name, network) gateway_ip = _container_ip(gateway_name, network)
source_ip = next_free_ip(cidr, _network_container_ips(network)) source_ip = next_free_ip(cidr, _taken_ips(client, gateway_ip))
inputs = registration_inputs(egress_plan) inputs = registration_inputs(egress_plan)
reg = client.register_bottle( reg = client.register_bottle(
source_ip, image_ref=image_ref, policy=inputs.policy, source_ip, image_ref=image_ref, policy=inputs.policy, metadata=inputs.metadata,
metadata=inputs.metadata, tokens=tokens,
) )
try: try:
provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan) provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan)
@@ -67,12 +67,6 @@ def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None:
_require_safe(bottle_id) _require_safe(bottle_id)
if not plan.upstreams: if not plan.upstreams:
return return
# The pre-receive + access hooks are bottle-agnostic and shared by every
# bottle's repos; install them into the gateway (idempotent — same content
# each time). The per-bottle model cp'd these into each bundle at start.
_exec(gateway, ["mkdir", "-p", "/etc/git-gate"])
_cp_into(gateway, str(plan.hook_script), "/etc/git-gate/pre-receive")
_cp_into(gateway, str(plan.access_hook_script), "/etc/git-gate/access-hook")
creds = _creds_dir(bottle_id) creds = _creds_dir(bottle_id)
_exec(gateway, ["mkdir", "-p", creds]) _exec(gateway, ["mkdir", "-p", creds])
for u in plan.upstreams: for u in plan.upstreams:
+61 -56
View File
@@ -42,6 +42,7 @@ from ...git_gate import (
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...log import info, warn
from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -52,6 +53,7 @@ from ...bottle_state import (
read_committed_image, read_committed_image,
) )
from .compose import ( from .compose import (
bottle_plan_to_compose,
compose_down, compose_down,
compose_dump_logs, compose_dump_logs,
compose_file_path, compose_file_path,
@@ -60,9 +62,7 @@ from .compose import (
compose_up, compose_up,
write_compose_file, write_compose_file,
) )
from .consolidated_compose import consolidated_agent_compose from .egress import egress_tls_init
from .consolidated_launch import launch_consolidated, teardown_consolidated
from ...orchestrator.gateway import DockerGateway
# Where the repo root lives, for `docker build` context. Computed once. # Where the repo root lives, for `docker build` context. Computed once.
@@ -97,7 +97,8 @@ def launch(
try: try:
# Step 1: agent image. Use a committed snapshot when one exists # Step 1: agent image. Use a committed snapshot when one exists
# and is present in the local daemon; otherwise build from the # and is present in the local daemon; otherwise build from the
# Dockerfile. (The gateway image is built by the orchestrator.) # Dockerfile. Sidecar images get built lazily by `docker compose
# up` via the renderer's `build:` directives.
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed): if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}") info(f"using committed image {committed!r}")
@@ -111,82 +112,82 @@ def launch(
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before internal_network = network_mod.network_name_for_slug(plan.slug)
# provisioning the bottle's repos into the shared gateway. egress_network = network_mod.network_egress_name_for_slug(plan.slug)
egress_ca_host, egress_ca_cert_only = egress_tls_init(
egress_state_dir(plan.slug),
)
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys( git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug), plan.manifest.bottle,
git_gate_plan,
git_gate_state_dir(plan.slug),
)
git_gate_plan = dataclasses.replace(
git_gate_plan,
internal_network=internal_network,
egress_network=egress_network,
) )
# Step 3: register on the orchestrator + provision this bottle's
# git-gate state into the shared gateway; get the agent's attach
# context (pinned source IP, gateway address, shared network). The
# per-bottle egress auth tokens are resolved from the host env now and
# handed to the orchestrator (in memory) for the gateway to inject —
# the agent never sees them.
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
ctx = launch_consolidated(
plan.egress_plan, git_gate_plan, image_ref=plan.image, tokens=token_values,
)
stack.callback(
teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url,
)
# Step 4: install the SHARED gateway CA into the agent (replaces the
# per-bottle CA) — read it out of the running gateway.
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem"
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
egress_plan = dataclasses.replace( egress_plan = dataclasses.replace(
plan.egress_plan, plan.egress_plan,
mitmproxy_ca_host_path=ca_file, internal_network=internal_network,
mitmproxy_ca_cert_only_host_path=ca_file, egress_network=egress_network,
) mitmproxy_ca_host_path=egress_ca_host,
# Point the agent's git-gate insteadOf rewrites at the shared gateway's mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
# HTTP git endpoint (9420) instead of the dead per-bottle `git-gate`
# alias. git-http + supervise on the gateway bypass the egress proxy
# (NO_PROXY includes the gateway address).
git_gate_url = (
f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else ""
)
supervise_url = (
f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else ""
) )
supervise_plan = plan.supervise_plan
if supervise_plan is not None:
supervise_plan = dataclasses.replace(
supervise_plan,
internal_network=internal_network,
)
plan = dataclasses.replace( plan = dataclasses.replace(
plan, plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
agent_git_gate_url=git_gate_url, supervise_plan=supervise_plan,
agent_supervise_url=supervise_url,
) )
# Step 5: render + up the agent-only compose, pinned on the shared # Step 6: render + write the compose file. metadata.json
# gateway network and proxied through the gateway's address. # was written at prepare time and already carries
# compose_project; nothing to update here.
state_dir = bottle_state_dir(plan.slug) state_dir = bottle_state_dir(plan.slug)
spec = consolidated_agent_compose( spec = bottle_plan_to_compose(plan)
plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network,
)
compose_file = write_compose_file(spec, compose_file_path(state_dir)) compose_file = write_compose_file(spec, compose_file_path(state_dir))
project = compose_project_name(plan.slug) project = compose_project_name(plan.slug)
# Forwarded vars (OAuth token, host interpolations) flow through the
# subprocess env as bare names so values never land in the file. # Step 7: compose up. Token values + the OAuth placeholder
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env} # flow through subprocess env; the compose file holds only
# bare names for the secret-carrying entries.
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
compose_env: dict[str, str] = {
**os.environ,
**plan.forwarded_env,
**token_values,
}
info( info(
f"docker compose up -d (project {project}, agent on shared " f"docker compose up -d (project {project}, "
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})" f"{len(spec['services'])} services)"
) )
compose_up(project, compose_file, env=compose_env) compose_up(project, compose_file, env=compose_env)
# Register teardown in reverse order: log dump first, then
# `compose down`. Networks come down last via callbacks
# registered in step 2.
stack.callback(compose_down, project, compose_file) stack.callback(compose_down, project, compose_file)
stack.callback( stack.callback(
compose_dump_logs, project, compose_file, compose_log_path(state_dir), compose_dump_logs, project, compose_file, compose_log_path(state_dir),
) )
# Step 6: provision (CA install now uses the gateway CA) + yield. # Step 8: provision. Create the bottle first so provisioners
# can use bottle.exec / bottle.cp_in; set the prompt path
# returned by provision_prompt after the fact.
bottle = DockerBottle( bottle = DockerBottle(
plan.container_name, plan.container_name,
teardown, teardown,
@@ -199,6 +200,10 @@ def launch(
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
bottle.prompt_path = provision(plan, bottle) bottle.prompt_path = provision(plan, bottle)
# Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's
# service spec.
yield bottle yield bottle
finally: finally:
teardown() teardown()
+34 -8
View File
@@ -7,9 +7,8 @@ from __future__ import annotations
import re import re
import shutil import shutil
import subprocess import subprocess
from typing import Iterator from typing import Iterable, Iterator
from ...docker_cmd import run_docker
from ...log import die, info from ...log import die, info
# from ...workspace import WorkspacePlan # from ...workspace import WorkspacePlan
@@ -31,7 +30,12 @@ def container_name_candidates(base: str) -> Iterator[str]:
def runsc_available() -> bool: def runsc_available() -> bool:
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime """Return True if the Docker daemon has the gVisor (`runsc`) runtime
registered. Called once per prepare; the result lives on the plan.""" registered. Called once per prepare; the result lives on the plan."""
r = run_docker(["docker", "info", "--format", "{{json .Runtimes}}"]) r = subprocess.run(
["docker", "info", "--format", "{{json .Runtimes}}"],
capture_output=True,
text=True,
check=False,
)
return r.returncode == 0 and "runsc" in r.stdout return r.returncode == 0 and "runsc" in r.stdout
@@ -45,15 +49,20 @@ def require_docker() -> None:
def image_exists(ref: str) -> bool: def image_exists(ref: str) -> bool:
return run_docker(["docker", "image", "inspect", ref]).returncode == 0 return _silent_run(["docker", "image", "inspect", ref]) == 0
def container_exists(name: str) -> bool: def container_exists(name: str) -> bool:
"""Returns True if a container (running or stopped) with the given """Returns True if a container (running or stopped) with the given
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
matches don't false-positive.""" matches don't false-positive."""
result = run_docker(["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"]) result = subprocess.run(
return result.returncode == 0 and bool(result.stdout.strip()) ["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
capture_output=True,
text=True,
check=True,
)
return bool(result.stdout.strip())
def force_remove_container(name: str) -> None: def force_remove_container(name: str) -> None:
@@ -61,7 +70,12 @@ def force_remove_container(name: str) -> None:
doesn't — and the rm itself is best-effort (errors swallowed) so doesn't — and the rm itself is best-effort (errors swallowed) so
this is safe to register as a teardown callback.""" this is safe to register as a teardown callback."""
if container_exists(name): if container_exists(name):
run_docker(["docker", "rm", "-f", name]) subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
def docker_exec_root(container: str, argv: list[str]) -> None: def docker_exec_root(container: str, argv: list[str]) -> None:
@@ -141,10 +155,22 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
def commit_container(container_name: str, image_tag: str) -> None: def commit_container(container_name: str, image_tag: str) -> None:
"""Run `docker commit <container_name> <image_tag>` to snapshot the """Run `docker commit <container_name> <image_tag>` to snapshot the
running container's filesystem state as a local Docker image.""" running container's filesystem state as a local Docker image."""
result = run_docker(["docker", "commit", container_name, image_tag]) result = subprocess.run(
["docker", "commit", container_name, image_tag],
capture_output=True, text=True, check=False,
)
if result.returncode != 0: if result.returncode != 0:
die( die(
f"docker commit {container_name!r}{image_tag!r} failed: " f"docker commit {container_name!r}{image_tag!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}" f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
info(f"committed {container_name!r}{image_tag!r}") info(f"committed {container_name!r}{image_tag!r}")
def _silent_run(cmd: Iterable[str]) -> int:
return subprocess.run(
list(cmd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode
+33 -46
View File
@@ -10,7 +10,6 @@ import json
import os import os
import signal import signal
import sys import sys
import typing
from pathlib import Path from pathlib import Path
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
@@ -231,27 +230,21 @@ class EgressAddon:
+ "\n" + "\n"
) )
def _resolve_flow( def _resolve_flow(self, flow: http.HTTPFlow) -> "tuple[Config, str]":
self, flow: http.HTTPFlow, """The `(Config, supervise slug)` to apply to this request. Single-tenant
) -> "tuple[Config, str, typing.Mapping[str, str]]": the static `self.config` and the env slug. Consolidated the calling
"""The `(Config, supervise slug, env)` to apply to this request. bottle's Config and its bottle id, resolved by source IP in one
Single-tenant the static `self.config`, the env slug, and the process round-trip (fail-closed to deny-all + empty slug if unattributed). The
env. Consolidated the calling bottle's Config + bottle id + auth identity token, if the agent injected one, is read then stripped so it
tokens, resolved by source IP in one round-trip (fail-closed to deny-all never leaks upstream. The slug keys both the proposal queue and the
+ empty slug if unattributed); `env` is the process env overlaid with per-bottle safelist, so an approval only ever affects its own bottle."""
the bottle's tokens, so upstream-auth injection (and DLP) use *this*
bottle's credentials — exactly what the per-bottle sidecar's env did.
The identity token, if the agent injected one, is read then stripped so
it never leaks upstream."""
if self._resolver is None: if self._resolver is None:
return self.config, self._supervise_slug, os.environ return self.config, self._supervise_slug
conn = flow.client_conn conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else "" client_ip = conn.peername[0] if conn and conn.peername else ""
token = flow.request.headers.get(IDENTITY_HEADER, "") token = flow.request.headers.get(IDENTITY_HEADER, "")
flow.request.headers.pop(IDENTITY_HEADER, None) flow.request.headers.pop(IDENTITY_HEADER, None)
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token) return resolve_client_context(self._resolver, client_ip, token)
env = {**os.environ, **tokens} if tokens else os.environ
return config, slug, env
async def request(self, flow: http.HTTPFlow) -> None: async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
@@ -260,14 +253,14 @@ class EgressAddon:
self._serve_introspection(flow, request_path) self._serve_introspection(flow, request_path)
return return
config, slug, env = self._resolve_flow(flow) config, slug = self._resolve_flow(flow)
# DLP outbound scan BEFORE stripping auth — catches tokens the # DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body. # agent tried to smuggle in any header, path, query param, or body.
# Hostname is included to catch DNS-tunnelling exfiltration attempts. # Hostname is included to catch DNS-tunnelling exfiltration attempts.
route = match_route(config.routes, flow.request.pretty_host) route = match_route(config.routes, flow.request.pretty_host)
if route is not None: if route is not None:
if not await self._handle_outbound_dlp(flow, route, slug, env): if not await self._handle_outbound_dlp(flow, route, slug):
return return
# The redact policy may have rewritten the request line; recompute # The redact policy may have rewritten the request line; recompute
# the path/query the git checks below rely on. # the path/query the git checks below rely on.
@@ -306,7 +299,7 @@ class EgressAddon:
config.routes, config.routes,
flow.request.pretty_host, flow.request.pretty_host,
request_path, request_path,
env, os.environ,
request_method=flow.request.method, request_method=flow.request.method,
request_headers=req_headers, request_headers=req_headers,
) )
@@ -332,12 +325,10 @@ class EgressAddon:
flow: http.HTTPFlow, flow: http.HTTPFlow,
route: Route, route: Route,
slug: str, slug: str,
env: "typing.Mapping[str, str]",
) -> bool: ) -> bool:
"""Scan the outbound request and apply the route's on-match policy """Scan the outbound request and apply the route's on-match policy
(PRD 0062). `env` is the per-bottle env overlay (process env + this (PRD 0062). Returns True if the request may be forwarded, False if a
bottle's tokens) used for DLP detection. Returns True if the request may 403 response has been written to `flow`.
be forwarded, False if a 403 response has been written to `flow`.
Loops so the supervise policy can re-scan after each approval a Loops so the supervise policy can re-scan after each approval a
second, un-approved token in the same request is still caught.""" second, un-approved token in the same request is still caught."""
@@ -354,7 +345,7 @@ class EgressAddon:
flow.request.pretty_host, request_path, query, headers, "", flow.request.pretty_host, request_path, query, headers, "",
) )
result = scan_outbound( result = scan_outbound(
route, scan_text, env, route, scan_text, os.environ,
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text, safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
) )
if result is None or result.severity != "block": if result is None or result.severity != "block":
@@ -365,7 +356,7 @@ class EgressAddon:
# redact scrubs every detection (tokens and structural CRLF) and # redact scrubs every detection (tokens and structural CRLF) and
# forwards; it fails closed only if a match survives the scrub. # forwards; it fails closed only if a match survives the scrub.
if policy == ON_MATCH_REDACT: if policy == ON_MATCH_REDACT:
if self._redact_outbound(flow, route, env): if self._redact_outbound(flow, route):
if self.config.log >= LOG_BLOCKS: if self.config.log >= LOG_BLOCKS:
sys.stderr.write(json.dumps({ sys.stderr.write(json.dumps({
"event": "egress_redacted", "event": "egress_redacted",
@@ -393,31 +384,29 @@ class EgressAddon:
if not self._supervise_available(slug): if not self._supervise_available(slug):
self._block_dlp(flow, result) self._block_dlp(flow, result)
return False return False
approved = await self._supervise_token_block(flow, request_path, result, slug, env) approved = await self._supervise_token_block(flow, request_path, result, slug)
if not approved: if not approved:
return False # _supervise_token_block wrote the 403 response return False # _supervise_token_block wrote the 403 response
# loop: the approved value is now in safe_tokens; re-scan. # loop: the approved value is now in safe_tokens; re-scan.
def _redact_outbound( def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
) -> bool:
"""Scrub detected tokens (and CRLF injection sequences) from the mutable """Scrub detected tokens (and CRLF injection sequences) from the mutable
request surfaces (body, headers, path/query) and re-scan. `env` is the request surfaces (body, headers, path/query) and re-scan. Returns True
per-bottle env overlay. Returns True if the request is now clean; False if the request is now clean; False if a block-severity match remains on
if a block-severity match remains on a surface redaction cannot rewrite a surface redaction cannot rewrite (the hostname) so the caller fails
(the hostname) so the caller fails closed.""" closed."""
body = flow.request.get_text(strict=False) body = flow.request.get_text(strict=False)
if body: if body:
redacted_body = redact_tokens(body, env=env) redacted_body = redact_tokens(body, env=os.environ)
if redacted_body != body: if redacted_body != body:
flow.request.text = redacted_body flow.request.text = redacted_body
for name, value in list(flow.request.headers.items()): for name, value in list(flow.request.headers.items()):
if name.lower() == "host": if name.lower() == "host":
continue # routing-critical; never a legitimate token continue # routing-critical; never a legitimate token
redacted = strip_crlf(redact_tokens(value, env=env)) redacted = strip_crlf(redact_tokens(value, env=os.environ))
if redacted != value: if redacted != value:
flow.request.headers[name] = redacted flow.request.headers[name] = redacted
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env)) redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
if redacted_path != flow.request.path: if redacted_path != flow.request.path:
flow.request.path = redacted_path flow.request.path = redacted_path
@@ -430,7 +419,7 @@ class EgressAddon:
crlf_text = build_outbound_scan_text( crlf_text = build_outbound_scan_text(
flow.request.pretty_host, request_path, query, headers, "", flow.request.pretty_host, request_path, query, headers, "",
) )
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text) result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
return result is None or result.severity != "block" return result is None or result.severity != "block"
async def _supervise_token_block( async def _supervise_token_block(
@@ -439,22 +428,20 @@ class EgressAddon:
request_path: str, request_path: str,
result: ScanResult, result: ScanResult,
slug: str, slug: str,
env: "typing.Mapping[str, str]",
) -> bool: ) -> bool:
"""Route a token DLP block to the operator's supervisor queue and wait. """Route a token DLP block to the operator's supervisor queue and wait.
`slug` attributes the proposal to the calling bottle (its own queue + `slug` attributes the proposal to the calling bottle (its own queue +
safelist) in the shared gateway this is what keeps one bottle's safelist) in the shared gateway this is what keeps one bottle's
approval from unblocking another's request. `env` is the per-bottle env approval from unblocking another's request. Returns True if the operator
overlay used to redact secrets from the proposal text. Returns True if approved (the matched value is added to that bottle's safelist and the
the operator approved (the matched value is added to that bottle's caller re-scans); False if the request must be blocked (a 403 response
safelist and the caller re-scans); False if the request must be blocked has been written to `flow`)."""
(a 403 response has been written to `flow`)."""
host = flow.request.pretty_host host = flow.request.pretty_host
payload = build_token_allow_payload( payload = build_token_allow_payload(
redact_tokens(host, env=env), redact_tokens(host, env=os.environ),
flow.request.method, flow.request.method,
redact_tokens(request_path, env=env), redact_tokens(request_path, env=os.environ),
result, result,
) )
proposal = _sv.Proposal.new( proposal = _sv.Proposal.new(
+10 -13
View File
@@ -450,31 +450,28 @@ def resolve_client_config(
class ContextResolverLike(typing.Protocol): class ContextResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context` """The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
needs one round-trip returning policy, bottle id, and auth tokens.""" needs one round-trip returning both policy and bottle id."""
def resolve_policy_and_bottle_id( def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = ..., self, source_ip: str, identity_token: str = ...,
) -> "tuple[str | None, str | None, dict[str, str]]": ) -> "tuple[str | None, str | None]":
... ...
def resolve_client_context( def resolve_client_context(
resolver: ContextResolverLike, client_ip: str, identity_token: str = "", resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
) -> "tuple[Config, str, dict[str, str]]": ) -> "tuple[Config, str]":
"""The calling client's `(Config, bottle_id, tokens)` in one round-trip — """The calling client's `(Config, bottle_id)` in one round-trip —
**fail-closed**. The Config follows `resolve_client_config`'s deny-all **fail-closed**. The Config follows `resolve_client_config`'s deny-all
rules; the bottle id is `""` whenever unattributed or the orchestrator rules; the bottle id is `""` whenever unattributed or the orchestrator
errored (caller treats as "supervise unavailable", never another bottle's errored, which the caller treats as "supervise unavailable for this
queue); `tokens` are the per-bottle upstream auth values the addon injects. bottle" (never another bottle's queue). One `/resolve` keys both the
One `/resolve` keys the egress policy, the supervise queue + safelist, and per-request egress policy and the per-bottle supervise queue + safelist."""
auth injection."""
try: try:
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id( policy, bottle_id = resolver.resolve_policy_and_bottle_id(client_ip, identity_token)
client_ip, identity_token,
)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=()), "", {} # orchestrator unreachable/errored → deny return Config(routes=()), "" # orchestrator unreachable/errored → deny
return _config_from_policy(policy), (bottle_id or ""), tokens return _config_from_policy(policy), (bottle_id or "")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -7
View File
@@ -51,13 +51,7 @@ def main(argv: list[str] | None = None) -> int:
# anything; 'docker' runs real containers (firecracker drops in later). # anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32) secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret) broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
# Standalone `--gateway` (not the consolidated flow, where the host gateway: Gateway | None = DockerGateway() if args.gateway else None
# lifecycle runs the gateway). The gateway resolves against this same
# process; the URL is only reachable when they share a docker network.
gateway: Gateway | None = (
DockerGateway(orchestrator_url=f"http://{args.host}:{args.port}")
if args.gateway else None
)
orchestrator = Orchestrator(registry, broker, secret, gateway) orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host gateway, shared by every bottle: build the # One persistent per-host gateway, shared by every bottle: build the
+2 -6
View File
@@ -90,18 +90,14 @@ class OrchestratorClient:
image_ref: str = "", image_ref: str = "",
metadata: str = "", metadata: str = "",
policy: str = "", policy: str = "",
tokens: dict[str, str] | None = None,
) -> RegisteredBottle: ) -> RegisteredBottle:
"""Register a bottle and broker its launch (`POST /bottles`). `tokens` """Register a bottle and broker its launch (`POST /bottles`). Returns
are the per-bottle egress auth values (env_name -> value) the its minted id + identity token."""
orchestrator holds in memory for the gateway to inject. Returns the
minted id + identity token."""
payload = self._ok("POST", "/bottles", { payload = self._ok("POST", "/bottles", {
"source_ip": source_ip, "source_ip": source_ip,
"image_ref": image_ref, "image_ref": image_ref,
"metadata": metadata, "metadata": metadata,
"policy": policy, "policy": policy,
"tokens": tokens or {},
}) })
bottle_id = payload.get("bottle_id") bottle_id = payload.get("bottle_id")
token = payload.get("identity_token") token = payload.get("identity_token")
+3 -23
View File
@@ -34,7 +34,6 @@ import http.server
import json import json
import os import os
import socketserver import socketserver
import sys
import typing import typing
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -81,16 +80,11 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
image_ref = data.get("image_ref") image_ref = data.get("image_ref")
metadata = data.get("metadata") metadata = data.get("metadata")
policy = data.get("policy") policy = data.get("policy")
raw_tokens = data.get("tokens")
tokens = {
k: v for k, v in raw_tokens.items() if isinstance(k, str) and isinstance(v, str)
} if isinstance(raw_tokens, dict) else {}
rec = orch.launch_bottle( rec = orch.launch_bottle(
source_ip, source_ip,
image_ref=image_ref if isinstance(image_ref, str) else "", image_ref=image_ref if isinstance(image_ref, str) else "",
metadata=metadata if isinstance(metadata, str) else "", metadata=metadata if isinstance(metadata, str) else "",
policy=policy if isinstance(policy, str) else "", policy=policy if isinstance(policy, str) else "",
tokens=tokens,
) )
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token} return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
@@ -142,13 +136,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
rec = orch.resolve(source_ip, token if isinstance(token, str) else "") rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None: if rec is None:
return 403, {"error": "unattributed"} return 403, {"error": "unattributed"}
# tokens are the in-memory per-bottle egress auth values the gateway return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
# injects; served here, never persisted.
return 200, {
"bottle_id": rec.bottle_id,
"policy": rec.policy,
"tokens": orch.tokens_for(rec.bottle_id),
}
return 404, {"error": "not found"} return 404, {"error": "not found"}
@@ -163,20 +151,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
super().log_message(format, *args) super().log_message(format, *args)
def _serve(self, method: str) -> None: def _serve(self, method: str) -> None:
"""Read the request body, dispatch it, and write the JSON reply. A """Read the request body, dispatch it, and write the JSON reply."""
dispatch failure (e.g. a broker error) returns a 500 rather than
crashing the connection, so one bad request can't take the control
plane down for the caller."""
server = self.server server = self.server
assert isinstance(server, ControlPlaneServer) assert isinstance(server, ControlPlaneServer)
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
try: status, payload = dispatch(server.orchestrator, method, self.path, body)
status, payload = dispatch(server.orchestrator, method, self.path, body)
except Exception as e: # noqa: BLE001 — the control plane must stay up
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
sys.stderr.flush()
status, payload = 500, {"error": f"internal error: {e}"}
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
+26 -70
View File
@@ -19,16 +19,10 @@ from __future__ import annotations
import abc import abc
import os import os
import time
from pathlib import Path from pathlib import Path
from ..docker_cmd import run_docker from ..docker_cmd import run_docker
# The gateway's mitmproxy writes its CA a beat after the container starts, so
# reads poll for it rather than assuming it's there on a fresh launch.
_CA_POLL_SECONDS = 0.5
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
GATEWAY_NAME = "bot-bottle-orch-gateway" GATEWAY_NAME = "bot-bottle-orch-gateway"
GATEWAY_LABEL = "bot-bottle-orch-gateway=1" GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# The single user-defined network the gateway and every agent bottle share. # The single user-defined network the gateway and every agent bottle share.
@@ -97,17 +91,12 @@ class DockerGateway(Gateway):
*, *,
name: str = GATEWAY_NAME, name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
orchestrator_url: str = "",
build_context: Path | None = None, build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE, dockerfile: str | None = GATEWAY_DOCKERFILE,
) -> None: ) -> None:
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
self.network = network self.network = network
# The control-plane URL the gateway's data plane resolves per bottle
# against — reached by container name over docker DNS on the shared
# network (container↔container, no host firewall). Empty → single-tenant.
self._orchestrator_url = orchestrator_url
self._build_context = build_context or _REPO_ROOT self._build_context = build_context or _REPO_ROOT
self._dockerfile = dockerfile self._dockerfile = dockerfile
@@ -115,23 +104,17 @@ class DockerGateway(Gateway):
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0 return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
def ensure_built(self) -> None: def ensure_built(self) -> None:
"""Build the bundle image from its Dockerfile, **cache-aware** — cheap """Build the bundle image from its Dockerfile when it's missing.
(a cache check) when nothing changed, a real rebuild when the flat No-op when the image is already present, or when no dockerfile is
sources (egress addon / git-http / policy_resolver / supervise) moved. configured (e.g. a pre-pulled image)."""
if self._dockerfile is None or self.image_exists():
This deliberately builds every time rather than build-if-missing: the
per-bottle model kept the image fresh via compose's `build:` on up, and
a stale image silently runs the OLD single-tenant daemons. No-op only
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
forces a full rebuild (parity with `start --no-cache`)."""
if self._dockerfile is None:
return return
argv = ["docker", "build", "-t", self.image_ref, proc = run_docker([
"-f", str(self._build_context / self._dockerfile), "docker", "build",
str(self._build_context)] "-t", self.image_ref,
if os.environ.get("BOT_BOTTLE_NO_CACHE"): "-f", str(self._build_context / self._dockerfile),
argv.insert(2, "--no-cache") str(self._build_context),
proc = run_docker(argv) ])
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}") raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
@@ -144,18 +127,6 @@ class DockerGateway(Gateway):
]) ])
return self.name in proc.stdout.split() return self.name in proc.stdout.split()
def _running_image_is_current(self) -> bool:
"""True iff the running gateway was created from the *current*
`image_ref`. When `ensure_built` rebuilds the image (a source change),
the running container is still the OLD image running the OLD flat
daemons so this is how a rebuild actually takes effect: a mismatch
means recreate."""
running = run_docker(["docker", "inspect", "--format", "{{.Image}}", self.name])
current = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", self.image_ref])
if running.returncode != 0 or current.returncode != 0:
return True # can't compare → don't churn a working container
return running.stdout.strip() == current.stdout.strip()
def _ensure_network(self) -> None: def _ensure_network(self) -> None:
"""Create the shared gateway network if it doesn't exist. Idempotent — """Create the shared gateway network if it doesn't exist. Idempotent —
a concurrent create loses harmlessly (the loser sees 'already exists'). a concurrent create loses harmlessly (the loser sees 'already exists').
@@ -169,17 +140,13 @@ class DockerGateway(Gateway):
) )
def ensure_running(self) -> None: def ensure_running(self) -> None:
# Recreate when the running container's image is stale (a rebuild), if self.is_running():
# so source changes to the gateway's flat daemons take effect — not
# just when the container is absent.
if self.is_running() and self._running_image_is_current():
return return
self._ensure_network() self._ensure_network()
# Clear any stale (stopped OR outdated-image) container holding the # Clear any stale (stopped) container holding the fixed name, then
# fixed name, then start fresh. `rm --force` on an absent name is a # start fresh. `rm --force` on an absent name is a tolerated no-op.
# tolerated no-op.
run_docker(["docker", "rm", "--force", self.name]) run_docker(["docker", "rm", "--force", self.name])
argv = [ proc = run_docker([
"docker", "run", "--detach", "docker", "run", "--detach",
"--name", self.name, "--name", self.name,
"--label", GATEWAY_LABEL, "--label", GATEWAY_LABEL,
@@ -187,33 +154,22 @@ class DockerGateway(Gateway):
# Persist the self-generated CA so it survives restarts (agents # Persist the self-generated CA so it survives restarts (agents
# trust it) — see GATEWAY_CA_VOLUME. # trust it) — see GATEWAY_CA_VOLUME.
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}", "--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
] self.image_ref,
if self._orchestrator_url: ])
# Makes the gateway's egress / git / supervise daemons multi-tenant:
# each request resolves source-IP -> policy against the control plane.
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
argv.append(self.image_ref)
proc = run_docker(argv)
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}") raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: def ca_cert_pem(self) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its """The gateway's CA certificate (PEM) that agents install to trust its
TLS interception. mitmproxy generates it a moment after the container TLS interception. Read from the running container; raises if the
starts, so this **polls** for it (up to `timeout`) rather than assuming gateway hasn't generated it yet (it's created on first mitmproxy
it's already there on a fresh gateway — raising only if it never start)."""
appears.""" proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
deadline = time.monotonic() + timeout if proc.returncode != 0 or not proc.stdout.strip():
while True: raise GatewayError(
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT]) f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}"
if proc.returncode == 0 and proc.stdout.strip(): )
return proc.stdout return proc.stdout
if time.monotonic() >= deadline:
raise GatewayError(
f"gateway CA cert not available after {timeout:g}s: "
f"{proc.stderr.strip() or 'empty'}"
)
time.sleep(_CA_POLL_SECONDS)
def stop(self) -> None: def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name]) proc = run_docker(["docker", "rm", "--force", self.name])
+84 -108
View File
@@ -1,145 +1,91 @@
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice). """Orchestrator process lifecycle (PRD 0070, docker slice).
Runs the orchestrator control plane **as a container** on the shared gateway Before the CLI can register or launch bottles against the consolidated
network, alongside the gateway container. This is the PRD's "virtualize the model, exactly one orchestrator control plane and the single per-host
orchestrator": container↔container between the gateway and the orchestrator gateway it manages must be running. This starts the orchestrator
avoids the host firewall (which drops containerhost traffic), and the gateway dev-harness (`python -m bot_bottle.orchestrator`) as a background host
reaches the control plane by container name over docker DNS. The host CLI process and health-checks it.
reaches it via a published loopback port.
The orchestrator runs with the **register-only broker** the *backend* It is an **idempotent singleton**: `ensure_running` returns immediately if a
launches agent containers (compose), so the orchestrator needs no docker healthy control plane already answers on the port, and otherwise spawns one
socket. That keeps this control-plane container unprivileged; the host manages and waits for it to come up. The control-plane port is the singleton key
both containers. `ensure_running` is an idempotent singleton (fixed container a second orchestrator can't bind it, so a stray double-start fails fast
names + the published port). rather than forking a rival.
Host-process (not container) on purpose: the PRD sequences the orchestrator
as a plain-process dev-harness first (fast iteration, and it already has the
host user's docker access to broker launches), while the data-plane
*gateway* it manages runs as a container. Wrapping the orchestrator itself
in a backend-native unit is a later step.
""" """
from __future__ import annotations from __future__ import annotations
import subprocess
import sys
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path
from .. import log from .. import log
from ..docker_cmd import run_docker
from ..paths import bot_bottle_root from ..paths import bot_bottle_root
from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway
DEFAULT_PORT = 8099
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
# The repo root is bind-mounted into the control-plane container so
# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator
# is stdlib-only, so the bundle image's python is enough).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_APP_DIR = "/app"
_ROOT_IN_CONTAINER = "/bot-bottle-root"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8080
# Poll cadence + default ceiling while waiting for a freshly-spawned control
# plane to answer /health (the first start also builds/boots the gateway).
_HEALTH_POLL_SECONDS = 0.25 _HEALTH_POLL_SECONDS = 0.25
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0 DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0 _HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
class OrchestratorStartError(RuntimeError): class OrchestratorStartError(RuntimeError):
"""The orchestrator container did not become healthy within the timeout.""" """The orchestrator process did not become healthy within the timeout."""
class OrchestratorService: class OrchestratorProcess:
"""Manages the orchestrator control-plane container + the shared gateway. """Manages the local orchestrator control-plane process for the docker
Callers only need `ensure_running()` + `url`.""" backend. Backend-neutral callers only need `ensure_running()` + `url`."""
def __init__( def __init__(
self, self,
*, host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK, *,
image: str = GATEWAY_IMAGE, broker: str = "docker",
repo_root: Path = _REPO_ROOT, gateway: bool = True,
host_root: Path | None = None,
) -> None: ) -> None:
self.host = host
self.port = port self.port = port
self.network = network self._broker = broker
self.image = image self._gateway = gateway
self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root()
@property @property
def url(self) -> str: def url(self) -> str:
"""Host-side control-plane URL (published loopback port).""" """The control-plane base URL — also what the data plane's
return f"http://127.0.0.1:{self.port}" BOT_BOTTLE_ORCHESTRATOR_URL points at."""
return f"http://{self.host}:{self.port}"
@property
def internal_url(self) -> str:
"""Control-plane URL as the gateway container reaches it — by name over
docker DNS on the shared network. This is the gateway's
BOT_BOTTLE_ORCHESTRATOR_URL."""
return f"http://{ORCHESTRATOR_NAME}:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool: def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
"""True iff a control plane answers `GET /health` with 200 — the
singleton liveness check."""
try: try:
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp: with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
return resp.status == 200 return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError): except (urllib.error.URLError, TimeoutError, OSError):
return False return False
def _container_running(self, name: str) -> bool:
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
return name in proc.stdout.split()
def _run_orchestrator_container(self) -> None:
"""Start the control-plane container (idempotent: clears a stale
fixed-name container first). Register-only broker no docker socket."""
run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME])
proc = run_docker([
"docker", "run", "--detach",
"--name", ORCHESTRATOR_NAME,
"--label", ORCHESTRATOR_LABEL,
"--network", self.network,
# Host CLI reaches the control plane here; bound to loopback so it
# is not exposed on the host's external interfaces.
"--publish", f"127.0.0.1:{self.port}:{self.port}",
"--volume", f"{self._repo_root}:{_APP_DIR}:ro",
"--workdir", _APP_DIR,
# Persist the registry DB on the host (sole-owner: only the
# orchestrator opens bot-bottle.db).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
"--entrypoint", "python3",
self.image,
"-m", "bot_bottle.orchestrator",
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
])
if proc.returncode != 0:
raise OrchestratorStartError(
f"orchestrator container failed to start: {proc.stderr.strip()}"
)
def _gateway(self) -> DockerGateway:
return DockerGateway(self.image, network=self.network, orchestrator_url=self.internal_url)
def ensure_running( def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS, self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str: ) -> str:
"""Ensure the control plane + shared gateway are up; return the host """Return the control-plane URL, starting the orchestrator first if it
control-plane URL. Idempotent a healthy control plane and a running isn't already healthy. Idempotent — a healthy control plane is left
gateway are left untouched. Raises `OrchestratorStartError` on untouched. Raises `OrchestratorStartError` if a freshly-spawned one
timeout.""" doesn't answer within `startup_timeout`."""
gateway = self._gateway() if self.is_healthy():
gateway.ensure_built() # rebuild the bundle image on a source change return self.url
gateway.ensure_running() # creates the shared network + (re)starts gateway log.info("starting orchestrator", context={"url": self.url, "broker": self._broker})
self._spawn()
# Always (re)create the orchestrator container. It runs the repo's code
# bind-mounted, but the Python process loaded that code at startup and
# won't reload — so reusing a healthy-but-stale container would keep
# running OLD control-plane code (e.g. dropping the tokens field). Cheap
# (~seconds); the registry DB persists and the current launch
# re-registers its own in-memory state. (The dedicated orchestrator
# image follow-up replaces this with image-staleness detection.)
log.info("starting orchestrator container", context={"name": ORCHESTRATOR_NAME})
self._run_orchestrator_container()
deadline = time.monotonic() + startup_timeout deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
if self.is_healthy(): if self.is_healthy():
@@ -147,19 +93,49 @@ class OrchestratorService:
return self.url return self.url
time.sleep(_HEALTH_POLL_SECONDS) time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError( raise OrchestratorStartError(
f"orchestrator at {self.url} did not become healthy within {startup_timeout:g}s" f"orchestrator at {self.url} did not become healthy within "
f"{startup_timeout:g}s"
) )
def stop(self) -> None: def _argv(self) -> list[str]:
"""Remove the orchestrator + gateway containers (idempotent).""" """`python -m bot_bottle.orchestrator ...` — static flags only."""
run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME]) argv = [
self._gateway().stop() sys.executable, "-m", "bot_bottle.orchestrator",
"--host", self.host, "--port", str(self.port),
"--broker", self._broker,
]
if self._gateway:
argv.append("--gateway")
return argv
def _log_path(self) -> str:
"""Where the detached orchestrator's stdout/stderr goes so a failed
start is diagnosable after the CLI has moved on."""
return str(bot_bottle_root() / "orchestrator.log")
def _spawn(self) -> None:
"""Launch the orchestrator detached so it outlives this CLI process,
with its output tee'd to a log file under the bot-bottle root."""
root = bot_bottle_root()
root.mkdir(parents=True, exist_ok=True)
logfile = open(self._log_path(), "a", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
try:
subprocess.Popen( # noqa: S603 # pylint: disable=consider-using-with
self._argv(),
stdout=logfile,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
finally:
# The child inherits its own dup'd fd; this handle is ours to drop.
logfile.close()
__all__ = [ __all__ = [
"OrchestratorService", "OrchestratorProcess",
"OrchestratorStartError", "OrchestratorStartError",
"ORCHESTRATOR_NAME", "DEFAULT_HOST",
"DEFAULT_PORT", "DEFAULT_PORT",
"DEFAULT_STARTUP_TIMEOUT_SECONDS", "DEFAULT_STARTUP_TIMEOUT_SECONDS",
] ]
+1 -14
View File
@@ -149,15 +149,7 @@ class RegistryStore(DbStore):
policy: str = "", policy: str = "",
) -> BottleRecord: ) -> BottleRecord:
"""Register (or replace) a bottle. Mints a bottle_id / identity token """Register (or replace) a bottle. Mints a bottle_id / identity token
when not supplied. Live this is the control-plane reload path. when not supplied. Live this is the control-plane reload path."""
Supersedes any *other* active bottle at the same source IP first:
`by_source_ip` fail-closes on ambiguity (>1 active row None), so a
leftover row at a reused IP from an untorn-down bottle, crash
recovery, or a persisted DB would otherwise *brick* the new bottle's
attribution. The new registration is authoritative for its IP; the
stale rows go. INSERT OR REPLACE handles a same-`bottle_id` reload in
place (its own row is exempt from the sweep)."""
rec = BottleRecord( rec = BottleRecord(
bottle_id=bottle_id or secrets.token_hex(8), bottle_id=bottle_id or secrets.token_hex(8),
source_ip=source_ip, source_ip=source_ip,
@@ -168,11 +160,6 @@ class RegistryStore(DbStore):
policy=policy, policy=policy,
) )
with self._connect() as conn: with self._connect() as conn:
conn.execute(
"DELETE FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
(rec.source_ip, rec.bottle_id),
)
conn.execute( conn.execute(
"INSERT OR REPLACE INTO orchestrator_bottles " "INSERT OR REPLACE INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) " "(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
+3 -20
View File
@@ -38,12 +38,6 @@ class Orchestrator:
self._broker = broker self._broker = broker
self._secret = sign_secret self._secret = sign_secret
self._gateway = gateway self._gateway = gateway
# Per-bottle egress auth tokens (env_name -> value), keyed by bottle_id.
# Held **in memory only** — never written to the registry DB — so the
# gateway can inject each bottle's upstream credential without secrets
# at rest. Lost on restart (re-launch re-registers them); the future
# SecretProvider (#355) replaces this with per-request minting.
self._tokens: dict[str, dict[str, str]] = {}
def launch_bottle( def launch_bottle(
self, self,
@@ -53,14 +47,11 @@ class Orchestrator:
slot: int | None = None, slot: int | None = None,
metadata: str = "", metadata: str = "",
policy: str = "", policy: str = "",
tokens: dict[str, str] | None = None,
) -> BottleRecord: ) -> BottleRecord:
"""Register a bottle (with its gateway policy + in-memory egress auth """Register a bottle (with its gateway policy) and broker its launch.
tokens) and broker its launch. Rolls the registry entry back if the Rolls the registry entry back if the launch doesn't take, so a
launch doesn't take, so a failure leaves no orphan.""" failure leaves no orphan."""
rec = self.registry.register(source_ip, metadata=metadata, policy=policy) rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
if tokens:
self._tokens[rec.bottle_id] = dict(tokens)
req = LaunchRequest( req = LaunchRequest(
op="launch", op="launch",
bottle_id=rec.bottle_id, bottle_id=rec.bottle_id,
@@ -75,7 +66,6 @@ class Orchestrator:
finally: finally:
if not launched: if not launched:
self.registry.deregister(rec.bottle_id) self.registry.deregister(rec.bottle_id)
self._tokens.pop(rec.bottle_id, None)
return rec return rec
def teardown_bottle(self, bottle_id: str) -> bool: def teardown_bottle(self, bottle_id: str) -> bool:
@@ -86,15 +76,8 @@ class Orchestrator:
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip) req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
self._broker.submit(sign_request(req, self._secret)) self._broker.submit(sign_request(req, self._secret))
self.registry.deregister(bottle_id) self.registry.deregister(bottle_id)
self._tokens.pop(bottle_id, None)
return True return True
def tokens_for(self, bottle_id: str) -> dict[str, str]:
"""The bottle's in-memory egress auth tokens (env_name -> value), or
empty. The gateway injects these per request; they are never
persisted."""
return dict(self._tokens.get(bottle_id, {}))
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None: def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution (delegates to the registry).""" """Fail-closed attribution (delegates to the registry)."""
return self.registry.attribute(source_ip, identity_token) return self.registry.attribute(source_ip, identity_token)
+8 -13
View File
@@ -102,26 +102,21 @@ class PolicyResolver:
def resolve_policy_and_bottle_id( def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "", self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None, dict[str, str]]: ) -> tuple[str | None, str | None]:
"""The policy blob, bottle id, **and per-bottle egress auth tokens** in """Both the policy blob and the bottle id in a single `/resolve` — so
a single `/resolve` so the egress addon gets everything it needs a caller that needs each (the egress addon: policy for routing, bottle
(policy for routing, bottle id for the supervise queue/safelist, tokens id to key the calling bottle's supervise queue + safelist) makes one
to inject upstream auth) in one round-trip. Returns `(None, None, {})` round-trip, not two. Returns `(None, None)` when unattributed (a clean
when unattributed (a clean `403`). Raises `PolicyResolveError` if the `403`). Raises `PolicyResolveError` if the orchestrator can't be
orchestrator can't be reached, so the caller still fails closed.""" reached, so the caller still fails closed."""
payload = self._post_resolve(source_ip, identity_token) payload = self._post_resolve(source_ip, identity_token)
if payload is None: if payload is None:
return None, None, {} return None, None
policy = payload.get("policy") policy = payload.get("policy")
bottle_id = payload.get("bottle_id") bottle_id = payload.get("bottle_id")
raw = payload.get("tokens")
tokens = {
k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)
} if isinstance(raw, dict) else {}
return ( return (
policy if isinstance(policy, str) else "", policy if isinstance(policy, str) else "",
bottle_id if isinstance(bottle_id, str) and bottle_id else None, bottle_id if isinstance(bottle_id, str) and bottle_id else None,
tokens,
) )
@@ -15,25 +15,6 @@ the biggest credential risk).
## Summary ## Summary
> **Status — implemented (2026-07-14).** This note's recommendation
> shipped, so the "today" / "in-flight" tenses below are now historical
> (kept as the reasoning that led here). Current reality: bot-bottle no
> longer injects raw provider tokens into the agent. The agent's environ
> carries only a **placeholder** (e.g.
> `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`); the real token is held
> in the egress sidecar and injected as the upstream `Authorization`
> header. Rather than adding a *separate* in-container reverse proxy (as
> proposed below), credential injection was folded into the **existing
> pipelock/mitmproxy egress firewall**: an `EgressRoute` carries
> `auth_scheme` + `token_ref`, the host token is forwarded into the
> sidecar addon under an `EGRESS_TOKEN_N` slot, and the addon rewrites
> `Authorization` on matching routes — **one MITM, not two** (this
> resolves the Infisical-doubling concern noted later). The same
> mechanism now covers Codex and Pi provider tokens and git-host PATs.
> A `printenv` inside the bottle yields the placeholder, not the secret.
> For current wiring see `bot_bottle/egress.py` and
> `bot_bottle/contrib/*/agent_provider.py`.
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
any `bottle.env` secrets like a Gitea PAT) injected as env vars, any `bottle.env` secrets like a Gitea PAT) injected as env vars,
which means the agent process can read them with `printenv` or which means the agent process can read them with `printenv` or
@@ -55,24 +36,6 @@ small bot-bottle-specific reverse proxy modeled on the
phantom-token shape is probably the right call. For Gitea / GitHub / phantom-token shape is probably the right call. For Gitea / GitHub /
GitLab, the same proxy generalizes by config. GitLab, the same proxy generalizes by config.
**Updated 2026-07-14:** OneCLI ([onecli.sh](https://onecli.sh/)) —
already listed below — has matured into a GA, YC-backed Rust
credential gateway ("The Identity Gateway for AI Agents", ~2.5k⭐,
300k+ downloads) that implements the **same phantom-token pattern**
this note recommends: the agent holds a placeholder token, the
gateway swaps it for the real (AES-256-GCM-encrypted) credential at
request time. It's now the most mature open-source realization of the
exact design proposed here — a production-ready alternative to
alpha-stage nono — at the cost of being a broader product (built-in
vault + management dashboard + hosted cloud tier + 50+ app
integrations) rather than a minimal proxy. Its managed/cloud tier and
per-agent dashboard also overlap bot-bottle's own planned paid control
plane (bot-bottle-console, issue #327), so it's worth tracking as both
a build-vs-adopt option *and* a product-level competitor. The
build-first recommendation still stands (see synthesis below), but
adopting OneCLI's OSS core is now a credible alternative where nono was
too green.
## The shared problem ## The shared problem
Linux has no per-env-var ACL. Once a var is in a process's Linux has no per-env-var ACL. Once a var is in a process's
@@ -114,9 +77,7 @@ The remaining credible designs reduce to three:
### Anthropic / Claude Code ### Anthropic / Claude Code
**Original wiring** (pre-gateway; **superseded 2026-07-14** — see the **Today's wiring** (`bot_bottle/cli/start.py`): the host's
Status note in the Summary; the token now lives in the egress sidecar
and the agent gets a placeholder): the host's
`BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN` `CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
(no `=value`, so the value never lands on argv — good). Inside the (no `=value`, so the value never lands on argv — good). Inside the
@@ -262,7 +223,7 @@ Two categories:
| **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ | | **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ |
| **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes**`BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ | | **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes**`BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ |
| **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ | | **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ |
| **OneCLI** | B | Apache-2.0 | Reverse proxy + built-in vault + dashboard | **Phantom token** (placeholder→real swap at request time), AES-256-GCM vault | Yes (host match) | Per-agent + endpoint/method + rate limits | GA; Rust; YC-backed; ~2.5k⭐, 300k+ downloads (Jul 2026) | | **OneCLI** | B | Apache-2.0 | Reverse proxy + management UI | Host/path matching, Bitwarden integration | Configurable | Per-agent scoping | Active; v1.23.0 May 2026, 2.1k⭐ |
| **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) | | **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) |
| **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 | | **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 |
| **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host | | **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host |
@@ -281,18 +242,6 @@ Two categories:
`ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly `ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly
"early alpha, not security audited." "early alpha, not security audited."
**Update (2026-07-14):** OneCLI ([onecli.sh](https://onecli.sh/))
ships the same phantom-token shape but is GA and YC-backed (Rust,
~2.5k⭐, 300k+ downloads) — the maturity nono lacks. Trade-offs: it's
a full identity-gateway *product* (encrypted vault, management
dashboard, 50+ one-click app integrations, hosted cloud tier), much
heavier than the ~100-line proxy proposed below, and its hosted tier
is a third-party credential custodian — precisely the trust
dependency bot-bottle's isolation model exists to avoid. So: its
Apache-2.0 OSS core is a credible *adopt* candidate for the
phantom-token slice; its managed offering is a *competitor*, not a
dependency to lean on.
- **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare - **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare
Sandbox Auth, Aembit, the existing pipelock) all double up on Sandbox Auth, Aembit, the existing pipelock) all double up on
the CA-trust machinery PRD 0006 already built for pipelock. the CA-trust machinery PRD 0006 already built for pipelock.
@@ -324,15 +273,6 @@ routing matches the design recommended here exactly; zero TLS
work. But "not security audited" + "early alpha" means adopting it work. But "not security audited" + "early alpha" means adopting it
is a bet on the project rather than a buy-vs-build win. is a bet on the project rather than a buy-vs-build win.
**Mature phantom-token option (added 2026-07-14):** OneCLI — same
architecture as nono, but GA, Rust, and YC-backed. Its Apache-2.0 OSS
core is now the strongest *adopt* candidate for the phantom-token slice
if building is undesirable; the caveats are product surface you don't
need (bundled vault + dashboard) and that its hosted tier is a
competitor rather than a dependency. Doesn't change the build-first
recommendation for the narrow Anthropic-token slice, but it does mean
"is there a mature drop-in?" now has a real answer.
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT, **Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
v0.19.0 active, v0.17.0 added a containerized agent mode that v0.19.0 active, v0.17.0 added a containerized agent mode that
maps directly to bot-bottle. Friction is the TLS-MITM topology maps directly to bot-bottle. Friction is the TLS-MITM topology
@@ -353,12 +293,6 @@ exposed.
## Recommended path forward ## Recommended path forward
> **Mostly implemented (2026-07-14).** Steps 13 shipped — the token
> injection moved into the egress sidecar (folded into pipelock rather
> than a standalone reverse proxy) and generalized to Codex/Pi/git-host
> tokens. Steps 45 (issuance-side scope narrowing, per-route allowlists)
> remain good hygiene. See the Status note in the Summary.
In priority order: In priority order:
1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.** 1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.**
@@ -442,9 +376,6 @@ already gives us for upstream push credentials.
- [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection) - [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection)
- [Aegis — GitHub](https://github.com/getaegis/aegis) - [Aegis — GitHub](https://github.com/getaegis/aegis)
- [OneCLI — GitHub](https://github.com/onecli/onecli) - [OneCLI — GitHub](https://github.com/onecli/onecli)
- [OneCLI — homepage](https://onecli.sh/)
- [OneCLI — Y Combinator company page](https://www.ycombinator.com/companies/onecli)
- [Show HN: OneCLI Vault for AI Agents in Rust](https://news.ycombinator.com/item?id=47353558)
- [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0) - [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0)
- [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom) - [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom)
- [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/) - [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/)
@@ -71,56 +71,6 @@ manifest merge.
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
is persisted to SQLite. is persisted to SQLite.
- **OneCLI** ([onecli.sh](https://onecli.sh/)) — YC-backed, GA, open-source
(Apache-2.0, Rust) "identity gateway for AI agents": a credential/secret
broker that holds API keys and OAuth tokens out of the agent's reach and
injects them at the network layer (phantom-token — the agent sees a
placeholder, the gateway swaps in the real, AES-256-GCM-encrypted credential
at request time). Framework-agnostic and drop-in for any HTTP-calling agent,
50+ app integrations, plus a hosted cloud tier with a per-agent dashboard and
audit logs. Full technical breakdown in
[`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md).
**How close a competitor:** near-exact on the *single axis of agent secret
custody* — the exact thing bot-bottle sells as "the agent never sees real
credentials, even via `printenv`." OneCLI does that one job well, is mature
and funded, and is *more portable* (it sits in front of anything; bot-bottle
only helps agents launched through bot-bottle). Takeaway: bot-bottle should
stop treating secret custody as a *unique* differentiator. But OneCLI is
**not** a competitor to bot-bottle's actual product — it does no agent
sandboxing (containers/microVMs), no fleet/manifest layer, no named agents /
skills / per-agent system prompts, no multi-provider launching, no egress
firewall.
**Our edge:** (1) *Isolation is the product, not a proxy.* OneCLI keeps the
key out of reach at the network layer, but the agent itself still runs
unsandboxed — a hijacked agent behind OneCLI has full run of its host and can
exfil captured data through any allowed host. bot-bottle runs the agent inside
a kernel/VM-enforced sandbox, injects credentials across that same
out-of-process boundary, *and* clamps egress with pipelock — defense in depth
vs. a single network layer. (2) *Fleet + manifest model* with named agents,
skills, per-agent system prompts, multi-provider and multi-backend — OneCLI
has no equivalent. (3) *Trust posture:* OneCLI's managed tier reintroduces a
third-party credential custodian, whereas bot-bottle's OSS-runtime +
paid-control-plane split keeps custody inside the operator's own boundary —
the stronger story for the security-minded self-hoster. (4) *Runs inside your
network boundary — local/internal reach.* Because bot-bottle executes the
agent on your own host (homelab, corporate LAN, a Tailnet) and egress is a
manifest field, giving an agent *scoped* access to **internal** resources — a
private Gitea, a LAN database, a Tailscale node — is just another egress-route
line, not a networking project (the same move an operator already makes to
reach their Tailscale services). OneCLI's OSS core can self-host too, but it's
a credential *broker* for outbound API calls, not an agent runtime, and its
managed tier + 50+ integrations are oriented at public SaaS — it doesn't put
the agent behind your firewall for you. This is a reach advantage, distinct
from the isolation ones above, and it's a wedge cloud-first agent products
(Devin, Copilot Workspace, OneCLI Cloud) structurally can't match. **Tactical
read:**
adopt OneCLI's OSS core for the credential slice if building is undesirable
(it's mature now); don't build atop its managed tier (competitor, not
dependency); re-position bot-bottle on isolation + fleet + self-hosted custody
rather than "we hide your secrets."
## What no found project does ## What no found project does
None combine: None combine:
@@ -1,161 +0,0 @@
"""Integration: two bottles share one gateway; source-IP attribution keeps
their egress tokens *and* allowlists isolated (PRD 0070 multi-tenancy).
The whole premise of the consolidation is one shared gateway for every
bottle, isolated only by the unspoofable source IP. A single-bottle test
can't catch cross-bottle token bleed or an allowlist leak — this brings up
the real orchestrator + gateway and drives two bottles through the one
gateway to prove each gets exactly its own token and its own routes.
Gated on a reachable Docker daemon and builds the bundle image, so it runs
with the other docker integration tests, not in unit CI. Uses the real
fixed gateway/orchestrator names (that's what it's testing), so it can't run
concurrently with a live per-host gateway; it points the orchestrator at a
throwaway BOT_BOTTLE_ROOT for a clean registry and tears everything down.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend.docker.consolidated_launch import (
_network_cidr,
_network_container_ips,
)
from bot_bottle.backend.docker.egress import EGRESS_PORT
from bot_bottle.backend.docker.gateway_net import next_free_ip
from bot_bottle.orchestrator.client import OrchestratorClient
from bot_bottle.orchestrator.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK
from bot_bottle.orchestrator.lifecycle import OrchestratorService
from tests._docker import skip_unless_docker
# One upstream reachable under two names; reflects the Authorization header so
# the probe can see exactly what the gateway injected (or didn't).
_ECHO_NAME = "bot-bottle-mtitest-echo"
_ECHO_ALIASES = ("echo-shared", "echo-bonly")
_ECHO_SRC = (
"from http.server import BaseHTTPRequestHandler, HTTPServer\n"
"class H(BaseHTTPRequestHandler):\n"
" def do_GET(s):\n"
" a=s.headers.get('Authorization','NONE'); s.send_response(200); s.end_headers()\n"
" s.wfile.write(('AUTH='+a).encode())\n"
" def log_message(s,*a): pass\n"
"HTTPServer(('0.0.0.0',80),H).serve_forever()\n"
)
# A: echo-shared only, authed. B: echo-shared authed + echo-bonly open.
_POLICY_A = (
'routes:\n'
' - host: echo-shared\n'
' auth_scheme: "Bearer"\n'
' token_env: "EGRESS_TOKEN_0"\n'
)
_POLICY_B = _POLICY_A + ' - host: echo-bonly\n'
_TOKEN_A = "sk-AAA-alice"
_TOKEN_B = "sk-BBB-bob"
# Client probe: open http://<host>/ through the gateway proxy from a container
# pinned to the bottle's source IP, printing "<status> <body>". Uses only the
# bundle image's python3 — no dependency on the agent image.
_PROBE_SRC = (
"import sys,urllib.request,urllib.error\n"
"op=urllib.request.build_opener(urllib.request.ProxyHandler({'http':sys.argv[1]}))\n"
"try:\n"
" r=op.open('http://'+sys.argv[2]+'/',timeout=8)\n"
" sys.stdout.write(str(r.status)+' '+r.read().decode())\n"
"except urllib.error.HTTPError as e:\n"
" sys.stdout.write(str(e.code)+' '+e.read().decode())\n"
)
@skip_unless_docker()
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: the orchestrator container bind-mounts the repo "
"path into a container on the socket-shared host daemon, which can't see the "
"runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests",
)
class TestMultitenantIsolation(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
# Throwaway root → a clean registry DB, independent of the host's.
self.svc = OrchestratorService(host_root=Path(self._tmp.name))
self.addCleanup(self._teardown_docker)
# ensure_running builds the bundle image (slow on a cold cache) and
# brings up the shared network + gateway + orchestrator.
url = self.svc.ensure_running(startup_timeout=300)
self.client = OrchestratorClient(url)
self.gw_ip = self._container_ip(GATEWAY_NAME)
self._run_echo()
def _teardown_docker(self) -> None:
subprocess.run(["docker", "rm", "--force", _ECHO_NAME],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
self.svc.stop()
subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
# The orchestrator container wrote the registry DB as root into the
# throwaway root; chown it back so the (non-root) tempdir cleanup can
# remove it.
subprocess.run(
["docker", "run", "--rm", "-v", f"{self._tmp.name}:/r",
"--entrypoint", "chown", GATEWAY_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
@staticmethod
def _container_ip(name: str) -> str:
proc = subprocess.run(
["docker", "inspect", "-f",
'{{(index .NetworkSettings.Networks "' + GATEWAY_NETWORK + '").IPAddress}}', name],
stdout=subprocess.PIPE, text=True, check=True,
)
return proc.stdout.strip()
def _run_echo(self) -> None:
argv = ["docker", "run", "--detach", "--name", _ECHO_NAME,
"--network", GATEWAY_NETWORK]
for alias in _ECHO_ALIASES:
argv += ["--network-alias", alias]
argv += ["--entrypoint", "python3", GATEWAY_IMAGE, "-c", _ECHO_SRC]
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, check=False)
self.assertEqual(0, proc.returncode, f"echo upstream failed: {proc.stderr}")
def _free_ip(self, extra_taken: list[str]) -> str:
taken = _network_container_ips(GATEWAY_NETWORK) + extra_taken
return next_free_ip(_network_cidr(GATEWAY_NETWORK), taken)
def _probe(self, source_ip: str, host: str) -> str:
proc = subprocess.run(
["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip,
"--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC,
f"http://{self.gw_ip}:{EGRESS_PORT}", host],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, timeout=90,
)
return proc.stdout.strip()
def test_two_bottles_share_gateway_with_isolated_tokens_and_allowlists(self) -> None:
ip_a = self._free_ip([])
ip_b = self._free_ip([ip_a])
self.client.register_bottle(ip_a, policy=_POLICY_A, tokens={"EGRESS_TOKEN_0": _TOKEN_A})
self.client.register_bottle(ip_b, policy=_POLICY_B, tokens={"EGRESS_TOKEN_0": _TOKEN_B})
# Each bottle gets its OWN token injected on the shared route — no bleed.
self.assertEqual(f"200 AUTH=Bearer {_TOKEN_A}", self._probe(ip_a, "echo-shared"))
self.assertEqual(f"200 AUTH=Bearer {_TOKEN_B}", self._probe(ip_b, "echo-shared"))
# Allowlist is per-bottle: echo-bonly is only in B's policy.
self.assertTrue(self._probe(ip_a, "echo-bonly").startswith("403"), # fail-closed for A
"A reached a host outside its allowlist")
self.assertEqual("200 AUTH=NONE", self._probe(ip_b, "echo-bonly")) # allowed, unauthed for B
if __name__ == "__main__":
unittest.main()
+8 -8
View File
@@ -40,7 +40,7 @@ class TestGetBottleBackend(unittest.TestCase):
return True return True
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod, "_backends", { patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend(), "macos-container": _FakeBackend(),
"docker": _FakeBackend(), "docker": _FakeBackend(),
}): }):
@@ -61,7 +61,7 @@ class TestGetBottleBackend(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod.FirecrackerBottleBackend, patch.object(backend_mod.FirecrackerBottleBackend,
"is_host_capable", classmethod(lambda cls: False)), \ "is_host_capable", classmethod(lambda cls: False)), \
patch.object(backend_mod, "_backends", { patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend("macos-container", False), "macos-container": _FakeBackend("macos-container", False),
"docker": _FakeBackend("docker", True), "docker": _FakeBackend("docker", True),
}): }):
@@ -83,7 +83,7 @@ class TestGetBottleBackend(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod.FirecrackerBottleBackend, patch.object(backend_mod.FirecrackerBottleBackend,
"is_host_capable", classmethod(lambda cls: True)), \ "is_host_capable", classmethod(lambda cls: True)), \
patch.object(backend_mod, "_backends", { patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend("macos-container", False), "macos-container": _FakeBackend("macos-container", False),
"firecracker": _FakeBackend("firecracker", False), "firecracker": _FakeBackend("firecracker", False),
"docker": _FakeBackend("docker", True), "docker": _FakeBackend("docker", True),
@@ -133,7 +133,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
return self._items return self._items
with patch.object( with patch.object(
backend_mod, "_backends", backend_mod, "_BACKENDS",
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])}, {"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
): ):
self.assertEqual([a, b], enumerate_active_agents()) self.assertEqual([a, b], enumerate_active_agents())
@@ -167,7 +167,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
return self._items return self._items
with patch.object( with patch.object(
backend_mod, "_backends", backend_mod, "_BACKENDS",
{ {
"docker": _FakeBackend([newer, tie_b]), "docker": _FakeBackend([newer, tie_b]),
"firecracker": _FakeBackend([missing_metadata, tie_a]), "firecracker": _FakeBackend([missing_metadata, tie_a]),
@@ -187,7 +187,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
return [] return []
with patch.object( with patch.object(
backend_mod, "_backends", backend_mod, "_BACKENDS",
{"docker": _FakeBackend(), "firecracker": _FakeBackend()}, {"docker": _FakeBackend(), "firecracker": _FakeBackend()},
): ):
self.assertEqual([], enumerate_active_agents()) self.assertEqual([], enumerate_active_agents())
@@ -218,7 +218,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
return self._items return self._items
with patch.object( with patch.object(
backend_mod, "_backends", backend_mod, "_BACKENDS",
{ {
"docker": _FakeBackend([present], available=True), "docker": _FakeBackend([present], available=True),
"firecracker": _FakeBackend([hidden], available=False), "firecracker": _FakeBackend([hidden], available=False),
@@ -234,7 +234,7 @@ class TestHasBackend(unittest.TestCase):
return False return False
with patch.object( with patch.object(
backend_mod, "_backends", {"docker": _FakeBackend()}, backend_mod, "_BACKENDS", {"docker": _FakeBackend()},
): ):
from bot_bottle.backend import has_backend from bot_bottle.backend import has_backend
self.assertFalse(has_backend("docker")) self.assertFalse(has_backend("docker"))
+8 -12
View File
@@ -39,18 +39,14 @@ def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock:
class TestLaunchConsolidated(unittest.TestCase): class TestLaunchConsolidated(unittest.TestCase):
def _run( def _run(self, client: Mock, provision: Mock | None = None):
self, client: Mock, provision: Mock | None = None, process = MagicMock()
*, on_network: tuple[str, ...] = ("172.18.0.2",), process.ensure_running.return_value = "http://orch:8080"
):
service = MagicMock()
service.ensure_running.return_value = "http://orch:8080"
with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \ with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \
patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \ patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \
patch(f"{_MOD}._network_container_ips", return_value=list(on_network)), \
patch(f"{_MOD}.OrchestratorClient", return_value=client), \ patch(f"{_MOD}.OrchestratorClient", return_value=client), \
patch(f"{_MOD}.provision_git_gate", provision or Mock()): patch(f"{_MOD}.provision_git_gate", provision or Mock()):
return launch_consolidated(_egress_plan(), _git_plan(), service=service) return launch_consolidated(_egress_plan(), _git_plan(), process=process)
def test_allocates_ip_registers_and_provisions(self) -> None: def test_allocates_ip_registers_and_provisions(self) -> None:
client = _client() client = _client()
@@ -68,10 +64,10 @@ class TestLaunchConsolidated(unittest.TestCase):
self.assertIn("api.example.com", kwargs.kwargs["policy"]) self.assertIn("api.example.com", kwargs.kwargs["policy"])
provision.assert_called_once() provision.assert_called_once()
def test_skips_all_addresses_on_the_network(self) -> None: def test_skips_gateway_and_live_bottle_addresses(self) -> None:
# Gateway .2 + orchestrator .3 already attached -> agent gets .4. client = _client(bottles=[{"source_ip": "172.18.0.3"}])
ctx = self._run(_client(), on_network=("172.18.0.2", "172.18.0.3")) ctx = self._run(client)
self.assertEqual("172.18.0.4", ctx.source_ip) self.assertEqual("172.18.0.4", ctx.source_ip) # .2 gw, .3 taken → .4
def test_provision_failure_rolls_back_registration(self) -> None: def test_provision_failure_rolls_back_registration(self) -> None:
client = _client() client = _client()
+113 -64
View File
@@ -1,4 +1,4 @@
"""Unit: Docker launch step uses committed image when available (consolidated).""" """Unit: Docker launch step uses committed image when available."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,6 @@ import contextlib
import io import io
import tempfile import tempfile
import unittest import unittest
from collections.abc import Callable, Generator
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest import mock from unittest import mock
@@ -15,11 +14,9 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests.unit import use_bottle_root
_SLUG = "dev-abc12" _SLUG = "dev-abc12"
@@ -31,111 +28,163 @@ _IDX = ManifestIndex.from_json_obj({
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
_CTX = LaunchContext(
bottle_id="b1", identity_token="t", source_ip="172.20.0.4",
network="bot-bottle-gateway", gateway_ip="172.20.0.2",
orchestrator_url="http://orch:8099",
)
def _plan(tmp: str) -> DockerBottlePlan: def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
spec = BottleSpec( spec = BottleSpec(
manifest=_IDX, agent_name="demo", copy_cwd=False, user_cwd=tmp, identity=_SLUG, manifest=_IDX,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
identity=_SLUG,
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=_IDX.load_for_agent("demo"), manifest=_IDX.load_for_agent("demo"),
stage_dir=stage, stage_dir=stage,
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug=_SLUG, entrypoint_script=stage / "e.sh", hook_script=stage / "h.sh", slug=_SLUG,
access_hook_script=stage / "a.sh", upstreams=(), entrypoint_script=stage / "entrypoint.sh",
hook_script=stage / "hook.sh",
access_hook_script=stage / "access-hook.sh",
upstreams=(),
), ),
egress_plan=EgressPlan( egress_plan=EgressPlan(
slug=_SLUG, routes_path=stage / "egress.yaml", routes=(), token_env_map={}, slug=_SLUG,
routes_path=stage / "egress.yaml",
routes=(),
token_env_map={},
), ),
supervise_plan=None, supervise_plan=None,
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", template="claude",
image=_DEFAULT_IMAGE, dockerfile="", guest_home="/home/node", command="claude",
instance_name=f"bot-bottle-{_SLUG}", prompt_file=stage / "prompt.txt", prompt_mode="append_file",
image=_DEFAULT_IMAGE,
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{_SLUG}",
prompt_file=stage / "prompt.txt",
guest_env={}, guest_env={},
), ),
slug=_SLUG, forwarded_env={}, use_runsc=False, slug=_SLUG,
forwarded_env={},
use_runsc=False,
) )
class TestLaunchCommittedImage(unittest.TestCase): class TestLaunchCommittedImage(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.") self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True))
# The launch writes the gateway CA under the bottle root — sandbox it.
self.addCleanup(use_bottle_root(Path(self._tmp)))
@contextlib.contextmanager def tearDown(self) -> None:
def _patched( import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def _run_launch(
self, self,
plan: DockerBottlePlan,
*, *,
committed_tag: str | None, committed_tag: str | None = None,
image_present: bool, image_present: bool = True,
compose: Callable[..., dict[str, Any]], ) -> list[str]:
) -> Generator[list[str], None, None]: """Drive launch() through its full sequence with the committed-image
behaviour controlled by the arguments. Returns the images that were
passed to `build_image` (empty list if it was never called)."""
built: list[str] = [] built: list[str] = []
gw = mock.Mock()
gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
def _build(image: str, ctx: str, *, dockerfile: str = "") -> None: def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
del ctx, dockerfile del ctx, dockerfile
built.append(image) built.append(image)
with mock.patch.object(launch_mod, "read_committed_image", return_value=committed_tag), \ with mock.patch.object(
mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \ launch_mod, "read_committed_image", return_value=committed_tag,
mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \ ), mock.patch.object(
mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \ launch_mod.docker_mod, "image_exists", return_value=image_present,
mock.patch.object(launch_mod, "teardown_consolidated"), \ ), mock.patch.object(
mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \ launch_mod.docker_mod, "build_image", side_effect=fake_build,
mock.patch.object(launch_mod, "consolidated_agent_compose", side_effect=compose), \ ), mock.patch.object(
mock.patch.object(launch_mod, "write_compose_file", return_value=Path("/tmp/c.yml")), \ launch_mod, "egress_tls_init",
mock.patch.object(launch_mod, "compose_up"), \ return_value=(Path("/egress_ca"), Path("/egress_cert")),
mock.patch.object(launch_mod, "compose_dump_logs"), \ ), mock.patch.object(
mock.patch.object(launch_mod, "compose_down"), \ launch_mod.network_mod, "network_name_for_slug",
contextlib.redirect_stderr(io.StringIO()): return_value="bb-internal",
yield built ), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
def _run_launch( return_value="bb-egress",
self, plan: DockerBottlePlan, *, ), mock.patch.object(
committed_tag: str | None = None, image_present: bool = True, launch_mod, "bottle_plan_to_compose",
) -> list[str]: return_value={"services": {"agent": {}}},
def compose(_p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]: ), mock.patch.object(
return {"services": {"agent": {}}} launch_mod, "write_compose_file",
with self._patched( return_value=Path("/tmp/compose.yml"),
committed_tag=committed_tag, image_present=image_present, compose=compose, ), mock.patch.object(launch_mod, "compose_up"), \
) as built: mock.patch.object(launch_mod, "compose_dump_logs"), \
with launch_mod.launch(plan, provision=mock.Mock(return_value=None)): mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass pass
return built return built
def test_skips_build_when_committed_image_present(self) -> None: def test_skips_build_when_committed_image_present(self) -> None:
built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=True) plan = _plan(self._tmp)
self.assertEqual([], built) # committed image reused, no build built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
self.assertEqual([], built, "build_image should not be called when committed image exists")
def test_uses_committed_image_in_compose_spec(self) -> None: def test_uses_committed_image_in_compose_spec(self) -> None:
captured: list[DockerBottlePlan] = [] """The compose spec renderer receives the committed image tag via
plan.image captured here by checking what bottle_plan_to_compose
was called with."""
plan = _plan(self._tmp)
captured_plans: list[DockerBottlePlan] = []
def compose(p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]: def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
captured.append(p) captured_plans.append(p)
return {"services": {"agent": {}}} return {"services": {"agent": {}}}
with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose): with mock.patch.object(
with launch_mod.launch(_plan(self._tmp), provision=mock.Mock(return_value=None)): launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=True,
), mock.patch.object(
launch_mod.docker_mod, "build_image",
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass pass
self.assertEqual(_COMMITTED_TAG, captured[0].image)
self.assertEqual(1, len(captured_plans))
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
def test_falls_back_to_build_when_no_committed_image(self) -> None: def test_falls_back_to_build_when_no_committed_image(self) -> None:
self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None)) plan = _plan(self._tmp)
built = self._run_launch(plan, committed_tag=None)
self.assertEqual([_DEFAULT_IMAGE], built)
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None: def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False) plan = _plan(self._tmp)
built = self._run_launch(
plan, committed_tag=_COMMITTED_TAG, image_present=False,
)
self.assertEqual([_DEFAULT_IMAGE], built) self.assertEqual([_DEFAULT_IMAGE], built)
+20 -17
View File
@@ -19,11 +19,9 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests.unit import use_bottle_root
_INDEX = ManifestIndex.from_json_obj({ _INDEX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
@@ -79,36 +77,41 @@ def _plan(tmp: str) -> DockerBottlePlan:
class TestTeardownWarning(unittest.TestCase): class TestTeardownWarning(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.") self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.")
self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True))
self.addCleanup(use_bottle_root(Path(self._tmp))) # sandbox the gateway CA write def tearDown(self) -> None:
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def test_teardown_failure_emits_warning_with_container_and_operation(self): def test_teardown_failure_emits_warning_with_container_and_operation(self):
plan = _plan(self._tmp) plan = _plan(self._tmp)
buf = io.StringIO() buf = io.StringIO()
gw = mock.Mock()
gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
ctx = LaunchContext(
bottle_id="b1", identity_token="t", source_ip="172.20.0.4",
network="bot-bottle-gateway", gateway_ip="172.20.0.2",
orchestrator_url="http://orch:8099",
)
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \
mock.patch.object(launch_mod, "teardown_consolidated"), \
mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \
mock.patch.object( mock.patch.object(
launch_mod, "consolidated_agent_compose", launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), \
mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal-test",
), \
mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress-test",
), \
mock.patch.object(
launch_mod, "bottle_plan_to_compose",
return_value={"services": {"agent": {}}}, return_value={"services": {"agent": {}}},
), \ ), \
mock.patch.object( mock.patch.object(
launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"), launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), \ ), \
mock.patch.object(launch_mod, "compose_up"), \ mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \ mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object( mock.patch.object(
launch_mod, "compose_down", launch_mod, "compose_down",
side_effect=RuntimeError("compose down failed"), side_effect=RuntimeError("network remove failed"),
), \ ), \
contextlib.redirect_stderr(buf): contextlib.redirect_stderr(buf):
provision = mock.Mock(return_value=None) provision = mock.Mock(return_value=None)
+3 -3
View File
@@ -29,7 +29,7 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
class TestCommitContainer(unittest.TestCase): class TestCommitContainer(unittest.TestCase):
def test_runs_docker_commit(self): def test_runs_docker_commit(self):
with patch.object( with patch.object(
docker_mod, "run_docker", return_value=_ok(), docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"): ) as run, patch.object(docker_mod, "info"):
docker_mod.commit_container( docker_mod.commit_container(
"bot-bottle-dev-abc12", "bot-bottle-dev-abc12",
@@ -47,7 +47,7 @@ class TestCommitContainer(unittest.TestCase):
def test_dies_on_docker_commit_failure(self): def test_dies_on_docker_commit_failure(self):
with patch.object( with patch.object(
docker_mod, "run_docker", return_value=_fail("No such container"), docker_mod.subprocess, "run", return_value=_fail("No such container"),
), patch.object( ), patch.object(
docker_mod, "die", side_effect=SystemExit("die"), docker_mod, "die", side_effect=SystemExit("die"),
) as die: ) as die:
@@ -58,7 +58,7 @@ class TestCommitContainer(unittest.TestCase):
def test_die_message_includes_image_tag(self): def test_die_message_includes_image_tag(self):
with patch.object( with patch.object(
docker_mod, "run_docker", return_value=_fail("boom"), docker_mod.subprocess, "run", return_value=_fail("boom"),
), patch.object( ), patch.object(
docker_mod, "die", side_effect=SystemExit("die"), docker_mod, "die", side_effect=SystemExit("die"),
) as die: ) as die:
+3 -46
View File
@@ -233,19 +233,16 @@ class _CtxResolver:
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the """Fake orchestrator resolver: maps source IP -> bottle id, and grants the
same allow-list to any attributed bottle (unattributed -> deny).""" same allow-list to any attributed bottle (unattributed -> deny)."""
def __init__( def __init__(self, ip_to_bottle: dict[str, str]) -> None:
self, ip_to_bottle: dict[str, str], tokens: dict[str, str] | None = None,
) -> None:
self._map = ip_to_bottle self._map = ip_to_bottle
self._tokens = tokens or {}
def resolve_policy_and_bottle_id( def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "", self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None, dict[str, str]]: ) -> tuple[str | None, str | None]:
del identity_token del identity_token
bottle_id = self._map.get(source_ip) bottle_id = self._map.get(source_ip)
policy = "routes:\n - host: api.example.com\n" if bottle_id else None policy = "routes:\n - host: api.example.com\n" if bottle_id else None
return policy, bottle_id, (self._tokens if bottle_id else {}) return policy, bottle_id
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -806,46 +803,6 @@ class TestSuperviseMultiTenant(unittest.TestCase):
_run_request(addon, flow) _run_request(addon, flow)
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
def test_auth_token_injected_from_resolved_tokens(self) -> None:
# The bottle's upstream token comes from /resolve (in-memory on the
# orchestrator), NOT the gateway's env — the request is forwarded with
# Authorization injected. This is the token-injection cut-over fix.
policy = (
'routes:\n - host: api.example.com\n'
' auth_scheme: "Bearer"\n token_env: "EGRESS_TOKEN_0"\n'
)
class _AuthResolver:
def resolve_policy_and_bottle_id(self, ip: str, identity_token: str = ""):
del ip, identity_token
return policy, "bottle-a", {"EGRESS_TOKEN_0": "sk-secret"}
addon = _addon(Config(routes=()))
addon._resolver = cast(Any, _AuthResolver())
flow = _with_client_ip(_Flow(_Request(host="api.example.com", method="GET")), "10.0.0.1")
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded, not blocked
self.assertEqual("Bearer sk-secret", flow.request.headers.get("authorization"))
def test_authed_route_without_token_is_blocked(self) -> None:
# No token resolved for the bottle → the authed route can't inject, so
# the request is blocked (the error the cut-over originally produced).
policy = (
'routes:\n - host: api.example.com\n'
' auth_scheme: "Bearer"\n token_env: "EGRESS_TOKEN_0"\n'
)
class _NoTokenResolver:
def resolve_policy_and_bottle_id(self, ip: str, identity_token: str = ""):
del ip, identity_token
return policy, "bottle-a", {} # no tokens
addon = _addon(Config(routes=()))
addon._resolver = cast(Any, _NoTokenResolver())
flow = _with_client_ip(_Flow(_Request(host="api.example.com", method="GET")), "10.0.0.1")
_run_request(addon, flow)
self.assertIsNotNone(flow.response) # blocked — token unset
def test_unattributed_source_ip_cannot_supervise(self) -> None: def test_unattributed_source_ip_cannot_supervise(self) -> None:
addon = self._consolidated_addon() addon = self._consolidated_addon()
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug. # 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
+10 -20
View File
@@ -51,55 +51,45 @@ class TestResolveClientConfig(unittest.TestCase):
class _FakeContextResolver: class _FakeContextResolver:
def __init__( def __init__(
self, policy: str | None = None, bottle_id: str | None = None, self, policy: str | None = None, bottle_id: str | None = None, raises: bool = False,
raises: bool = False, tokens: dict[str, str] | None = None,
) -> None: ) -> None:
self._policy = policy self._policy = policy
self._bottle_id = bottle_id self._bottle_id = bottle_id
self._raises = raises self._raises = raises
self._tokens = tokens or {}
def resolve_policy_and_bottle_id( def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "", self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None, dict[str, str]]: ) -> tuple[str | None, str | None]:
if self._raises: if self._raises:
raise PolicyResolveError("orchestrator down") raise PolicyResolveError("orchestrator down")
return self._policy, self._bottle_id, self._tokens return self._policy, self._bottle_id
class TestResolveClientContext(unittest.TestCase): class TestResolveClientContext(unittest.TestCase):
def test_returns_config_bottle_id_and_tokens(self) -> None: def test_returns_config_and_bottle_id(self) -> None:
cfg, slug, tokens = resolve_client_context( cfg, slug = resolve_client_context(
_FakeContextResolver( _FakeContextResolver(policy="routes:\n - host: example.com\n", bottle_id="b1"),
policy="routes:\n - host: example.com\n", bottle_id="b1",
tokens={"EGRESS_TOKEN_0": "sekret"},
),
"10.243.0.1", "10.243.0.1",
) )
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes)) self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
self.assertEqual("b1", slug) self.assertEqual("b1", slug)
self.assertEqual({"EGRESS_TOKEN_0": "sekret"}, tokens) # for auth injection
def test_unattributed_denies_and_empty_slug(self) -> None: def test_unattributed_denies_and_empty_slug(self) -> None:
cfg, slug, tokens = resolve_client_context( cfg, slug = resolve_client_context(
_FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9", _FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9",
) )
self.assertEqual((), cfg.routes) self.assertEqual((), cfg.routes)
self.assertEqual("", slug) # no bottle → supervise unavailable self.assertEqual("", slug) # no bottle → supervise unavailable
self.assertEqual({}, tokens)
def test_resolver_error_denies_empty_slug_no_tokens(self) -> None: def test_resolver_error_denies_and_empty_slug(self) -> None:
cfg, slug, tokens = resolve_client_context( cfg, slug = resolve_client_context(_FakeContextResolver(raises=True), "10.243.0.1")
_FakeContextResolver(raises=True), "10.243.0.1",
)
self.assertEqual((), cfg.routes) self.assertEqual((), cfg.routes)
self.assertEqual("", slug) self.assertEqual("", slug)
self.assertEqual({}, tokens)
def test_unparseable_policy_denies_but_keeps_slug(self) -> None: def test_unparseable_policy_denies_but_keeps_slug(self) -> None:
# A bad policy denies egress, but the bottle is still attributed (its # A bad policy denies egress, but the bottle is still attributed (its
# supervise queue is keyed by the id, independent of route parsing). # supervise queue is keyed by the id, independent of route parsing).
cfg, slug, _tokens = resolve_client_context( cfg, slug = resolve_client_context(
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1", _FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
) )
self.assertEqual((), cfg.routes) self.assertEqual((), cfg.routes)
+6 -8
View File
@@ -31,9 +31,9 @@ def _recorder(calls: list[list[str]]):
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan: def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
return GitGatePlan( return GitGatePlan(
slug="demo", slug="demo",
entrypoint_script=Path("/stage/entrypoint.sh"), entrypoint_script=Path(),
hook_script=Path("/stage/pre-receive"), hook_script=Path(),
access_hook_script=Path("/stage/access-hook"), access_hook_script=Path(),
upstreams=tuple(upstreams), upstreams=tuple(upstreams),
) )
@@ -61,8 +61,6 @@ class TestProvisionGitGate(unittest.TestCase):
self.assertIn( self.assertIn(
["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps, ["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
) )
# The shared (bottle-agnostic) hooks are installed into the gateway.
self.assertIn(["docker", "cp", "/stage/pre-receive", "gw:/etc/git-gate/pre-receive"], cps)
# The init script runs in the gateway, namespaced under the bottle id. # The init script runs in the gateway, namespaced under the bottle id.
exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"] exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
self.assertEqual(1, len(exec_scripts)) self.assertEqual(1, len(exec_scripts))
@@ -72,9 +70,9 @@ class TestProvisionGitGate(unittest.TestCase):
calls: list[list[str]] = [] calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)): with patch(_RUN, side_effect=_recorder(calls)):
provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts
creds_cps = [c for c in calls if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]] cps = [c for c in calls if c[:2] == ["docker", "cp"]]
self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts self.assertEqual(1, len(cps)) # only the key, not known_hosts
self.assertTrue(creds_cps[0][3].endswith("/foo-key")) self.assertTrue(cps[0][3].endswith("/foo-key"))
def test_no_upstreams_is_noop(self) -> None: def test_no_upstreams_is_noop(self) -> None:
with patch(_RUN) as m: with patch(_RUN) as m:
+24 -66
View File
@@ -35,43 +35,11 @@ class TestDockerGateway(unittest.TestCase):
with patch(_RUN_DOCKER, return_value=_proc(stdout="")): with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
self.assertFalse(self.sc.is_running()) self.assertFalse(self.sc.is_running())
def test_ensure_running_noop_when_up_and_image_current(self) -> None: def test_ensure_running_is_noop_when_already_up(self) -> None:
calls: list[list[str]] = [] with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name)) as m:
def fake(argv: list[str]) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]:
return _proc(stdout=self.sc.name) # running
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(stdout="img-A") # current image id
if argv[:2] == ["docker", "inspect"]:
return _proc(stdout="img-A") # container's image (same)
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_running() self.sc.ensure_running()
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]]) # Only the is_running() ps probe — no rm / run.
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]]) self.assertEqual(1, m.call_count)
def test_ensure_running_recreates_when_image_is_stale(self) -> None:
# Running, but the container was built from an OLD image → recreate so
# a rebuild's new flat daemons take effect.
calls: list[list[str]] = []
def fake(argv: list[str]) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]:
return _proc(stdout=self.sc.name)
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(stdout="img-NEW")
if argv[:2] == ["docker", "inspect"]:
return _proc(stdout="img-OLD")
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_running()
self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]]))
self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls))
def test_ensure_running_starts_the_singleton_when_absent(self) -> None: def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []
@@ -113,17 +81,9 @@ class TestDockerGateway(unittest.TestCase):
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv) self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
def test_ca_cert_pem_raises_when_absent(self) -> None: def test_ca_cert_pem_raises_when_absent(self) -> None:
# timeout=0 → one probe then give up (no polling delay in the test).
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")): with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")):
with self.assertRaises(GatewayError): with self.assertRaises(GatewayError):
self.sc.ca_cert_pem(timeout=0) self.sc.ca_cert_pem()
def test_ca_cert_pem_polls_until_mitmproxy_writes_it(self) -> None:
# First read: CA not there yet; second read: present.
seq = [_proc(returncode=1, stderr="No such file"), _proc(stdout=_CA_PEM)]
with patch(_RUN_DOCKER, side_effect=seq), \
patch("bot_bottle.orchestrator.gateway.time.sleep"):
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem(timeout=5))
def test_ensure_running_reuses_existing_network(self) -> None: def test_ensure_running_reuses_existing_network(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []
@@ -169,35 +129,28 @@ class TestDockerGatewayBuild(unittest.TestCase):
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)): with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
self.assertFalse(self.sc.image_exists()) self.assertFalse(self.sc.image_exists())
def test_ensure_built_builds_even_when_image_present(self) -> None: def test_ensure_built_is_noop_when_image_present(self) -> None:
# Always build (cache-aware) so a flat-source change rebuilds; the old with patch(_RUN_DOCKER, return_value=_proc(returncode=0)) as m:
# build-if-missing silently ran a stale single-tenant image. self.sc.ensure_built()
self.assertEqual(1, m.call_count) # only the image-inspect probe
def test_ensure_built_builds_from_dockerfile_when_missing(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []
def rec(argv: list[str]) -> Mock: def fake(argv: list[str]) -> Mock:
calls.append(argv) calls.append(argv)
return _proc() # image present, build succeeds if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1) # missing
return _proc()
with patch(_RUN_DOCKER, side_effect=rec): with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_built() self.sc.ensure_built()
builds = [c for c in calls if c[:2] == ["docker", "build"]] builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertEqual(1, len(builds)) self.assertEqual(1, len(builds))
self.assertIn(self.sc.image_ref, builds[0]) self.assertIn(self.sc.image_ref, builds[0])
self.assertIn("-f", builds[0])
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0])) self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
self.assertNotIn("--no-cache", builds[0])
def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None:
calls: list[list[str]] = []
def rec(argv: list[str]) -> Mock:
calls.append(argv)
return _proc()
with patch(_RUN_DOCKER, side_effect=rec), \
patch.dict("os.environ", {"BOT_BOTTLE_NO_CACHE": "1"}):
self.sc.ensure_built()
builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertIn("--no-cache", builds[0])
def test_ensure_built_noop_when_no_dockerfile(self) -> None: def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerGateway("busybox", dockerfile=None) sc = DockerGateway("busybox", dockerfile=None)
@@ -206,7 +159,12 @@ class TestDockerGatewayBuild(unittest.TestCase):
m.assert_not_called() m.assert_not_called()
def test_ensure_built_raises_on_build_failure(self) -> None: def test_ensure_built_raises_on_build_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="build boom")): def fake(argv: list[str]) -> Mock:
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1)
return _proc(returncode=1, stderr="build boom")
with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(GatewayError): with self.assertRaises(GatewayError):
self.sc.ensure_built() self.sc.ensure_built()
+44 -49
View File
@@ -1,4 +1,4 @@
"""Unit: orchestrator+gateway container lifecycle — idempotent singleton (PRD 0070).""" """Unit: orchestrator process lifecycle — idempotent singleton (PRD 0070)."""
from __future__ import annotations from __future__ import annotations
@@ -6,86 +6,81 @@ import tempfile
import unittest import unittest
import urllib.error import urllib.error
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, patch
from bot_bottle.orchestrator.lifecycle import ( from bot_bottle.orchestrator.lifecycle import (
ORCHESTRATOR_NAME, OrchestratorProcess,
OrchestratorService,
OrchestratorStartError, OrchestratorStartError,
) )
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen" _URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
_RUN = "bot_bottle.orchestrator.lifecycle.run_docker" _POPEN = "bot_bottle.orchestrator.lifecycle.subprocess.Popen"
_GATEWAY = "bot_bottle.orchestrator.lifecycle.DockerGateway"
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep" _SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic" _MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
def _health(status: int) -> MagicMock: def _health(status: int) -> MagicMock:
"""A urlopen() context-manager whose `.status` is `status`."""
m = MagicMock() m = MagicMock()
m.__enter__.return_value.status = status m.__enter__.return_value.status = status
return m return m
class TestOrchestratorService(unittest.TestCase): class TestOrchestratorProcess(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup) self.addCleanup(self._tmp.cleanup)
self.addCleanup(use_bottle_root(Path(self._tmp.name))) self.addCleanup(use_bottle_root(Path(self._tmp.name)))
self.svc = OrchestratorService(port=8099) self.p = OrchestratorProcess(port=8099)
def test_urls(self) -> None: def test_url(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url) self.assertEqual("http://127.0.0.1:8099", self.p.url)
# The gateway reaches the control plane by container name over docker DNS.
self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.svc.internal_url)
def test_is_healthy(self) -> None: def test_is_healthy_true_on_200(self) -> None:
with patch(_URLOPEN, return_value=_health(200)): with patch(_URLOPEN, return_value=_health(200)):
self.assertTrue(self.svc.is_healthy()) self.assertTrue(self.p.is_healthy())
def test_is_healthy_false_on_error(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
self.assertFalse(self.svc.is_healthy()) self.assertFalse(self.p.is_healthy())
def test_ensure_running_always_recreates_orchestrator(self) -> None: def test_ensure_running_noop_when_already_healthy(self) -> None:
# Even when a control plane is already healthy, the orchestrator is with patch(_URLOPEN, return_value=_health(200)), patch(_POPEN) as popen:
# recreated so bind-mounted code changes take effect (its process self.assertEqual(self.p.url, self.p.ensure_running())
# won't reload). The gateway is ensured too. popen.assert_not_called() # a live control plane is left untouched
run = Mock(return_value=Mock(returncode=0, stderr=""))
with patch(_URLOPEN, return_value=_health(200)), \
patch(_GATEWAY) as gw_cls, patch(_RUN, run), patch(_SLEEP):
self.assertEqual(self.svc.url, self.svc.ensure_running())
gw_cls.return_value.ensure_running.assert_called() # gateway kept up
runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
self.assertEqual(1, len(runs)) # orchestrator recreated
self.assertIn(ORCHESTRATOR_NAME, runs[0])
def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None: def test_ensure_running_spawns_then_waits_for_health(self) -> None:
run = Mock(return_value=Mock(returncode=0, stderr="")) # First check (before spawn) fails; after spawn the poll succeeds.
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_GATEWAY), patch(_RUN, run), patch(_SLEEP): patch(_POPEN) as popen, patch(_SLEEP):
self.assertEqual(self.svc.url, self.svc.ensure_running()) url = self.p.ensure_running()
runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]] self.assertEqual(self.p.url, url)
self.assertEqual(1, len(runs)) popen.assert_called_once()
argv = runs[0]
self.assertIn(ORCHESTRATOR_NAME, argv)
self.assertIn("--broker", argv)
self.assertEqual("stub", argv[argv.index("--broker") + 1]) # register-only, no socket
self.assertIn("bot_bottle.orchestrator", argv)
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
def test_ensure_running_raises_on_timeout(self) -> None: def test_ensure_running_raises_on_startup_timeout(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \ with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
patch(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \ patch(_POPEN), patch(_SLEEP), \
patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]): patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
with self.assertRaises(OrchestratorStartError): with self.assertRaises(OrchestratorStartError):
self.svc.ensure_running(startup_timeout=1.0) self.p.ensure_running(startup_timeout=1.0)
def test_stop_removes_orchestrator_and_gateway(self) -> None: def test_argv_includes_gateway_and_broker(self) -> None:
with patch(_RUN) as run, patch(_GATEWAY) as gw_cls: argv = OrchestratorProcess(port=8099, broker="docker", gateway=True)._argv()
self.svc.stop() self.assertIn("--gateway", argv)
rms = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "rm", "--force"]] self.assertIn("bot_bottle.orchestrator", argv)
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms)) self.assertEqual("docker", argv[argv.index("--broker") + 1])
gw_cls.return_value.stop.assert_called_once()
def test_argv_omits_gateway_when_disabled(self) -> None:
self.assertNotIn("--gateway", OrchestratorProcess(gateway=False)._argv())
def test_spawn_launches_detached_and_logs(self) -> None:
with patch(_POPEN) as popen:
self.p._spawn()
popen.assert_called_once()
kwargs = popen.call_args.kwargs
self.assertTrue(kwargs["start_new_session"]) # outlives the CLI
self.assertTrue((Path(self._tmp.name) / "orchestrator.log").exists())
if __name__ == "__main__": if __name__ == "__main__":
+3 -36
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -24,20 +23,6 @@ class TestRegistryStore(unittest.TestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
self._tmp.cleanup() self._tmp.cleanup()
def _force_active_row(self, source_ip: str, bottle_id: str) -> None:
"""Insert a raw active row, bypassing `register`'s same-IP supersede —
the only way to manufacture the ambiguity the fail-closed guard exists
for (crash-orphaned / externally-injected duplicate)."""
conn = sqlite3.connect(self.db)
conn.execute(
"INSERT INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
"VALUES (?, ?, ?, 'active', 0.0, '', '')",
(bottle_id, source_ip, "tok-" + bottle_id),
)
conn.commit()
conn.close()
def test_register_mints_id_and_token(self) -> None: def test_register_mints_id_and_token(self) -> None:
rec = self.store.register("10.243.0.1") rec = self.store.register("10.243.0.1")
self.assertTrue(rec.bottle_id) self.assertTrue(rec.bottle_id)
@@ -91,29 +76,11 @@ class TestRegistryStore(unittest.TestCase):
def test_attribute_ambiguous_ip_denied(self) -> None: def test_attribute_ambiguous_ip_denied(self) -> None:
# Two active bottles on one source IP is a misconfiguration — deny # Two active bottles on one source IP is a misconfiguration — deny
# rather than guess (fail-closed), even with a valid token. (Forced # rather than guess (fail-closed), even with a valid token.
# directly: `register` now supersedes, so this can only arise from an
# orphaned/injected row.)
a = self.store.register("10.243.0.1", bottle_id="a") a = self.store.register("10.243.0.1", bottle_id="a")
self._force_active_row("10.243.0.1", "b") self.store.register("10.243.0.1", bottle_id="b")
self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token)) self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token))
def test_reregister_same_source_ip_supersedes(self) -> None:
# Re-registering a source IP (reused IP / crash recovery / persisted
# DB) must supersede the prior active bottle, not add a second active
# row that would fail-closed the whole IP to 403.
a = self.store.register("10.243.0.1", bottle_id="a", policy="A")
b = self.store.register("10.243.0.1", bottle_id="b", policy="B")
# exactly one active row at that IP, and it's the new bottle
got = self.store.by_source_ip("10.243.0.1")
assert got is not None
self.assertEqual("b", got.bottle_id)
self.assertEqual("B", got.policy)
# the superseded bottle is gone and its token no longer attributes
self.assertIsNone(self.store.get("a"))
self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token))
self.assertIsNotNone(self.store.attribute("10.243.0.1", b.identity_token))
def test_state_persists_across_reopen(self) -> None: def test_state_persists_across_reopen(self) -> None:
rec = self.store.register("10.243.0.1") rec = self.store.register("10.243.0.1")
reopened = RegistryStore(self.db) reopened = RegistryStore(self.db)
@@ -163,7 +130,7 @@ class TestRegistryStore(unittest.TestCase):
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None: def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
self.store.register("10.243.0.1", bottle_id="a") self.store.register("10.243.0.1", bottle_id="a")
self._force_active_row("10.243.0.1", "b") # orphaned duplicate self.store.register("10.243.0.1", bottle_id="b")
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
-12
View File
@@ -92,18 +92,6 @@ class TestOrchestrator(unittest.TestCase):
assert got is not None assert got is not None
self.assertEqual('{"routes":[]}', got.policy) self.assertEqual('{"routes":[]}', got.policy)
def test_tokens_held_in_memory_and_cleared_on_teardown(self) -> None:
rec = self.orch.launch_bottle("10.243.0.5", tokens={"EGRESS_TOKEN_0": "sk"})
self.assertEqual({"EGRESS_TOKEN_0": "sk"}, self.orch.tokens_for(rec.bottle_id))
# not persisted in the registry (redacted record has no tokens field)
self.assertNotIn("tokens", rec.redacted())
self.orch.teardown_bottle(rec.bottle_id)
self.assertEqual({}, self.orch.tokens_for(rec.bottle_id))
def test_tokens_default_empty(self) -> None:
rec = self.orch.launch_bottle("10.243.0.6")
self.assertEqual({}, self.orch.tokens_for(rec.bottle_id))
def test_set_policy_live_reload(self) -> None: def test_set_policy_live_reload(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3") rec = self.orch.launch_bottle("10.243.0.3")
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}')) self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))
+5 -9
View File
@@ -89,17 +89,13 @@ class TestPolicyResolver(unittest.TestCase):
self.r.resolve_bottle_id("10.243.0.1", "tok") self.r.resolve_bottle_id("10.243.0.1", "tok")
def test_resolve_policy_and_bottle_id_one_call(self) -> None: def test_resolve_policy_and_bottle_id_one_call(self) -> None:
payload = {"bottle_id": "b1", "policy": "P", "tokens": {"EGRESS_TOKEN_0": "s"}} with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})) as m:
with patch(_URLOPEN, return_value=_resp(payload)) as m: self.assertEqual(("P", "b1"), self.r.resolve_policy_and_bottle_id("10.243.0.1", "t"))
self.assertEqual( self.assertEqual(1, m.call_count) # both from a single /resolve
("P", "b1", {"EGRESS_TOKEN_0": "s"}),
self.r.resolve_policy_and_bottle_id("10.243.0.1", "t"),
)
self.assertEqual(1, m.call_count) # policy + id + tokens from a single /resolve
def test_resolve_policy_and_bottle_id_403_is_none_none_empty(self) -> None: def test_resolve_policy_and_bottle_id_403_is_none_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)): with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertEqual((None, None, {}), self.r.resolve_policy_and_bottle_id("10.243.0.9")) self.assertEqual((None, None), self.r.resolve_policy_and_bottle_id("10.243.0.9"))
def test_resolve_policy_and_bottle_id_error_raises(self) -> None: def test_resolve_policy_and_bottle_id_error_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):