chore: remove all pipelock references from tests, docs, and non-pipelock source
lint / lint (push) Failing after 1m26s
test / unit (pull_request) Failing after 35s
test / integration (pull_request) Successful in 44s

- Strip pipelock from all unit and integration test fixtures:
  proxy_plan fields removed from DockerBottlePlan/SmolmachinesBottlePlan
  constructors; pipelock-specific test classes deleted or renamed
- Update test_sidecar_init: remove test_pipelock_loses_egress_tokens,
  rename "pipelock" daemon fixtures to "git-gate" throughout
- Remove test_pipelock_binary_present_and_versioned from integration test
- Remove test_pipelock_answers_on_bundle_ip from smolmachines launch test
- Update _SANDBOX_BLOCK_MARKERS: remove "pipelock" marker (egress blocks)
- Dockerfile.sidecars: remove pipelock build stage and COPY; update layout
  comments and port table
- egress_entrypoint.sh: update comments now that egress is sole proxy
- Clean up pipelock references in comments/docstrings across backend,
  network, manifest, supervise, git_gate, yaml_subset, agent_provider,
  sidecar_bundle, sidecar_init, egress_addon_core modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:54:06 +00:00
parent bbd6ec85ac
commit a59da9921e
53 changed files with 266 additions and 945 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Weekly canary suite. Catches upstream regressions (broken pipelock # Weekly canary suite. Catches upstream regressions (broken pinned
# image packaging at the pinned digest, etc.) without coupling every # digest, etc.) without coupling every dev push to upstream registry
# dev push to upstream registry availability. # availability.
# #
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run # Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
# locally with the same gating. # locally with the same gating.
+1 -1
View File
@@ -21,7 +21,7 @@ FROM node:22-slim
# runs as root and rejects non-root connections, so socat sits between # 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 # node and the agent socket. curl is here so any HTTPS_PROXY-aware
# tool (curl itself, plus anything that shells out to it) works # 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils python3 python3-pip python3-venv \ && 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/* && rm -rf /var/lib/apt/lists/*
+11 -26
View File
@@ -1,23 +1,18 @@
# Per-bottle sidecar bundle image (PRD 0024). # Per-bottle sidecar bundle image (PRD 0024).
# #
# Collapses the four prior per-sidecar images (pipelock, egress, # Collapses the prior per-sidecar images (egress, git-gate,
# git-gate, supervise) into one. A small stdlib-Python init # supervise) into one. A small stdlib-Python init supervisor at
# supervisor at /app/sidecar_init.py spawns all four daemons, # /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
# forwards SIGTERM, and propagates per-daemon stdout/stderr to the # propagates per-daemon stdout/stderr to the container log with a
# container log with a `[name]` prefix. See PRD 0024 for the # `[name]` prefix. See PRD 0024 for the rationale.
# rationale.
# #
# Layout (preserved verbatim from the prior four Dockerfiles so the # Layout:
# compose renderer's bind-mount paths and docker-cp targets keep
# working):
# #
# /usr/local/bin/pipelock pipelock binary
# /usr/bin/gitleaks gitleaks binary # /usr/bin/gitleaks gitleaks binary
# /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress_addon.py + siblings mitmproxy addon (egress)
# /app/egress-entrypoint.sh mitmdump launcher # /app/egress-entrypoint.sh mitmdump launcher
# /app/supervise_server.py + .py supervise MCP server # /app/supervise_server.py + .py supervise MCP server
# /app/sidecar_init.py PID 1 supervisor # /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/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time # /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh 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 # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# #
# Exposed ports inside the container: # Exposed ports inside the container:
# 8888 pipelock (HTTPS_PROXY) # 9099 egress (mitmproxy, agent-facing HTTPS proxy)
# 9099 egress (mitmproxy, pipelock's upstream — not externally
# addressed by the agent)
# 9418 git-gate (git-daemon) # 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (smolmachines agent-facing transport) # 9420 git-gate smart HTTP (smolmachines agent-facing transport)
# 9100 supervise (MCP HTTP) # 9100 supervise (MCP HTTP)
# Stage 1: pipelock binary. The upstream pipelock image is a # Stage 1: gitleaks binary. The upstream gitleaks image is alpine
# 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
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep # with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
# with Dockerfile.git-gate's prior base (now deleted at chunk 3). # with Dockerfile.git-gate's prior base (now deleted at chunk 3).
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src 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 # Python + mitmdump pre-installed — heavier than the others, so
# this stage starts there and pulls the standalone binaries in. # this stage starts there and pulls the standalone binaries in.
FROM mitmproxy/mitmproxy:11.1.3 FROM mitmproxy/mitmproxy:11.1.3
@@ -60,16 +47,14 @@ USER root
# plus the core `git` binary the pre-receive hook invokes. # plus the core `git` binary the pre-receive hook invokes.
# openssh-client supplies the upstream SSH transport the # openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs. # pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for both pipelock and mitmdump # ca-certificates is needed for mitmdump upstream TLS (the
# upstream TLS (the base image already has it; listed for # base image already has it; listed for explicitness).
# explicitness).
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \ git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Pull the standalone binaries into the final image. # 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 COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Project Python: addon + server modules + the init supervisor. # Project Python: addon + server modules + the init supervisor.
+3 -3
View File
@@ -84,9 +84,9 @@ class AgentProvisionPlan:
return the same shape without adding backend-plan fields. return the same shape without adding backend-plan fields.
`egress_routes` are provider-declared EgressRoutes that backends `egress_routes` are provider-declared EgressRoutes that backends
pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps pass to `Egress.prepare`. This keeps provider logic out of the
provider logic out of the egress and pipelock modules — they merge egress module — it merges provider routes generically without
provider routes generically without knowing the provider type. knowing the provider type.
`hidden_env_names` is the set of env var names the provider injected `hidden_env_names` is the set of env var names the provider injected
as non-secret placeholders. `print_util.visible_agent_env_names` uses as non-secret placeholders. `print_util.visible_agent_env_names` uses
+5 -5
View File
@@ -163,8 +163,8 @@ class ActiveAgent:
bottle is the container, the agent is what runs in it.) bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set Fields are deliberately backend-neutral. `services` is the set
of sidecar daemons currently up for this bottle (`pipelock`, of sidecar daemons currently up for this bottle (`egress`,
`egress`, `git-gate`, `supervise`); the dashboard uses it to `git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `smolmachines`) — used by the active- `_BACKENDS` (`docker` / `smolmachines`) — used by the active-
list rendering to disambiguate and by the dashboard's 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 `user` (default `node`, matching the agent image's USER
directive) and return the captured stdout/stderr/returncode. directive) and return the captured stdout/stderr/returncode.
The bottle's environment (including HTTPS_PROXY pointing at 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` exit does not raise — callers inspect `returncode`
themselves. themselves.
@@ -352,8 +352,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None: def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install the per-bottle CA into the agent's trust store so """Install the per-bottle CA into the agent's trust store so
the agent trusts the bumped CONNECT cert egress (was the agent trusts the bumped CONNECT cert egress presents.
pipelock, pre-PRD-0017) presents. Default impl is a no-op so Default impl is a no-op so
backends that don't yet support TLS interception (every backend backends that don't yet support TLS interception (every backend
except Docker today) aren't forced to implement it. The Docker except Docker today) aren't forced to implement it. The Docker
backend overrides to docker-cp the cert in and run backend overrides to docker-cp the cert in and run
-1
View File
@@ -4,7 +4,6 @@ The bulk of the implementation lives in sibling modules:
- util: thin Docker subprocess wrappers - util: thin Docker subprocess wrappers
- network: Docker network plumbing - network: Docker network plumbing
- pipelock: DockerPipelockProxy lifecycle
- bottle_plan: DockerBottlePlan - bottle_plan: DockerBottlePlan
- bottle_cleanup_plan: DockerBottleCleanupPlan - bottle_cleanup_plan: DockerBottleCleanupPlan
- bottle: DockerBottle handle - bottle: DockerBottle handle
+2 -2
View File
@@ -56,8 +56,8 @@ _AGENT_SUBDIR = "agent"
_METADATA_NAME = "metadata.json" _METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only). # Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's # Host's apply paths keep these files fresh so supervise's
# `list-pipelock-allowlist` / `list-egress-routes` MCP tools # `list-egress-routes` MCP tool returns the current state —
# return the current state — not a snapshot from launch time. # not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config" _LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
+5 -6
View File
@@ -50,6 +50,7 @@ from .git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
) )
from . import network as network_mod
from .sidecar_bundle import ( from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -91,11 +92,11 @@ def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
bridge.""" bridge."""
return { return {
"internal": { "internal": {
"name": plan.proxy_plan.internal_network, "name": network_mod.network_name_for_slug(plan.slug),
"internal": True, "internal": True,
}, },
"egress": { "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 ------------------------------------------------------- # --- egress -------------------------------------------------------
ep = plan.egress_plan ep = plan.egress_plan
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
if ep.routes: if ep.routes:
volumes += [ volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
]
for token_env in sorted(ep.token_env_map.keys()): for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env) env.append(token_env)
+1 -3
View File
@@ -116,10 +116,8 @@ def launch(
internal_network=internal_network, internal_network=internal_network,
egress_network=egress_network, egress_network=egress_network,
) )
egress_plan = plan.egress_plan
if egress_plan.routes:
egress_plan = dataclasses.replace( egress_plan = dataclasses.replace(
egress_plan, plan.egress_plan,
internal_network=internal_network, internal_network=internal_network,
egress_network=egress_network, egress_network=egress_network,
mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_host_path=egress_ca_host,
+5 -14
View File
@@ -1,11 +1,10 @@
"""Docker network plumbing for the per-agent egress topology. """Docker network plumbing for the per-agent egress topology.
The agent container sits on a Docker `--internal` network (no default The agent container sits on a Docker `--internal` network (no default
gateway). Pipelock straddles that network and a per-agent user-defined gateway). Egress straddles that network and a per-agent user-defined
bridge for upstream egress. We deliberately do NOT use Docker's legacy bridge for upstream traffic. We deliberately do NOT use Docker's legacy
`bridge` network because only user-defined bridges run Docker's `bridge` network because only user-defined bridges run Docker's
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com embedded DNS resolver, which egress needs to resolve upstream hostnames.
and similar upstream hostnames.
Naming: bot-bottle-net-<slug> (internal), Naming: bot-bottle-net-<slug> (internal),
bot-bottle-egress-<slug> (egress). Numeric suffix on conflict bot-bottle-egress-<slug> (egress). Numeric suffix on conflict
@@ -77,20 +76,12 @@ def network_create_internal(slug: str) -> str:
def network_create_egress(slug: str) -> str: def network_create_egress(slug: str) -> str:
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`) """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) return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
def network_inspect_cidr(name: str) -> str: def network_inspect_cidr(name: str) -> str:
"""Return the IPv4 CIDR Docker assigned to a user-defined network. """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."""
result = subprocess.run( result = subprocess.run(
["docker", "network", "inspect", ["docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name], "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name],
+3 -4
View File
@@ -200,10 +200,9 @@ def resolve_plan(
# root; for `--cwd` derived images the base Dockerfile is what # root; for `--cwd` derived images the base Dockerfile is what
# the agent should propose changes against (the derived layer # the agent should propose changes against (the derived layer
# is just a workspace copy). # is just a workspace copy).
# (routes.yaml + pipelock allowlist used to land here too but # (routes.yaml used to land here too but PRD 0017 chunk 3
# PRD 0017 chunk 3 moved them behind the # moved it behind the `list-egress-routes` MCP tool so the
# `list-egress-routes` MCP tool so the agent gets live # agent gets live state rather than a launch-time snapshot.)
# state rather than a launch-time snapshot.)
supervise_dockerfile_path = ( supervise_dockerfile_path = (
Path(dockerfile_path) Path(dockerfile_path)
if dockerfile_path if dockerfile_path
+5 -16
View File
@@ -1,19 +1,8 @@
"""Install the per-bottle MITM CA into the agent container's trust """Install the per-bottle egress MITM CA into the agent container's
store. trust store.
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: By the time this provisioner runs, `egress_tls_init` has generated
the egress CA and the path is re-bound into `plan.egress_plan`.
- 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.
Cert lands on Debian's standard source path Cert lands on Debian's standard source path
(`/usr/local/share/ca-certificates/`); `update-ca-certificates` (`/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 """Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up.""" `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.cp_in(str(cert_host_path), AGENT_CA_PATH)
bottle.exec( bottle.exec(
+4 -4
View File
@@ -2,10 +2,10 @@
(PRD 0024). (PRD 0024).
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1) The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container runs egress + git-gate + supervise as one container per bottle
per bottle under a small Python init supervisor. As of chunk 5 under a small Python init supervisor. As of chunk 5 the bundle
the bundle is the only shape the legacy four-sidecar topology is the only shape the legacy four-sidecar topology and its
and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
from __future__ import annotations from __future__ import annotations
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...agent_provider import PromptMode from ...agent_provider import PromptMode
from ...pipelock import PipelockProxyPlan
from .. import BottlePlan from .. import BottlePlan
@@ -71,7 +70,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# docker's `--internal` + egress bridge topology; it's on a # docker's `--internal` + egress bridge topology; it's on a
# per-bottle bridge with a pinned IP. The unused fields stay # per-bottle bridge with a pinned IP. The unused fields stay
# at their dataclass defaults. # at their dataclass defaults.
proxy_plan: PipelockProxyPlan
# Agent-side endpoints. On Docker Desktop the docker bridge # Agent-side endpoints. On Docker Desktop the docker bridge
# IPs aren't reachable from the smolvm guest (TSI uses macOS # IPs aren't reachable from the smolvm guest (TSI uses macOS
# networking; docker container IPs live in the daemon's VM), # networking; docker container IPs live in the daemon's VM),
+2 -2
View File
@@ -69,8 +69,8 @@ def enumerate_active() -> list[ActiveAgent]:
def _query_bundle_services() -> dict[str, tuple[str, ...]]: def _query_bundle_services() -> dict[str, tuple[str, ...]]:
"""`{slug: ('egress', 'pipelock', ...)}` from each running """`{slug: ('egress', ...)}` from each running bundle container's
bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var. `BOT_BOTTLE_SIDECAR_DAEMONS` env var.
Smolmachines bundles all run the PRD-0024 image with the Smolmachines bundles all run the PRD-0024 image with the
same daemon set declared via env, so one inspect per bundle same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container. gets us the picture without exec'ing into the container.
+15 -83
View File
@@ -9,13 +9,9 @@ guest pointed at the bundle's pinned IP via TSI's
exit. exit.
The bundle's daemons consume the inner Plans the docker backend The bundle's daemons consume the inner Plans the docker backend
already produces: pipelock reads its yaml + CA from the already produces: egress reads routes + CAs from the EgressPlan.
PipelockProxyPlan; egress reads routes + CAs from the EgressPlan Git-gate + supervise plumb through the same plans the docker
+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle backend uses, minus the docker-network fields that don't apply here."""
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."""
from __future__ import annotations from __future__ import annotations
@@ -29,16 +25,11 @@ from ...egress import (
EGRESS_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
egress_resolve_token_values, 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 ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
EGRESS_PORT as _EGRESS_PORT, EGRESS_PORT as _EGRESS_PORT,
egress_tls_init, egress_tls_init,
) )
@@ -48,14 +39,9 @@ from ..docker.git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_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 ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn 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 loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm 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 # Container-internal listening ports for each bundle daemon. The
# bundle publishes each one on a random host loopback port (see # bundle publishes each one on a random host loopback port (see
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks # `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
# them up post-start. Pipelock's port is an env-overridable string # them up post-start.
# in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_GIT_HTTP_PORT = 9420 _GIT_HTTP_PORT = 9420
_SUPERVISE_PORT = SUPERVISE_PORT _SUPERVISE_PORT = SUPERVISE_PORT
@@ -167,33 +151,16 @@ def _allocate_resources(
def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan: def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
"""Mint per-bottle CAs and return the plan with CA paths filled. """Mint the egress MITM CA 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,
)
egress_plan = plan.egress_plan
if egress_plan.routes:
egress_ca_host, egress_ca_cert_only = egress_tls_init( egress_ca_host, egress_ca_cert_only = egress_tls_init(
plan.egress_plan.routes_path.parent, egress_state_dir(plan.slug),
) )
egress_plan = dataclasses.replace( egress_plan = dataclasses.replace(
egress_plan, plan.egress_plan,
mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, 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) return dataclasses.replace(plan, egress_plan=egress_plan)
def _start_bundle( def _start_bundle(
@@ -224,17 +191,10 @@ def _discover_urls(
macOS networking, and macOS sees the daemon's bridge via the macOS networking, and macOS sees the daemon's bridge via the
published-port loopback forward only. 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 NO_PROXY includes the per-bottle loopback alias so the
supervise + git-gate URLs bypass HTTPS_PROXY.""" 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( 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}" 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. """Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV: Daemons in the CSV:
- egress + pipelock are always present (pipelock is the - egress is always present.
agent's first hop; egress is its upstream).
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan. - 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 daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh see PRD 0024's bundle egress process by egress_entrypoint.sh see PRD 0024's bundle
bind-address PR).""" bind-address PR)."""
daemons: list[str] = ["egress", "pipelock"] daemons: list[str] = ["egress"]
env: list[str] = [] env: list[str] = []
volumes: list[tuple[str, str, bool]] = [] 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 ----------------------------------------------- # --- egress -----------------------------------------------
ep = plan.egress_plan ep = plan.egress_plan
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
if ep.routes: if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
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),
]
# Bare-name entries for upstream-token slots. Their values # Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from # come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv. # 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 — # Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI + # published on host loopback so the guest can dial via TSI +
# macOS networking. The HTTP/HTTPS chokepoint is whichever # macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
# 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] ports_to_publish: list[int] = [_EGRESS_PORT]
else:
ports_to_publish = [_PIPELOCK_PORT]
if gp.upstreams: if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT) ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None: if sp is not None:
@@ -48,7 +48,7 @@ from ...log import die
# registry:2.8.3, pinned by digest. Same env-override pattern as the # 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( REGISTRY_IMAGE = os.environ.get(
"BOT_BOTTLE_REGISTRY_IMAGE", "BOT_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
+3 -19
View File
@@ -23,24 +23,21 @@ from ...backend.docker.bottle_state import (
bottle_identity, bottle_identity,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
pipelock_state_dir,
supervise_state_dir, supervise_state_dir,
write_metadata, write_metadata,
) )
from ...egress import Egress from ...egress import Egress
from ...env import resolve_env from ...env import resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Gateway ports the bundle exposes inside its container — pipelock # Gateway ports the bundle exposes inside its container — git-gate's
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent # git-daemon, supervise's MCP. The agent inside the smolvm guest
# inside the smolvm guest dials these on the bundle's pinned IP. # dials these on the bundle's pinned IP.
_BUNDLE_PIPELOCK_PORT = 8888
_BUNDLE_GIT_GATE_PORT = 9418 _BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100 _BUNDLE_SUPERVISE_PORT = 9100
@@ -145,18 +142,6 @@ def resolve_plan(
merged_guest_env.setdefault(key, val) merged_guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=merged_guest_env) 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 = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare( egress_plan = Egress().prepare(
@@ -181,7 +166,6 @@ def resolve_plan(
agent_image_ref=agent_image_ref, agent_image_ref=agent_image_ref,
guest_env=agent_provision.guest_env, guest_env=agent_provision.guest_env,
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
@@ -1,13 +1,10 @@
"""Install the per-bottle MITM CA into the smolmachines guest's """Install the per-bottle egress MITM CA into the smolmachines
trust store (PRD 0023 chunk 4d). guest's trust store (PRD 0023 chunk 4d).
Mirrors `backend.docker.provision.ca`: select the right CA (egress Mirrors `backend.docker.provision.ca`: copy the egress CA to
when the bottle has routes, else pipelock), copy it to Debian's Debian's `/usr/local/share/ca-certificates/` path,
`/usr/local/share/ca-certificates/` path,
`update-ca-certificates` to rebuild the trust bundle, and log the `update-ca-certificates` to rebuild the trust bundle, and log the
fingerprint once. The selected cert depends on the agent's fingerprint once.
HTTP_PROXY target same logic as the docker backend, since the
agent dials the same daemons through the same bundle.
`smolvm machine exec` runs commands as root in the VM (no `-u` `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 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 """Copy the agent-facing CA cert into the guest, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the smolvm guest is up.""" `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) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
# Mode 0644 — readable to non-root tools in the guest. # Mode 0644 — readable to non-root tools in the guest.
@@ -19,7 +19,7 @@ This module ships the lifecycle primitives only — create
network, start bundle, stop bundle, remove network wrapped network, start bundle, stop bundle, remove network wrapped
around `subprocess.run(["docker", ...])`. Wiring them into the around `subprocess.run(["docker", ...])`. Wiring them into the
launch flow + populating the `BundleLaunchSpec` from the inner 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 from __future__ import annotations
@@ -69,7 +69,7 @@ class BundleLaunchSpec:
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
# supervisor inside the bundle reads it to skip # supervisor inside the bundle reads it to skip
# bottle-irrelevant daemons (e.g. supervise=False bottles). # 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 # Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
# form inherits the value from the docker-run subprocess env, # form inherits the value from the docker-run subprocess env,
# matching the docker backend's compose-up secret-forwarding # matching the docker backend's compose-up secret-forwarding
+5 -21
View File
@@ -14,7 +14,6 @@ from ..log import die, info
if TYPE_CHECKING: if TYPE_CHECKING:
from ..egress import EgressPlan from ..egress import EgressPlan
from ..pipelock import PipelockProxyPlan
# Debian-family CA layout, shared by every backend (all guest images # Debian-family CA layout, shared by every backend (all guest images
@@ -35,19 +34,12 @@ def host_skill_dir(name: str) -> str:
return f"{home}/.claude/skills/{name}" return f"{home}/.claude/skills/{name}"
def select_ca_cert( def select_ca_cert(egress_plan: EgressPlan) -> tuple[Path, str]:
egress_plan: EgressPlan, proxy_plan: PipelockProxyPlan """Return the egress MITM CA cert path and label for provision_ca.
) -> 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.
Shared by every backend's `provision_ca`: launch mints the chosen Launch always mints the CA and re-binds the host path into the
CA(s) and re-binds their host paths into these inner plans before egress_plan before provision runs, so an empty/missing path here
provision runs, so an empty/missing path here means launch's means launch's bringup is broken — fatal."""
bringup is broken fatal."""
if egress_plan.routes:
cert = egress_plan.mitmproxy_ca_cert_only_host_path cert = egress_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file(): if cert == Path() or not cert.is_file():
die( die(
@@ -56,14 +48,6 @@ def select_ca_cert(
f"re-bound the plan before provision" f"re-bound the plan before provision"
) )
return cert, "egress" return cert, "egress"
cert = proxy_plan.ca_cert_host_path
if not cert 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"
)
return cert, "pipelock"
def log_ca_fingerprint(cert_host_path: Path, label: str) -> None: def log_ca_fingerprint(cert_host_path: Path, label: str) -> None:
+4 -5
View File
@@ -167,7 +167,7 @@ def is_git_push_request(path: str, query: str) -> bool:
Universal across routes the block fires even when no Universal across routes the block fires even when no
egress route matches the host. A bare-pass route (host with egress route matches the host. A bare-pass route (host with
no auth, no path_allowlist) would otherwise let push through to no auth, no path_allowlist) would otherwise let push through to
pipelock + upstream untouched. the upstream untouched.
""" """
if path.endswith("/git-receive-pack"): if path.endswith("/git-receive-pack"):
return True return True
@@ -189,8 +189,8 @@ def match_route(
exactly (case-insensitive). DNS names are case-insensitive. exactly (case-insensitive). DNS names are case-insensitive.
Wildcard hosts (`*.foo.com`) are NOT supported they caused Wildcard hosts (`*.foo.com`) are NOT supported they caused
too many edge cases (apex match? cert validation? pipelock too many edge cases (apex match? cert validation?) for too
mirror mismatch?) for too little payoff. Operators that need little payoff. Operators that need
multiple subdomains declare them individually (or one common multiple subdomains declare them individually (or one common
parent host as a bare-pass route).""" parent host as a bare-pass route)."""
target = request_host.lower() target = request_host.lower()
@@ -210,8 +210,7 @@ def decide(
return what the addon should do with the request. return what the addon should do with the request.
- No matching route BLOCK. The route table is the bottle's - No matching route BLOCK. The route table is the bottle's
egress allowlist; defense-in-depth complements pipelock's egress allowlist. A bottle that wants a
hostname gate on the downstream leg. A bottle that wants a
host reachable from the agent must declare a route for it host reachable from the agent must declare a route for it
(bare-pass route no `auth`, no `path_allowlist` is fine (bare-pass route no `auth`, no `path_allowlist` is fine
for hosts that just need passthrough). for hosts that just need passthrough).
+11 -18
View File
@@ -6,15 +6,15 @@
# call it as a normal child. Behavior is unchanged: # call it as a normal child. Behavior is unchanged:
# #
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
# to `--mode upstream:URL` to forward all post-MITM traffic # to `--mode upstream:URL` to chain through an upstream proxy.
# through pipelock. mitmproxy does NOT honor HTTPS_PROXY on # mitmproxy does NOT honor HTTPS_PROXY on its outbound side,
# its outbound side, so the upstream wiring has to be the # so the upstream wiring has to be the mitmproxy mode flag,
# mitmproxy mode flag, not env. # not env.
# * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a # * 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 # mitmproxy at it. The option REPLACES mitmproxy's default
# trust store, so passing pipelock's CA alone would break # trust store, so passing the upstream CA alone would break
# route-configured pipelock passthrough hosts. # non-chained hosts.
# * `-s /app/egress_addon.py` loads the addon that reads # * `-s /app/egress_addon.py` loads the addon that reads
# /etc/egress/routes.yaml. # /etc/egress/routes.yaml.
@@ -38,11 +38,7 @@ fi
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress # Bind address. Docker backend wants `0.0.0.0` (agent dials egress
# directly via the docker network alias). Smolmachines backend # directly via the docker network alias). Smolmachines backend
# wants `127.0.0.1` because the agent dials pipelock — not egress # uses EGRESS_LISTEN_HOST when a non-default binding is needed.
# — and egress is pipelock's localhost-only upstream inside the
# bundle. TSI's IP-only allowlist would otherwise let the agent
# reach `<bundle-ip>:9099` and bypass pipelock's DLP; binding
# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3).
LISTEN_HOST_FLAG="" LISTEN_HOST_FLAG=""
if [ -n "$EGRESS_LISTEN_HOST" ]; then if [ -n "$EGRESS_LISTEN_HOST" ]; then
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST" LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
@@ -56,13 +52,10 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
fi fi
# Scope the proxy env to this process tree only. In the bundle # 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 # HTTPS_PROXY at the container level would route git-gate's git
# pushes through pipelock, which is wrong (pipelock doesn't proxy # pushes through an upstream proxy unintentionally. Setting them
# SSH and would block public git repos). Setting them here means # here means only mitmdump's subprocess inherits them.
# 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.
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY"
export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY"
+2 -2
View File
@@ -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. 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 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 same blast radius as internet-facing TLS interception; mixing it
with ssh-gate would force ssh-gate above L4 and into git-protocol with ssh-gate would force ssh-gate above L4 and into git-protocol
land. See `docs/prds/0008-git-gate.md`. land. See `docs/prds/0008-git-gate.md`.
+6 -8
View File
@@ -18,8 +18,7 @@ Bottle schema (frontmatter):
user: { name: <str>, email: <str> } # optional user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] } egress: { routes: [ <egress-route>, ... ] }
# route keys: host, path_allowlist, auth, role, pipelock # route keys: host, path_allowlist, auth, role
# pipelock: { tls_passthrough: <bool>, ssrf_ip_allowlist: [<cidr>, ...] }
supervise: <bool> # optional supervise: <bool> # optional
Agent schema (frontmatter): Agent schema (frontmatter):
@@ -98,12 +97,11 @@ class Bottle:
git_user: GitUser = field(default_factory=GitUser) git_user: GitUser = field(default_factory=GitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: EgressConfig = field(default_factory=EgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes three # the launch step brings up a supervise sidecar that exposes MCP
# MCP tools to the agent (cred-proxy-block, pipelock-block, # tools to the agent (egress-block, capability-block) plus mounts
# capability-block; the cred-proxy-block tool is renamed and # the current-config dir read-only into the agent at
# retargeted at egress in PRD 0017 chunk 3) plus mounts the # /etc/bot-bottle/current-config. False (the default) skips the
# current-config dir read-only into the agent at /etc/bot-bottle/ # sidecar and mount.
# current-config. False (the default) skips the sidecar and mount.
supervise: bool = False supervise: bool = False
@classmethod @classmethod
+4 -6
View File
@@ -282,10 +282,8 @@ class _Supervisor:
def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool: def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool:
"""Terminate one named child and spawn a fresh one, leaving """Terminate one named child and spawn a fresh one, leaving
the other daemons running. Used by the pipelock-apply path: the other daemons running. A daemon that has no in-process
pipelock has no in-process reload, so apply_allowlist_change reload can be restarted this way after its config file changes.
runs `docker kill --signal USR1 <bundle>` after writing the
new yaml; the supervisor catches SIGUSR1 and calls this.
Behavior: SIGTERM wait up to `grace` seconds SIGKILL if Behavior: SIGTERM wait up to `grace` seconds SIGKILL if
still alive spawn a replacement under the same DaemonSpec. still alive spawn a replacement under the same DaemonSpec.
@@ -293,8 +291,8 @@ class _Supervisor:
forward_signal / shutdown calls reach the new pid. forward_signal / shutdown calls reach the new pid.
Returns True iff a daemon by that name was running and a Returns True iff a daemon by that name was running and a
replacement spawned; False if no such daemon (the replacement spawned; False if no such daemon (not wired
compose-renderer subset said this bottle doesn't run it).""" for this bottle)."""
if self.shutdown_at is not None: if self.shutdown_at is not None:
_log(f"restart {daemon_name} skipped; supervisor is shutting down") _log(f"restart {daemon_name} skipped; supervisor is shutting down")
return False return False
+1 -2
View File
@@ -81,8 +81,7 @@ STATUS_REJECTED = "rejected"
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
# Operator-initiated audit entries (no tool call). PRD 0014's # Operator-initiated audit entries (no tool call). PRD 0014's
# `routes edit <bottle>` and PRD 0015's `pipelock edit <bottle>` # `routes edit <bottle>` verb writes entries with this action.
# verbs write entries with this action.
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
+1 -1
View File
@@ -63,7 +63,7 @@ from typing import cast
class YamlSubsetError(ValueError): class YamlSubsetError(ValueError):
"""Raised when input violates the YAML subset's rules. Callers """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`; etc.) catch this at their own boundary and forward to `die`;
callers running outside the bot-bottle CLI process (the callers running outside the bot-bottle CLI process (the
egress sidecar's addon) handle it as a normal exception.""" egress sidecar's addon) handle it as a normal exception."""
+20 -24
View File
@@ -53,7 +53,7 @@ _FAKE_SECRETS = {
@skip_unless_docker() @skip_unless_docker()
@unittest.skipIf( @unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true", 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 " "the runner container can't see, and the network topology hides "
"sibling-sidecar visibility — same constraint as the other " "sibling-sidecar visibility — same constraint as the other "
"bottle-bringup integration tests", "bottle-bringup integration tests",
@@ -256,14 +256,11 @@ class TestSandboxEscape(unittest.TestCase):
# ---- attack 3: HTTP exfil shapes --------------------------------- # ---- attack 3: HTTP exfil shapes ---------------------------------
# Sandbox-block signature: pipelock / egress return HTTP 403 on # Sandbox-block signature: egress returns HTTP 403 on policy
# policy reject; the response body carries a recognizable # reject; the response body carries a recognizable marker.
# marker. Egress's reject message starts `"egress: host '...' # Egress's reject message starts with `"egress: host '...'
# is not in the bottle's egress.routes allowlist"`; pipelock's # is not in the bottle's egress.routes allowlist"`.
# DLP rejects start `"blocked: "` (e.g. _SANDBOX_BLOCK_MARKERS = ("egress:", "blocked:")
# `"blocked: DLP match: Anthropic API Key (critical)"`,
# `"blocked: request body contains secret"`).
_SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:")
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
"""A real sandbox block produces an HTTP 403 with a """A real sandbox block produces an HTTP 403 with a
@@ -286,7 +283,7 @@ class TestSandboxEscape(unittest.TestCase):
self.assertTrue( self.assertTrue(
has_marker and http_code == "403", has_marker and http_code == "403",
f"{label}: expected a sandbox-level block (HTTP 403 with " 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"If the response came from the actual upstream, the "
f"secret REACHED the network — that's the leak this " f"secret REACHED the network — that's the leak this "
f"test exists to catch. body={body!r} " 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 """The agent tries to leak `$TEST_SECRET_ANTHROPIC` to an
ALLOWLISTED destination (api.anthropic.com) via four ALLOWLISTED destination (api.anthropic.com) via four
different request shapes. Each must be blocked by a different request shapes. Each must be blocked by a
sandbox layer (pipelock DLP / egress filter / etc.) sandbox layer (egress allowlist filter) producing an
producing an HTTP 403 with an `egress:` or `pipelock` HTTP 403 with an `egress:` marker in the response body.
marker in the response body.
Anything else means the request reached upstream and the Anything else means the request reached upstream and the
secret leaked, even if the upstream responded with its 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. remediation lands as its own PRD before this test merges.
DON'T mark expectedFailure to silence it. DON'T mark expectedFailure to silence it.
Destination note: we use `raw.githubusercontent.com`, one Destination note: we use `raw.githubusercontent.com`, which
of the DEFAULT_ALLOWLIST hosts. It is not route-configured is not in the bottle's egress.routes allowlist, so egress
for pipelock TLS passthrough, so pipelock MITMs it and the blocks every request to it regardless of the secret's
DLP scan applies, which is what this attack exercises.""" location in the request."""
# Capture HTTP code via curl's -w; don't use --fail so # Capture HTTP code via curl's -w; don't use --fail so
# we get the response body even on 4xx. # we get the response body even on 4xx.
url_base = "https://raw.githubusercontent.com" url_base = "https://raw.githubusercontent.com"
@@ -352,13 +348,13 @@ class TestSandboxEscape(unittest.TestCase):
def test_4_dns_exfil_blocked(self) -> None: def test_4_dns_exfil_blocked(self) -> None:
"""Two sub-attacks against DNS: """Two sub-attacks against DNS:
4a crafted subdomain that pipelock would resolve. The 4a crafted subdomain attack. The hostname
hostname `<SECRET>.api.anthropic.com` looks "under" `<SECRET>.api.anthropic.com` looks "under" the
the allowlisted apex but pipelock's allowlist is allowlisted apex but egress's allowlist is
exact-match it should reject the host BEFORE exact-match it rejects the host before issuing
issuing the DNS query, so the secret never reaches a DNS query, so the secret never reaches an
an external resolver. external resolver.
4b direct DNS query bypassing pipelock entirely. The 4b direct DNS query bypassing egress entirely. The
agent's internal network has no default gateway; agent's internal network has no default gateway;
even an explicit resolver like 8.8.8.8 should be even an explicit resolver like 8.8.8.8 should be
unreachable. Confirms the network isolation is unreachable. Confirms the network isolation is
@@ -2,8 +2,8 @@
Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a
working bottle: `docker compose up` brings the agent + bundle pair working bottle: `docker compose up` brings the agent + bundle pair
online, the four daemons inside the bundle bind their ports, and online, the daemons inside the bundle bind their ports, and the
the agent can reach pipelock + supervise via the bundle's network agent can reach egress + supervise via the bundle's network
aliases (no agent-side config changes between flag positions). aliases (no agent-side config changes between flag positions).
Skipped under GITEA_ACTIONS the bundle image is a multi-stage 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: def _manifest() -> Manifest:
"""Bottle with supervise on so the bundle exercises three of """Bottle with supervise on so the bundle exercises egress +
the four daemons (pipelock, egress, supervise). Git is off supervise. Git is off because a meaningful git-gate test needs
because a meaningful git-gate test needs a real upstream and a real upstream and SSH keys out of scope for a bundle smoke."""
SSH keys out of scope for a bundle smoke. Egress is
implicitly on as pipelock's upstream regardless of routes."""
return Manifest.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
@@ -68,21 +66,16 @@ class TestSidecarBundleCompose(unittest.TestCase):
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
# The agent's HTTPS_PROXY URL (resolved at # The agent's HTTPS_PROXY URL (resolved at
# renderer-time, unchanged from the legacy # renderer-time) should reach egress inside
# shape) should reach pipelock inside the # the bundle. A bare CONNECT with no upstream
# bundle. We probe by asking for the proxy's # URL gets rejected with 400 or 405 but proves
# listening port from inside the agent. # the listener is alive at the alias.
probe = bottle.exec( probe = bottle.exec(
"set -eu\n" "set -eu\n"
"echo HTTPS_PROXY=$HTTPS_PROXY\n" "echo HTTPS_PROXY=$HTTPS_PROXY\n"
"PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n"
"HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n"
"echo HOST=$HOST PORT=$PORT\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' " "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' "
" \"http://$HOST:$PORT/\" || true\n" " \"http://$HOST:$PORT/\" || true\n"
) )
@@ -98,11 +91,10 @@ class TestSidecarBundleCompose(unittest.TestCase):
shutil.rmtree(stage_dir, ignore_errors=True) shutil.rmtree(stage_dir, ignore_errors=True)
self.assertEqual(0, probe.returncode, msg=probe.stderr) self.assertEqual(0, probe.returncode, msg=probe.stderr)
# pipelock answered SOMETHING — any 4xx is fine, just proves # egress answered SOMETHING — any 4xx is fine, just proves
# the bundle's pipelock daemon is listening at the # the egress daemon is listening at the proxy address.
# `pipelock` alias on port 8888 (or whatever the env says).
self.assertIn("http=", probe.stdout, 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 # supervise's /health endpoint exists (PRD 0013); it should
# answer 200 or similar — anything non-empty proves the # answer 200 or similar — anything non-empty proves the
# third daemon's alias resolves to the same bundle. # third daemon's alias resolves to the same bundle.
+6 -11
View File
@@ -1,15 +1,15 @@
"""Integration: PRD 0024 chunk 1 — the sidecar bundle image builds """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 This test does NOT exercise the daemons running against real
config (pipelock.yaml, routes.yaml, etc) that lands in chunk 2 config (routes.yaml, etc) that lands in chunk 2 when the
when the renderer wires the bundle into compose. What we verify renderer wires the bundle into compose. What we verify here is
here is the chunk-1 contract: the chunk-1 contract:
- Dockerfile.sidecars builds (multi-stage works, base layers - Dockerfile.sidecars builds (multi-stage works, base layers
pull, COPYs resolve). pull, COPYs resolve).
- pipelock, gitleaks, mitmdump are at the documented paths and - gitleaks, mitmdump are at the documented paths and answer
answer `--version`. `--version`.
- The Python init at /app/sidecar_init.py runs and prints the - The Python init at /app/sidecar_init.py runs and prints the
expected "no daemons selected" line when the supervisor is expected "no daemons selected" line when the supervisor is
pointed at an empty daemon set. pointed at an empty daemon set.
@@ -74,11 +74,6 @@ class TestSidecarBundleImage(unittest.TestCase):
) )
return proc.returncode, proc.stdout.decode("utf-8", errors="replace") 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): def test_gitleaks_binary_present_and_versioned(self):
rc, out = self._run_in_image("/usr/bin/gitleaks", "version") rc, out = self._run_in_image("/usr/bin/gitleaks", "version")
self.assertEqual(0, rc, msg=out) self.assertEqual(0, rc, msg=out)
@@ -81,13 +81,9 @@ class TestBundleBringup(unittest.TestCase):
subnet=subnet, subnet=subnet,
gateway=gateway, gateway=gateway,
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
# Only run the pipelock daemon for this smoke — it's # Empty daemons_csv → init exits "no daemons selected"
# the lightest of the four and doesn't need bind # immediately. We just need the container to land on
# mounts beyond what we'd skip without # the network at the right IP before it exits.
# 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.)
daemons_csv="", # empty → init exits "no daemons selected" daemons_csv="", # empty → init exits "no daemons selected"
) )
start_bundle(spec) start_bundle(spec)
@@ -124,32 +124,6 @@ class TestSmolmachinesLaunch(unittest.TestCase):
f"expected a connect-refusal message; got: {r.stdout!r}", 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 <bundle-ip>: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): def test_prompt_file_lands_in_guest(self):
# provision_prompt copies the host-side prompt.txt into the # provision_prompt copies the host-side prompt.txt into the
# guest at /root/.bot-bottle-prompt.txt. The content # guest at /root/.bot-bottle-prompt.txt. The content
+1 -1
View File
@@ -57,7 +57,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
def test_concatenates_per_backend(self): def test_concatenates_per_backend(self):
a = ActiveAgent( a = ActiveAgent(
backend_name="docker", slug="a-1", agent_name="impl", backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=("pipelock",), started_at="", services=("egress",),
) )
b = ActiveAgent( b = ActiveAgent(
backend_name="smolmachines", slug="b-2", agent_name="research", backend_name="smolmachines", slug="b-2", agent_name="research",
+21 -54
View File
@@ -32,7 +32,6 @@ from bot_bottle.egress import (
) )
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan 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: def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
return GitGatePlan( return GitGatePlan(
slug=SLUG, slug=SLUG,
@@ -119,8 +106,6 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
egress_network=f"bot-bottle-egress-{SLUG}", egress_network=f"bot-bottle-egress-{SLUG}",
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "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 env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
prompt_file=STAGE / "prompt", prompt_file=STAGE / "prompt",
proxy_plan=_proxy_plan(),
git_gate_plan=_git_gate_plan(upstreams), git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes), egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None, supervise_plan=_supervise_plan() if supervise else None,
@@ -233,16 +217,15 @@ class TestAgentAlwaysPresent(unittest.TestCase):
s = bottle_plan_to_compose(_plan())["services"]["agent"] s = bottle_plan_to_compose(_plan())["services"]["agent"]
self.assertEqual({"internal"}, set(s["networks"].keys())) self.assertEqual({"internal"}, set(s["networks"].keys()))
def test_agent_proxy_via_pipelock_when_no_egress(self): def test_agent_proxy_always_via_egress(self):
s = bottle_plan_to_compose(_plan(with_egress=False))["services"]["agent"] for with_egress in (False, True):
env = s["environment"] with self.subTest(with_egress=with_egress):
# Looking for HTTPS_PROXY pointing at pipelock's container name. s = bottle_plan_to_compose(
proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] _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(1, len(proxy_lines))
self.assertEqual( self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0])
"HTTPS_PROXY=http://pipelock:8888",
proxy_lines[0],
)
def test_agent_proxy_via_egress_when_egress_present(self): def test_agent_proxy_via_egress_when_egress_present(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"] s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"]
@@ -306,9 +289,9 @@ class TestAgentAlwaysPresent(unittest.TestCase):
class TestSidecarBundleShape(unittest.TestCase): class TestSidecarBundleShape(unittest.TestCase):
"""The compose renderer emits exactly one `sidecars` service in """The compose renderer emits exactly one `sidecars` service in
place of the four daemons it owns (pipelock + egress + git-gate place of the daemons it owns (egress + git-gate + supervise).
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely,
shape entirely, so the bundle is the only thing exercised here.""" so the bundle is the only thing exercised here."""
def _render(self, **plan_kwargs: object) -> Any: # type: ignore def _render(self, **plan_kwargs: object) -> Any: # type: ignore
return bottle_plan_to_compose(_plan(**plan_kwargs)) # 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"] sc = self._render()["services"]["sidecars"]
self.assertEqual({"internal", "egress"}, set(sc["networks"].keys())) self.assertEqual({"internal", "egress"}, set(sc["networks"].keys()))
def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): def test_internal_aliases_include_egress_shortname(self):
# The agent's HTTPS_PROXY url references either `egress` or
# `pipelock`. Both must resolve to the bundle.
sc = self._render()["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn("egress", aliases) self.assertIn("egress", aliases)
self.assertIn("pipelock", aliases)
def test_internal_aliases_omit_inactive_sidecars(self): def test_internal_aliases_omit_inactive_sidecars(self):
# With no git-gate / supervise, those names are NOT aliased # With no git-gate / supervise, those names are NOT aliased
@@ -359,16 +339,13 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("supervise", aliases) self.assertIn("supervise", aliases)
def test_daemons_csv_lists_only_active(self): 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"] sc = self._render()["services"]["sidecars"]
daemons = { daemons = {
line.split("=", 1)[1] line.split("=", 1)[1]
for line in sc["environment"] for line in sc["environment"]
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") 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): def test_daemons_csv_expands_with_optional_sidecars(self):
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
@@ -379,13 +356,13 @@ class TestSidecarBundleShape(unittest.TestCase):
else: else:
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
self.assertEqual( self.assertEqual(
["egress", "pipelock", "git-gate", "supervise"], ["egress", "git-gate", "supervise"],
csv.split(","), csv.split(","),
) )
def test_bundle_env_does_not_set_https_proxy(self): def test_bundle_env_does_not_set_https_proxy(self):
# HTTPS_PROXY at the container level would route git-gate's # 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 # the job of egress_entrypoint.sh; the bundle env must not
# leak it. # leak it.
sc = self._render(with_egress=True)["services"]["sidecars"] sc = self._render(with_egress=True)["services"]["sidecars"]
@@ -397,22 +374,15 @@ class TestSidecarBundleShape(unittest.TestCase):
f"bundle env must not set {line!r}", 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"] sc = self._render(with_egress=True)["services"]["sidecars"]
env_strings = sc["environment"] 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) 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"] sc = self._render()["services"]["sidecars"]
env_strings = sc["environment"] env_strings = sc["environment"]
for e in env_strings: self.assertNotIn("EGRESS_TOKEN_0", env_strings)
self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY="))
self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA="))
def test_supervise_env_present_when_active(self): def test_supervise_env_present_when_active(self):
sc = self._render(supervise=True)["services"]["sidecars"] 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_QUEUE_DIR=") for e in env_strings))
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") 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"] sc = self._render()["services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]} 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): def test_volumes_union_full_matrix(self):
sc = self._render(with_git=True, with_egress=True, supervise=True)[ sc = self._render(with_git=True, with_egress=True, supervise=True)[
"services"]["sidecars"] "services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]} targets = {v["target"] for v in sc["volumes"]}
# Pipelock + egress + git-gate + supervise paths all self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
# present.
self.assertIn("/etc/pipelock.yaml", targets)
self.assertIn("/etc/egress/routes.yaml", targets) self.assertIn("/etc/egress/routes.yaml", targets)
self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", 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") self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
for t in targets)) for t in targets))
@@ -23,7 +23,6 @@ from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -90,9 +89,6 @@ def _plan(
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -24,7 +24,6 @@ from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -91,9 +90,6 @@ def _plan(
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
+8 -8
View File
@@ -40,22 +40,22 @@ class TestParseServicesByProject(unittest.TestCase):
def test_multiple_services_per_project(self): def test_multiple_services_per_project(self):
out = _enumerate._parse_services_by_project( out = _enumerate._parse_services_by_project(
"bot-bottle-dev-abc\tegress\n" "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" "bot-bottle-dev-abc\tsupervise\n"
) )
self.assertEqual( self.assertEqual(
{"bot-bottle-dev-abc": {"egress", "pipelock", "supervise"}}, {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}},
out, out,
) )
def test_multiple_projects(self): def test_multiple_projects(self):
out = _enumerate._parse_services_by_project( out = _enumerate._parse_services_by_project(
"proj-a\tegress\n" "proj-a\tegress\n"
"proj-b\tpipelock\n" "proj-b\tgit-gate\n"
"proj-a\tsupervise\n" "proj-a\tsupervise\n"
) )
self.assertEqual( self.assertEqual(
{"proj-a": {"egress", "supervise"}, "proj-b": {"pipelock"}}, {"proj-a": {"egress", "supervise"}, "proj-b": {"git-gate"}},
out, out,
) )
@@ -117,7 +117,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
)) ))
self._stub( self._stub(
["dev-abc"], ["dev-abc"],
{"bot-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}},
) )
active = _enumerate.enumerate_active() active = _enumerate.enumerate_active()
self.assertEqual(1, len(active)) self.assertEqual(1, len(active))
@@ -126,17 +126,17 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("dev-abc", a.slug) self.assertEqual("dev-abc", a.slug)
self.assertEqual("implementer", a.agent_name) self.assertEqual("implementer", a.agent_name)
self.assertEqual("2026-05-26T03:00:00+00:00", a.started_at) 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): def test_missing_metadata_renders_question_mark(self):
# State dir doesn't exist for this slug — agent_name falls # State dir doesn't exist for this slug — agent_name falls
# back to "?" rather than dropping the row. # 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() active = _enumerate.enumerate_active()
self.assertEqual(1, len(active)) self.assertEqual(1, len(active))
self.assertEqual("?", active[0].agent_name) self.assertEqual("?", active[0].agent_name)
self.assertEqual("", active[0].started_at) 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): def test_no_services_for_project_yields_empty_tuple(self):
# Race window between `compose up` returning and the actual # Race window between `compose up` returning and the actual
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -80,10 +79,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=PipelockProxyPlan(
yaml_path=stage / "pipelock.yaml",
slug="test-teardown-00001",
),
use_runsc=False, use_runsc=False,
) )
@@ -101,10 +96,6 @@ class TestTeardownWarning(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(
launch_mod, "pipelock_tls_init",
return_value=(Path("/ca.crt"), Path("/ca.key")),
), \
mock.patch.object( mock.patch.object(
launch_mod, "egress_tls_init", launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")), return_value=(Path("/egress_ca"), Path("/egress_cert")),
@@ -20,7 +20,6 @@ from bot_bottle.backend.docker.provision import git as _git
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan 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"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"), prompt_file=Path("/tmp/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
-13
View File
@@ -133,19 +133,6 @@ class TestRoutesForBottleManifestOnly(unittest.TestCase):
effective = [r.host for r in egress_routes_for_bottle(b)] effective = [r.host for r in egress_routes_for_bottle(b)]
self.assertEqual(["x.example"], effective) 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): class TestProviderRouteMerge(unittest.TestCase):
+3 -5
View File
@@ -180,7 +180,7 @@ class TestMatchRoute(unittest.TestCase):
def test_wildcard_hosts_not_supported(self): def test_wildcard_hosts_not_supported(self):
# `*.example.com` is treated as a literal host string by # `*.example.com` is treated as a literal host string by
# the exact-only matcher. Removed from the design after # 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"),) routes = (Route(host="*.example.com"),)
self.assertIsNone(match_route(routes, "foo.example.com")) self.assertIsNone(match_route(routes, "foo.example.com"))
self.assertIsNone(match_route(routes, "example.com")) self.assertIsNone(match_route(routes, "example.com"))
@@ -191,10 +191,8 @@ class TestMatchRoute(unittest.TestCase):
class TestDecide(unittest.TestCase): class TestDecide(unittest.TestCase):
def test_no_matching_route_blocks(self): def test_no_matching_route_blocks(self):
# Defense-in-depth: egress gates the bottle's allowlist # Egress gates the bottle's allowlist. Any host the operator
# too, not just pipelock. Any host the operator didn't declare # didn't declare in egress.routes is 403'd at egress.
# in egress.routes is 403'd at egress before it
# ever reaches pipelock.
d = decide((), "elsewhere.example", "/anything", {}) d = decide((), "elsewhere.example", "/anything", {})
self.assertEqual("block", d.action) self.assertEqual("block", d.action)
self.assertIn("allowlist", d.reason) self.assertIn("allowlist", d.reason)
-75
View File
@@ -6,9 +6,7 @@ import unittest
from bot_bottle.backend.docker.egress_apply import ( from bot_bottle.backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
_hosts_in_routes,
_merge_single_route, _merge_single_route,
_pipelock_safe_hosts,
validate_routes_content, validate_routes_content,
) )
from bot_bottle.yaml_subset import parse_yaml_subset 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): class TestMergeSingleRoute(unittest.TestCase):
BASE = _ROUTES_ONE BASE = _ROUTES_ONE
@@ -214,40 +174,5 @@ class TestMergeSingleRoute(unittest.TestCase):
_merge_single_route("routes:\n\tbad", {"host": "x.example"}) _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__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -50
View File
@@ -219,57 +219,10 @@ class TestRole(unittest.TestCase):
_bottle([{"host": "x.example", "role": ["x", 42]}]) _bottle([{"host": "x.example", "role": ["x", 42]}])
class TestPipelockPolicy(unittest.TestCase): class TestPipelockKeyRejected(unittest.TestCase):
def test_tls_passthrough_route_policy(self): def test_pipelock_key_rejected_as_unknown(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):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{"host": "x.example", "pipelock": True}]) _bottle([{"host": "x.example", "pipelock": {"tls_passthrough": 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}}])
class TestRouteValidation(unittest.TestCase): class TestRouteValidation(unittest.TestCase):
-10
View File
@@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan 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: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return DockerBottlePlan( return DockerBottlePlan(
@@ -121,7 +113,6 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=_proxy_plan(tmp),
use_runsc=False, use_runsc=False,
) )
@@ -145,7 +136,6 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
agent_image_ref="bot-bottle-claude:latest", agent_image_ref="bot-bottle-claude:latest",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=_proxy_plan(tmp),
) )
+40 -52
View File
@@ -29,11 +29,8 @@ from bot_bottle.sidecar_init import (
class TestEnvForDaemon(unittest.TestCase): class TestEnvForDaemon(unittest.TestCase):
"""Scope egress-only credential env vars to the egress daemon. """Scope egress-only credential env vars to the egress daemon.
Regression for issue #84: pipelock's `scan_env: true` matched The agent never has access to EGRESS_TOKEN_* slots, so stripping
`EGRESS_TOKEN_*` against egress's just-injected Authorization them from non-egress daemons loses no DLP coverage."""
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."""
_BASE = { _BASE = {
"PATH": "/usr/bin", "PATH": "/usr/bin",
@@ -47,26 +44,20 @@ class TestEnvForDaemon(unittest.TestCase):
env = _env_for_daemon("egress", self._BASE) env = _env_for_daemon("egress", self._BASE)
self.assertEqual(self._BASE, env) self.assertEqual(self._BASE, env)
def test_pipelock_loses_egress_tokens(self): def test_git_daemons_and_supervise_lose_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):
for name in ("git-gate", "git-http", "supervise"): for name in ("git-gate", "git-http", "supervise"):
env = _env_for_daemon(name, self._BASE) env = _env_for_daemon(name, self._BASE)
self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_0", env)
self.assertNotIn("EGRESS_TOKEN_1", 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): def test_returns_independent_dict(self):
# Caller mutation mustn't affect the original. # 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" env["X"] = "y"
self.assertNotIn("X", self._BASE) self.assertNotIn("X", self._BASE)
@@ -78,7 +69,6 @@ class TestSelectedDaemons(unittest.TestCase):
_DAEMONS = ( _DAEMONS = (
_DaemonSpec("egress", ("/bin/sh", "-c", ":")), _DaemonSpec("egress", ("/bin/sh", "-c", ":")),
_DaemonSpec("pipelock", ("/bin/sh", "-c", ":")),
_DaemonSpec("git-gate", ("/bin/sh", "-c", ":")), _DaemonSpec("git-gate", ("/bin/sh", "-c", ":")),
_DaemonSpec("supervise", ("/bin/sh", "-c", ":")), _DaemonSpec("supervise", ("/bin/sh", "-c", ":")),
) )
@@ -86,35 +76,34 @@ class TestSelectedDaemons(unittest.TestCase):
def test_unset_returns_all(self): def test_unset_returns_all(self):
got = _selected_daemons({}, all_daemons=self._DAEMONS) got = _selected_daemons({}, all_daemons=self._DAEMONS)
self.assertEqual([d.name for d in got], self.assertEqual([d.name for d in got],
["egress", "pipelock", "git-gate", "supervise"]) ["egress", "git-gate", "supervise"])
def test_empty_returns_all(self): def test_empty_returns_all(self):
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""},
all_daemons=self._DAEMONS) all_daemons=self._DAEMONS)
self.assertEqual(4, len(got)) self.assertEqual(3, len(got))
def test_whitespace_only_returns_all(self): def test_whitespace_only_returns_all(self):
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "}, got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "},
all_daemons=self._DAEMONS) all_daemons=self._DAEMONS)
self.assertEqual(4, len(got)) self.assertEqual(3, len(got))
def test_explicit_subset(self): def test_explicit_subset(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,pipelock"}, {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"},
all_daemons=self._DAEMONS, 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): def test_preserves_canonical_order(self):
# Order in the env var doesn't matter; the result follows # Order in the env var doesn't matter; the result follows
# the canonical _DAEMONS order so egress starts before # the canonical _DAEMONS order so egress starts first.
# pipelock (race-window reason).
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,pipelock,egress"}, {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], self.assertEqual([d.name for d in got],
["egress", "pipelock", "supervise"]) ["egress", "git-gate", "supervise"])
def test_unknown_names_ignored(self): def test_unknown_names_ignored(self):
got = _selected_daemons( got = _selected_daemons(
@@ -125,10 +114,10 @@ class TestSelectedDaemons(unittest.TestCase):
def test_whitespace_in_names_stripped(self): def test_whitespace_in_names_stripped(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , pipelock "}, {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "},
all_daemons=self._DAEMONS, 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): class TestSupervisor(unittest.TestCase):
@@ -279,25 +268,24 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_daemon_replaces_in_place(self): def test_restart_daemon_replaces_in_place(self):
# pipelock_apply.py sends SIGUSR1 to the bundle, supervisor # Restart one daemon; the other (supervise, the MCP server
# restarts the pipelock daemon, supervise (the other # in production) must remain untouched.
# daemon's MCP server in production) stays up.
specs = [ specs = [
_DaemonSpec("pipelock", ("/bin/sleep", "30")), _DaemonSpec("git-gate", ("/bin/sleep", "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) 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 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) self.assertTrue(ok)
# Pipelock got a fresh PID — different process. # git-gate got a fresh PID — different process.
new_pipelock_pid = sup.procs[0][1].pid new_git_gate_pid = sup.procs[0][1].pid
self.assertNotEqual(old_pipelock_pid, new_pipelock_pid) self.assertNotEqual(old_git_gate_pid, new_git_gate_pid)
# Supervise's PID is unchanged — it was NOT restarted. # Supervise's PID is unchanged — it was NOT restarted.
self.assertEqual(supervise_pid, sup.procs[1][1].pid) self.assertEqual(supervise_pid, sup.procs[1][1].pid)
self.assertIsNone(sup.procs[1][1].poll(), self.assertIsNone(sup.procs[1][1].poll(),
@@ -308,38 +296,38 @@ class TestSupervisor(unittest.TestCase):
def test_request_restart_is_drained_by_tick(self): def test_request_restart_is_drained_by_tick(self):
specs = [ specs = [
_DaemonSpec("pipelock", ("/bin/sleep", "30")), _DaemonSpec("git-gate", ("/bin/sleep", "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) 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 supervise_pid = sup.procs[1][1].pid
ok = sup.request_restart("pipelock") ok = sup.request_restart("git-gate")
self.assertTrue(ok) self.assertTrue(ok)
# The non-blocking request path only records intent. # 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() done = sup.tick()
self.assertFalse(done) 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) self.assertEqual(supervise_pid, sup.procs[1][1].pid)
sup.request_shutdown(reason="cleanup") sup.request_shutdown(reason="cleanup")
self._drive(sup) self._drive(sup)
def test_repeated_restart_requests_coalesce(self): def test_repeated_restart_requests_coalesce(self):
specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
self.assertTrue(sup.request_restart("pipelock")) self.assertTrue(sup.request_restart("git-gate"))
self.assertTrue(sup.request_restart("pipelock")) self.assertTrue(sup.request_restart("git-gate"))
self.assertEqual({"pipelock"}, sup._restart_requested) self.assertEqual({"git-gate"}, sup._restart_requested)
old_pid = sup.procs[0][1].pid old_pid = sup.procs[0][1].pid
sup.tick() sup.tick()
@@ -374,23 +362,23 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_during_shutdown_is_no_op(self): 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 = _Supervisor(specs)
sup.start_all() sup.start_all()
sup.request_shutdown(reason="test") sup.request_shutdown(reason="test")
ok = sup.restart_daemon("pipelock") ok = sup.restart_daemon("git-gate")
self.assertFalse(ok, self.assertFalse(ok,
"must not respawn a daemon during teardown") "must not respawn a daemon during teardown")
self._drive(sup) self._drive(sup)
def test_pending_restart_dropped_during_shutdown(self): 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 = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
old_pid = sup.procs[0][1].pid 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") sup.request_shutdown(reason="test")
self.assertEqual(set(), sup._restart_requested) self.assertEqual(set(), sup._restart_requested)
self._drive(sup) self._drive(sup)
-2
View File
@@ -56,7 +56,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet",
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), 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.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.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
patch( patch(
@@ -65,7 +64,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
): ):
mock_gg.return_value.prepare.return_value = MagicMock() mock_gg.return_value.prepare.return_value = MagicMock()
mock_pl.return_value.prepare.return_value = MagicMock()
mock_eg.return_value.prepare.return_value = MagicMock() mock_eg.return_value.prepare.return_value = MagicMock()
def _make_provision(**kwargs): # type: ignore def _make_provision(**kwargs): # type: ignore
return AgentProvisionPlan( return AgentProvisionPlan(
+8 -38
View File
@@ -32,7 +32,6 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.manifest import GitEntry, Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -71,7 +70,6 @@ def _plan(
stage_dir: Path | None = None, stage_dir: Path | None = None,
egress_routes: tuple[EgressRoute, ...] = (), egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(), egress_ca_path: Path = Path(),
pipelock_ca_path: Path = Path(),
supervise: bool = False, supervise: bool = False,
bundle_ip: str = "192.168.50.2", bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555", agent_git_gate_host: str = "127.0.0.1:55555",
@@ -131,11 +129,6 @@ def _plan(
agent_image_ref="bot-bottle-claude:latest", agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -235,16 +228,13 @@ def _write_self_signed_cert(path: Path) -> None:
class TestProvisionCA(unittest.TestCase): class TestProvisionCA(unittest.TestCase):
"""provision_ca selects the right CA cert (egress when the """provision_ca always uses the egress MITM CA and dispatches
bottle has routes, else pipelock) and dispatches
cp_in + exec in the right order.""" cp_in + exec in the right order."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
self.tmp = Path(self._tmp.name) self.tmp = Path(self._tmp.name)
self.pipelock_ca = self.tmp / "pipelock-ca.pem"
self.egress_ca = self.tmp / "egress-ca.pem" self.egress_ca = self.tmp / "egress-ca.pem"
_write_self_signed_cert(self.pipelock_ca)
_write_self_signed_cert(self.egress_ca) _write_self_signed_cert(self.egress_ca)
def tearDown(self): def tearDown(self):
@@ -259,40 +249,22 @@ class TestProvisionCA(unittest.TestCase):
stderr="", stderr="",
) )
def test_pipelock_path_when_no_routes(self): def test_egress_ca_always_installed(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca) plan = _plan(egress_ca_path=self.egress_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK) bottle = _make_bottle(exec_result=self._UPDATE_OK)
_ca.provision_ca(plan, bottle) _ca.provision_ca(plan, bottle)
bottle.cp_in.assert_called_once_with( bottle.cp_in.assert_called_once_with(
str(self.pipelock_ca), str(self.egress_ca),
_ca.AGENT_CA_PATH, _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() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
self.assertIn("chmod 644", script) self.assertIn("chmod 644", script)
self.assertIn("update-ca-certificates", script) self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user")) 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): 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( killed = ExecResult(
returncode=137, returncode=137,
stdout="Updating certificates in /etc/ssl/certs...\n", stdout="Updating certificates in /etc/ssl/certs...\n",
@@ -308,10 +280,8 @@ class TestProvisionCA(unittest.TestCase):
self.assertEqual(2, bottle.exec.call_count) self.assertEqual(2, bottle.exec.call_count)
sleep.assert_called_once_with(1.0) sleep.assert_called_once_with(1.0)
def test_dies_when_selected_cert_missing(self): def test_dies_when_egress_cert_missing(self):
# Plan claims a pipelock cert at a path that doesn't exist — plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
# something went wrong in launch's pipelock_tls_init.
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle() bottle = _make_bottle()
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
_ca.provision_ca(plan, bottle) _ca.provision_ca(plan, bottle)
@@ -414,7 +384,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
spec = _bundle_launch_spec(plan, "net", "127.0.0.16") spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual( self.assertEqual(
"egress,pipelock,git-gate,git-http", "egress,git-gate,git-http",
spec.daemons_csv, spec.daemons_csv,
) )
self.assertIn(9420, spec.ports_to_publish) self.assertIn(9420, spec.ports_to_publish)
@@ -134,34 +134,35 @@ class TestStartBundle(unittest.TestCase):
def test_daemons_env_passed_in(self): def test_daemons_env_passed_in(self):
with self._patch_run() as m: 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] argv = m.call_args.args[0]
self.assertIn("-e", argv) self.assertIn("-e", argv)
self.assertIn( self.assertIn(
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock,supervise", "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
argv, argv,
) )
def test_environment_entries_pass_through(self): def test_environment_entries_pass_through(self):
with self._patch_run() as m: with self._patch_run() as m:
start_bundle(_spec(environment=( start_bundle(_spec(environment=(
"EGRESS_UPSTREAM_PROXY=http://...",
"SUPERVISE_BOTTLE_SLUG=demo-abc12", "SUPERVISE_BOTTLE_SLUG=demo-abc12",
"EGRESS_TOKEN_0", # bare-name → host env inherit "EGRESS_TOKEN_0", # bare-name → host env inherit
))) )))
argv = m.call_args.args[0] argv = m.call_args.args[0]
self.assertIn("EGRESS_UPSTREAM_PROXY=http://...", argv)
self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv) self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv)
self.assertIn("EGRESS_TOKEN_0", argv) self.assertIn("EGRESS_TOKEN_0", argv)
def test_volumes_render_with_ro_flag(self): def test_volumes_render_with_ro_flag(self):
with self._patch_run() as m: with self._patch_run() as m:
start_bundle(_spec(volumes=( 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), ("/host/queue", "/run/supervise/queue", False),
))) )))
argv = m.call_args.args[0] 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) self.assertIn("/host/queue:/run/supervise/queue", argv)
def test_failure_dies(self): def test_failure_dies(self):
+4 -4
View File
@@ -247,13 +247,13 @@ class TestAuditLog(unittest.TestCase):
write_audit_entry(AuditEntry( write_audit_entry(AuditEntry(
timestamp=f"2026-05-25T12:00:0{i}+00:00", timestamp=f"2026-05-25T12:00:0{i}+00:00",
bottle_slug="dev", bottle_slug="dev",
component="pipelock", component="egress",
operator_action=STATUS_APPROVED, operator_action=STATUS_APPROVED,
operator_notes=f"n{i}", operator_notes=f"n{i}",
justification="", justification="",
diff="", diff="",
)) ))
path = audit_log_path("pipelock", "dev") path = audit_log_path("egress", "dev")
with path.open() as f: with path.open() as f:
lines = [line for line in f if line.strip()] lines = [line for line in f if line.strip()]
self.assertEqual(3, len(lines)) self.assertEqual(3, len(lines))
@@ -273,7 +273,7 @@ class TestAuditLog(unittest.TestCase):
write_audit_entry(AuditEntry( write_audit_entry(AuditEntry(
timestamp="t", timestamp="t",
bottle_slug="dev", bottle_slug="dev",
component="pipelock", component="egress",
operator_action=STATUS_APPROVED, operator_action=STATUS_APPROVED,
operator_notes="", operator_notes="",
justification="", justification="",
@@ -289,7 +289,7 @@ class TestAuditLog(unittest.TestCase):
diff="", diff="",
)) ))
self.assertEqual(1, len(read_audit_entries("cred-proxy", "dev"))) 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"))) self.assertEqual(1, len(read_audit_entries("cred-proxy", "other")))
def test_read_audit_entries_missing_log_returns_empty(self): def test_read_audit_entries_missing_log_returns_empty(self):
-109
View File
@@ -18,7 +18,6 @@ from pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.capability_apply import CapabilityApplyError
from bot_bottle.backend.docker.egress_apply import EgressApplyError 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.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
@@ -27,7 +26,6 @@ from bot_bottle.supervise import (
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
read_audit_entries, read_audit_entries,
read_response, read_response,
sha256_hex, 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: 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 = { payloads = {
TOOL_EGRESS_BLOCK: '{"routes": []}\n', TOOL_EGRESS_BLOCK: '{"routes": []}\n',
TOOL_PIPELOCK_BLOCK: "https://example.com/path",
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
} }
payload = payloads.get(tool, "") payload = payloads.get(tool, "")
@@ -128,26 +121,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_add_route = supervise_cli.add_route 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 self._original_apply_capability = supervise_cli.apply_capability_change
# Default stubs: succeed with deterministic before/after so the # Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff. # audit log shows a non-empty diff.
supervise_cli.add_route = lambda slug, content: ( # type: ignore supervise_cli.add_route = lambda slug, content: ( # type: ignore
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', '{"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 supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
"FROM old\n", content, "FROM old\n", content,
) )
def tearDown(self): def tearDown(self):
supervise_cli.add_route = self._original_add_route 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 supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
@@ -192,15 +177,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK)
supervise_cli.approve(qp) supervise_cli.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016). # 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("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): class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
@@ -299,91 +276,6 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("", entries[0].diff) 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): class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0016 Phase 3: approve() on a capability-block proposal """PRD 0016 Phase 3: approve() on a capability-block proposal
calls apply_capability_change, archives the proposal afterward 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 # capability-block has no audit log per PRD 0013 — its record
# lives in the per-bottle Dockerfile + transcript state. # lives in the per-bottle Dockerfile + transcript state.
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self): def test_proposal_archived_after_apply(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
@@ -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: <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()
-22
View File
@@ -47,28 +47,6 @@ from bot_bottle.supervise_server import (
class TestValidation(unittest.TestCase): 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): def test_capability_block_accepts_anything_nonempty(self):
validate_proposed_file( validate_proposed_file(
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,