refactor: rename egress-proxy → egress everywhere
The manifest key is `egress:` now; finish the rename so the rest of the codebase matches. Files (Dockerfile.egress, claude_bottle/egress.py etc.), classes (Egress, EgressConfig, EgressRoute, EgressPlan, DockerEgress), constants (EGRESS_HOSTNAME, EGRESS_ROUTES, ...), container name prefix (claude-bottle-egress-*), docker network alias (egress), the introspection host (_egress.local), the MCP tool IDs (egress-block, list-egress-routes), and the preflight label all drop the `-proxy` suffix.
This commit is contained in:
@@ -23,7 +23,7 @@ from . import prepare as _prepare
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress_proxy import DockerEgressProxy
|
||||
from .egress import DockerEgress
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy
|
||||
from .provision import ca as _ca
|
||||
@@ -43,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def __init__(self) -> None:
|
||||
self._proxy = DockerPipelockProxy()
|
||||
self._git_gate = DockerGitGate()
|
||||
self._egress_proxy = DockerEgressProxy()
|
||||
self._egress = DockerEgress()
|
||||
self._supervise = DockerSupervise()
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
@@ -52,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
stage_dir=stage_dir,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
egress_proxy=self._egress_proxy,
|
||||
egress=self._egress,
|
||||
supervise=self._supervise,
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
plan,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
egress_proxy=self._egress_proxy,
|
||||
egress=self._egress,
|
||||
supervise=self._supervise,
|
||||
provision=self.provision,
|
||||
) as bottle:
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...egress_proxy import EgressProxyPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...pipelock import PipelockProxyPlan
|
||||
@@ -45,7 +45,7 @@ class DockerBottlePlan(BottlePlan):
|
||||
prompt_file: Path
|
||||
proxy_plan: PipelockProxyPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
egress_proxy_plan: EgressProxyPlan
|
||||
egress_plan: EgressPlan
|
||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
@@ -65,14 +65,14 @@ class DockerBottlePlan(BottlePlan):
|
||||
# --env-file) and forwarded env names (`-e NAME` with the
|
||||
# value arriving via subprocess env). The forwarded set holds
|
||||
# the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
|
||||
# interpolations from the manifest; egress-proxy holds
|
||||
# interpolations from the manifest; egress holds
|
||||
# upstream tokens in its own environ, so no token forwarding
|
||||
# from the agent to the proxy is needed.
|
||||
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
|
||||
|
||||
def _multi(label: str, values: list[str]) -> None:
|
||||
"""Print a label with N continuation-indented values. Used
|
||||
for env / skills / git-gate / egress-proxy where one item
|
||||
for env / skills / git-gate / egress where one item
|
||||
per line keeps the summary scannable."""
|
||||
if not values:
|
||||
info(f"{label}: (none)")
|
||||
@@ -95,11 +95,11 @@ class DockerBottlePlan(BottlePlan):
|
||||
if git_lines:
|
||||
_multi(" git gate ", git_lines)
|
||||
|
||||
if self.egress_proxy_plan.routes:
|
||||
if self.egress_plan.routes:
|
||||
egress_lines = []
|
||||
for r in self.egress_proxy_plan.routes:
|
||||
for r in self.egress_plan.routes:
|
||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||
egress_lines.append(f"{r.host}{auth}")
|
||||
_multi(" egress-proxy ", egress_lines)
|
||||
_multi(" egress ", egress_lines)
|
||||
print(file=sys.stderr)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ _TRANSCRIPT_SUBDIR = "transcript"
|
||||
_METADATA_NAME = "metadata.json"
|
||||
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
||||
# Host's apply paths keep these files fresh so supervise's
|
||||
# `list-pipelock-allowlist` / `list-egress-proxy-routes` MCP tools
|
||||
# `list-pipelock-allowlist` / `list-egress-routes` MCP tools
|
||||
# return the current state — not a snapshot from launch time.
|
||||
_LIVE_CONFIG_SUBDIR = "live-config"
|
||||
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
||||
|
||||
+75
-75
@@ -1,7 +1,7 @@
|
||||
"""DockerEgressProxy — the Docker-specific lifecycle for the
|
||||
per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform-
|
||||
"""DockerEgress — the Docker-specific lifecycle for the
|
||||
per-bottle egress sidecar (PRD 0017). Inherits the platform-
|
||||
agnostic prepare step (route lift + routes.yaml render + token-env
|
||||
map derivation) from `EgressProxy`.
|
||||
map derivation) from `Egress`.
|
||||
|
||||
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
|
||||
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
|
||||
@@ -13,12 +13,12 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...egress_proxy import (
|
||||
EGRESS_PROXY_HOSTNAME,
|
||||
EGRESS_PROXY_ROUTES_IN_CONTAINER,
|
||||
EgressProxy,
|
||||
EgressProxyPlan,
|
||||
egress_proxy_resolve_token_values,
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
Egress,
|
||||
EgressPlan,
|
||||
egress_resolve_token_values,
|
||||
)
|
||||
from ...log import die, info, warn
|
||||
from . import util as docker_mod
|
||||
@@ -26,63 +26,63 @@ from . import util as docker_mod
|
||||
|
||||
|
||||
|
||||
EGRESS_PROXY_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE",
|
||||
"claude-bottle-egress-proxy:latest",
|
||||
EGRESS_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_EGRESS_IMAGE",
|
||||
"claude-bottle-egress:latest",
|
||||
)
|
||||
|
||||
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
|
||||
EGRESS_DOCKERFILE = "Dockerfile.egress"
|
||||
|
||||
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
||||
# resolves to `http://egress-proxy:<port>`.
|
||||
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
|
||||
# resolves to `http://egress:<port>`.
|
||||
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
||||
|
||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||
# file holding BOTH the cert and the private key, concatenated. The
|
||||
# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream
|
||||
# upstream-trust CA (pipelock's, so egress trusts the upstream
|
||||
# leg) is a separate file because pipelock keeps a different CA on
|
||||
# its end.
|
||||
EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
||||
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = (
|
||||
EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER = (
|
||||
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
||||
)
|
||||
|
||||
# Repo root, for `docker build` context. Resolved from this file's
|
||||
# location: claude_bottle/backend/docker/egress_proxy.py → repo root.
|
||||
# location: claude_bottle/backend/docker/egress.py → repo root.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def egress_proxy_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-egress-proxy-{slug}"
|
||||
def egress_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-egress-{slug}"
|
||||
|
||||
|
||||
def egress_proxy_url() -> str:
|
||||
def egress_url() -> str:
|
||||
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
|
||||
across bottles because the sidecar attaches `--network-alias
|
||||
egress-proxy` on the internal network; the container name (which
|
||||
egress` on the internal network; the container name (which
|
||||
carries the slug) is not referenced by agent-side config."""
|
||||
return f"http://{EGRESS_PROXY_HOSTNAME}:{EGRESS_PROXY_PORT}"
|
||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def build_egress_proxy_image() -> None:
|
||||
"""Build the egress-proxy image from `Dockerfile.egress-proxy`.
|
||||
Called by `DockerEgressProxy.start`; exposed at module level so
|
||||
def build_egress_image() -> None:
|
||||
"""Build the egress image from `Dockerfile.egress`.
|
||||
Called by `DockerEgress.start`; exposed at module level so
|
||||
integration tests can build it without running the full launch
|
||||
pipeline."""
|
||||
docker_mod.build_image(
|
||||
EGRESS_PROXY_IMAGE, _REPO_DIR, dockerfile=EGRESS_PROXY_DOCKERFILE,
|
||||
EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE,
|
||||
)
|
||||
|
||||
|
||||
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`.
|
||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||
|
||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
|
||||
- `cert_only_pem` is the cert alone — installed into the agent's
|
||||
trust store by `provision_ca` so the agent trusts the bumped
|
||||
CONNECT cert egress-proxy presents.
|
||||
CONNECT cert egress presents.
|
||||
|
||||
Why openssl req (not the pipelock binary's `tls init`):
|
||||
pipelock's CA generator stamps a non-standard `Subject Key
|
||||
@@ -95,11 +95,11 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
store. openssl req's `subjectKeyIdentifier=hash` extension uses
|
||||
SHA-1(pubkey), matching mitmproxy's computation.
|
||||
|
||||
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 —
|
||||
Both files live under `<stage_dir>/egress-ca/` (mode 644 —
|
||||
`docker cp` preserves the mode into the container, where the
|
||||
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
||||
mode 700 so the private key isn't world-exposed)."""
|
||||
work = stage_dir / "egress-proxy-ca"
|
||||
work = stage_dir / "egress-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
key_path = work / "ca-key.pem"
|
||||
cert_path = work / "ca.pem"
|
||||
@@ -113,7 +113,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if keygen.returncode != 0:
|
||||
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}")
|
||||
die(f"egress ca keygen failed: {keygen.stderr.strip()}")
|
||||
|
||||
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
||||
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
||||
@@ -127,7 +127,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"\n"
|
||||
"[req_dn]\n"
|
||||
"O = claude-bottle\n"
|
||||
"CN = claude-bottle egress-proxy CA\n"
|
||||
"CN = claude-bottle egress CA\n"
|
||||
"\n"
|
||||
"[v3_ca]\n"
|
||||
"basicConstraints = critical, CA:TRUE\n"
|
||||
@@ -145,7 +145,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if req.returncode != 0:
|
||||
die(f"egress-proxy ca cert generation failed: {req.stderr.strip()}")
|
||||
die(f"egress ca cert generation failed: {req.stderr.strip()}")
|
||||
|
||||
cert_path.chmod(0o644)
|
||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||
@@ -155,20 +155,20 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
return (mitm, cert_path)
|
||||
|
||||
|
||||
class DockerEgressProxy(EgressProxy):
|
||||
"""Brings the egress-proxy sidecar up and down via Docker."""
|
||||
class DockerEgress(Egress):
|
||||
"""Brings the egress sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: EgressProxyPlan) -> str:
|
||||
"""Boot the egress-proxy sidecar:
|
||||
def start(self, plan: EgressPlan) -> str:
|
||||
"""Boot the egress sidecar:
|
||||
1. Resolve every host TokenRef env var into a concrete
|
||||
value. Fails early if any are unset.
|
||||
2. Build the egress-proxy image (no-op when cache is hot).
|
||||
2. Build the egress image (no-op when cache is hot).
|
||||
3. `docker create` on the internal network with
|
||||
`--network-alias egress-proxy`, the `HTTPS_PROXY=pipelock`
|
||||
`--network-alias egress`, the `HTTPS_PROXY=pipelock`
|
||||
env (so the upstream leg traverses pipelock), the
|
||||
`EGRESS_PROXY_UPSTREAM_CA` env pointing at the in-container
|
||||
`EGRESS_UPSTREAM_CA` env pointing at the in-container
|
||||
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
|
||||
and one `-e EGRESS_PROXY_TOKEN_N` flag per token slot.
|
||||
and one `-e EGRESS_TOKEN_N` flag per token slot.
|
||||
Secret values arrive via subprocess env, never argv.
|
||||
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
|
||||
concat), and pipelock CA (cert only) into the container.
|
||||
@@ -177,67 +177,67 @@ class DockerEgressProxy(EgressProxy):
|
||||
6. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.routes:
|
||||
die("DockerEgressProxy.start called with no routes; caller should skip")
|
||||
die("DockerEgress.start called with no routes; caller should skip")
|
||||
if not plan.internal_network or not plan.egress_network:
|
||||
die(
|
||||
"DockerEgressProxy.start: internal_network / egress_network must be "
|
||||
"DockerEgress.start: internal_network / egress_network must be "
|
||||
"populated on the plan before start"
|
||||
)
|
||||
if not plan.routes_path.is_file():
|
||||
die(
|
||||
f"egress-proxy routes file missing at {plan.routes_path}; "
|
||||
f"EgressProxy.prepare must run first"
|
||||
f"egress routes file missing at {plan.routes_path}; "
|
||||
f"Egress.prepare must run first"
|
||||
)
|
||||
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
|
||||
die(
|
||||
f"DockerEgressProxy.start: mitmproxy CA missing at "
|
||||
f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first"
|
||||
f"DockerEgress.start: mitmproxy CA missing at "
|
||||
f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first"
|
||||
)
|
||||
# pipelock CA + upstream proxy URL: both must be present (we
|
||||
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
|
||||
# upstream leg) or both absent (egress-proxy goes direct, for
|
||||
# upstream leg) or both absent (egress goes direct, for
|
||||
# standalone integration tests that don't bring pipelock up).
|
||||
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
|
||||
if route_via_pipelock:
|
||||
if not plan.pipelock_proxy_url:
|
||||
die(
|
||||
"DockerEgressProxy.start: pipelock_ca_host_path is set but "
|
||||
"DockerEgress.start: pipelock_ca_host_path is set but "
|
||||
"pipelock_proxy_url is empty; populate both or neither."
|
||||
)
|
||||
if not plan.pipelock_ca_host_path.is_file():
|
||||
die(
|
||||
f"DockerEgressProxy.start: pipelock CA missing at "
|
||||
f"DockerEgress.start: pipelock CA missing at "
|
||||
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
||||
)
|
||||
|
||||
# Resolve host env vars into concrete values. Must happen at
|
||||
# start time (not prepare) — the values flow into the sidecar's
|
||||
# environ via subprocess env. The plan never holds them.
|
||||
token_values = egress_proxy_resolve_token_values(
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.token_env_map, dict(os.environ),
|
||||
)
|
||||
|
||||
build_egress_proxy_image()
|
||||
build_egress_image()
|
||||
|
||||
name = egress_proxy_container_name(plan.slug)
|
||||
info(f"starting egress-proxy sidecar {name} on network {plan.internal_network}")
|
||||
name = egress_container_name(plan.slug)
|
||||
info(f"starting egress sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
||||
"--network-alias", EGRESS_HOSTNAME,
|
||||
]
|
||||
if route_via_pipelock:
|
||||
# Route egress-proxy's outbound traffic through pipelock
|
||||
# Route egress's outbound traffic through pipelock
|
||||
# so the egress allowlist + DLP body scanner apply to
|
||||
# the egress-proxy → upstream leg. Pipelock MITMs each
|
||||
# the egress → upstream leg. Pipelock MITMs each
|
||||
# handshake with its per-bottle CA, which is docker-cp'd
|
||||
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA
|
||||
# in below and pointed to via the EGRESS_UPSTREAM_CA
|
||||
# env (entrypoint conditionally adds the matching --set
|
||||
# flag).
|
||||
#
|
||||
# EGRESS_PROXY_UPSTREAM_PROXY is the mechanism: mitmproxy
|
||||
# EGRESS_UPSTREAM_PROXY is the mechanism: mitmproxy
|
||||
# does NOT honor HTTPS_PROXY env vars on its outbound
|
||||
# side (it's a proxy server, not a client). The
|
||||
# entrypoint reads this env and switches mitmdump to
|
||||
@@ -247,22 +247,22 @@ class DockerEgressProxy(EgressProxy):
|
||||
# bundled client libraries (mitmproxy plugin requests,
|
||||
# etc.) that might honor them — harmless if ignored.
|
||||
create_args.extend([
|
||||
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||
"-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}",
|
||||
"-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
|
||||
])
|
||||
# One -e flag per token slot; values arrive via subprocess env.
|
||||
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
||||
# current process env at create time. We pass `env=child_env`
|
||||
# to subprocess.run so the value comes from token_values, not
|
||||
# the host's os.environ directly — keeps the resolver in one
|
||||
# place and lets egress_proxy_resolve_token_values surface
|
||||
# place and lets egress_resolve_token_values surface
|
||||
# missing-env errors with a clear hint.
|
||||
for token_env in sorted(plan.token_env_map.keys()):
|
||||
create_args.extend(["-e", token_env])
|
||||
create_args.append(EGRESS_PROXY_IMAGE)
|
||||
create_args.append(EGRESS_IMAGE)
|
||||
|
||||
child_env: dict[str, str] = {**os.environ, **token_values}
|
||||
|
||||
@@ -271,7 +271,7 @@ class DockerEgressProxy(EgressProxy):
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create egress-proxy sidecar {name}: "
|
||||
f"failed to create egress sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
@@ -281,19 +281,19 @@ class DockerEgressProxy(EgressProxy):
|
||||
# isn't actually exposed to other host users.
|
||||
plan.routes_path.chmod(0o644)
|
||||
# Pipelock CA: pipelock itself runs as root so its in-pipelock
|
||||
# copy doesn't care about mode, but egress-proxy's mitmproxy
|
||||
# user does. Bump on the host so docker cp into egress-proxy
|
||||
# copy doesn't care about mode, but egress's mitmproxy
|
||||
# user does. Bump on the host so docker cp into egress
|
||||
# carries world-readable.
|
||||
if route_via_pipelock:
|
||||
plan.pipelock_ca_host_path.chmod(0o644)
|
||||
cps: list[tuple[Path, str, str]] = [
|
||||
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
|
||||
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
|
||||
(plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"),
|
||||
(plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"),
|
||||
]
|
||||
if route_via_pipelock:
|
||||
cps.append((
|
||||
plan.pipelock_ca_host_path,
|
||||
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER,
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||
"pipelock CA",
|
||||
))
|
||||
for src, dst, label in cps:
|
||||
@@ -327,7 +327,7 @@ class DockerEgressProxy(EgressProxy):
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to attach egress-proxy sidecar {name} to egress network "
|
||||
f"failed to attach egress sidecar {name} to egress network "
|
||||
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ class DockerEgressProxy(EgressProxy):
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start egress-proxy sidecar {name}: "
|
||||
f"failed to start egress sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
@@ -364,6 +364,6 @@ class DockerEgressProxy(EgressProxy):
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove egress-proxy sidecar {target}; "
|
||||
f"failed to remove egress sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
+43
-43
@@ -1,21 +1,21 @@
|
||||
"""Host-side helper to apply a routes.yaml change to a running
|
||||
egress-proxy sidecar (PRD 0014 retargeted by PRD 0017 chunk 3).
|
||||
egress sidecar (PRD 0014 retargeted by PRD 0017 chunk 3).
|
||||
|
||||
Used by the supervise dashboard when the operator approves an
|
||||
egress-proxy-block proposal (or runs the operator-initiated
|
||||
egress-block proposal (or runs the operator-initiated
|
||||
`routes edit <bottle>` verb). Fetches the current routes.yaml via
|
||||
`docker exec cat`, validates the new content, writes it into the
|
||||
sidecar via `docker cp`, then `docker kill --signal HUP` to make
|
||||
the addon reload without dropping connections.
|
||||
|
||||
Also mirrors the new route hosts into pipelock's hostname allowlist
|
||||
so the downstream leg lets them through — egress-proxy enforces
|
||||
so the downstream leg lets them through — egress enforces
|
||||
the path-aware allowlist on the agent leg, pipelock enforces the
|
||||
hostname allowlist + DLP body scan on the upstream leg, and a
|
||||
host added to one must be in the other or the request 403s
|
||||
somewhere along the chain.
|
||||
|
||||
Raises EgressProxyApplyError on any failure — the dashboard
|
||||
Raises EgressApplyError on any failure — the dashboard
|
||||
surfaces the message and keeps the proposal pending so the
|
||||
operator can retry.
|
||||
"""
|
||||
@@ -29,9 +29,9 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER
|
||||
from ...egress_proxy_addon_core import load_routes
|
||||
from .egress_proxy import egress_proxy_container_name
|
||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||
from ...egress_addon_core import load_routes
|
||||
from .egress import egress_container_name
|
||||
from .pipelock_apply import (
|
||||
PipelockApplyError,
|
||||
apply_allowlist_change,
|
||||
@@ -41,23 +41,23 @@ from .pipelock_apply import (
|
||||
)
|
||||
|
||||
|
||||
class EgressProxyApplyError(RuntimeError):
|
||||
class EgressApplyError(RuntimeError):
|
||||
"""Raised when fetch / apply fails. Caller renders to the
|
||||
operator; does not crash the dashboard."""
|
||||
|
||||
|
||||
def fetch_current_routes(slug: str) -> str:
|
||||
"""Read the live routes.yaml from the running egress-proxy sidecar
|
||||
"""Read the live routes.yaml from the running egress sidecar
|
||||
for `slug`. Returns the file content as a string. Raises
|
||||
EgressProxyApplyError if the sidecar isn't reachable or the read
|
||||
EgressApplyError if the sidecar isn't reachable or the read
|
||||
fails."""
|
||||
container = egress_proxy_container_name(slug)
|
||||
container = egress_container_name(slug)
|
||||
r = subprocess.run(
|
||||
["docker", "exec", container, "cat", EGRESS_PROXY_ROUTES_IN_CONTAINER],
|
||||
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"could not read routes.yaml from {container}: "
|
||||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
||||
)
|
||||
@@ -71,7 +71,7 @@ def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
load_routes(content)
|
||||
except ValueError as e:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
|
||||
@@ -83,7 +83,7 @@ def _hosts_in_routes(content: str) -> list[str]:
|
||||
try:
|
||||
routes = load_routes(content)
|
||||
except ValueError as e:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
return sorted({r.host for r in routes if r.host})
|
||||
@@ -93,10 +93,10 @@ def _hosts_in_routes(content: str) -> list[str]:
|
||||
# `[A-Za-z0-9_.-]+`. Anything else (wildcards, IPv6 literals,
|
||||
# stray characters) is silently dropped from the mirror so the
|
||||
# pipelock apply doesn't fail parse before the new yaml is even
|
||||
# written. The dropped hosts stay on egress-proxy's route table —
|
||||
# written. The dropped hosts stay on egress's route table —
|
||||
# but the addon does exact-host match only, so they'll never
|
||||
# match anything either. (Wildcard host matching was removed —
|
||||
# see `match_route` in egress_proxy_addon_core for the rationale.)
|
||||
# see `match_route` in egress_addon_core for the rationale.)
|
||||
_PIPELOCK_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
@@ -110,10 +110,10 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
|
||||
"""Ensure every pipelock-compatible `hosts` entry is on
|
||||
pipelock's allowlist. Fetches pipelock's current allowlist,
|
||||
merges, re-applies. Hosts pipelock can't represent (wildcards,
|
||||
etc.) are silently skipped — they stay live on egress-proxy
|
||||
etc.) are silently skipped — they stay live on egress
|
||||
but aren't enforced at pipelock. No-op if every host is already
|
||||
present (apply still restarts pipelock if any host is new).
|
||||
Raises EgressProxyApplyError on pipelock failures so the
|
||||
Raises EgressApplyError on pipelock failures so the
|
||||
caller's diff/audit reflects the half-state."""
|
||||
safe_hosts = _pipelock_safe_hosts(hosts)
|
||||
try:
|
||||
@@ -124,42 +124,42 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
|
||||
return # nothing to add
|
||||
apply_allowlist_change(slug, render_allowlist_content(merged))
|
||||
except PipelockApplyError as e:
|
||||
# Mirror runs BEFORE the egress-proxy write, so egress-proxy
|
||||
# Mirror runs BEFORE the egress write, so egress
|
||||
# is unchanged on this failure path. Report it as a
|
||||
# pipelock-side problem so the operator looks in the right
|
||||
# place; their `pipelock edit` flow can repair manually.
|
||||
raise EgressProxyApplyError(
|
||||
f"pipelock allowlist mirror failed (egress-proxy NOT "
|
||||
raise EgressApplyError(
|
||||
f"pipelock allowlist mirror failed (egress NOT "
|
||||
f"updated): {e}. Fix pipelock's allowlist manually with "
|
||||
f"`pipelock edit <bottle>` then retry the proposal."
|
||||
) from e
|
||||
|
||||
|
||||
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
"""Apply `new_content` to the egress-proxy sidecar for `slug`:
|
||||
"""Apply `new_content` to the egress sidecar for `slug`:
|
||||
1. Fetch current routes.yaml (for the before-diff).
|
||||
2. Validate the new content via the addon's own parser.
|
||||
3. Mirror the route hosts onto pipelock's allowlist (so the
|
||||
downstream hostname gate lets them through).
|
||||
4. Write to a temp file, `docker cp` into the egress-proxy
|
||||
4. Write to a temp file, `docker cp` into the egress
|
||||
sidecar.
|
||||
5. `docker kill --signal HUP` so the addon reloads.
|
||||
|
||||
Order matters: pipelock first, then egress-proxy. If the
|
||||
pipelock step fails, egress-proxy hasn't been touched and the
|
||||
old routes stay live. If the egress-proxy step fails after
|
||||
Order matters: pipelock first, then egress. If the
|
||||
pipelock step fails, egress hasn't been touched and the
|
||||
old routes stay live. If the egress step fails after
|
||||
pipelock succeeded, pipelock has the host in its allowlist but
|
||||
egress-proxy doesn't enforce it yet — harmless extra-permissive
|
||||
state at pipelock, and a re-approval will land the egress-proxy
|
||||
egress doesn't enforce it yet — harmless extra-permissive
|
||||
state at pipelock, and a re-approval will land the egress
|
||||
side.
|
||||
|
||||
Returns (before, after) where `after` == `new_content`. Raises
|
||||
EgressProxyApplyError on any step."""
|
||||
container = egress_proxy_container_name(slug)
|
||||
EgressApplyError on any step."""
|
||||
container = egress_container_name(slug)
|
||||
before = fetch_current_routes(slug)
|
||||
validate_routes_content(new_content)
|
||||
|
||||
# Pipelock mirror first — if it fails, egress-proxy stays intact
|
||||
# Pipelock mirror first — if it fails, egress stays intact
|
||||
# and the operator gets a clear error about the half-state.
|
||||
_mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content))
|
||||
|
||||
@@ -180,11 +180,11 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
os.chmod(tmp_path, 0o644)
|
||||
cp = subprocess.run(
|
||||
["docker", "cp", tmp_path,
|
||||
f"{container}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"],
|
||||
f"{container}:{EGRESS_ROUTES_IN_CONTAINER}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if cp.returncode != 0:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"failed to copy routes.yaml into {container}: "
|
||||
f"{(cp.stderr or '').strip()}"
|
||||
)
|
||||
@@ -193,7 +193,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if sig.returncode != 0:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"failed to SIGHUP {container}: "
|
||||
f"{(sig.stderr or '').strip()}"
|
||||
)
|
||||
@@ -228,18 +228,18 @@ def _merge_single_route(
|
||||
try:
|
||||
cfg = json.loads(current_yaml)
|
||||
except json.JSONDecodeError as e:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"current routes.yaml is not valid JSON: {e}"
|
||||
) from e
|
||||
routes = cfg.get("routes")
|
||||
if not isinstance(routes, list):
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
"current routes.yaml: 'routes' is not a list"
|
||||
)
|
||||
|
||||
new_host = str(new_route.get("host", "")).lower()
|
||||
if not new_host:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
"proposed route is missing 'host'"
|
||||
)
|
||||
|
||||
@@ -280,7 +280,7 @@ def _merge_single_route(
|
||||
})
|
||||
next_idx = len(existing_slots)
|
||||
entry["auth_scheme"] = str(auth["scheme"])
|
||||
entry["token_env"] = f"EGRESS_PROXY_TOKEN_{next_idx}"
|
||||
entry["token_env"] = f"EGRESS_TOKEN_{next_idx}"
|
||||
# NOTE: the addon reads token VALUES from its container's
|
||||
# environ keyed by token_env. A newly-added auth route at
|
||||
# runtime points at a slot that has no env value → the
|
||||
@@ -295,18 +295,18 @@ def _merge_single_route(
|
||||
|
||||
|
||||
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
||||
"""Apply a single-route addition to the egress-proxy. Parses the
|
||||
"""Apply a single-route addition to the egress. Parses the
|
||||
agent's proposed route, fetches the current routes file, merges,
|
||||
and applies via `apply_routes_change`. Returns (before, after)
|
||||
full-file content for the audit log."""
|
||||
try:
|
||||
proposed = json.loads(proposed_route_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
f"proposed route is not valid JSON: {e}"
|
||||
) from e
|
||||
if not isinstance(proposed, dict):
|
||||
raise EgressProxyApplyError(
|
||||
raise EgressApplyError(
|
||||
"proposed route must be a JSON object"
|
||||
)
|
||||
current = fetch_current_routes(slug)
|
||||
@@ -315,7 +315,7 @@ def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EgressProxyApplyError",
|
||||
"EgressApplyError",
|
||||
"add_route",
|
||||
"apply_routes_change",
|
||||
"fetch_current_routes",
|
||||
@@ -24,10 +24,10 @@ from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress_proxy import (
|
||||
DockerEgressProxy,
|
||||
egress_proxy_tls_init,
|
||||
egress_proxy_url,
|
||||
from .egress import (
|
||||
DockerEgress,
|
||||
egress_tls_init,
|
||||
egress_url,
|
||||
)
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import (
|
||||
@@ -51,7 +51,7 @@ def launch(
|
||||
*,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
egress_proxy: DockerEgressProxy,
|
||||
egress: DockerEgress,
|
||||
supervise: DockerSupervise,
|
||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||
) -> Generator[DockerBottle, None, None]:
|
||||
@@ -88,7 +88,7 @@ def launch(
|
||||
# Docker assigns a CIDR to the new internal network. Pipelock's
|
||||
# SSRF guard otherwise rejects any destination resolving into
|
||||
# RFC1918 space — which includes the sibling sidecars
|
||||
# (egress-proxy → pipelock on the upstream leg, etc.).
|
||||
# (egress → pipelock on the upstream leg, etc.).
|
||||
# Allowlist the bottle's own internal subnet so internal
|
||||
# traffic passes through pipelock; api_allowlist + body-scanning
|
||||
# still apply.
|
||||
@@ -97,16 +97,16 @@ def launch(
|
||||
# Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two
|
||||
# separate CAs:
|
||||
# - pipelock CA: signs MITM certs pipelock presents on the
|
||||
# egress-proxy → upstream leg.
|
||||
# - egress-proxy CA: signs MITM certs egress-proxy presents
|
||||
# to the agent on the agent → egress-proxy leg.
|
||||
# egress → upstream leg.
|
||||
# - egress CA: signs MITM certs egress presents
|
||||
# to the agent on the agent → egress leg.
|
||||
# Both are minted by one-shot pipelock containers (pipelock's
|
||||
# `tls init` is a known-good RSA CA minter) under stage_dir;
|
||||
# the .start steps docker-cp the files in. Private keys never
|
||||
# leave the host stage dir, which start.py's outer finally
|
||||
# `shutil.rmtree`s after the sidecars are torn down.
|
||||
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
|
||||
egress_proxy_ca_host, egress_proxy_ca_cert_only = egress_proxy_tls_init(
|
||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
||||
plan.stage_dir,
|
||||
)
|
||||
|
||||
@@ -156,26 +156,26 @@ def launch(
|
||||
|
||||
# Egress-proxy (PRD 0017). One sidecar per bottle when
|
||||
# bottle.egress.routes is non-empty. Must come up AFTER
|
||||
# pipelock — egress-proxy routes its outbound HTTPS through
|
||||
# pipelock — egress routes its outbound HTTPS through
|
||||
# pipelock (HTTPS_PROXY in environ + the pipelock CA in its
|
||||
# trust store) so the egress allowlist + body scanner sit on
|
||||
# the egress-proxy → upstream leg. Must come up BEFORE the
|
||||
# agent so DNS resolution for `egress-proxy` succeeds on the
|
||||
# the egress → upstream leg. Must come up BEFORE the
|
||||
# agent so DNS resolution for `egress` succeeds on the
|
||||
# agent's first call; tokens flow from the host env into the
|
||||
# sidecar's environ, not the agent's.
|
||||
if plan.egress_proxy_plan.routes:
|
||||
egress_proxy_plan = dataclasses.replace(
|
||||
plan.egress_proxy_plan,
|
||||
if plan.egress_plan.routes:
|
||||
egress_plan = dataclasses.replace(
|
||||
plan.egress_plan,
|
||||
internal_network=internal_network,
|
||||
egress_network=egress_network,
|
||||
mitmproxy_ca_host_path=egress_proxy_ca_host,
|
||||
mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only,
|
||||
mitmproxy_ca_host_path=egress_ca_host,
|
||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||
pipelock_ca_host_path=ca_cert_host,
|
||||
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
||||
)
|
||||
plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan)
|
||||
egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan)
|
||||
stack.callback(egress_proxy.stop, egress_proxy_name)
|
||||
plan = dataclasses.replace(plan, egress_plan=egress_plan)
|
||||
egress_name = egress.start(plan.egress_plan)
|
||||
stack.callback(egress.stop, egress_name)
|
||||
|
||||
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
|
||||
# Internal-network only — the sidecar makes no outbound calls.
|
||||
@@ -225,13 +225,13 @@ def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||
|
||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||
"""Pick the proxy URL the agent's HTTP_PROXY env points at. PRD
|
||||
0017: when an egress-proxy is declared, the agent goes through
|
||||
egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its
|
||||
0017: when an egress is declared, the agent goes through
|
||||
egress (which in turn uses HTTPS_PROXY=pipelock on its
|
||||
outbound leg). Otherwise the agent talks straight to pipelock —
|
||||
keeps the network surface minimal for bottles that don't need
|
||||
path filtering or credential injection."""
|
||||
if plan.egress_proxy_plan.routes:
|
||||
return egress_proxy_url()
|
||||
if plan.egress_plan.routes:
|
||||
return egress_url()
|
||||
return pipelock_proxy_url(plan.slug)
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
||||
# httpoxy mitigation makes it ignore uppercase `HTTP_PROXY` for
|
||||
# `http://` URLs and only honor lowercase `http_proxy`. Without
|
||||
# the lowercase var, plain-HTTP requests from the agent bypass
|
||||
# egress-proxy entirely (going direct, then failing with
|
||||
# egress entirely (going direct, then failing with
|
||||
# "network unreachable" because the agent's bridge is
|
||||
# --internal). Lowercase HTTPS_PROXY isn't strictly needed but
|
||||
# we set it for symmetry — some tools check one or the other.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Docker network plumbing for the per-agent egress-proxy topology.
|
||||
"""Docker network plumbing for the per-agent egress topology.
|
||||
|
||||
The agent container sits on a Docker `--internal` network (no default
|
||||
gateway). Pipelock straddles that network and a per-agent user-defined
|
||||
|
||||
@@ -19,7 +19,7 @@ from ...log import die
|
||||
from .. import BottleSpec
|
||||
from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress_proxy import DockerEgressProxy, egress_proxy_container_name
|
||||
from .egress import DockerEgress, egress_container_name
|
||||
from .git_gate import DockerGitGate, git_gate_container_name
|
||||
from .bottle_state import (
|
||||
BottleMetadata,
|
||||
@@ -40,7 +40,7 @@ def resolve_plan(
|
||||
stage_dir: Path,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
egress_proxy: DockerEgressProxy,
|
||||
egress: DockerEgress,
|
||||
supervise: DockerSupervise,
|
||||
) -> DockerBottlePlan:
|
||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||
@@ -122,14 +122,14 @@ def resolve_plan(
|
||||
# actionable hint. Fail fast here with a cleanup pointer instead.
|
||||
# Only probe sidecars this launch will actually try to create:
|
||||
# pipelock always; git-gate when bottle.git is non-empty;
|
||||
# egress-proxy when bottle.egress.routes is non-empty.
|
||||
# egress when bottle.egress.routes is non-empty.
|
||||
sidecar_probes: list[tuple[str, str]] = [
|
||||
("pipelock", pipelock_container_name(slug)),
|
||||
]
|
||||
if bottle.git:
|
||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
||||
if bottle.egress.routes:
|
||||
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug)))
|
||||
sidecar_probes.append(("egress", egress_container_name(slug)))
|
||||
if bottle.supervise:
|
||||
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
||||
for label, sidecar_name in sidecar_probes:
|
||||
@@ -148,7 +148,7 @@ def resolve_plan(
|
||||
|
||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
|
||||
egress_plan = egress.prepare(bottle, slug, stage_dir)
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
# Current Dockerfile for the agent image. Read from the repo
|
||||
@@ -157,7 +157,7 @@ def resolve_plan(
|
||||
# is just a workspace copy).
|
||||
# (routes.yaml + pipelock allowlist used to land here too but
|
||||
# PRD 0017 chunk 3 moved them behind the
|
||||
# `list-egress-proxy-routes` MCP tool so the agent gets live
|
||||
# `list-egress-routes` MCP tool so the agent gets live
|
||||
# state rather than a launch-time snapshot.)
|
||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||
@@ -170,22 +170,22 @@ def resolve_plan(
|
||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||
# mutates the host os.environ.
|
||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||
# When the bottle declares an egress-proxy route with the
|
||||
# When the bottle declares an egress route with the
|
||||
# `claude_code_oauth` role marker, claude-code's outbound
|
||||
# Authorization gets stripped + re-injected by egress-proxy. The
|
||||
# Authorization gets stripped + re-injected by egress. The
|
||||
# agent's environ still needs *something* claude-code recognises
|
||||
# as a credential or it refuses to start; ship a non-secret
|
||||
# placeholder. The placeholder isn't any real token value, so
|
||||
# leaking it would tell an attacker only that egress-proxy is in
|
||||
# leaking it would tell an attacker only that egress is in
|
||||
# front. Manifest validation enforces singleton on this role.
|
||||
has_anthropic_auth = any(
|
||||
"claude_code_oauth" in r.roles
|
||||
for r in egress_proxy_plan.routes
|
||||
for r in egress_plan.routes
|
||||
)
|
||||
if has_anthropic_auth:
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||
# error reporting) that egress-proxy can't gate by auth.
|
||||
# error reporting) that egress can't gate by auth.
|
||||
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
||||
_write_env_file(resolved, env_file)
|
||||
@@ -208,7 +208,7 @@ def resolve_plan(
|
||||
prompt_file=prompt_file,
|
||||
proxy_plan=proxy_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_proxy_plan=egress_proxy_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
)
|
||||
|
||||
@@ -3,16 +3,16 @@ store.
|
||||
|
||||
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target:
|
||||
|
||||
- Bottle declares `egress_proxy.routes[]` → agent's HTTP_PROXY
|
||||
points at egress-proxy; the cert the agent must trust is the
|
||||
one egress-proxy mints leaf certs with (the egress-proxy CA).
|
||||
- No egress_proxy routes → agent's HTTP_PROXY points straight at
|
||||
- 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-proxy) is up referencing the
|
||||
sidecar (pipelock or egress) is up referencing the
|
||||
in-container CA paths.
|
||||
|
||||
Cert lands on Debian's standard source path
|
||||
@@ -52,16 +52,16 @@ def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
|
||||
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
|
||||
wins when the bottle declares any routes (it sits in front of
|
||||
pipelock); else pipelock."""
|
||||
if plan.egress_proxy_plan.routes:
|
||||
cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path
|
||||
if plan.egress_plan.routes:
|
||||
cert = plan.egress_plan.mitmproxy_ca_cert_only_host_path
|
||||
if cert == Path() or not cert.is_file():
|
||||
from ....log import die
|
||||
die(
|
||||
f"egress-proxy CA cert missing at {cert or '(empty)'}; "
|
||||
f"launch must have called egress_proxy_tls_init and "
|
||||
f"egress CA cert missing at {cert or '(empty)'}; "
|
||||
f"launch must have called egress_tls_init and "
|
||||
f"re-bound the plan before provision"
|
||||
)
|
||||
return cert, "egress-proxy"
|
||||
return cert, "egress"
|
||||
cert = plan.proxy_plan.ca_cert_host_path
|
||||
if not cert or not cert.is_file():
|
||||
from ....log import die
|
||||
|
||||
Reference in New Issue
Block a user