From 4abea282e0ee53cc0053ebd131bf200ac7b9e6f5 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 15:02:01 -0400 Subject: [PATCH] revert(egress-proxy): drop Role + agent provisioner (keep git-push block) Partial revert of fa06a3a. The role + agent-side provisioner felt overengineered: anthropic-base-url + npm-registry's only realistic host values match the tool defaults, so the role tags drove no-op dotfile writes most of the time. If non-default npm registry / tea config is needed in a future bottle, we can ship it through a more direct mechanism then. What stays from fa06a3a: - Universal HTTPS git-push block in the egress-proxy addon (`is_git_push_request` in egress_proxy_addon_core, called from the request hook before route matching; 403s git-receive-pack regardless of route). This is the security backstop so git-gate remains the only outbound write path; PR #29 keeps it. What gets reverted: - `Role` field on EgressProxyRoute (manifest + runtime). - `EGRESS_PROXY_ROLES` + `EGRESS_PROXY_SINGLETON_ROLES` constants and singleton-role validation. - `backend/docker/provision/egress_proxy.py` (npmrc + tea config). - `provision_egress_proxy` slot in `BottleBackend.provision`. - `prepare.py`'s role-based ANTHROPIC_BASE_URL detection (back to the token_ref="CLAUDE_CODE_OAUTH_TOKEN" auto-detect). - Manifest + provisioner tests for the above. 355 unit + 24 integration tests pass. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/__init__.py | 27 ++-- claude_bottle/backend/docker/backend.py | 4 - claude_bottle/backend/docker/prepare.py | 34 ++--- .../backend/docker/provision/egress_proxy.py | 142 ------------------ claude_bottle/egress_proxy.py | 10 +- claude_bottle/manifest.py | 91 +---------- tests/unit/test_manifest_egress_proxy.py | 58 ------- tests/unit/test_manifest_md_load.py | 2 - tests/unit/test_provision_egress_proxy.py | 109 -------------- 9 files changed, 24 insertions(+), 453 deletions(-) delete mode 100644 claude_bottle/backend/docker/provision/egress_proxy.py delete mode 100644 tests/unit/test_provision_egress_proxy.py diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 106ef12..04c3d35 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -219,23 +219,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): argv. Default orchestration: ca → prompt → skills → git → - 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. + 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. - 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.""" + 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.""" 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 @@ -265,12 +262,6 @@ 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 32dcdbb..b46986a 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -27,7 +27,6 @@ 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 @@ -81,9 +80,6 @@ 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 f9f02f9..52cd8d6 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -175,31 +175,21 @@ 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) - # 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, + # 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 ) - 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. + if has_anthropic_auth: forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder" # Belt-and-braces: turn off telemetry endpoints (statsig, - # error reporting) that don't route through ANTHROPIC_BASE_URL. + # error reporting) that egress-proxy 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) diff --git a/claude_bottle/backend/docker/provision/egress_proxy.py b/claude_bottle/backend/docker/provision/egress_proxy.py deleted file mode 100644 index 131a565..0000000 --- a/claude_bottle/backend/docker/provision/egress_proxy.py +++ /dev/null @@ -1,142 +0,0 @@ -"""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 313e5d5..fe4b849 100644 --- a/claude_bottle/egress_proxy.py +++ b/claude_bottle/egress_proxy.py @@ -62,19 +62,13 @@ 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. - - `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.""" + one `token_env` slot.""" host: str path_allowlist: tuple[str, ...] = () auth_scheme: str = "" token_env: str = "" token_ref: str = "" - roles: tuple[str, ...] = () @dataclass(frozen=True) @@ -154,13 +148,11 @@ 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/manifest.py b/claude_bottle/manifest.py index 00e580c..dfea962 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -129,44 +129,6 @@ 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: @@ -181,10 +143,6 @@ 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. @@ -192,17 +150,12 @@ 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": @@ -273,37 +226,11 @@ 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", "role"): + if k not in ("host", "path_allowlist", "auth"): die( f"{label} has unknown key {k!r}; accepted keys are " - f"'host', 'path_allowlist', 'auth', 'role'" + f"'host', 'path_allowlist', 'auth'" ) return cls( @@ -311,7 +238,6 @@ class EgressProxyRoute: PathAllowlist=prefixes, AuthScheme=auth_scheme, TokenRef=token_ref, - Role=roles, ) @@ -789,10 +715,6 @@ 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 @@ -807,15 +729,6 @@ 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_manifest_egress_proxy.py b/tests/unit/test_manifest_egress_proxy.py index 771a779..4ffa998 100644 --- a/tests/unit/test_manifest_egress_proxy.py +++ b/tests/unit/test_manifest_egress_proxy.py @@ -128,64 +128,6 @@ 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 a444371..c8a8dde 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -25,7 +25,6 @@ _BOTTLE_DEV = """ egress_proxy: routes: - host: api.anthropic.com - role: anthropic-base-url auth: scheme: Bearer token_ref: CLAUDE_CODE_OAUTH_TOKEN @@ -93,7 +92,6 @@ 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 deleted file mode 100644 index e58cee8..0000000 --- a/tests/unit/test_provision_egress_proxy.py +++ /dev/null @@ -1,109 +0,0 @@ -"""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()