refactor(manifest): rename egress_proxy key to egress
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 1m4s

Now that `bottle.egress` (the old allowlist/dlp_action block) is
gone, the longer `egress_proxy:` disambiguator isn't needed. The
manifest field reads more naturally as just `egress:` with the
same nested `routes: [...]` shape.

Renamed:
  - Manifest YAML key:    `egress_proxy:` → `egress:`
  - Bottle dataclass attr: `bottle.egress_proxy` → `bottle.egress`
  - `_BOTTLE_KEYS` entry, schema docstring, and all
    user-facing error message labels (`egress.routes[N]`,
    `egress has unknown key …`, etc.).

Kept (these refer to the egress-proxy SIDECAR, not the manifest
field):
  - File names: `egress_proxy.py`, `egress_proxy_apply.py`,
    `egress_proxy_addon.py`, `egress_proxy_addon_core.py`.
  - Class names: `EgressProxyConfig`, `EgressProxyRoute`,
    `EgressProxyPlan`, `EgressProxy`, `DockerEgressProxy`.
  - Helper names: `egress_proxy_manifest_routes`,
    `egress_proxy_routes_for_bottle`,
    `egress_proxy_token_env_map`, etc.
  - Constants: `EGRESS_PROXY_HOSTNAME`, `EGRESS_PROXY_ROLES`,
    `EGRESS_PROXY_AUTH_SCHEMES`, `EGRESS_PROXY_FORWARD_PROXY`,
    `EGRESS_PROXY_INTROSPECT_URL`, `EGRESS_PROXY_PORT`, etc.
  - Container name prefix `claude-bottle-egress-proxy-*`, the
    `egress-proxy` docker network alias, the
    `egress-proxy-block` + `list-egress-proxy-routes` MCP tool
    IDs, the `egress-proxy` audit-log component label.

Local bottle migrated (`~/.claude-bottle/bottles/dev.md` already
updated). The legacy `egress_proxy` key isn't surfaced anywhere
anymore; the generic unknown-key validator catches typos with a
"did you mean: egress, env, git, supervise" hint.

