diff --git a/.gitea/workflows/canaries.yml b/.gitea/workflows/canaries.yml index 0085b76..bf568b5 100644 --- a/.gitea/workflows/canaries.yml +++ b/.gitea/workflows/canaries.yml @@ -1,6 +1,6 @@ -# Weekly canary suite. Catches upstream regressions (broken pipelock -# image packaging at the pinned digest, etc.) without coupling every -# dev push to upstream registry availability. +# Weekly canary suite. Catches upstream regressions (broken pinned +# digest, etc.) without coupling every dev push to upstream registry +# availability. # # Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run # locally with the same gating. diff --git a/Dockerfile.claude b/Dockerfile.claude index d5bec57..c7346d1 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -21,7 +21,7 @@ FROM node:22-slim # runs as root and rejects non-root connections, so socat sits between # node and the agent socket. curl is here so any HTTPS_PROXY-aware # tool (curl itself, plus anything that shells out to it) works -# against pipelock's bumped TLS without the agent needing local DNS. +# against egress's bumped TLS without the agent needing local DNS. RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index 3483974..c5cbe42 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -1,23 +1,18 @@ # Per-bottle sidecar bundle image (PRD 0024). # -# Collapses the four prior per-sidecar images (pipelock, egress, -# git-gate, supervise) into one. A small stdlib-Python init -# supervisor at /app/sidecar_init.py spawns all four daemons, -# forwards SIGTERM, and propagates per-daemon stdout/stderr to the -# container log with a `[name]` prefix. See PRD 0024 for the -# rationale. +# Collapses the prior per-sidecar images (egress, git-gate, +# supervise) into one. A small stdlib-Python init supervisor at +# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and +# propagates per-daemon stdout/stderr to the container log with a +# `[name]` prefix. See PRD 0024 for the rationale. # -# Layout (preserved verbatim from the prior four Dockerfiles so the -# compose renderer's bind-mount paths and docker-cp targets keep -# working): +# Layout: # -# /usr/local/bin/pipelock pipelock binary # /usr/bin/gitleaks gitleaks binary # /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress-entrypoint.sh mitmdump launcher # /app/supervise_server.py + .py supervise MCP server # /app/sidecar_init.py PID 1 supervisor -# /etc/pipelock.yaml bind-mounted at run time # /etc/egress/routes.yaml bind-mounted at run time # /etc/git-gate/pre-receive docker-cp'd at start time # /git-gate-entrypoint.sh docker-cp'd at start time @@ -27,25 +22,17 @@ # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir # # Exposed ports inside the container: -# 8888 pipelock (HTTPS_PROXY) -# 9099 egress (mitmproxy, pipelock's upstream — not externally -# addressed by the agent) +# 9099 egress (mitmproxy, agent-facing HTTPS proxy) # 9418 git-gate (git-daemon) # 9420 git-gate smart HTTP (smolmachines agent-facing transport) # 9100 supervise (MCP HTTP) -# Stage 1: pipelock binary. The upstream pipelock image is a -# scratch image with the binary at /pipelock (entrypoint). -# Pinned by digest in lockstep with -# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE. -FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src - -# Stage 2: gitleaks binary. The upstream gitleaks image is alpine +# Stage 1: gitleaks binary. The upstream gitleaks image is alpine # with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep # with Dockerfile.git-gate's prior base (now deleted at chunk 3). FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src -# Stage 3: assembly. mitmproxy/mitmproxy is debian-slim-based with +# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with # Python + mitmdump pre-installed — heavier than the others, so # this stage starts there and pulls the standalone binaries in. FROM mitmproxy/mitmproxy:11.1.3 @@ -60,16 +47,14 @@ USER root # plus the core `git` binary the pre-receive hook invokes. # openssh-client supplies the upstream SSH transport the # pre-receive hook uses to forward accepted refs. -# ca-certificates is needed for both pipelock and mitmdump -# upstream TLS (the base image already has it; listed for -# explicitness). +# ca-certificates is needed for mitmdump upstream TLS (the +# base image already has it; listed for explicitness). RUN apt-get update \ && apt-get install -y --no-install-recommends \ git openssh-client ca-certificates \ && rm -rf /var/lib/apt/lists/* # Pull the standalone binaries into the final image. -COPY --from=pipelock-src /pipelock /usr/local/bin/pipelock COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks # Project Python: addon + server modules + the init supervisor. diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 4a5e01a..eeb52c4 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -84,9 +84,9 @@ class AgentProvisionPlan: return the same shape without adding backend-plan fields. `egress_routes` are provider-declared EgressRoutes that backends - pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps - provider logic out of the egress and pipelock modules — they merge - provider routes generically without knowing the provider type. + pass to `Egress.prepare`. This keeps provider logic out of the + egress module — it merges provider routes generically without + knowing the provider type. `hidden_env_names` is the set of env var names the provider injected as non-secret placeholders. `print_util.visible_agent_env_names` uses diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index cc408b7..b761ed5 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -163,8 +163,8 @@ class ActiveAgent: bottle is the container, the agent is what runs in it.) Fields are deliberately backend-neutral. `services` is the set - of sidecar daemons currently up for this bottle (`pipelock`, - `egress`, `git-gate`, `supervise`); the dashboard uses it to + of sidecar daemons currently up for this bottle (`egress`, + `git-gate`, `supervise`); the dashboard uses it to gate edit verbs. `backend_name` is the matching key in `_BACKENDS` (`docker` / `smolmachines`) — used by the active- list rendering to disambiguate and by the dashboard's @@ -213,7 +213,7 @@ class Bottle(ABC): `user` (default `node`, matching the agent image's USER directive) and return the captured stdout/stderr/returncode. The bottle's environment (including HTTPS_PROXY pointing at - the pipelock sidecar) is inherited by the child. Non-zero + the egress sidecar) is inherited by the child. Non-zero exit does not raise — callers inspect `returncode` themselves. @@ -352,8 +352,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None: """Install the per-bottle CA into the agent's trust store so - the agent trusts the bumped CONNECT cert egress (was - pipelock, pre-PRD-0017) presents. Default impl is a no-op so + the agent trusts the bumped CONNECT cert egress presents. + Default impl is a no-op so backends that don't yet support TLS interception (every backend except Docker today) aren't forced to implement it. The Docker backend overrides to docker-cp the cert in and run diff --git a/bot_bottle/backend/docker/__init__.py b/bot_bottle/backend/docker/__init__.py index 9ad729d..dc2340b 100644 --- a/bot_bottle/backend/docker/__init__.py +++ b/bot_bottle/backend/docker/__init__.py @@ -4,7 +4,6 @@ The bulk of the implementation lives in sibling modules: - util: thin Docker subprocess wrappers - network: Docker network plumbing - - pipelock: DockerPipelockProxy lifecycle - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - bottle: DockerBottle handle diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py index 5195264..673a278 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/backend/docker/bottle_state.py @@ -56,8 +56,8 @@ _AGENT_SUBDIR = "agent" _METADATA_NAME = "metadata.json" # Live-config dir bind-mounted into the supervise sidecar (read-only). # Host's apply paths keep these files fresh so supervise's -# `list-pipelock-allowlist` / `list-egress-routes` MCP tools -# return the current state — not a snapshot from launch time. +# `list-egress-routes` MCP tool returns the current state — +# not a snapshot from launch time. _LIVE_CONFIG_SUBDIR = "live-config" LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 88bd233..4ba695d 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -50,6 +50,7 @@ from .git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) +from . import network as network_mod from .sidecar_bundle import ( SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_IMAGE, @@ -91,11 +92,11 @@ def _networks(plan: DockerBottlePlan) -> dict[str, Any]: bridge.""" return { "internal": { - "name": plan.proxy_plan.internal_network, + "name": network_mod.network_name_for_slug(plan.slug), "internal": True, }, "egress": { - "name": plan.proxy_plan.egress_network, + "name": network_mod.network_egress_name_for_slug(plan.slug), }, } @@ -131,11 +132,9 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: # --- egress ------------------------------------------------------- ep = plan.egress_plan + volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER)) if ep.routes: - volumes += [ - _bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER), - _bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER), - ] + volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER)) for token_env in sorted(ep.token_env_map.keys()): env.append(token_env) diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index dc09b30..2d1bf05 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -116,15 +116,13 @@ def launch( internal_network=internal_network, egress_network=egress_network, ) - egress_plan = plan.egress_plan - if egress_plan.routes: - egress_plan = dataclasses.replace( - egress_plan, - internal_network=internal_network, - egress_network=egress_network, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - ) + egress_plan = dataclasses.replace( + plan.egress_plan, + internal_network=internal_network, + egress_network=egress_network, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) supervise_plan = plan.supervise_plan if supervise_plan is not None: supervise_plan = dataclasses.replace( diff --git a/bot_bottle/backend/docker/network.py b/bot_bottle/backend/docker/network.py index 3247636..6ec13f9 100644 --- a/bot_bottle/backend/docker/network.py +++ b/bot_bottle/backend/docker/network.py @@ -1,11 +1,10 @@ """Docker network plumbing for the per-agent egress topology. The agent container sits on a Docker `--internal` network (no default -gateway). Pipelock straddles that network and a per-agent user-defined -bridge for upstream egress. We deliberately do NOT use Docker's legacy +gateway). Egress straddles that network and a per-agent user-defined +bridge for upstream traffic. We deliberately do NOT use Docker's legacy `bridge` network because only user-defined bridges run Docker's -embedded DNS resolver, which pipelock needs to resolve api.anthropic.com -and similar upstream hostnames. +embedded DNS resolver, which egress needs to resolve upstream hostnames. Naming: bot-bottle-net- (internal), bot-bottle-egress- (egress). Numeric suffix on conflict @@ -77,20 +76,12 @@ def network_create_internal(slug: str) -> str: def network_create_egress(slug: str) -> str: """Create a per-agent user-defined bridge (NOT the legacy `bridge`) - so the pipelock sidecar has working DNS for upstream hostnames.""" + so the egress sidecar has working DNS for upstream hostnames.""" return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) def network_inspect_cidr(name: str) -> str: - """Return the IPv4 CIDR Docker assigned to a user-defined network. - - Used by pipelock's SSRF guard exception: the bottle's internal - network sits in RFC1918 space, so pipelock's `internal:` list - would block any agent request whose destination resolves there - — including the cred-proxy sidecar's address. Adding the - network's CIDR to pipelock's `ssrf.ip_allowlist` lets traffic - targeted at the bottle's own sidecars through while pipelock - still body-scans and api_allowlist-gates as usual.""" + """Return the IPv4 CIDR Docker assigned to a user-defined network.""" result = subprocess.run( ["docker", "network", "inspect", "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name], diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 38e7f67..f86373f 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -200,10 +200,9 @@ def resolve_plan( # root; for `--cwd` derived images the base Dockerfile is what # the agent should propose changes against (the derived layer # is just a workspace copy). - # (routes.yaml + pipelock allowlist used to land here too but - # PRD 0017 chunk 3 moved them behind the - # `list-egress-routes` MCP tool so the agent gets live - # state rather than a launch-time snapshot.) + # (routes.yaml used to land here too but PRD 0017 chunk 3 + # moved it behind the `list-egress-routes` MCP tool so the + # agent gets live state rather than a launch-time snapshot.) supervise_dockerfile_path = ( Path(dockerfile_path) if dockerfile_path diff --git a/bot_bottle/backend/docker/provision/ca.py b/bot_bottle/backend/docker/provision/ca.py index 5b5ef31..40dee9f 100644 --- a/bot_bottle/backend/docker/provision/ca.py +++ b/bot_bottle/backend/docker/provision/ca.py @@ -1,19 +1,8 @@ -"""Install the per-bottle MITM CA into the agent container's trust -store. +"""Install the per-bottle egress MITM CA into the agent container's +trust store. -Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: - - - Bottle declares `egress.routes[]` → agent's HTTP_PROXY - points at egress; the cert the agent must trust is the - one egress mints leaf certs with (the egress CA). - - No egress routes → agent's HTTP_PROXY points straight at - pipelock; the cert the agent must trust is pipelock's CA (the - pre-cutover behavior). - -By the time this provisioner runs, the corresponding `tls_init` -helper has generated the chosen CA under `plan.stage_dir`, and the -sidecar (pipelock or egress) is up referencing the -in-container CA paths. +By the time this provisioner runs, `egress_tls_init` has generated +the egress CA and the path is re-bound into `plan.egress_plan`. Cert lands on Debian's standard source path (`/usr/local/share/ca-certificates/`); `update-ca-certificates` @@ -40,7 +29,7 @@ def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None: """Copy the agent-facing CA cert into the agent, rebuild the trust bundle, emit a one-line fingerprint log. Called from `BottleBackend.provision` after the agent container is up.""" - cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) + cert_host_path, label = select_ca_cert(plan.egress_plan) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) bottle.exec( diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py index 85a2402..fca0dfe 100644 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ b/bot_bottle/backend/docker/sidecar_bundle.py @@ -2,10 +2,10 @@ (PRD 0024). The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1) -runs pipelock + egress + git-gate + supervise as one container -per bottle under a small Python init supervisor. As of chunk 5 -the bundle is the only shape — the legacy four-sidecar topology -and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" +runs egress + git-gate + supervise as one container per bottle +under a small Python init supervisor. As of chunk 5 the bundle +is the only shape — the legacy four-sidecar topology and its +`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" from __future__ import annotations diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index e3d3cb2..2e8b583 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from pathlib import Path from ...agent_provider import PromptMode -from ...pipelock import PipelockProxyPlan from .. import BottlePlan @@ -71,7 +70,6 @@ class SmolmachinesBottlePlan(BottlePlan): # docker's `--internal` + egress bridge topology; it's on a # per-bottle bridge with a pinned IP. The unused fields stay # at their dataclass defaults. - proxy_plan: PipelockProxyPlan # Agent-side endpoints. On Docker Desktop the docker bridge # IPs aren't reachable from the smolvm guest (TSI uses macOS # networking; docker container IPs live in the daemon's VM), diff --git a/bot_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py index 668c6e9..f1a81ff 100644 --- a/bot_bottle/backend/smolmachines/enumerate.py +++ b/bot_bottle/backend/smolmachines/enumerate.py @@ -69,8 +69,8 @@ def enumerate_active() -> list[ActiveAgent]: def _query_bundle_services() -> dict[str, tuple[str, ...]]: - """`{slug: ('egress', 'pipelock', ...)}` from each running - bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var. + """`{slug: ('egress', ...)}` from each running bundle container's + `BOT_BOTTLE_SIDECAR_DAEMONS` env var. Smolmachines bundles all run the PRD-0024 image with the same daemon set declared via env, so one inspect per bundle gets us the picture without exec'ing into the container. diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index bf0fbd4..0c5a6b5 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -9,13 +9,9 @@ guest pointed at the bundle's pinned IP via TSI's exit. The bundle's daemons consume the inner Plans the docker backend -already produces: pipelock reads its yaml + CA from the -PipelockProxyPlan; egress reads routes + CAs from the EgressPlan -+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle -local), since the agent dials pipelock first (not egress) on the -smolmachines path. Git-gate + supervise plumb through the same -plans the docker backend uses, minus the docker-network fields -that don't apply here.""" +already produces: egress reads routes + CAs from the EgressPlan. +Git-gate + supervise plumb through the same plans the docker +backend uses, minus the docker-network fields that don't apply here.""" from __future__ import annotations @@ -29,16 +25,11 @@ from ...egress import ( EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values, ) -from ...pipelock import ( - PIPELOCK_CA_CERT_IN_CONTAINER, - PIPELOCK_CA_KEY_IN_CONTAINER, -) from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde from ..docker import util as docker_mod from ..docker.egress import ( EGRESS_CA_IN_CONTAINER, - EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PORT as _EGRESS_PORT, egress_tls_init, ) @@ -48,14 +39,9 @@ from ..docker.git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ..docker.pipelock import ( - BUNDLE_LOCAL_PIPELOCK_URL, - PIPELOCK_PORT as _PIPELOCK_PORT_STR, - pipelock_tls_init, -) from ...git_gate import revoke_git_gate_provisioned_keys from ...log import warn -from ..docker.bottle_state import git_gate_state_dir +from ..docker.bottle_state import egress_state_dir, git_gate_state_dir from . import loopback_alias as _loopback from . import sidecar_bundle as _bundle from . import smolvm as _smolvm @@ -78,9 +64,7 @@ _SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines" # Container-internal listening ports for each bundle daemon. The # bundle publishes each one on a random host loopback port (see # `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks -# them up post-start. Pipelock's port is an env-overridable string -# in docker.pipelock; coerce to int here. -_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR) +# them up post-start. _GIT_HTTP_PORT = 9420 _SUPERVISE_PORT = SUPERVISE_PORT @@ -167,33 +151,16 @@ def _allocate_resources( def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan: - """Mint per-bottle CAs and return the plan with CA paths filled. - - Pipelock always runs in the bundle. Egress's CA is only minted - when the bottle declares routes — otherwise egress runs idle - without MITM and the CA files would be unused.""" - ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent) - proxy_plan = dataclasses.replace( - plan.proxy_plan, - ca_cert_host_path=ca_cert_host, - ca_key_host_path=ca_key_host, + """Mint the egress MITM CA and return the plan with CA paths filled.""" + egress_ca_host, egress_ca_cert_only = egress_tls_init( + egress_state_dir(plan.slug), ) - egress_plan = plan.egress_plan - if egress_plan.routes: - egress_ca_host, egress_ca_cert_only = egress_tls_init( - plan.egress_plan.routes_path.parent, - ) - egress_plan = dataclasses.replace( - egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - pipelock_ca_host_path=ca_cert_host, - # On smolmachines, egress's upstream is pipelock on the - # bundle's localhost — they're in the same container's - # network namespace. - pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL, - ) - return dataclasses.replace(plan, proxy_plan=proxy_plan, egress_plan=egress_plan) + egress_plan = dataclasses.replace( + plan.egress_plan, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) + return dataclasses.replace(plan, egress_plan=egress_plan) def _start_bundle( @@ -224,17 +191,10 @@ def _discover_urls( macOS networking, and macOS sees the daemon's bridge via the published-port loopback forward only. - Proxy hop order: when the bottle declares egress routes, the - agent's first hop is egress (for token injection), then - pipelock. Without routes, the agent dials pipelock directly. NO_PROXY includes the per-bottle loopback alias so the supervise + git-gate URLs bypass HTTPS_PROXY.""" - if plan.egress_plan.routes: - agent_facing_port = _EGRESS_PORT - else: - agent_facing_port = _PIPELOCK_PORT agent_facing_host_port = _bundle.bundle_host_port( - plan.slug, agent_facing_port, host_ip=loopback_ip, + plan.slug, _EGRESS_PORT, host_ip=loopback_ip, ) agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}" @@ -328,8 +288,7 @@ def _bundle_launch_spec( """Build a BundleLaunchSpec from the resolved inner Plans. Daemons in the CSV: - - egress + pipelock are always present (pipelock is the - agent's first hop; egress is its upstream). + - egress is always present. - git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - supervise is conditional on plan.supervise_plan. @@ -337,36 +296,15 @@ def _bundle_launch_spec( daemon-private values only (HTTPS_PROXY is scoped to the egress process by egress_entrypoint.sh — see PRD 0024's bundle bind-address PR).""" - daemons: list[str] = ["egress", "pipelock"] + daemons: list[str] = ["egress"] env: list[str] = [] volumes: list[tuple[str, str, bool]] = [] - # In this Docker-Desktop-compatible topology, whichever daemon - # is "agent-facing" gets its port published on the host - # loopback (see `_ensure_smolmachine`'s discovery loop) and the - # other stays bundle-internal. The bundle is NOT reachable by - # bridge IP from the smolvm guest on macOS — TSI uses macOS - # networking, and macOS sees the daemon's bridge via the - # published-port loopback forward only. - - # --- pipelock --------------------------------------------- - pp = plan.proxy_plan - volumes += [ - (str(pp.yaml_path), "/etc/pipelock.yaml", True), - (str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True), - (str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True), - ] - # --- egress ----------------------------------------------- ep = plan.egress_plan + volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True)) if ep.routes: - env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") - env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}") - volumes += [ - (str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True), - (str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True), - (str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True), - ] + volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True)) # Bare-name entries for upstream-token slots. Their values # come from the docker-run subprocess env (inherited from # the operator's shell), never landing on argv. @@ -409,14 +347,8 @@ def _bundle_launch_spec( # Container ports the agent reaches from the smolvm guest — # published on host loopback so the guest can dial via TSI + - # macOS networking. The HTTP/HTTPS chokepoint is whichever - # daemon's port we publish: egress when routes are declared - # (token injection first, then forwards to bundle-internal - # pipelock), pipelock otherwise. - if ep.routes: - ports_to_publish: list[int] = [_EGRESS_PORT] - else: - ports_to_publish = [_PIPELOCK_PORT] + # macOS networking. Egress is always the agent's HTTP/HTTPS proxy. + ports_to_publish: list[int] = [_EGRESS_PORT] if gp.upstreams: ports_to_publish.append(_GIT_HTTP_PORT) if sp is not None: diff --git a/bot_bottle/backend/smolmachines/local_registry.py b/bot_bottle/backend/smolmachines/local_registry.py index ba60076..ed864c0 100644 --- a/bot_bottle/backend/smolmachines/local_registry.py +++ b/bot_bottle/backend/smolmachines/local_registry.py @@ -48,7 +48,7 @@ from ...log import die # registry:2.8.3, pinned by digest. Same env-override pattern as the -# pipelock image pin in bot_bottle/backend/docker/pipelock.py. +# sidecar image pin in bot_bottle/backend/docker/sidecar_bundle.py. REGISTRY_IMAGE = os.environ.get( "BOT_BOTTLE_REGISTRY_IMAGE", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index b4c7690..87c13f6 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -23,24 +23,21 @@ from ...backend.docker.bottle_state import ( bottle_identity, egress_state_dir, git_gate_state_dir, - pipelock_state_dir, supervise_state_dir, write_metadata, ) from ...egress import Egress from ...env import resolve_env from ...git_gate import GitGate -from ...pipelock import PipelockProxy from ...supervise import Supervise from ...workspace import workspace_plan as resolve_workspace_plan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight -# Gateway ports the bundle exposes inside its container — pipelock -# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent -# inside the smolvm guest dials these on the bundle's pinned IP. -_BUNDLE_PIPELOCK_PORT = 8888 +# Gateway ports the bundle exposes inside its container — git-gate's +# git-daemon, supervise's MCP. The agent inside the smolvm guest +# dials these on the bundle's pinned IP. _BUNDLE_GIT_GATE_PORT = 9418 _BUNDLE_SUPERVISE_PORT = 9100 @@ -145,18 +142,6 @@ def resolve_plan( merged_guest_env.setdefault(key, val) agent_provision = replace(agent_provision, guest_env=merged_guest_env) - # Inner Plans for the four bundle daemons. The ABCs are - # platform-neutral — `.prepare()` writes config files + returns - # a Plan dataclass with no backend-specific assumptions. State - # dirs are still keyed by slug under the docker backend's - # bottle_state layout (shared on-host convention; not a docker - # dependency). - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = PipelockProxy().prepare( - bottle, slug, pipelock_dir, agent_provision.egress_routes, - ) - egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) egress_plan = Egress().prepare( @@ -181,7 +166,6 @@ def resolve_plan( agent_image_ref=agent_image_ref, guest_env=agent_provision.guest_env, prompt_file=prompt_file, - proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, supervise_plan=supervise_plan, diff --git a/bot_bottle/backend/smolmachines/provision/ca.py b/bot_bottle/backend/smolmachines/provision/ca.py index a745f1f..dbce04a 100644 --- a/bot_bottle/backend/smolmachines/provision/ca.py +++ b/bot_bottle/backend/smolmachines/provision/ca.py @@ -1,13 +1,10 @@ -"""Install the per-bottle MITM CA into the smolmachines guest's -trust store (PRD 0023 chunk 4d). +"""Install the per-bottle egress MITM CA into the smolmachines +guest's trust store (PRD 0023 chunk 4d). -Mirrors `backend.docker.provision.ca`: select the right CA (egress -when the bottle has routes, else pipelock), copy it to Debian's -`/usr/local/share/ca-certificates/` path, +Mirrors `backend.docker.provision.ca`: copy the egress CA to +Debian's `/usr/local/share/ca-certificates/` path, `update-ca-certificates` to rebuild the trust bundle, and log the -fingerprint once. The selected cert depends on the agent's -HTTP_PROXY target — same logic as the docker backend, since the -agent dials the same daemons through the same bundle. +fingerprint once. `smolvm machine exec` runs commands as root in the VM (no `-u` flag exists; the VM init is root), so we don't need the explicit @@ -35,7 +32,7 @@ def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None: """Copy the agent-facing CA cert into the guest, rebuild the trust bundle, emit a one-line fingerprint log. Called from `BottleBackend.provision` after the smolvm guest is up.""" - cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) + cert_host_path, label = select_ca_cert(plan.egress_plan) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) # Mode 0644 — readable to non-root tools in the guest. diff --git a/bot_bottle/backend/smolmachines/sidecar_bundle.py b/bot_bottle/backend/smolmachines/sidecar_bundle.py index 4fe7085..60b3413 100644 --- a/bot_bottle/backend/smolmachines/sidecar_bundle.py +++ b/bot_bottle/backend/smolmachines/sidecar_bundle.py @@ -19,7 +19,7 @@ This module ships the lifecycle primitives only — create network, start bundle, stop bundle, remove network — wrapped around `subprocess.run(["docker", ...])`. Wiring them into the launch flow + populating the `BundleLaunchSpec` from the inner -Plans (PipelockProxyPlan, EgressPlan, …) lands in chunk 2d.""" +Plans (EgressPlan, …) lands in chunk 2d.""" from __future__ import annotations @@ -69,7 +69,7 @@ class BundleLaunchSpec: # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The # supervisor inside the bundle reads it to skip # bottle-irrelevant daemons (e.g. supervise=False bottles). - daemons_csv: str = "egress,pipelock" + daemons_csv: str = "egress" # Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name # form inherits the value from the docker-run subprocess env, # matching the docker backend's compose-up secret-forwarding diff --git a/bot_bottle/backend/util.py b/bot_bottle/backend/util.py index 8e64b1d..f5ea929 100644 --- a/bot_bottle/backend/util.py +++ b/bot_bottle/backend/util.py @@ -14,7 +14,6 @@ from ..log import die, info if TYPE_CHECKING: from ..egress import EgressPlan - from ..pipelock import PipelockProxyPlan # Debian-family CA layout, shared by every backend (all guest images @@ -35,35 +34,20 @@ def host_skill_dir(name: str) -> str: return f"{home}/.claude/skills/{name}" -def select_ca_cert( - egress_plan: EgressPlan, proxy_plan: PipelockProxyPlan -) -> tuple[Path, str]: - """Pick the agent-facing CA cert (and a short label for the log - line) that matches the proxy the agent's HTTP_PROXY points at. - Egress wins when the bottle declares any routes (it sits in front - of pipelock); else pipelock. +def select_ca_cert(egress_plan: EgressPlan) -> tuple[Path, str]: + """Return the egress MITM CA cert path and label for provision_ca. - Shared by every backend's `provision_ca`: launch mints the chosen - CA(s) and re-binds their host paths into these inner plans before - provision runs, so an empty/missing path here means launch's - bringup is broken — fatal.""" - if egress_plan.routes: - cert = egress_plan.mitmproxy_ca_cert_only_host_path - if cert == Path() or not cert.is_file(): - die( - f"egress CA cert missing at {cert or '(empty)'}; " - f"launch must have called egress_tls_init and " - f"re-bound the plan before provision" - ) - return cert, "egress" - cert = proxy_plan.ca_cert_host_path - if not cert or not cert.is_file(): + Launch always mints the CA and re-binds the host path into the + egress_plan before provision runs, so an empty/missing path here + means launch's bringup is broken — fatal.""" + cert = egress_plan.mitmproxy_ca_cert_only_host_path + if cert == Path() or not cert.is_file(): die( - f"pipelock CA cert missing at {cert or '(empty)'}; " - f"launch must have called pipelock_tls_init and re-bound " - f"the plan before provision" + f"egress CA cert missing at {cert or '(empty)'}; " + f"launch must have called egress_tls_init and " + f"re-bound the plan before provision" ) - return cert, "pipelock" + return cert, "egress" def log_ca_fingerprint(cert_host_path: Path, label: str) -> None: diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index b59a4dd..af9e674 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -167,7 +167,7 @@ def is_git_push_request(path: str, query: str) -> bool: Universal across routes — the block fires even when no egress route matches the host. A bare-pass route (host with no auth, no path_allowlist) would otherwise let push through to - pipelock + upstream untouched. + the upstream untouched. """ if path.endswith("/git-receive-pack"): return True @@ -189,8 +189,8 @@ def match_route( exactly (case-insensitive). DNS names are case-insensitive. Wildcard hosts (`*.foo.com`) are NOT supported — they caused - too many edge cases (apex match? cert validation? pipelock - mirror mismatch?) for too little payoff. Operators that need + too many edge cases (apex match? cert validation?) for too + little payoff. Operators that need multiple subdomains declare them individually (or one common parent host as a bare-pass route).""" target = request_host.lower() @@ -210,8 +210,7 @@ def decide( return what the addon should do with the request. - No matching route → BLOCK. The route table is the bottle's - egress allowlist; defense-in-depth complements pipelock's - hostname gate on the downstream leg. A bottle that wants a + egress allowlist. A bottle that wants a host reachable from the agent must declare a route for it (bare-pass route — no `auth`, no `path_allowlist` — is fine for hosts that just need passthrough). diff --git a/bot_bottle/egress_entrypoint.sh b/bot_bottle/egress_entrypoint.sh index c56de23..fb64ff4 100644 --- a/bot_bottle/egress_entrypoint.sh +++ b/bot_bottle/egress_entrypoint.sh @@ -6,15 +6,15 @@ # call it as a normal child. Behavior is unchanged: # # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch -# to `--mode upstream:URL` to forward all post-MITM traffic -# through pipelock. mitmproxy does NOT honor HTTPS_PROXY on -# its outbound side, so the upstream wiring has to be the -# mitmproxy mode flag, not env. +# to `--mode upstream:URL` to chain through an upstream proxy. +# mitmproxy does NOT honor HTTPS_PROXY on its outbound side, +# so the upstream wiring has to be the mitmproxy mode flag, +# not env. # * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a -# combined trust bundle (system roots + pipelock CA) and point +# combined trust bundle (system roots + upstream CA) and point # mitmproxy at it. The option REPLACES mitmproxy's default -# trust store, so passing pipelock's CA alone would break -# route-configured pipelock passthrough hosts. +# trust store, so passing the upstream CA alone would break +# non-chained hosts. # * `-s /app/egress_addon.py` loads the addon that reads # /etc/egress/routes.yaml. @@ -38,11 +38,7 @@ fi # Bind address. Docker backend wants `0.0.0.0` (agent dials egress # directly via the docker network alias). Smolmachines backend -# wants `127.0.0.1` because the agent dials pipelock — not egress -# — and egress is pipelock's localhost-only upstream inside the -# bundle. TSI's IP-only allowlist would otherwise let the agent -# reach `:9099` and bypass pipelock's DLP; binding -# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3). +# uses EGRESS_LISTEN_HOST when a non-default binding is needed. LISTEN_HOST_FLAG="" if [ -n "$EGRESS_LISTEN_HOST" ]; then LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST" @@ -56,13 +52,10 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then fi # Scope the proxy env to this process tree only. In the bundle -# image (PRD 0024) the four daemons share one container — setting +# image (PRD 0024) multiple daemons share one container — setting # HTTPS_PROXY at the container level would route git-gate's git -# pushes through pipelock, which is wrong (pipelock doesn't proxy -# SSH and would block public git repos). Setting them here means -# only mitmdump's subprocess inherits them. In the legacy -# four-sidecar setup these env vars are also set in compose; here -# they're additionally defensive. +# pushes through an upstream proxy unintentionally. Setting them +# here means only mitmdump's subprocess inherits them. if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY" diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 6a5c0ac..58427a2 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -15,9 +15,9 @@ a bare repo on the gate; `git daemon` serves the bare repos over The agent never sees the upstream credential under either path. -Why a third sidecar (not folded into pipelock or ssh-gate): the +Why a separate sidecar (not folded into egress or ssh-gate): the gate is the only one of the three that holds upstream push -credentials. Mixing it with pipelock would put push creds in the +credentials. Mixing it with egress would put push creds in the same blast radius as internet-facing TLS interception; mixing it with ssh-gate would force ssh-gate above L4 and into git-protocol land. See `docs/prds/0008-git-gate.md`. diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 63ad90d..2ab2c0c 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -18,8 +18,7 @@ Bottle schema (frontmatter): user: { name: , email: } # optional repos: { : , ... } # optional egress: { routes: [ , ... ] } - # route keys: host, path_allowlist, auth, role, pipelock - # pipelock: { tls_passthrough: , ssrf_ip_allowlist: [, ...] } + # route keys: host, path_allowlist, auth, role supervise: # optional Agent schema (frontmatter): @@ -98,12 +97,11 @@ class Bottle: git_user: GitUser = field(default_factory=GitUser) egress: EgressConfig = field(default_factory=EgressConfig) # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, - # the launch step brings up a supervise sidecar that exposes three - # MCP tools to the agent (cred-proxy-block, pipelock-block, - # capability-block; the cred-proxy-block tool is renamed and - # retargeted at egress in PRD 0017 chunk 3) plus mounts the - # current-config dir read-only into the agent at /etc/bot-bottle/ - # current-config. False (the default) skips the sidecar and mount. + # the launch step brings up a supervise sidecar that exposes MCP + # tools to the agent (egress-block, capability-block) plus mounts + # the current-config dir read-only into the agent at + # /etc/bot-bottle/current-config. False (the default) skips the + # sidecar and mount. supervise: bool = False @classmethod diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index afabd0a..9ea62b8 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -282,10 +282,8 @@ class _Supervisor: def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool: """Terminate one named child and spawn a fresh one, leaving - the other daemons running. Used by the pipelock-apply path: - pipelock has no in-process reload, so apply_allowlist_change - runs `docker kill --signal USR1 ` after writing the - new yaml; the supervisor catches SIGUSR1 and calls this. + the other daemons running. A daemon that has no in-process + reload can be restarted this way after its config file changes. Behavior: SIGTERM → wait up to `grace` seconds → SIGKILL if still alive → spawn a replacement under the same DaemonSpec. @@ -293,8 +291,8 @@ class _Supervisor: forward_signal / shutdown calls reach the new pid. Returns True iff a daemon by that name was running and a - replacement spawned; False if no such daemon (the - compose-renderer subset said this bottle doesn't run it).""" + replacement spawned; False if no such daemon (not wired + for this bottle).""" if self.shutdown_at is not None: _log(f"restart {daemon_name} skipped; supervisor is shutting down") return False diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 3e26c46..b6a2806 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -81,8 +81,7 @@ STATUS_REJECTED = "rejected" STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) # Operator-initiated audit entries (no tool call). PRD 0014's -# `routes edit ` and PRD 0015's `pipelock edit ` -# verbs write entries with this action. +# `routes edit ` verb writes entries with this action. ACTION_OPERATOR_EDIT = "operator-edit" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" diff --git a/bot_bottle/yaml_subset.py b/bot_bottle/yaml_subset.py index e110d95..159189f 100644 --- a/bot_bottle/yaml_subset.py +++ b/bot_bottle/yaml_subset.py @@ -63,7 +63,7 @@ from typing import cast class YamlSubsetError(ValueError): """Raised when input violates the YAML subset's rules. Callers - that want fatal-exit semantics (manifest loader, pipelock-apply, + that want fatal-exit semantics (manifest loader, egress-apply, etc.) catch this at their own boundary and forward to `die`; callers running outside the bot-bottle CLI process (the egress sidecar's addon) handle it as a normal exception.""" diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index 63e9fb6..14a2e44 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -53,7 +53,7 @@ _FAKE_SECRETS = { @skip_unless_docker() @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: pipelock_tls_init uses a host bind mount " + "skipped under act_runner: egress_tls_init uses a host bind mount " "the runner container can't see, and the network topology hides " "sibling-sidecar visibility — same constraint as the other " "bottle-bringup integration tests", @@ -256,14 +256,11 @@ class TestSandboxEscape(unittest.TestCase): # ---- attack 3: HTTP exfil shapes --------------------------------- - # Sandbox-block signature: pipelock / egress return HTTP 403 on - # policy reject; the response body carries a recognizable - # marker. Egress's reject message starts `"egress: host '...' - # is not in the bottle's egress.routes allowlist"`; pipelock's - # DLP rejects start `"blocked: "` (e.g. - # `"blocked: DLP match: Anthropic API Key (critical)"`, - # `"blocked: request body contains secret"`). - _SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:") + # Sandbox-block signature: egress returns HTTP 403 on policy + # reject; the response body carries a recognizable marker. + # Egress's reject message starts with `"egress: host '...' + # is not in the bottle's egress.routes allowlist"`. + _SANDBOX_BLOCK_MARKERS = ("egress:", "blocked:") def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore """A real sandbox block produces an HTTP 403 with a @@ -286,7 +283,7 @@ class TestSandboxEscape(unittest.TestCase): self.assertTrue( has_marker and http_code == "403", f"{label}: expected a sandbox-level block (HTTP 403 with " - f"egress/pipelock marker), got HTTP {http_code!r}. " + f"egress marker), got HTTP {http_code!r}. " f"If the response came from the actual upstream, the " f"secret REACHED the network — that's the leak this " f"test exists to catch. body={body!r} " @@ -297,9 +294,8 @@ class TestSandboxEscape(unittest.TestCase): """The agent tries to leak `$TEST_SECRET_ANTHROPIC` to an ALLOWLISTED destination (api.anthropic.com) via four different request shapes. Each must be blocked by a - sandbox layer (pipelock DLP / egress filter / etc.) — - producing an HTTP 403 with an `egress:` or `pipelock` - marker in the response body. + sandbox layer (egress allowlist filter) — producing an + HTTP 403 with an `egress:` marker in the response body. Anything else means the request reached upstream and the secret leaked, even if the upstream responded with its @@ -310,10 +306,10 @@ class TestSandboxEscape(unittest.TestCase): remediation lands as its own PRD before this test merges. DON'T mark expectedFailure to silence it. - Destination note: we use `raw.githubusercontent.com`, one - of the DEFAULT_ALLOWLIST hosts. It is not route-configured - for pipelock TLS passthrough, so pipelock MITMs it and the - DLP scan applies, which is what this attack exercises.""" + Destination note: we use `raw.githubusercontent.com`, which + is not in the bottle's egress.routes allowlist, so egress + blocks every request to it regardless of the secret's + location in the request.""" # Capture HTTP code via curl's -w; don't use --fail so # we get the response body even on 4xx. url_base = "https://raw.githubusercontent.com" @@ -352,13 +348,13 @@ class TestSandboxEscape(unittest.TestCase): def test_4_dns_exfil_blocked(self) -> None: """Two sub-attacks against DNS: - 4a — crafted subdomain that pipelock would resolve. The - hostname `.api.anthropic.com` looks "under" - the allowlisted apex but pipelock's allowlist is - exact-match — it should reject the host BEFORE - issuing the DNS query, so the secret never reaches - an external resolver. - 4b — direct DNS query bypassing pipelock entirely. The + 4a — crafted subdomain attack. The hostname + `.api.anthropic.com` looks "under" the + allowlisted apex but egress's allowlist is + exact-match — it rejects the host before issuing + a DNS query, so the secret never reaches an + external resolver. + 4b — direct DNS query bypassing egress entirely. The agent's internal network has no default gateway; even an explicit resolver like 8.8.8.8 should be unreachable. Confirms the network isolation is diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py index 5829588..2051ea3 100644 --- a/tests/integration/test_sidecar_bundle_compose.py +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -2,8 +2,8 @@ Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a working bottle: `docker compose up` brings the agent + bundle pair -online, the four daemons inside the bundle bind their ports, and -the agent can reach pipelock + supervise via the bundle's network +online, the daemons inside the bundle bind their ports, and the +agent can reach egress + supervise via the bundle's network aliases (no agent-side config changes between flag positions). Skipped under GITEA_ACTIONS — the bundle image is a multi-stage @@ -27,11 +27,9 @@ from tests._docker import skip_unless_docker def _manifest() -> Manifest: - """Bottle with supervise on so the bundle exercises three of - the four daemons (pipelock, egress, supervise). Git is off - because a meaningful git-gate test needs a real upstream and - SSH keys — out of scope for a bundle smoke. Egress is - implicitly on as pipelock's upstream regardless of routes.""" + """Bottle with supervise on so the bundle exercises egress + + supervise. Git is off because a meaningful git-gate test needs + a real upstream and SSH keys — out of scope for a bundle smoke.""" return Manifest.from_json_obj({ "bottles": { "dev": { @@ -68,21 +66,16 @@ class TestSidecarBundleCompose(unittest.TestCase): plan = backend.prepare(spec, stage_dir=stage_dir) with backend.launch(plan) as bottle: # The agent's HTTPS_PROXY URL (resolved at - # renderer-time, unchanged from the legacy - # shape) should reach pipelock inside the - # bundle. We probe by asking for the proxy's - # listening port from inside the agent. + # renderer-time) should reach egress inside + # the bundle. A bare CONNECT with no upstream + # URL gets rejected with 400 or 405 but proves + # the listener is alive at the alias. probe = bottle.exec( "set -eu\n" "echo HTTPS_PROXY=$HTTPS_PROXY\n" "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" "echo HOST=$HOST PORT=$PORT\n" - # nc is not in the agent image but curl is — - # a CONNECT with no upstream URL will get - # rejected by pipelock with 400 or 405 but - # confirms the listener is alive at the - # alias. "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' " " \"http://$HOST:$PORT/\" || true\n" ) @@ -98,11 +91,10 @@ class TestSidecarBundleCompose(unittest.TestCase): shutil.rmtree(stage_dir, ignore_errors=True) self.assertEqual(0, probe.returncode, msg=probe.stderr) - # pipelock answered SOMETHING — any 4xx is fine, just proves - # the bundle's pipelock daemon is listening at the - # `pipelock` alias on port 8888 (or whatever the env says). + # egress answered SOMETHING — any 4xx is fine, just proves + # the egress daemon is listening at the proxy address. self.assertIn("http=", probe.stdout, - f"no HTTP response from pipelock: {probe.stdout!r}") + f"no HTTP response from egress: {probe.stdout!r}") # supervise's /health endpoint exists (PRD 0013); it should # answer 200 or similar — anything non-empty proves the # third daemon's alias resolves to the same bundle. diff --git a/tests/integration/test_sidecar_bundle_image.py b/tests/integration/test_sidecar_bundle_image.py index 08c2644..771f438 100644 --- a/tests/integration/test_sidecar_bundle_image.py +++ b/tests/integration/test_sidecar_bundle_image.py @@ -1,15 +1,15 @@ """Integration: PRD 0024 chunk 1 — the sidecar bundle image builds -and the four daemon binaries are present + executable inside it. +and the daemon binaries are present + executable inside it. This test does NOT exercise the daemons running against real -config (pipelock.yaml, routes.yaml, etc) — that lands in chunk 2 -when the renderer wires the bundle into compose. What we verify -here is the chunk-1 contract: +config (routes.yaml, etc) — that lands in chunk 2 when the +renderer wires the bundle into compose. What we verify here is +the chunk-1 contract: - Dockerfile.sidecars builds (multi-stage works, base layers pull, COPYs resolve). - - pipelock, gitleaks, mitmdump are at the documented paths and - answer `--version`. + - gitleaks, mitmdump are at the documented paths and answer + `--version`. - The Python init at /app/sidecar_init.py runs and prints the expected "no daemons selected" line when the supervisor is pointed at an empty daemon set. @@ -74,11 +74,6 @@ class TestSidecarBundleImage(unittest.TestCase): ) return proc.returncode, proc.stdout.decode("utf-8", errors="replace") - def test_pipelock_binary_present_and_versioned(self): - rc, out = self._run_in_image("/usr/local/bin/pipelock", "version") - self.assertEqual(0, rc, msg=out) - self.assertIn("pipelock version", out) - def test_gitleaks_binary_present_and_versioned(self): rc, out = self._run_in_image("/usr/bin/gitleaks", "version") self.assertEqual(0, rc, msg=out) diff --git a/tests/integration/test_smolmachines_bundle_bringup.py b/tests/integration/test_smolmachines_bundle_bringup.py index 350abfc..043a4a9 100644 --- a/tests/integration/test_smolmachines_bundle_bringup.py +++ b/tests/integration/test_smolmachines_bundle_bringup.py @@ -81,13 +81,9 @@ class TestBundleBringup(unittest.TestCase): subnet=subnet, gateway=gateway, bundle_ip=bundle_ip, - # Only run the pipelock daemon for this smoke — it's - # the lightest of the four and doesn't need bind - # mounts beyond what we'd skip without - # BOT_BOTTLE_SIDECAR_DAEMONS. (The init - # supervisor will exit if pipelock fails to find its - # yaml — that's expected here; we just need the - # container to land on the network at the right IP.) + # Empty daemons_csv → init exits "no daemons selected" + # immediately. We just need the container to land on + # the network at the right IP before it exits. daemons_csv="", # empty → init exits "no daemons selected" ) start_bundle(spec) diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 75c2b41..b4ea4bd 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -124,32 +124,6 @@ class TestSmolmachinesLaunch(unittest.TestCase): f"expected a connect-refusal message; got: {r.stdout!r}", ) - def test_pipelock_answers_on_bundle_ip(self): - # Chunk 4b: the bundle's pipelock daemon is now actually - # running (was daemons_csv="" in chunks 2d/3). From inside - # the guest, a TCP connect to :8888 must succeed - # — distinct from the egress-port-bypass probe below where - # the connect must FAIL. - # - # We don't try to speak proxy protocol here — pipelock will - # 4xx a bare GET — we just verify the socket answers. - r = self.bottle.exec( - f"wget -T 5 -t 1 -O - http://{self.plan.bundle_ip}:8888/ " - "2>&1 || true" - ) - # Any HTTP response (even a 4xx) proves pipelock is up. - # "connection refused" / "unable to connect" / "timed out" - # would mean it isn't. - msg = r.stdout.lower() - self.assertNotIn( - "connection refused", msg, - f"pipelock connect refused — daemon not listening? {r.stdout!r}", - ) - self.assertNotIn( - "timed out", msg, - f"pipelock connect timed out: {r.stdout!r}", - ) - def test_prompt_file_lands_in_guest(self): # provision_prompt copies the host-side prompt.txt into the # guest at /root/.bot-bottle-prompt.txt. The content diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index a4ce017..3df9003 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -57,7 +57,7 @@ class TestEnumerateActiveAgents(unittest.TestCase): def test_concatenates_per_backend(self): a = ActiveAgent( backend_name="docker", slug="a-1", agent_name="impl", - started_at="", services=("pipelock",), + started_at="", services=("egress",), ) b = ActiveAgent( backend_name="smolmachines", slug="b-2", agent_name="research", diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 3633f39..426ee8f 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -32,7 +32,6 @@ from bot_bottle.egress import ( ) from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -80,18 +79,6 @@ def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec: ) -def _proxy_plan() -> PipelockProxyPlan: - return PipelockProxyPlan( - yaml_path=STATE / "pipelock.yaml", - slug=SLUG, - internal_network=f"bot-bottle-net-{SLUG}", - internal_network_cidr="10.1.2.0/24", - egress_network=f"bot-bottle-egress-{SLUG}", - ca_cert_host_path=STATE / "pipelock-ca" / "ca.pem", - ca_key_host_path=STATE / "pipelock-ca" / "ca-key.pem", - ) - - def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: return GitGatePlan( slug=SLUG, @@ -119,8 +106,6 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan: egress_network=f"bot-bottle-egress-{SLUG}", mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", - pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem", - pipelock_proxy_url="http://127.0.0.1:8888", ) @@ -178,7 +163,6 @@ def _plan( env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, prompt_file=STAGE / "prompt", - proxy_plan=_proxy_plan(), git_gate_plan=_git_gate_plan(upstreams), egress_plan=_egress_plan(routes), supervise_plan=_supervise_plan() if supervise else None, @@ -233,16 +217,15 @@ class TestAgentAlwaysPresent(unittest.TestCase): s = bottle_plan_to_compose(_plan())["services"]["agent"] self.assertEqual({"internal"}, set(s["networks"].keys())) - def test_agent_proxy_via_pipelock_when_no_egress(self): - s = bottle_plan_to_compose(_plan(with_egress=False))["services"]["agent"] - env = s["environment"] - # Looking for HTTPS_PROXY pointing at pipelock's container name. - proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] - self.assertEqual(1, len(proxy_lines)) - self.assertEqual( - "HTTPS_PROXY=http://pipelock:8888", - proxy_lines[0], - ) + def test_agent_proxy_always_via_egress(self): + for with_egress in (False, True): + with self.subTest(with_egress=with_egress): + s = bottle_plan_to_compose( + _plan(with_egress=with_egress) + )["services"]["agent"] + proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")] + self.assertEqual(1, len(proxy_lines)) + self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0]) def test_agent_proxy_via_egress_when_egress_present(self): s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"] @@ -306,9 +289,9 @@ class TestAgentAlwaysPresent(unittest.TestCase): class TestSidecarBundleShape(unittest.TestCase): """The compose renderer emits exactly one `sidecars` service in - place of the four daemons it owns (pipelock + egress + git-gate - + supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar - shape entirely, so the bundle is the only thing exercised here.""" + place of the daemons it owns (egress + git-gate + supervise). + PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely, + so the bundle is the only thing exercised here.""" def _render(self, **plan_kwargs: object) -> Any: # type: ignore return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore @@ -335,13 +318,10 @@ class TestSidecarBundleShape(unittest.TestCase): sc = self._render()["services"]["sidecars"] self.assertEqual({"internal", "egress"}, set(sc["networks"].keys())) - def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): - # The agent's HTTPS_PROXY url references either `egress` or - # `pipelock`. Both must resolve to the bundle. + def test_internal_aliases_include_egress_shortname(self): sc = self._render()["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) self.assertIn("egress", aliases) - self.assertIn("pipelock", aliases) def test_internal_aliases_omit_inactive_sidecars(self): # With no git-gate / supervise, those names are NOT aliased @@ -359,16 +339,13 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertIn("supervise", aliases) def test_daemons_csv_lists_only_active(self): - # Egress + pipelock are always in the daemon set even when - # the bottle has no routes (egress falls back to regular@9099 - # and is just unused; cheaper than special-casing). sc = self._render()["services"]["sidecars"] daemons = { line.split("=", 1)[1] for line in sc["environment"] if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") } - self.assertEqual({"egress,pipelock"}, daemons) + self.assertEqual({"egress"}, daemons) def test_daemons_csv_expands_with_optional_sidecars(self): sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] @@ -379,13 +356,13 @@ class TestSidecarBundleShape(unittest.TestCase): else: self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") self.assertEqual( - ["egress", "pipelock", "git-gate", "supervise"], + ["egress", "git-gate", "supervise"], csv.split(","), ) def test_bundle_env_does_not_set_https_proxy(self): # HTTPS_PROXY at the container level would route git-gate's - # git fetches through pipelock. Scoping it to mitmdump is + # git fetches through the proxy. Scoping it to mitmdump is # the job of egress_entrypoint.sh; the bundle env must not # leak it. sc = self._render(with_egress=True)["services"]["sidecars"] @@ -397,22 +374,15 @@ class TestSidecarBundleShape(unittest.TestCase): f"bundle env must not set {line!r}", ) - def test_egress_env_present_when_routes_declared(self): + def test_egress_token_env_present_when_routes_declared(self): sc = self._render(with_egress=True)["services"]["sidecars"] env_strings = sc["environment"] - self.assertTrue(any( - e.startswith("EGRESS_UPSTREAM_PROXY=") for e in env_strings)) - self.assertTrue(any( - e.startswith("EGRESS_UPSTREAM_CA=") for e in env_strings)) - # Token env name is forwarded as a bare entry. self.assertIn("EGRESS_TOKEN_0", env_strings) - def test_egress_env_omitted_when_no_routes(self): + def test_egress_token_env_omitted_when_no_routes(self): sc = self._render()["services"]["sidecars"] env_strings = sc["environment"] - for e in env_strings: - self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY=")) - self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA=")) + self.assertNotIn("EGRESS_TOKEN_0", env_strings) def test_supervise_env_present_when_active(self): sc = self._render(supervise=True)["services"]["sidecars"] @@ -421,22 +391,19 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings)) self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) - def test_volumes_union_minimal_includes_pipelock(self): + def test_volumes_always_includes_egress_ca(self): sc = self._render()["services"]["sidecars"] targets = {v["target"] for v in sc["volumes"]} - self.assertIn("/etc/pipelock.yaml", targets) + self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) def test_volumes_union_full_matrix(self): sc = self._render(with_git=True, with_egress=True, supervise=True)[ "services"]["sidecars"] targets = {v["target"] for v in sc["volumes"]} - # Pipelock + egress + git-gate + supervise paths all - # present. - self.assertIn("/etc/pipelock.yaml", targets) + self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) self.assertIn("/etc/egress/routes.yaml", targets) self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate/creds/upstream-known_hosts", targets) - # supervise queue dir target = QUEUE_DIR_IN_CONTAINER self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") for t in targets)) diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 9225d90..a53a51b 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -23,7 +23,6 @@ from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -90,9 +89,6 @@ def _plan( env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 7de7b0e..db9f4a2 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -24,7 +24,6 @@ from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -91,9 +90,6 @@ def _plan( env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index 0ba71c2..f0eb26a 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -40,22 +40,22 @@ class TestParseServicesByProject(unittest.TestCase): def test_multiple_services_per_project(self): out = _enumerate._parse_services_by_project( "bot-bottle-dev-abc\tegress\n" - "bot-bottle-dev-abc\tpipelock\n" + "bot-bottle-dev-abc\tgit-gate\n" "bot-bottle-dev-abc\tsupervise\n" ) self.assertEqual( - {"bot-bottle-dev-abc": {"egress", "pipelock", "supervise"}}, + {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}}, out, ) def test_multiple_projects(self): out = _enumerate._parse_services_by_project( "proj-a\tegress\n" - "proj-b\tpipelock\n" + "proj-b\tgit-gate\n" "proj-a\tsupervise\n" ) self.assertEqual( - {"proj-a": {"egress", "supervise"}, "proj-b": {"pipelock"}}, + {"proj-a": {"egress", "supervise"}, "proj-b": {"git-gate"}}, out, ) @@ -117,7 +117,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): )) self._stub( ["dev-abc"], - {"bot-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, + {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}}, ) active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) @@ -126,17 +126,17 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): self.assertEqual("dev-abc", a.slug) self.assertEqual("implementer", a.agent_name) self.assertEqual("2026-05-26T03:00:00+00:00", a.started_at) - self.assertEqual(("egress", "pipelock", "supervise"), a.services) + self.assertEqual(("egress", "git-gate", "supervise"), a.services) def test_missing_metadata_renders_question_mark(self): # State dir doesn't exist for this slug — agent_name falls # back to "?" rather than dropping the row. - self._stub(["mystery-zzz"], {"bot-bottle-mystery-zzz": {"pipelock"}}) + self._stub(["mystery-zzz"], {"bot-bottle-mystery-zzz": {"egress"}}) active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) self.assertEqual("?", active[0].agent_name) self.assertEqual("", active[0].started_at) - self.assertEqual(("pipelock",), active[0].services) + self.assertEqual(("egress",), active[0].services) def test_no_services_for_project_yields_empty_tuple(self): # Race window between `compose up` returning and the actual diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 73b3b1e..8761237 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -80,10 +79,6 @@ def _plan(tmp: str) -> DockerBottlePlan: env_file=stage / "env", forwarded_env={}, prompt_file=stage / "prompt.txt", - proxy_plan=PipelockProxyPlan( - yaml_path=stage / "pipelock.yaml", - slug="test-teardown-00001", - ), use_runsc=False, ) @@ -101,10 +96,6 @@ class TestTeardownWarning(unittest.TestCase): buf = io.StringIO() with mock.patch.object(launch_mod.docker_mod, "build_image"), \ - mock.patch.object( - launch_mod, "pipelock_tls_init", - return_value=(Path("/ca.crt"), Path("/ca.key")), - ), \ mock.patch.object( launch_mod, "egress_tls_init", return_value=(Path("/egress_ca"), Path("/egress_cert")), diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 9e6fc39..1fb447c 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -20,7 +20,6 @@ from bot_bottle.backend.docker.provision import git as _git from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -53,9 +52,6 @@ def _plan(*, git_user: dict | None = None, # type: ignore env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index bb8de25..9eb715a 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -133,19 +133,6 @@ class TestRoutesForBottleManifestOnly(unittest.TestCase): effective = [r.host for r in egress_routes_for_bottle(b)] self.assertEqual(["x.example"], effective) - def test_tls_passthrough_lifted_from_manifest(self): - b = _bottle([{ - "host": "api.openai.com", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - "pipelock": {"tls_passthrough": True}, - }]) - routes = egress_routes_for_bottle(b) - self.assertTrue(routes[0].tls_passthrough) - - def test_tls_passthrough_false_by_default(self): - b = _bottle([{"host": "api.github.com"}]) - routes = egress_routes_for_bottle(b) - self.assertFalse(routes[0].tls_passthrough) class TestProviderRouteMerge(unittest.TestCase): diff --git a/tests/unit/test_egress_addon_core.py b/tests/unit/test_egress_addon_core.py index 0b1fa90..904b870 100644 --- a/tests/unit/test_egress_addon_core.py +++ b/tests/unit/test_egress_addon_core.py @@ -180,7 +180,7 @@ class TestMatchRoute(unittest.TestCase): def test_wildcard_hosts_not_supported(self): # `*.example.com` is treated as a literal host string by # the exact-only matcher. Removed from the design after - # the apex/RFC-6125/pipelock-mirror edge cases stacked up. + # the apex/RFC-6125 edge cases stacked up. routes = (Route(host="*.example.com"),) self.assertIsNone(match_route(routes, "foo.example.com")) self.assertIsNone(match_route(routes, "example.com")) @@ -191,10 +191,8 @@ class TestMatchRoute(unittest.TestCase): class TestDecide(unittest.TestCase): def test_no_matching_route_blocks(self): - # Defense-in-depth: egress gates the bottle's allowlist - # too, not just pipelock. Any host the operator didn't declare - # in egress.routes is 403'd at egress before it - # ever reaches pipelock. + # Egress gates the bottle's allowlist. Any host the operator + # didn't declare in egress.routes is 403'd at egress. d = decide((), "elsewhere.example", "/anything", {}) self.assertEqual("block", d.action) self.assertIn("allowlist", d.reason) diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index ea54f20..4a78c98 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -6,9 +6,7 @@ import unittest from bot_bottle.backend.docker.egress_apply import ( EgressApplyError, - _hosts_in_routes, _merge_single_route, - _pipelock_safe_hosts, validate_routes_content, ) from bot_bottle.yaml_subset import parse_yaml_subset @@ -66,44 +64,6 @@ class TestValidateRoutesContent(unittest.TestCase): ) -class TestHostsInRoutes(unittest.TestCase): - def test_extracts_each_unique_host(self): - hosts = _hosts_in_routes( - 'routes:\n' - ' - host: "api.github.com"\n' - ' - host: "github.com"\n' - ' - host: "api.anthropic.com"\n' - ) - # Sorted+deduped. - self.assertEqual( - ["api.anthropic.com", "api.github.com", "github.com"], - hosts, - ) - - def test_dedupes_same_host(self): - hosts = _hosts_in_routes( - 'routes:\n' - ' - host: "x.example"\n' - ' path_allowlist:\n' - ' - "/a/"\n' - ' - host: "x.example"\n' - ' path_allowlist:\n' - ' - "/b/"\n' - ) - self.assertEqual(["x.example"], hosts) - - def test_empty_routes_returns_empty(self): - self.assertEqual([], _hosts_in_routes(_ROUTES_EMPTY)) - - def test_invalid_routes_raises(self): - # The mirror helper relies on parsing succeeding; bad input - # should error before pipelock is touched. - with self.assertRaises(EgressApplyError): - _hosts_in_routes( - 'routes:\n - path_allowlist:\n - "/no-host/"\n' - ) - - class TestMergeSingleRoute(unittest.TestCase): BASE = _ROUTES_ONE @@ -214,40 +174,5 @@ class TestMergeSingleRoute(unittest.TestCase): _merge_single_route("routes:\n\tbad", {"host": "x.example"}) -class TestPipelockSafeHosts(unittest.TestCase): - def test_passes_normal_hostnames_through(self): - self.assertEqual( - ["api.github.com", "registry.npmjs.org"], - _pipelock_safe_hosts(["api.github.com", "registry.npmjs.org"]), - ) - - def test_drops_wildcards(self): - # Wildcard host matching was removed from egress too, - # so a `*.foo.com` route is dead weight anyway; we drop it - # entirely from the pipelock mirror so the apply doesn't - # fail parse. - self.assertEqual( - ["api.github.com"], - _pipelock_safe_hosts(["*.example.com", "api.github.com"]), - ) - - def test_drops_bare_wildcard(self): - self.assertEqual([], _pipelock_safe_hosts(["*"])) - - def test_drops_ipv6_literals(self): - self.assertEqual( - ["api.example.com"], - _pipelock_safe_hosts(["[::1]", "api.example.com"]), - ) - - def test_preserves_order(self): - self.assertEqual( - ["a.example", "b.example", "c.example"], - _pipelock_safe_hosts([ - "a.example", "*.junk", "b.example", "weird host", "c.example", - ]), - ) - - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index caf6cc4..4fa023e 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -219,57 +219,10 @@ class TestRole(unittest.TestCase): _bottle([{"host": "x.example", "role": ["x", 42]}]) -class TestPipelockPolicy(unittest.TestCase): - def test_tls_passthrough_route_policy(self): - b = _bottle([{ - "host": "api.openai.com", - "pipelock": {"tls_passthrough": True}, - }]) - self.assertTrue(b.egress.routes[0].Pipelock.TlsPassthrough) - - def test_ssrf_ip_allowlist_route_policy(self): - b = _bottle([{ - "host": "gitea.dideric.is", - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}, - }]) - self.assertEqual( - ("100.78.141.42/32",), - b.egress.routes[0].Pipelock.SsrfIpAllowlist, - ) - - def test_tls_passthrough_defaults_false(self): - b = _bottle([{"host": "api.openai.com"}]) - self.assertFalse(b.egress.routes[0].Pipelock.TlsPassthrough) - self.assertEqual((), b.egress.routes[0].Pipelock.SsrfIpAllowlist) - - def test_pipelock_policy_must_be_object(self): +class TestPipelockKeyRejected(unittest.TestCase): + def test_pipelock_key_rejected_as_unknown(self): with self.assertRaises(ManifestError): - _bottle([{"host": "x.example", "pipelock": True}]) - - def test_tls_passthrough_must_be_bool(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"tls_passthrough": "yes"}, - }]) - - def test_ssrf_ip_allowlist_must_be_array(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"ssrf_ip_allowlist": "100.78.141.42/32"}, - }]) - - def test_ssrf_ip_allowlist_items_must_be_cidr_or_ip(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"ssrf_ip_allowlist": ["not-an-ip"]}, - }]) - - def test_unknown_pipelock_key_rejected(self): - with self.assertRaises(ManifestError): - _bottle([{"host": "x.example", "pipelock": {"wat": True}}]) + _bottle([{"host": "x.example", "pipelock": {"tls_passthrough": True}}]) class TestRouteValidation(unittest.TestCase): diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index e0fdb29..66458eb 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -93,13 +92,6 @@ def _agent_provision() -> AgentProvisionPlan: ) -def _proxy_plan(tmp: str) -> PipelockProxyPlan: - return PipelockProxyPlan( - yaml_path=Path(tmp) / "pipelock.yaml", - slug="test-00001", - ) - - def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: stage = Path(tmp) return DockerBottlePlan( @@ -121,7 +113,6 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: env_file=stage / "env", forwarded_env={}, prompt_file=stage / "prompt.txt", - proxy_plan=_proxy_plan(tmp), use_runsc=False, ) @@ -145,7 +136,6 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: agent_image_ref="bot-bottle-claude:latest", guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, prompt_file=stage / "prompt.txt", - proxy_plan=_proxy_plan(tmp), ) diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index 1256af0..cb77a35 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -29,11 +29,8 @@ from bot_bottle.sidecar_init import ( class TestEnvForDaemon(unittest.TestCase): """Scope egress-only credential env vars to the egress daemon. - Regression for issue #84: pipelock's `scan_env: true` matched - `EGRESS_TOKEN_*` against egress's just-injected Authorization - header and 403-blocked the legitimate request. The agent - never has access to these slots, so stripping them from - non-egress daemons loses no DLP coverage.""" + The agent never has access to EGRESS_TOKEN_* slots, so stripping + them from non-egress daemons loses no DLP coverage.""" _BASE = { "PATH": "/usr/bin", @@ -47,26 +44,20 @@ class TestEnvForDaemon(unittest.TestCase): env = _env_for_daemon("egress", self._BASE) self.assertEqual(self._BASE, env) - def test_pipelock_loses_egress_tokens(self): - env = _env_for_daemon("pipelock", self._BASE) - self.assertNotIn("EGRESS_TOKEN_0", env) - self.assertNotIn("EGRESS_TOKEN_1", env) - # Non-token bundle env stays — supervise / git-gate / git-http / the - # upstream proxy URL are all load-bearing for other - # daemons. - self.assertEqual("/usr/bin", env["PATH"]) - self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) - self.assertEqual("9100", env["SUPERVISE_PORT"]) - - def test_git_daemons_and_supervise_also_lose_egress_tokens(self): + def test_git_daemons_and_supervise_lose_egress_tokens(self): for name in ("git-gate", "git-http", "supervise"): env = _env_for_daemon(name, self._BASE) self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_1", env) + # Non-token bundle env stays — supervise / git-gate / git-http are + # all load-bearing for other daemons. + self.assertEqual("/usr/bin", env["PATH"]) + self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) + self.assertEqual("9100", env["SUPERVISE_PORT"]) def test_returns_independent_dict(self): # Caller mutation mustn't affect the original. - env = _env_for_daemon("pipelock", self._BASE) + env = _env_for_daemon("git-gate", self._BASE) env["X"] = "y" self.assertNotIn("X", self._BASE) @@ -78,7 +69,6 @@ class TestSelectedDaemons(unittest.TestCase): _DAEMONS = ( _DaemonSpec("egress", ("/bin/sh", "-c", ":")), - _DaemonSpec("pipelock", ("/bin/sh", "-c", ":")), _DaemonSpec("git-gate", ("/bin/sh", "-c", ":")), _DaemonSpec("supervise", ("/bin/sh", "-c", ":")), ) @@ -86,35 +76,34 @@ class TestSelectedDaemons(unittest.TestCase): def test_unset_returns_all(self): got = _selected_daemons({}, all_daemons=self._DAEMONS) self.assertEqual([d.name for d in got], - ["egress", "pipelock", "git-gate", "supervise"]) + ["egress", "git-gate", "supervise"]) def test_empty_returns_all(self): got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, all_daemons=self._DAEMONS) - self.assertEqual(4, len(got)) + self.assertEqual(3, len(got)) def test_whitespace_only_returns_all(self): got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "}, all_daemons=self._DAEMONS) - self.assertEqual(4, len(got)) + self.assertEqual(3, len(got)) def test_explicit_subset(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,pipelock"}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"}, all_daemons=self._DAEMONS, ) - self.assertEqual([d.name for d in got], ["egress", "pipelock"]) + self.assertEqual([d.name for d in got], ["egress", "git-gate"]) def test_preserves_canonical_order(self): # Order in the env var doesn't matter; the result follows - # the canonical _DAEMONS order so egress starts before - # pipelock (race-window reason). + # the canonical _DAEMONS order so egress starts first. got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,pipelock,egress"}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], - ["egress", "pipelock", "supervise"]) + ["egress", "git-gate", "supervise"]) def test_unknown_names_ignored(self): got = _selected_daemons( @@ -125,10 +114,10 @@ class TestSelectedDaemons(unittest.TestCase): def test_whitespace_in_names_stripped(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , pipelock "}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "}, all_daemons=self._DAEMONS, ) - self.assertEqual([d.name for d in got], ["egress", "pipelock"]) + self.assertEqual([d.name for d in got], ["egress", "git-gate"]) class TestSupervisor(unittest.TestCase): @@ -279,25 +268,24 @@ class TestSupervisor(unittest.TestCase): self._drive(sup) def test_restart_daemon_replaces_in_place(self): - # pipelock_apply.py sends SIGUSR1 to the bundle, supervisor - # restarts the pipelock daemon, supervise (the other - # daemon's MCP server in production) stays up. + # Restart one daemon; the other (supervise, the MCP server + # in production) must remain untouched. specs = [ - _DaemonSpec("pipelock", ("/bin/sleep", "30")), + _DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")), ] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - old_pipelock_pid = sup.procs[0][1].pid + old_git_gate_pid = sup.procs[0][1].pid supervise_pid = sup.procs[1][1].pid - ok = sup.restart_daemon("pipelock", grace=2.0) + ok = sup.restart_daemon("git-gate", grace=2.0) self.assertTrue(ok) - # Pipelock got a fresh PID — different process. - new_pipelock_pid = sup.procs[0][1].pid - self.assertNotEqual(old_pipelock_pid, new_pipelock_pid) + # git-gate got a fresh PID — different process. + new_git_gate_pid = sup.procs[0][1].pid + self.assertNotEqual(old_git_gate_pid, new_git_gate_pid) # Supervise's PID is unchanged — it was NOT restarted. self.assertEqual(supervise_pid, sup.procs[1][1].pid) self.assertIsNone(sup.procs[1][1].poll(), @@ -308,38 +296,38 @@ class TestSupervisor(unittest.TestCase): def test_request_restart_is_drained_by_tick(self): specs = [ - _DaemonSpec("pipelock", ("/bin/sleep", "30")), + _DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")), ] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - old_pipelock_pid = sup.procs[0][1].pid + old_git_gate_pid = sup.procs[0][1].pid supervise_pid = sup.procs[1][1].pid - ok = sup.request_restart("pipelock") + ok = sup.request_restart("git-gate") self.assertTrue(ok) # The non-blocking request path only records intent. - self.assertEqual(old_pipelock_pid, sup.procs[0][1].pid) + self.assertEqual(old_git_gate_pid, sup.procs[0][1].pid) done = sup.tick() self.assertFalse(done) - self.assertNotEqual(old_pipelock_pid, sup.procs[0][1].pid) + self.assertNotEqual(old_git_gate_pid, sup.procs[0][1].pid) self.assertEqual(supervise_pid, sup.procs[1][1].pid) sup.request_shutdown(reason="cleanup") self._drive(sup) def test_repeated_restart_requests_coalesce(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - self.assertTrue(sup.request_restart("pipelock")) - self.assertTrue(sup.request_restart("pipelock")) - self.assertEqual({"pipelock"}, sup._restart_requested) + self.assertTrue(sup.request_restart("git-gate")) + self.assertTrue(sup.request_restart("git-gate")) + self.assertEqual({"git-gate"}, sup._restart_requested) old_pid = sup.procs[0][1].pid sup.tick() @@ -374,23 +362,23 @@ class TestSupervisor(unittest.TestCase): self._drive(sup) def test_restart_during_shutdown_is_no_op(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() sup.request_shutdown(reason="test") - ok = sup.restart_daemon("pipelock") + ok = sup.restart_daemon("git-gate") self.assertFalse(ok, "must not respawn a daemon during teardown") self._drive(sup) def test_pending_restart_dropped_during_shutdown(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) old_pid = sup.procs[0][1].pid - self.assertTrue(sup.request_restart("pipelock")) + self.assertTrue(sup.request_restart("git-gate")) sup.request_shutdown(reason="test") self.assertEqual(set(), sup._restart_requested) self._drive(sup) diff --git a/tests/unit/test_smolmachines_prepare.py b/tests/unit/test_smolmachines_prepare.py index c851f76..a1237b3 100644 --- a/tests/unit/test_smolmachines_prepare.py +++ b/tests/unit/test_smolmachines_prepare.py @@ -56,7 +56,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase): patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), patch("bot_bottle.backend.smolmachines.prepare.GitGate") as mock_gg, - patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl, patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg, patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch( @@ -65,7 +64,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase): patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), ): mock_gg.return_value.prepare.return_value = MagicMock() - mock_pl.return_value.prepare.return_value = MagicMock() mock_eg.return_value.prepare.return_value = MagicMock() def _make_provision(**kwargs): # type: ignore return AgentProvisionPlan( diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 48d8d60..586f9bb 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -32,7 +32,6 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import GitEntry, Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -71,7 +70,6 @@ def _plan( stage_dir: Path | None = None, egress_routes: tuple[EgressRoute, ...] = (), egress_ca_path: Path = Path(), - pipelock_ca_path: Path = Path(), supervise: bool = False, bundle_ip: str = "192.168.50.2", agent_git_gate_host: str = "127.0.0.1:55555", @@ -131,11 +129,6 @@ def _plan( agent_image_ref="bot-bottle-claude:latest", guest_env=dict(guest_env or {}), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), - slug="demo-abc12", - ca_cert_host_path=pipelock_ca_path, - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -235,16 +228,13 @@ def _write_self_signed_cert(path: Path) -> None: class TestProvisionCA(unittest.TestCase): - """provision_ca selects the right CA cert (egress when the - bottle has routes, else pipelock) and dispatches + """provision_ca always uses the egress MITM CA and dispatches cp_in + exec in the right order.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self.tmp = Path(self._tmp.name) - self.pipelock_ca = self.tmp / "pipelock-ca.pem" self.egress_ca = self.tmp / "egress-ca.pem" - _write_self_signed_cert(self.pipelock_ca) _write_self_signed_cert(self.egress_ca) def tearDown(self): @@ -259,40 +249,22 @@ class TestProvisionCA(unittest.TestCase): stderr="", ) - def test_pipelock_path_when_no_routes(self): - plan = _plan(pipelock_ca_path=self.pipelock_ca) + def test_egress_ca_always_installed(self): + plan = _plan(egress_ca_path=self.egress_ca) bottle = _make_bottle(exec_result=self._UPDATE_OK) _ca.provision_ca(plan, bottle) bottle.cp_in.assert_called_once_with( - str(self.pipelock_ca), + str(self.egress_ca), _ca.AGENT_CA_PATH, ) - # chmod + chown + update-ca-certificates are folded into - # one exec invocation; look at the single exec's script - # rather than expecting separate calls. bottle.exec.assert_called_once() script = bottle.exec.call_args.args[0] self.assertIn("chmod 644", script) self.assertIn("update-ca-certificates", script) self.assertEqual("root", bottle.exec.call_args.kwargs.get("user")) - def test_egress_path_when_routes_declared(self): - plan = _plan( - egress_routes=(EgressRoute(host="api.anthropic.com"),), - egress_ca_path=self.egress_ca, - pipelock_ca_path=self.pipelock_ca, - ) - bottle = _make_bottle(exec_result=self._UPDATE_OK) - _ca.provision_ca(plan, bottle) - # When routes are declared, egress is the agent's first hop, - # so egress's CA is the one that gets installed. - bottle.cp_in.assert_called_once_with( - str(self.egress_ca), - _ca.AGENT_CA_PATH, - ) - def test_retries_smolvm_sigkill_during_update_ca(self): - plan = _plan(pipelock_ca_path=self.pipelock_ca) + plan = _plan(egress_ca_path=self.egress_ca) killed = ExecResult( returncode=137, stdout="Updating certificates in /etc/ssl/certs...\n", @@ -308,10 +280,8 @@ class TestProvisionCA(unittest.TestCase): self.assertEqual(2, bottle.exec.call_count) sleep.assert_called_once_with(1.0) - def test_dies_when_selected_cert_missing(self): - # Plan claims a pipelock cert at a path that doesn't exist — - # something went wrong in launch's pipelock_tls_init. - plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem") + def test_dies_when_egress_cert_missing(self): + plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem") bottle = _make_bottle() with self.assertRaises(SystemExit): _ca.provision_ca(plan, bottle) @@ -414,7 +384,7 @@ class TestBundleLaunchSpec(unittest.TestCase): spec = _bundle_launch_spec(plan, "net", "127.0.0.16") self.assertEqual( - "egress,pipelock,git-gate,git-http", + "egress,git-gate,git-http", spec.daemons_csv, ) self.assertIn(9420, spec.ports_to_publish) diff --git a/tests/unit/test_smolmachines_sidecar_bundle.py b/tests/unit/test_smolmachines_sidecar_bundle.py index 5426770..a207582 100644 --- a/tests/unit/test_smolmachines_sidecar_bundle.py +++ b/tests/unit/test_smolmachines_sidecar_bundle.py @@ -134,34 +134,35 @@ class TestStartBundle(unittest.TestCase): def test_daemons_env_passed_in(self): with self._patch_run() as m: - start_bundle(_spec(daemons_csv="egress,pipelock,supervise")) + start_bundle(_spec(daemons_csv="egress,supervise")) argv = m.call_args.args[0] self.assertIn("-e", argv) self.assertIn( - "BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock,supervise", + "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", argv, ) def test_environment_entries_pass_through(self): with self._patch_run() as m: start_bundle(_spec(environment=( - "EGRESS_UPSTREAM_PROXY=http://...", "SUPERVISE_BOTTLE_SLUG=demo-abc12", "EGRESS_TOKEN_0", # bare-name → host env inherit ))) argv = m.call_args.args[0] - self.assertIn("EGRESS_UPSTREAM_PROXY=http://...", argv) self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv) self.assertIn("EGRESS_TOKEN_0", argv) def test_volumes_render_with_ro_flag(self): with self._patch_run() as m: start_bundle(_spec(volumes=( - ("/host/pipelock.yaml", "/etc/pipelock.yaml", True), + ("/host/egress-ca.pem", "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", True), ("/host/queue", "/run/supervise/queue", False), ))) argv = m.call_args.args[0] - self.assertIn("/host/pipelock.yaml:/etc/pipelock.yaml:ro", argv) + self.assertIn( + "/host/egress-ca.pem:/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem:ro", + argv, + ) self.assertIn("/host/queue:/run/supervise/queue", argv) def test_failure_dies(self): diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 1f8671d..0191e36 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -247,13 +247,13 @@ class TestAuditLog(unittest.TestCase): write_audit_entry(AuditEntry( timestamp=f"2026-05-25T12:00:0{i}+00:00", bottle_slug="dev", - component="pipelock", + component="egress", operator_action=STATUS_APPROVED, operator_notes=f"n{i}", justification="", diff="", )) - path = audit_log_path("pipelock", "dev") + path = audit_log_path("egress", "dev") with path.open() as f: lines = [line for line in f if line.strip()] self.assertEqual(3, len(lines)) @@ -273,7 +273,7 @@ class TestAuditLog(unittest.TestCase): write_audit_entry(AuditEntry( timestamp="t", bottle_slug="dev", - component="pipelock", + component="egress", operator_action=STATUS_APPROVED, operator_notes="", justification="", @@ -289,7 +289,7 @@ class TestAuditLog(unittest.TestCase): diff="", )) self.assertEqual(1, len(read_audit_entries("cred-proxy", "dev"))) - self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) + self.assertEqual(1, len(read_audit_entries("egress", "dev"))) self.assertEqual(1, len(read_audit_entries("cred-proxy", "other"))) def test_read_audit_entries_missing_log_returns_empty(self): diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index b02bf3c..3fd8a09 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -18,7 +18,6 @@ from pathlib import Path from bot_bottle import supervise from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.egress_apply import EgressApplyError -from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError from bot_bottle.cli import supervise as supervise_cli from bot_bottle.supervise import ( Proposal, @@ -27,7 +26,6 @@ from bot_bottle.supervise import ( STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, read_audit_entries, read_response, sha256_hex, @@ -38,13 +36,8 @@ FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_BLOCK) -> Proposal: - # Per-tool payload shape: cred-proxy gets routes.yaml, pipelock - # gets a failed URL (PR #25 follow-up), capability gets a - # Dockerfile-ish blob. Match the production dispatch in - # PROPOSED_FILE_FIELD. payloads = { TOOL_EGRESS_BLOCK: '{"routes": []}\n', - TOOL_PIPELOCK_BLOCK: "https://example.com/path", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", } payload = payloads.get(tool, "") @@ -128,26 +121,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() self._original_add_route = supervise_cli.add_route - self._original_apply_allowlist = supervise_cli.apply_allowlist_change - self._original_fetch_allowlist = supervise_cli.fetch_current_allowlist self._original_apply_capability = supervise_cli.apply_capability_change # Default stubs: succeed with deterministic before/after so the # audit log shows a non-empty diff. supervise_cli.add_route = lambda slug, content: ( # type: ignore '{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', ) - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - "old.example\n", content, - ) - supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n" # type: ignore supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore "FROM old\n", content, ) def tearDown(self): supervise_cli.add_route = self._original_add_route - supervise_cli.apply_allowlist_change = self._original_apply_allowlist - supervise_cli.fetch_current_allowlist = self._original_fetch_allowlist supervise_cli.apply_capability_change = self._original_apply_capability self._teardown_fake_home() @@ -192,15 +177,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) supervise_cli.approve(qp) # No audit log for capability-block (per PRD 0013 / 0016). - # cred-proxy and pipelock logs both empty. self.assertEqual([], read_audit_entries("egress", "dev")) - self.assertEqual([], read_audit_entries("pipelock", "dev")) - - def test_pipelock_audit_distinct_from_egress(self): - qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) - supervise_cli.approve(qp) - self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) - self.assertEqual(0, len(read_audit_entries("egress", "dev"))) class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): @@ -299,91 +276,6 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual("", entries[0].diff) -class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): - """PRD 0015 Phase 2 + PR #25 follow-up: approve() on a - pipelock-block proposal carries the failed URL; the supervise TUI - extracts the host, merges it into the running allowlist, and - calls apply_allowlist_change with the merged content.""" - - def setUp(self): - self._setup_fake_home() - self._original_apply = supervise_cli.apply_allowlist_change - self._original_fetch = supervise_cli.fetch_current_allowlist - - def tearDown(self): - supervise_cli.apply_allowlist_change = self._original_apply - supervise_cli.fetch_current_allowlist = self._original_fetch - self._teardown_fake_home() - - def _enqueue_pipelock(self, failed_url: str = "https://api.github.com/repos/foo/bar"): - p = Proposal.new( - bottle_slug="dev", tool=TOOL_PIPELOCK_BLOCK, - proposed_file=failed_url, - justification="need to read PR metadata", - current_file_hash=sha256_hex(failed_url), - now=FIXED, - ) - qdir = supervise.queue_dir_for_slug("dev") - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) - - def test_url_host_merged_into_current_allowlist(self): - supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore - applied = [] - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - applied.append((slug, content)) - or ("existing.example\n", content) - ) - qp = self._enqueue_pipelock("https://api.github.com/repos/foo/bar") - supervise_cli.approve(qp) - # apply_allowlist_change was called with the merged content: - # existing host + the URL's host (no path, since pipelock is - # hostname-only). - self.assertEqual(1, len(applied)) - slug, content = applied[0] - self.assertEqual("dev", slug) - self.assertIn("existing.example", content) - self.assertIn("api.github.com", content) - self.assertNotIn("/repos/foo/bar", content) # path stripped - - def test_host_already_in_allowlist_is_idempotent(self): - supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n" # type: ignore - applied = [] - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - applied.append(content) - or ("api.github.com\n", content) - ) - qp = self._enqueue_pipelock("https://api.github.com/some/path") - supervise_cli.approve(qp) - # Still applied, but the content is unchanged from current — - # before/after diff is empty. - self.assertEqual(1, len(applied)) - self.assertEqual("api.github.com\n", applied[0]) - - def test_apply_failure_blocks_response_and_audit(self): - supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore - supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore - PipelockApplyError("docker exec failed") - ) - qp = self._enqueue_pipelock() - with self.assertRaises(PipelockApplyError): - supervise_cli.approve(qp) - self.assertEqual( - [qp.proposal.id], - [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], - ) - self.assertEqual([], read_audit_entries("pipelock", "dev")) - - def test_url_without_host_raises(self): - supervise_cli.fetch_current_allowlist = lambda slug: "" # type: ignore - # supervise_server's validator would catch this; if a broken - # URL ever makes it through, the supervise TUI surfaces it too. - qp = self._enqueue_pipelock("https:///nohost") - with self.assertRaises(PipelockApplyError): - supervise_cli.approve(qp) - - class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): """PRD 0016 Phase 3: approve() on a capability-block proposal calls apply_capability_change, archives the proposal afterward @@ -439,7 +331,6 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): # capability-block has no audit log per PRD 0013 — its record # lives in the per-bottle Dockerfile + transcript state. self.assertEqual([], read_audit_entries("egress", "dev")) - self.assertEqual([], read_audit_entries("pipelock", "dev")) def test_proposal_archived_after_apply(self): supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore diff --git a/tests/unit/test_supervise_cli_detail_lines.py b/tests/unit/test_supervise_cli_detail_lines.py deleted file mode 100644 index da7d358..0000000 --- a/tests/unit/test_supervise_cli_detail_lines.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Unit: supervise's detail-view line builder. - -_detail_lines returns (text, attr) tuples. Most are plain; for -pipelock-block proposals it appends a "→ would allow host: " -line tagged with the green attr so the operator sees at a glance -which hostname will land in pipelock's allowlist on approval.""" - -import unittest - -from bot_bottle.cli import supervise as supervise_cli -from bot_bottle.supervise import ( - Proposal, - TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, - sha256_hex, -) - - -def _qp(tool: str, payload: str) -> supervise_cli.QueuedProposal: - from datetime import datetime, timezone - from pathlib import Path - p = Proposal.new( - bottle_slug="dev", - tool=tool, - proposed_file=payload, - justification="needs", - current_file_hash=sha256_hex(payload), - now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), - ) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) - - -class TestPipelockHostHighlight(unittest.TestCase): - GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through - - def test_appends_green_host_line_for_pipelock_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"), - green_attr=self.GREEN, - ) - # The host appears as its own green-tagged line — literal - # text of what gets appended to pipelock's allowlist on - # approve. - green_lines = [text for text, attr in lines if attr == self.GREEN] - self.assertEqual(["api.github.com"], green_lines) - - def test_no_green_lines_for_egress_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_no_green_lines_for_capability_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_skips_host_line_when_url_unparseable(self): - # Shouldn't happen in production — supervise_server validates - # the URL before queuing — but if a malformed payload ever - # reaches the supervise TUI, don't render a misleading host line. - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_no_green_attr_passed_still_renders_host(self): - # Even without color support (green_attr=0), the host line - # is still present — it just won't be coloured. - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"), - green_attr=0, - ) - # Last non-empty line should be the host. - non_empty = [t for t, _ in lines if t] - self.assertEqual("api.github.com", non_empty[-1]) - - -class TestFailedUrlHost(unittest.TestCase): - def test_extracts_hostname(self): - self.assertEqual( - "api.github.com", - supervise_cli._failed_url_host("https://api.github.com/repos/foo"), - ) - - def test_returns_empty_for_unparseable(self): - self.assertEqual("", supervise_cli._failed_url_host("not a url")) - - def test_returns_empty_for_url_without_host(self): - self.assertEqual("", supervise_cli._failed_url_host("https:///nohost")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index a9a0985..c53411c 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -47,28 +47,6 @@ from bot_bottle.supervise_server import ( class TestValidation(unittest.TestCase): - def test_pipelock_block_accepts_https_url(self): - validate_proposed_file( - _sv.TOOL_PIPELOCK_BLOCK, - "https://api.github.com/repos/foo/bar", - ) - - def test_pipelock_block_accepts_http_url(self): - validate_proposed_file( - _sv.TOOL_PIPELOCK_BLOCK, - "http://internal.example/path/to/thing", - ) - - def test_pipelock_block_rejects_missing_scheme(self): - with self.assertRaises(_RpcError) as cm: - validate_proposed_file(_sv.TOOL_PIPELOCK_BLOCK, "api.github.com/foo") - self.assertIn("http://", str(cm.exception.message)) - - def test_pipelock_block_rejects_missing_host(self): - with self.assertRaises(_RpcError) as cm: - validate_proposed_file(_sv.TOOL_PIPELOCK_BLOCK, "https:///just-a-path") - self.assertIn("hostname", str(cm.exception.message)) - def test_capability_block_accepts_anything_nonempty(self): validate_proposed_file( _sv.TOOL_CAPABILITY_BLOCK,