diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index f8e559c..070cc81 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -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 diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index e5cd695..a816df4 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -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))) diff --git a/claude_bottle/egress_proxy.py b/claude_bottle/egress_proxy.py index 71ad78a..b32d526 100644 --- a/claude_bottle/egress_proxy.py +++ b/claude_bottle/egress_proxy.py @@ -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: `). 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 diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 5cc90a9..54dda7f 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -14,7 +14,7 @@ the system prompt, for bottles the body is human documentation Bottle schema (frontmatter): env: { : , ... } git: [ , ... ] - egress_proxy: { routes: [ , ... ] } + egress: { routes: [ , ... ] } Agent schema (frontmatter): bottle: # 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"}) diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 0d537bd..ffb4bb5 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -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 ) diff --git a/tests/unit/test_egress_proxy.py b/tests/unit/test_egress_proxy.py index a053bac..40090b0 100644 --- a/tests/unit/test_egress_proxy.py +++ b/tests/unit/test_egress_proxy.py @@ -18,7 +18,7 @@ from claude_bottle.manifest import Manifest def _bottle(routes): return Manifest.from_json_obj({ - "bottles": {"dev": {"egress_proxy": {"routes": routes}}}, + "bottles": {"dev": {"egress": {"routes": routes}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] diff --git a/tests/unit/test_manifest_egress_proxy.py b/tests/unit/test_manifest_egress_proxy.py index 2e83cea..94936dc 100644 --- a/tests/unit/test_manifest_egress_proxy.py +++ b/tests/unit/test_manifest_egress_proxy.py @@ -1,4 +1,4 @@ -"""Unit: manifest parsing for `bottle.egress_proxy.routes[]` (PRD 0017). +"""Unit: manifest parsing for `bottle.egress.routes[]` (PRD 0017). The route shape is new: `host` (required), optional `path_allowlist`, optional nested `auth: { scheme, token_ref }`. Validation rules per @@ -13,7 +13,7 @@ from claude_bottle.manifest import EgressProxyRoute, Manifest def _bottle(routes): return Manifest.from_json_obj({ - "bottles": {"dev": {"egress_proxy": {"routes": routes}}}, + "bottles": {"dev": {"egress": {"routes": routes}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] @@ -21,8 +21,8 @@ def _bottle(routes): class TestMinimalRoute(unittest.TestCase): def test_host_only(self): b = _bottle([{"host": "api.example.com"}]) - self.assertEqual(1, len(b.egress_proxy.routes)) - r = b.egress_proxy.routes[0] + self.assertEqual(1, len(b.egress.routes)) + r = b.egress.routes[0] self.assertEqual("api.example.com", r.Host) self.assertEqual((), r.PathAllowlist) self.assertEqual("", r.AuthScheme) @@ -44,7 +44,7 @@ class TestMinimalRoute(unittest.TestCase): class TestPathAllowlist(unittest.TestCase): def test_optional(self): b = _bottle([{"host": "x.example"}]) - self.assertEqual((), b.egress_proxy.routes[0].PathAllowlist) + self.assertEqual((), b.egress.routes[0].PathAllowlist) def test_must_be_array(self): with self.assertRaises(Die): @@ -65,14 +65,14 @@ class TestPathAllowlist(unittest.TestCase): }]) self.assertEqual( ("/didericis/", "/users/didericis"), - b.egress_proxy.routes[0].PathAllowlist, + b.egress.routes[0].PathAllowlist, ) class TestAuth(unittest.TestCase): def test_omitted_means_no_auth(self): b = _bottle([{"host": "github.com"}]) - r = b.egress_proxy.routes[0] + r = b.egress.routes[0] self.assertEqual("", r.AuthScheme) self.assertEqual("", r.TokenRef) @@ -81,7 +81,7 @@ class TestAuth(unittest.TestCase): "host": "api.github.com", "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, }]) - r = b.egress_proxy.routes[0] + r = b.egress.routes[0] self.assertEqual("Bearer", r.AuthScheme) self.assertEqual("GH_PAT", r.TokenRef) @@ -118,7 +118,7 @@ class TestAuth(unittest.TestCase): "host": "git.example", "auth": {"scheme": "token", "token_ref": "GITEA_PAT"}, }]) - self.assertEqual("token", b.egress_proxy.routes[0].AuthScheme) + self.assertEqual("token", b.egress.routes[0].AuthScheme) def test_unknown_auth_key_rejected(self): with self.assertRaises(Die): @@ -131,7 +131,7 @@ class TestAuth(unittest.TestCase): class TestRole(unittest.TestCase): def test_omitted_means_no_roles(self): b = _bottle([{"host": "x.example"}]) - self.assertEqual((), b.egress_proxy.routes[0].Role) + self.assertEqual((), b.egress.routes[0].Role) def test_string_normalizes_to_tuple(self): b = _bottle([{ @@ -140,7 +140,7 @@ class TestRole(unittest.TestCase): "auth": {"scheme": "Bearer", "token_ref": "T"}, }]) self.assertEqual(("claude_code_oauth",), - b.egress_proxy.routes[0].Role) + b.egress.routes[0].Role) def test_list_supported(self): b = _bottle([{ @@ -149,7 +149,7 @@ class TestRole(unittest.TestCase): "auth": {"scheme": "Bearer", "token_ref": "T"}, }]) self.assertEqual(("claude_code_oauth",), - b.egress_proxy.routes[0].Role) + b.egress.routes[0].Role) def test_unknown_role_rejected(self): # The role enum is locked down — typos shouldn't silently @@ -199,7 +199,7 @@ class TestRouteValidation(unittest.TestCase): def test_empty_routes_allowed(self): b = _bottle([]) - self.assertEqual((), b.egress_proxy.routes) + self.assertEqual((), b.egress.routes) def test_no_egress_proxy_block_means_empty(self): # The bottle dataclass defaults to an empty EgressProxyConfig. @@ -207,14 +207,14 @@ class TestRouteValidation(unittest.TestCase): "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] - self.assertEqual((), b.egress_proxy.routes) + self.assertEqual((), b.egress.routes) class TestConfigShape(unittest.TestCase): def test_unknown_egress_proxy_key_rejected(self): with self.assertRaises(Die): Manifest.from_json_obj({ - "bottles": {"dev": {"egress_proxy": {"wat": []}}}, + "bottles": {"dev": {"egress": {"wat": []}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index db9e75c..fb841c8 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -22,7 +22,7 @@ def _write(p: Path, text: str) -> None: _BOTTLE_DEV = """ --- - egress_proxy: + egress: routes: - host: api.anthropic.com auth: @@ -85,7 +85,7 @@ class TestBottleFileParses(_ResolveCase): _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) m = self.resolve() self.assertIn("dev", m.bottles) - routes = m.bottles["dev"].egress_proxy.routes + routes = m.bottles["dev"].egress.routes self.assertEqual(2, len(routes)) self.assertEqual("api.anthropic.com", routes[0].Host) self.assertEqual("Bearer", routes[0].AuthScheme) @@ -132,7 +132,7 @@ class TestCwdAgentOverridesHome(_ResolveCase): m = self.resolve() self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt) # Home bottle still present - self.assertEqual(2, len(m.bottles["dev"].egress_proxy.routes)) + self.assertEqual(2, len(m.bottles["dev"].egress.routes)) class TestCwdBottlesIgnored(_ResolveCase): @@ -147,7 +147,7 @@ class TestCwdBottlesIgnored(_ResolveCase): self.cwd_cb / "bottles" / "dev.md", """ --- - egress_proxy: + egress: routes: - host: attacker.example.com auth: @@ -160,7 +160,7 @@ class TestCwdBottlesIgnored(_ResolveCase): # Home value wins because cwd bottles are ignored entirely. self.assertEqual( "api.anthropic.com", - m.bottles["dev"].egress_proxy.routes[0].Host, + m.bottles["dev"].egress.routes[0].Host, ) diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py index 84e72b5..61506f2 100644 --- a/tests/unit/test_pipelock_allowlist.py +++ b/tests/unit/test_pipelock_allowlist.py @@ -20,7 +20,7 @@ def _bottle(spec): def _routes(routes): - return {"egress_proxy": {"routes": routes}} + return {"egress": {"routes": routes}} class TestEffectiveAllowlist(unittest.TestCase): diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index e01f90f..30241b4 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -109,7 +109,7 @@ class TestBuildConfig(unittest.TestCase): # up to route claude through pipelock. from claude_bottle.manifest import Manifest bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress_proxy": {"routes": [ + "bottles": {"dev": {"egress": {"routes": [ {"host": "api.anthropic.com", "auth": {"scheme": "Bearer", "token_ref": "T"}}, ]}}}, @@ -158,7 +158,7 @@ class TestRenderAndWrite(unittest.TestCase): "MY_SECRET": "literal-value-should-not-appear", "ANOTHER": "?prompt-message", }, - "egress_proxy": {"routes": [{"host": "github.com"}]}, + "egress": {"routes": [{"host": "github.com"}]}, } }, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -204,7 +204,7 @@ class TestRenderAndWrite(unittest.TestCase): def test_render_emits_seed_phrase_off_for_anthropic_route(self): from claude_bottle.manifest import Manifest bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress_proxy": {"routes": [ + "bottles": {"dev": {"egress": {"routes": [ {"host": "api.anthropic.com", "auth": {"scheme": "Bearer", "token_ref": "T"}}, ]}}}, diff --git a/tests/unit/test_yaml_subset.py b/tests/unit/test_yaml_subset.py index 2e8dd02..04a515f 100644 --- a/tests/unit/test_yaml_subset.py +++ b/tests/unit/test_yaml_subset.py @@ -252,7 +252,7 @@ class TestRealisticBottleFile(unittest.TestCase): def test_dev_bottle(self): out = _y(""" - egress_proxy: + egress: routes: - host: api.anthropic.com auth: @@ -270,25 +270,21 @@ class TestRealisticBottleFile(unittest.TestCase): IdentityFile: ~/.ssh/gitea.pem ExtraHosts: gitea.dideric.is: 100.78.141.42 - egress: - allowlist: - - example.com """) # Spot-check the deep parts; the structure is large. - self.assertEqual(2, len(out["egress_proxy"]["routes"])) + self.assertEqual(2, len(out["egress"]["routes"])) self.assertEqual( ["/didericis/"], - out["egress_proxy"]["routes"][1]["path_allowlist"], + out["egress"]["routes"][1]["path_allowlist"], ) self.assertEqual( "Bearer", - out["egress_proxy"]["routes"][0]["auth"]["scheme"], + out["egress"]["routes"][0]["auth"]["scheme"], ) self.assertEqual( "100.78.141.42", out["git"][0]["ExtraHosts"]["gitea.dideric.is"], ) - self.assertEqual(["example.com"], out["egress"]["allowlist"]) class TestFrontmatter(unittest.TestCase):