409 unit + integration tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:25:51 -04:00
parent 6456904763
commit 14c8a51c16
11 changed files with 55 additions and 59 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ def launch(
stack.callback(git_gate.stop, git_gate_name)
# Egress-proxy (PRD 0017). One sidecar per bottle when
# bottle.egress_proxy.routes is non-empty. Must come up AFTER
# bottle.egress.routes is non-empty. Must come up AFTER
# pipelock — egress-proxy 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
+2 -2
View File
@@ -122,13 +122,13 @@ 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_proxy.routes is non-empty.
# egress-proxy 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_proxy.routes:
if bottle.egress.routes:
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug)))
if bottle.supervise:
sidecar_probes.append(("supervise", supervise_container_name(slug)))
+5 -5
View File
@@ -147,7 +147,7 @@ DEFAULT_ALLOWLIST: tuple[str, ...] = (
def egress_proxy_manifest_routes(
bottle: Bottle,
) -> tuple[EgressProxyRoute, ...]:
"""Lift each `bottle.egress_proxy.routes[]` manifest entry into a
"""Lift each `bottle.egress.routes[]` manifest entry into a
resolved EgressProxyRoute. Order is preserved so route lookup at
the proxy is stable.
@@ -163,7 +163,7 @@ def egress_proxy_manifest_routes(
addon enforces."""
out: list[EgressProxyRoute] = []
slot_for_token: dict[str, str] = {}
for r in bottle.egress_proxy.routes:
for r in bottle.egress.routes:
if r.AuthScheme and r.TokenRef:
token_env = slot_for_token.get(r.TokenRef)
if token_env is None:
@@ -199,7 +199,7 @@ def egress_proxy_routes_for_bottle(
Operators that want to allow an arbitrary host that isn't in
DEFAULT_ALLOWLIST declare it directly in
`bottle.egress_proxy.routes` as a bare-pass entry
`bottle.egress.routes` as a bare-pass entry
(`- host: <name>`). The legacy `bottle.egress.allowlist`
folding is gone — egress_proxy is the single allowlist surface."""
out: list[EgressProxyRoute] = list(egress_proxy_manifest_routes(bottle))
@@ -279,7 +279,7 @@ def egress_proxy_resolve_token_values(
die(
f"egress-proxy: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding auth block "
f"from bottle.egress_proxy.routes."
f"from bottle.egress.routes."
)
if not value:
die(
@@ -298,7 +298,7 @@ class EgressProxy(ABC):
concrete subclasses."""
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressProxyPlan:
"""Lift `bottle.egress_proxy.routes` into resolved routes,
"""Lift `bottle.egress.routes` into resolved routes,
render the routes file (mode 600) under `stage_dir`, and
return the plan. Pure host-side, no docker subprocess. The
token-env map records the mapping the launch step uses to
+17 -17
View File
@@ -14,7 +14,7 @@ the system prompt, for bottles the body is human documentation
Bottle schema (frontmatter):
env: { <NAME>: <env-entry>, ... }
git: [ <git-entry>, ... ]
egress_proxy: { routes: [ <egress-route>, ... ] }
egress: { routes: [ <egress-route>, ... ] }
Agent schema (frontmatter):
bottle: <bottle-name> # required
@@ -196,7 +196,7 @@ class EgressProxyRoute:
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
label = f"bottle '{bottle_name}' egress_proxy.routes[{idx}]"
label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = _as_json_object(raw, label)
host = d.get("host")
if not isinstance(host, str) or not host:
@@ -308,7 +308,7 @@ class EgressProxyRoute:
@dataclass(frozen=True)
class EgressProxyConfig:
"""Per-bottle egress-proxy configuration. Today this is just the
route table; the nesting under `egress_proxy:` leaves room for
route table; the nesting under `egress:` leaves room for
per-bottle proxy settings (port override, log level, etc.) in
follow-ups."""
@@ -316,13 +316,13 @@ class EgressProxyConfig:
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressProxyConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' egress_proxy")
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes")
routes: tuple[EgressProxyRoute, ...] = ()
if routes_raw is not None:
if not isinstance(routes_raw, list):
die(
f"bottle '{bottle_name}' egress_proxy.routes must be an array "
f"bottle '{bottle_name}' egress.routes must be an array "
f"(was {type(routes_raw).__name__})"
)
routes_list = cast(list[object], routes_raw)
@@ -334,7 +334,7 @@ class EgressProxyConfig:
for k in d:
if k != "routes":
die(
f"bottle '{bottle_name}' egress_proxy has unknown key {k!r}; "
f"bottle '{bottle_name}' egress has unknown key {k!r}; "
f"only 'routes' is accepted"
)
return cls(routes=routes)
@@ -344,7 +344,7 @@ class EgressProxyConfig:
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = ()
egress_proxy: EgressProxyConfig = field(default_factory=EgressProxyConfig)
egress: EgressProxyConfig = field(default_factory=EgressProxyConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes three
# MCP tools to the agent (cred-proxy-block, pipelock-block,
@@ -402,14 +402,14 @@ class Bottle:
if "tokens" in d:
die(
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
f"each route now lives under 'egress_proxy.routes' with explicit "
f"each route now lives under 'egress.routes' with explicit "
f"host / path_allowlist / auth. See docs/prds/0017-egress-proxy-via-mitmproxy.md."
)
if "cred_proxy" in d:
die(
f"bottle '{name}' has a 'cred_proxy' field, which has been removed "
f"(PRD 0017). Rename to 'egress_proxy' and migrate each route:\n"
f"(PRD 0017). Rename to 'egress' and migrate each route:\n"
f" - 'path' + 'upstream' (cred-proxy URL prefix + upstream URL)\n"
f"'host' (just the upstream hostname)\n"
f" - 'auth_scheme' + 'token_ref' (flat)\n"
@@ -422,9 +422,9 @@ class Bottle:
f"See docs/prds/0017-egress-proxy-via-mitmproxy.md."
)
egress_proxy = (
EgressProxyConfig.from_dict(name, d["egress_proxy"])
if "egress_proxy" in d
egress = (
EgressProxyConfig.from_dict(name, d["egress"])
if "egress" in d
else EgressProxyConfig()
)
@@ -436,7 +436,7 @@ class Bottle:
)
return cls(
env=env, git=git, egress_proxy=egress_proxy,
env=env, git=git, egress=egress,
supervise=supervise_raw,
)
@@ -715,7 +715,7 @@ def _validate_egress_proxy_routes(
bottle_name: str,
routes: tuple[EgressProxyRoute, ...],
) -> None:
"""Cross-validation for `bottle.egress_proxy.routes`:
"""Cross-validation for `bottle.egress.routes`:
- Hosts must be unique within the bottle. The proxy matches by
exact-host (v1, prefix matching is on path_allowlist only);
@@ -732,7 +732,7 @@ def _validate_egress_proxy_routes(
key = r.Host.lower()
if key in seen_hosts:
die(
f"bottle '{bottle_name}' egress_proxy.routes has duplicate host "
f"bottle '{bottle_name}' egress.routes has duplicate host "
f"{r.Host!r}; each host must be unique on the proxy."
)
seen_hosts[key] = None
@@ -741,7 +741,7 @@ def _validate_egress_proxy_routes(
if len(with_role) > 1:
hosts = ", ".join(r.Host for r in with_role)
die(
f"bottle '{bottle_name}' egress_proxy.routes has {len(with_role)} "
f"bottle '{bottle_name}' egress.routes has {len(with_role)} "
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single launch-step side effect — pick one."
)
@@ -772,7 +772,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer — typos shouldn't silently
# ghost into an empty config.
_BOTTLE_KEYS = frozenset(
{"env", "git", "egress_proxy", "supervise"}
{"env", "git", "egress", "supervise"}
)
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
+1 -1
View File
@@ -101,7 +101,7 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
seeds; the patterns that matter gh*_, sk-ant-, AKIA, etc.
keep firing)."""
return not any(
r.Host == "api.anthropic.com" for r in bottle.egress_proxy.routes
r.Host == "api.anthropic.com" for r in bottle.egress.routes
)