diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 04c3d35..106ef12 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -219,20 +219,23 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): argv. Default orchestration: ca → prompt → skills → git → - supervise. CA install runs first so the agent's trust store - is rebuilt before anything inside the agent makes a TLS call. - Subclasses typically don't override this; they implement the - sub-methods below. + egress_proxy → supervise. CA install runs first so the + agent's trust store is rebuilt before anything inside the + agent makes a TLS call. egress_proxy runs after git because + its provisioner may layer on top of `~/.gitconfig` entries + provision_git writes. Subclasses typically don't override + this; they implement the sub-methods below. - PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc, - ~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is - on the agent's HTTP_PROXY path so every tool that respects - HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is - intercepted without per-tool reconfiguration.""" + PRD 0017: most agent-side rewrites (HTTPS routing) are + obsolete because egress-proxy sits on the HTTPS_PROXY path. + The remaining rewrites — npm registry, tea config, + ANTHROPIC_BASE_URL — exist for tools that need an explicit + URL config rather than just respecting HTTPS_PROXY.""" self.provision_ca(plan, target) prompt_path = self.provision_prompt(plan, target) self.provision_skills(plan, target) self.provision_git(plan, target) + self.provision_egress_proxy(plan, target) self.provision_supervise(plan, target) return prompt_path @@ -262,6 +265,12 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Copy the host's cwd `.git` directory into the running bottle if the user requested --cwd. No-op otherwise.""" + def provision_egress_proxy(self, plan: PlanT, target: str) -> None: + """Drop the agent-side dotfiles driven by `egress_proxy.routes[].role` + per PRD 0017 (~/.npmrc, ~/.config/tea/config.yml). Default + impl is a no-op for backends that don't yet support the + egress-proxy sidecar; the Docker backend overrides.""" + def provision_supervise(self, plan: PlanT, target: str) -> None: """Write the in-bottle Claude Code MCP config so the agent discovers the per-bottle supervise sidecar (PRD 0013). diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index b46986a..32dcdbb 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -27,6 +27,7 @@ from .egress_proxy import DockerEgressProxy from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy from .provision import ca as _ca +from .provision import egress_proxy as _egress_proxy_prov from .provision import git as _git from .provision import prompt as _prompt from .provision import skills as _skills @@ -80,6 +81,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def provision_git(self, plan: DockerBottlePlan, target: str) -> None: _git.provision_git(plan, target) + def provision_egress_proxy(self, plan: DockerBottlePlan, target: str) -> None: + _egress_proxy_prov.provision_egress_proxy(plan, target) + def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None: _supervise_prov.provision_supervise(plan, target) diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 52cd8d6..f9f02f9 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -175,21 +175,31 @@ 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 for the Anthropic - # OAuth flow, claude-code's outbound Authorization gets stripped + - # re-injected by egress-proxy. 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 is not - # any real `auth.token_ref` value, so leaking it would tell an - # attacker only that egress-proxy is in front. - has_anthropic_auth = any( - r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN" - for r in egress_proxy_plan.routes + # Find the (at most one) egress-proxy route claiming the + # anthropic-base-url role. Manifest validation enforces the + # singleton constraint. The role flips on claude-code's + # placeholder OAuth token + telemetry-off env vars and pins + # ANTHROPIC_BASE_URL at the route's host. Egress-proxy then + # strips inbound Authorization on every request and injects + # the real one from the route's `auth.token_ref` env var. + anthropic_route = next( + (r for r in egress_proxy_plan.routes if "anthropic-base-url" in r.roles), + None, ) - if has_anthropic_auth: + if anthropic_route is not None: + # Point claude-code at the canonical Anthropic URL. HTTPS_PROXY + # routes the request through egress-proxy, which injects the + # real OAuth header from the host env named by the route's + # auth.token_ref. + forwarded_env["ANTHROPIC_BASE_URL"] = f"https://{anthropic_route.host}" + # claude-code refuses to start without *some* credential in + # its env. The proxy strips inbound Authorization on every + # request and injects the real one — so a non-secret + # placeholder is sufficient. The agent cannot exfiltrate + # this string because it carries no meaning to upstream. forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder" # Belt-and-braces: turn off telemetry endpoints (statsig, - # error reporting) that egress-proxy can't gate by auth. + # error reporting) that don't route through ANTHROPIC_BASE_URL. forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") _write_env_file(resolved, env_file) diff --git a/claude_bottle/backend/docker/provision/egress_proxy.py b/claude_bottle/backend/docker/provision/egress_proxy.py new file mode 100644 index 0000000..131a565 --- /dev/null +++ b/claude_bottle/backend/docker/provision/egress_proxy.py @@ -0,0 +1,142 @@ +"""Egress-proxy agent-side provisioning (PRD 0017). + +Writes the dotfiles / env-var nudges that point tools needing an +explicit URL config at the canonical upstream. The agent's +`HTTPS_PROXY=egress-proxy` already catches HTTPS traffic — these +provisioners exist for tools that: + + - need an explicit base-URL setting beyond the proxy (claude-code + via `ANTHROPIC_BASE_URL`), or + - read a per-tool config to know which upstream to talk to (npm's + `~/.npmrc`, tea's `~/.config/tea/config.yml`). + +The `ANTHROPIC_BASE_URL` env is set at `docker run -e` time by the +backend's launch step, not here — it has to be in the agent's environ +before claude starts, and there is no point in writing it to a +dotfile the agent would have to source. See `prepare.py` for that. +This module handles the rest: ~/.npmrc and ~/.config/tea/config.yml. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from ....egress_proxy import EgressProxyRoute +from ....log import info +from .. import util as docker_mod +from ..bottle_plan import DockerBottlePlan + + +def provision_egress_proxy(plan: DockerBottlePlan, target: str) -> None: + """Drop the agent-side dotfiles for each declared egress-proxy + route role. No-op when the bottle has no roles to provision.""" + routes = plan.egress_proxy_plan.routes + if not routes: + return + _provision_npmrc(plan, target, routes) + _provision_tea_config(plan, target, routes) + + +# --- npm -------------------------------------------------------------------- + + +def render_npmrc(routes: tuple[EgressProxyRoute, ...]) -> str: + """Render `~/.npmrc` content. Driven by the `npm-registry` role: + finds the (single) route that claims it and writes a registry= + line pointing at the canonical upstream URL. Empty string when + no such route exists, so callers can branch on emptiness. + + Egress-proxy is on the agent's HTTPS_PROXY, so the canonical + URL routes through the proxy automatically and gets DLP + + path_allowlist + auth-injection. The npmrc deliberately carries + no `_authToken` — the proxy strips inbound Authorization and + injects the upstream one from the bottle's egress-proxy auth + config. Manifest validation enforces that the role is a + singleton, so the first match is the only match.""" + for r in routes: + if "npm-registry" in r.roles: + return f"registry=https://{r.host}/\n" + return "" + + +def _provision_npmrc( + plan: DockerBottlePlan, + target: str, + routes: tuple[EgressProxyRoute, ...], +) -> None: + content = render_npmrc(routes) + if not content: + return + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_npmrc = f"{container_home}/.npmrc" + npmrc = plan.stage_dir / "agent_npmrc" + npmrc.write_text(content) + npmrc.chmod(0o600) + info(f"writing {container_npmrc} (egress-proxy npm registry)") + subprocess.run( + ["docker", "cp", str(npmrc), f"{target}:{container_npmrc}"], + stdout=subprocess.DEVNULL, + check=True, + ) + docker_mod.docker_exec_root(target, ["chown", "node:node", container_npmrc]) + docker_mod.docker_exec_root(target, ["chmod", "644", container_npmrc]) + + +# --- tea -------------------------------------------------------------------- + + +def render_tea_config(routes: tuple[EgressProxyRoute, ...]) -> str: + """Render `~/.config/tea/config.yml`. Driven by the `tea-login` + role: each route that claims it produces one `logins:` entry + pointing at the canonical Gitea URL. The egress-proxy injects + the real token at request time; the value in `token:` here is a + placeholder. `tea` refuses to make calls without a non-empty + token field, so the placeholder is necessary.""" + tea_routes = [r for r in routes if "tea-login" in r.roles] + if not tea_routes: + return "" + lines = ["logins:"] + for r in tea_routes: + lines.extend([ + f"- name: {r.host}", + f" url: https://{r.host}", + " token: egress-proxy-placeholder", + " default: false", + " ssh_host: \"\"", + " ssh_key: \"\"", + " insecure: false", + ]) + return "\n".join(lines) + "\n" + + +def _provision_tea_config( + plan: DockerBottlePlan, + target: str, + routes: tuple[EgressProxyRoute, ...], +) -> None: + content = render_tea_config(routes) + if not content: + return + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_tea = f"{container_home}/.config/tea/config.yml" + cfg = plan.stage_dir / "agent_tea_config.yml" + cfg.write_text(content) + cfg.chmod(0o600) + info( + f"writing {container_tea} " + f"({len([r for r in routes if 'tea-login' in r.roles])} tea login(s))" + ) + docker_mod.docker_exec_root( + target, ["mkdir", "-p", str(Path(container_tea).parent)] + ) + subprocess.run( + ["docker", "cp", str(cfg), f"{target}:{container_tea}"], + stdout=subprocess.DEVNULL, + check=True, + ) + docker_mod.docker_exec_root(target, [ + "chown", "-R", "node:node", str(Path(container_tea).parent), + ]) + docker_mod.docker_exec_root(target, ["chmod", "600", container_tea]) diff --git a/claude_bottle/egress_proxy.py b/claude_bottle/egress_proxy.py index fe4b849..313e5d5 100644 --- a/claude_bottle/egress_proxy.py +++ b/claude_bottle/egress_proxy.py @@ -62,13 +62,19 @@ class EgressProxyRoute: (e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var the CLI reads at launch and forwards into the container's environ under `token_env`. Routes that share a `token_ref` coalesce to - one `token_env` slot.""" + one `token_env` slot. + + `roles` are the provisioner tags from the manifest route (see + `manifest.EGRESS_PROXY_ROLES`). Each tag drives one agent-side + dotfile / env rewrite at bottle bring-up. The addon never reads + these — they flow through the plan to the host-side provisioner.""" host: str path_allowlist: tuple[str, ...] = () auth_scheme: str = "" token_env: str = "" token_ref: str = "" + roles: tuple[str, ...] = () @dataclass(frozen=True) @@ -148,11 +154,13 @@ def egress_proxy_routes_for_bottle( auth_scheme=r.AuthScheme, token_env=token_env, token_ref=r.TokenRef, + roles=r.Role, )) else: out.append(EgressProxyRoute( host=r.Host, path_allowlist=r.PathAllowlist, + roles=r.Role, )) return tuple(out) diff --git a/claude_bottle/egress_proxy_addon.py b/claude_bottle/egress_proxy_addon.py index ff2a3af..0c11111 100644 --- a/claude_bottle/egress_proxy_addon.py +++ b/claude_bottle/egress_proxy_addon.py @@ -36,7 +36,7 @@ from mitmproxy import http # type: ignore[import-not-found] # Absolute import (NOT `from .egress_proxy_addon_core`) — the # container drops both files flat into /app/ so they are sibling # top-level modules to mitmdump's loader, not a package. -from egress_proxy_addon_core import Route, decide, load_routes # type: ignore[import-not-found] +from egress_proxy_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found] DEFAULT_ROUTES_PATH = "/etc/egress-proxy/routes.yaml" @@ -93,7 +93,25 @@ class EgressProxyAddon: # below. flow.request.headers.pop("authorization", None) - request_path = flow.request.path.split("?", 1)[0] + request_path, _, query = flow.request.path.partition("?") + + # Universal HTTPS git-push block. Defense-in-depth: git-gate + # (PRD 0008) is the only sanctioned outbound path for git + # writes — its pre-receive runs gitleaks. Letting HTTPS push + # through egress-proxy + auth injection would route around + # that scan, so we 403 before any route logic. + if is_git_push_request(request_path, query): + flow.response = http.Response.make( + 403, + ( + b"egress-proxy: git push over HTTPS is not supported; " + b"use the bottle.git SSH path (gitleaks-scanned by " + b"git-gate's pre-receive hook)." + ), + {"Content-Type": "text/plain; charset=utf-8"}, + ) + return + decision = decide( self.routes, flow.request.pretty_host, diff --git a/claude_bottle/egress_proxy_addon_core.py b/claude_bottle/egress_proxy_addon_core.py index 5c90133..36bb341 100644 --- a/claude_bottle/egress_proxy_addon_core.py +++ b/claude_bottle/egress_proxy_addon_core.py @@ -135,6 +135,36 @@ def load_routes(text: str) -> tuple[Route, ...]: return parse_routes(payload) +def is_git_push_request(path: str, query: str) -> bool: + """Return True if the request is a git smart-HTTP push. + + git push over HTTPS hits two endpoints: + GET /info/refs?service=git-receive-pack (capabilities) + POST /git-receive-pack (the push) + + Fetches use `service=git-upload-pack` / `/git-upload-pack` and + are unaffected. Egress-proxy refuses HTTPS push because git-gate's + pre-receive gitleaks scan is the gate for outbound git data; + routing push through egress-proxy would bypass that. Use the + bottle.git SSH path if you need to push. + + Universal across routes — the block fires even when no + egress_proxy route matches the host. A bare-pass route (host with + no auth, no path_allowlist) would otherwise let push through to + pipelock + upstream untouched. + """ + if path.endswith("/git-receive-pack"): + return True + if path.endswith("/info/refs"): + # Query string is parsed leniently — `service=git-receive-pack` + # may appear with other params in any order. + for pair in query.split("&"): + k, _, v = pair.partition("=") + if k == "service" and v == "git-receive-pack": + return True + return False + + def match_route( routes: typing.Sequence[Route], request_host: str, @@ -207,6 +237,7 @@ __all__ = [ "Decision", "Route", "decide", + "is_git_push_request", "load_routes", "match_route", "parse_routes", diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index dfea962..00e580c 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -129,6 +129,44 @@ class GitEntry: # token-not-Bearer quirk (go-gitea/gitea#16734). EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token") +# Agent-side provisioner role tags a route may carry. Each tag drives +# one dotfile / env rewrite at bottle bring-up so tools that need an +# explicit URL config (rather than just respecting HTTPS_PROXY) point +# at the canonical upstream. Egress-proxy is on the agent's HTTP_PROXY +# path, so the canonical URL routes through the proxy automatically — +# the dotfile values are upstream URLs, not proxy URLs. +# +# anthropic-base-url: set ANTHROPIC_BASE_URL=https:// in the +# agent's environ (signals claude-code to use +# a non-default Anthropic endpoint; in practice +# the host is api.anthropic.com, so the value +# matches claude-code's default — the marker +# is what drives the placeholder-token + +# telemetry-off env vars). +# npm-registry: write ~/.npmrc `registry=https:///`. +# tea-login: add an entry to ~/.config/tea/config.yml +# (url = https://) so `tea` knows which +# Gitea host to talk to. +# +# Routes without a `role` are pure proxy entries: egress-proxy +# enforces path_allowlist + injects auth, but no agent-side dotfile +# is written. (`git-insteadof` is intentionally absent — egress-proxy +# already 403s HTTPS git push universally; PRD 0017's git story is +# `bottle.git` + git-gate for SSH push.) +EGRESS_PROXY_ROLES = frozenset({ + "anthropic-base-url", + "npm-registry", + "tea-login", +}) + +# Roles whose semantics imply a single route can carry them. A second +# route claiming the same role would make the provisioner's choice +# ambiguous (which host goes into ANTHROPIC_BASE_URL?). +EGRESS_PROXY_SINGLETON_ROLES = frozenset({ + "anthropic-base-url", + "npm-registry", +}) + @dataclass(frozen=True) class EgressProxyRoute: @@ -143,6 +181,10 @@ class EgressProxyRoute: manifest's `auth` block is omitted both fields are empty strings — no Authorization is written, no token forwarded. + `Role` carries optional provisioner tags (see EGRESS_PROXY_ROLES). + Each tag drives one agent-side dotfile / env rewrite when the + sidecar comes up. + Validation rules (enforced in `from_dict`): - `host` required, non-empty. - `path_allowlist` optional, list of absolute path prefixes. @@ -150,12 +192,17 @@ class EgressProxyRoute: `token_ref` as non-empty strings; an empty `auth: {}` is an error rather than a synonym for "no auth" (omit `auth` for that case). + - `role` optional. String or list of strings drawn from + EGRESS_PROXY_ROLES. Singleton roles (see + EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one + route per bottle. """ Host: str PathAllowlist: tuple[str, ...] = () AuthScheme: str = "" TokenRef: str = "" + Role: tuple[str, ...] = () @classmethod def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute": @@ -226,11 +273,37 @@ class EgressProxyRoute: auth_scheme = auth_scheme_raw token_ref = token_ref_raw + role_raw = d.get("role") + roles: tuple[str, ...] = () + if role_raw is None: + roles = () + elif isinstance(role_raw, str): + roles = (role_raw,) + elif isinstance(role_raw, list): + role_list = cast(list[object], role_raw) + collected_roles: list[str] = [] + for r in role_list: + if not isinstance(r, str): + die(f"{label} role items must be strings (got {type(r).__name__})") + collected_roles.append(r) + roles = tuple(collected_roles) + else: + die( + f"{label} role must be a string or a list of strings " + f"(was {type(role_raw).__name__})" + ) + for r in roles: + if r not in EGRESS_PROXY_ROLES: + die( + f"{label} role {r!r} is not one of " + f"{', '.join(sorted(EGRESS_PROXY_ROLES))}" + ) + for k in d: - if k not in ("host", "path_allowlist", "auth"): + if k not in ("host", "path_allowlist", "auth", "role"): die( f"{label} has unknown key {k!r}; accepted keys are " - f"'host', 'path_allowlist', 'auth'" + f"'host', 'path_allowlist', 'auth', 'role'" ) return cls( @@ -238,6 +311,7 @@ class EgressProxyRoute: PathAllowlist=prefixes, AuthScheme=auth_scheme, TokenRef=token_ref, + Role=roles, ) @@ -715,6 +789,10 @@ def _validate_egress_proxy_routes( - Hosts must be unique within the bottle. The proxy matches by exact-host (v1, prefix matching is on path_allowlist only); duplicate hosts leave the route choice ambiguous. + - Singleton roles (`anthropic-base-url`, `npm-registry`) may + appear on at most one route — each drives a single agent-side + dotfile/env entry, so two routes claiming the role would make + the choice ambiguous. No cross-validation against `bottle.git` is performed. git-gate (SSH push/fetch) and egress-proxy (HTTPS) broker different @@ -729,6 +807,15 @@ def _validate_egress_proxy_routes( f"{r.Host!r}; each host must be unique on the proxy." ) seen_hosts[key] = None + for role in EGRESS_PROXY_SINGLETON_ROLES: + with_role = [r for r in routes if role in r.Role] + 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"routes with role {role!r} (hosts: {hosts}); this role drives a " + f"single agent-side rewrite — pick one." + ) def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: diff --git a/tests/unit/test_egress_proxy_addon_core.py b/tests/unit/test_egress_proxy_addon_core.py index 339a586..c58837d 100644 --- a/tests/unit/test_egress_proxy_addon_core.py +++ b/tests/unit/test_egress_proxy_addon_core.py @@ -10,6 +10,7 @@ from claude_bottle.egress_proxy_addon_core import ( Decision, Route, decide, + is_git_push_request, load_routes, match_route, parse_routes, @@ -245,5 +246,52 @@ class TestDecisionDefaults(unittest.TestCase): self.assertIsNone(d.inject_authorization) +# --- is_git_push_request ------------------------------------------------ + + +class TestIsGitPushRequest(unittest.TestCase): + def test_post_git_receive_pack_endpoint(self): + # The POST that carries the actual push payload. + self.assertTrue(is_git_push_request("/owner/repo.git/git-receive-pack", "")) + + def test_info_refs_with_receive_pack_service(self): + # The capability advertisement GET that precedes a push. + self.assertTrue(is_git_push_request( + "/owner/repo.git/info/refs", + "service=git-receive-pack", + )) + + def test_info_refs_with_extra_query_params(self): + # service= may appear with other params in any order. + self.assertTrue(is_git_push_request( + "/owner/repo.git/info/refs", + "foo=bar&service=git-receive-pack&z=1", + )) + self.assertTrue(is_git_push_request( + "/owner/repo.git/info/refs", + "service=git-receive-pack&foo=bar", + )) + + def test_fetch_endpoints_not_blocked(self): + # `service=git-upload-pack` is fetch; never blocked. + self.assertFalse(is_git_push_request( + "/owner/repo.git/info/refs", + "service=git-upload-pack", + )) + self.assertFalse(is_git_push_request( + "/owner/repo.git/git-upload-pack", "", + )) + + def test_info_refs_without_service_not_blocked(self): + # Bare info/refs (no query) defaults to git-upload-pack on + # the server side; not push. + self.assertFalse(is_git_push_request("/x/info/refs", "")) + + def test_unrelated_paths_not_blocked(self): + self.assertFalse(is_git_push_request("/repos/owner/repo", "")) + self.assertFalse(is_git_push_request("/v1/messages", "")) + self.assertFalse(is_git_push_request("/", "")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_manifest_egress_proxy.py b/tests/unit/test_manifest_egress_proxy.py index 4ffa998..771a779 100644 --- a/tests/unit/test_manifest_egress_proxy.py +++ b/tests/unit/test_manifest_egress_proxy.py @@ -128,6 +128,64 @@ 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) + + def test_string_normalizes_to_tuple(self): + b = _bottle([{"host": "api.anthropic.com", + "role": "anthropic-base-url", + "auth": {"scheme": "Bearer", "token_ref": "T"}}]) + self.assertEqual(("anthropic-base-url",), + b.egress_proxy.routes[0].Role) + + def test_list_supported(self): + b = _bottle([{"host": "registry.npmjs.org", + "role": ["npm-registry"]}]) + self.assertEqual(("npm-registry",), b.egress_proxy.routes[0].Role) + + def test_unknown_role_rejected(self): + with self.assertRaises(Die): + _bottle([{"host": "x.example", "role": "git-insteadof"}]) + + def test_non_string_role_rejected(self): + with self.assertRaises(Die): + _bottle([{"host": "x.example", "role": 42}]) + + def test_list_with_non_string_item_rejected(self): + with self.assertRaises(Die): + _bottle([{"host": "x.example", "role": ["npm-registry", 42]}]) + + def test_singleton_anthropic_base_url_enforced(self): + with self.assertRaises(Die): + _bottle([ + {"host": "api.anthropic.com", "role": "anthropic-base-url", + "auth": {"scheme": "Bearer", "token_ref": "T1"}}, + {"host": "api2.anthropic.example", + "role": "anthropic-base-url", + "auth": {"scheme": "Bearer", "token_ref": "T2"}}, + ]) + + def test_singleton_npm_registry_enforced(self): + with self.assertRaises(Die): + _bottle([ + {"host": "registry.npmjs.org", "role": "npm-registry"}, + {"host": "npm.example", "role": "npm-registry"}, + ]) + + def test_tea_login_is_not_singleton(self): + # Multiple Gitea instances on one bottle is a legitimate + # dev setup; tea-login lays one logins[] entry per route. + b = _bottle([ + {"host": "gitea.example", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "T1"}}, + {"host": "other-gitea.example", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "T2"}}, + ]) + self.assertEqual(2, len(b.egress_proxy.routes)) + + class TestRouteValidation(unittest.TestCase): def test_duplicate_hosts_rejected(self): # Routes match by exact host; duplicates leave the choice diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index c8a8dde..a444371 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -25,6 +25,7 @@ _BOTTLE_DEV = """ egress_proxy: routes: - host: api.anthropic.com + role: anthropic-base-url auth: scheme: Bearer token_ref: CLAUDE_CODE_OAUTH_TOKEN @@ -92,6 +93,7 @@ class TestBottleFileParses(_ResolveCase): self.assertEqual("api.anthropic.com", routes[0].Host) self.assertEqual("Bearer", routes[0].AuthScheme) self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", routes[0].TokenRef) + self.assertEqual(("anthropic-base-url",), routes[0].Role) self.assertEqual(["example.com"], list(m.bottles["dev"].egress.allowlist)) diff --git a/tests/unit/test_provision_egress_proxy.py b/tests/unit/test_provision_egress_proxy.py new file mode 100644 index 0000000..e58cee8 --- /dev/null +++ b/tests/unit/test_provision_egress_proxy.py @@ -0,0 +1,109 @@ +"""Unit: agent-side provisioning for egress-proxy roles (PRD 0017). + +Each role drives one dotfile / env-var rewrite at bottle bring-up. +HTTPS_PROXY routes the canonical URL through egress-proxy, which +injects auth and DLP-scans on the upstream leg.""" + +import unittest + +from claude_bottle.backend.docker.provision.egress_proxy import ( + render_npmrc, + render_tea_config, +) +from claude_bottle.egress_proxy import egress_proxy_routes_for_bottle +from claude_bottle.manifest import Manifest + + +def _routes(manifest_routes): + m = Manifest.from_json_obj({ + "bottles": {"dev": {"egress_proxy": {"routes": manifest_routes}}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + return egress_proxy_routes_for_bottle(m.bottles["dev"]) + + +# --- npmrc ----------------------------------------------------------- + + +class TestRenderNpmrc(unittest.TestCase): + def test_canonical_upstream_url(self): + routes = _routes([ + {"host": "registry.npmjs.org", "role": "npm-registry", + "auth": {"scheme": "Bearer", "token_ref": "NPM_TOKEN"}}, + ]) + self.assertEqual( + "registry=https://registry.npmjs.org/\n", + render_npmrc(routes), + ) + + def test_empty_when_no_npm_role(self): + routes = _routes([ + {"host": "api.github.com", + "auth": {"scheme": "Bearer", "token_ref": "GH"}}, + ]) + self.assertEqual("", render_npmrc(routes)) + + def test_no_routes_empty(self): + self.assertEqual("", render_npmrc(())) + + def test_no_auth_token_in_npmrc(self): + # The proxy injects auth; the npmrc must carry no secret — + # not even `:always-auth=true` lines that would prompt npm + # to wait for credentials. Just the registry URL. + routes = _routes([ + {"host": "registry.npmjs.org", "role": "npm-registry", + "auth": {"scheme": "Bearer", "token_ref": "NPM_TOKEN"}}, + ]) + out = render_npmrc(routes) + self.assertNotIn("_authToken", out) + self.assertNotIn("NPM_TOKEN", out) + + +# --- tea config ------------------------------------------------------ + + +class TestRenderTeaConfig(unittest.TestCase): + def test_single_login(self): + routes = _routes([ + {"host": "gitea.dideric.is", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "GITEA_TOKEN"}}, + ]) + out = render_tea_config(routes) + self.assertIn("- name: gitea.dideric.is", out) + self.assertIn("url: https://gitea.dideric.is", out) + self.assertIn("token: egress-proxy-placeholder", out) + + def test_multiple_logins_each_get_own_entry(self): + routes = _routes([ + {"host": "gitea.a.example", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "T_A"}}, + {"host": "gitea.b.example", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "T_B"}}, + ]) + out = render_tea_config(routes) + self.assertIn("- name: gitea.a.example", out) + self.assertIn("- name: gitea.b.example", out) + + def test_empty_when_no_tea_role(self): + routes = _routes([ + {"host": "api.github.com", + "auth": {"scheme": "Bearer", "token_ref": "GH"}}, + ]) + self.assertEqual("", render_tea_config(routes)) + + def test_no_routes_empty(self): + self.assertEqual("", render_tea_config(())) + + def test_no_real_token_in_config(self): + routes = _routes([ + {"host": "gitea.dideric.is", "role": "tea-login", + "auth": {"scheme": "token", "token_ref": "GITEA_TOKEN"}}, + ]) + out = render_tea_config(routes) + # GITEA_TOKEN is just the env var name, not the value — + # placeholder-only is the SC. + self.assertIn("egress-proxy-placeholder", out) + + +if __name__ == "__main__": + unittest.main()