diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index 286d64d..e02ca9c 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -106,11 +106,11 @@ class DockerBottlePlan(BottlePlan): info(f" git gate : {'; '.join(git_lines)}") else: info(" git remotes : (none)") - if self.cred_proxy_plan.upstreams: - routes = [f"{u.path}→{u.upstream}" for u in self.cred_proxy_plan.upstreams] - refs = sorted({u.token_ref for u in self.cred_proxy_plan.upstreams}) - info(f" cred-proxy : {len(routes)} route(s); tokens: {', '.join(refs)}") - for line in routes: + if self.cred_proxy_plan.routes: + lines = [f"{r.path}→{r.upstream}" for r in self.cred_proxy_plan.routes] + refs = sorted({r.token_ref for r in self.cred_proxy_plan.routes}) + info(f" cred-proxy : {len(lines)} route(s); tokens: {', '.join(refs)}") + for line in lines: info(f" {line}") else: info(" cred-proxy : (none)") @@ -148,13 +148,13 @@ class DockerBottlePlan(BottlePlan): ], "cred_proxy": [ { - "path": u.path, - "upstream": u.upstream, - "auth_scheme": u.auth_scheme, - "token_ref": u.token_ref, - "roles": list(u.roles), + "path": r.path, + "upstream": r.upstream, + "auth_scheme": r.auth_scheme, + "token_ref": r.token_ref, + "roles": list(r.roles), } - for u in self.cred_proxy_plan.upstreams + for r in self.cred_proxy_plan.routes ], "egress": { "host_count": len(hosts), diff --git a/claude_bottle/backend/docker/cred_proxy.py b/claude_bottle/backend/docker/cred_proxy.py index 54213cc..8c40ced 100644 --- a/claude_bottle/backend/docker/cred_proxy.py +++ b/claude_bottle/backend/docker/cred_proxy.py @@ -1,6 +1,6 @@ """DockerCredProxy — the Docker-specific lifecycle for the per-bottle cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare -step (upstream lift + routes.json render + token-env-map derivation) +step (route lift + routes.json render + token-env-map derivation) from `CredProxy`.""" from __future__ import annotations @@ -91,8 +91,8 @@ class DockerCredProxy(CredProxy): reach the real upstream over HTTPS. 6. `docker start`. Returns the container name (the target passed to `.stop`).""" - if not plan.upstreams: - die("DockerCredProxy.start called with no upstreams; caller should skip") + if not plan.routes: + die("DockerCredProxy.start called with no routes; caller should skip") if not plan.internal_network or not plan.egress_network: die( "DockerCredProxy.start: internal_network / egress_network must be " diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index a32747d..407f925 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -105,7 +105,7 @@ def launch( stack.callback(git_gate.stop, git_gate_name) # Cred-proxy (PRD 0010). One sidecar per bottle when - # bottle.tokens declares any kind. Must come up AFTER pipelock + # bottle.cred_proxy.routes is non-empty. Must come up AFTER pipelock # — cred-proxy routes its outbound HTTPS through pipelock # (HTTPS_PROXY in environ + the per-bottle CA in its trust # store) so the egress allowlist + body scanner sit in the @@ -113,7 +113,7 @@ def launch( # resolution for `cred-proxy` succeeds on the agent's first # call; tokens flow from the host env into the sidecar's # environ, not the agent's. - if plan.cred_proxy_plan.upstreams: + if plan.cred_proxy_plan.routes: cred_proxy_plan = dataclasses.replace( plan.cred_proxy_plan, internal_network=internal_network, diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 46a81ea..2e22f7b 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -93,7 +93,7 @@ def resolve_plan( # anthropic-base-url role. Manifest validation enforces the # singleton constraint. anthropic_route = next( - (u for u in cred_proxy_plan.upstreams if "anthropic-base-url" in u.roles), + (r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles), None, ) if spec.forward_oauth_token and anthropic_route is None: diff --git a/claude_bottle/backend/docker/provision/cred_proxy.py b/claude_bottle/backend/docker/provision/cred_proxy.py index a375cd1..53da4ea 100644 --- a/claude_bottle/backend/docker/provision/cred_proxy.py +++ b/claude_bottle/backend/docker/provision/cred_proxy.py @@ -22,7 +22,7 @@ import os import subprocess from pathlib import Path -from ....cred_proxy import CredProxyUpstream +from ....cred_proxy import CredProxyRoute from ....log import info from .. import util as docker_mod from ..bottle_plan import DockerBottlePlan @@ -31,21 +31,21 @@ from ..cred_proxy import cred_proxy_url def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None: """Drop the agent-side dotfiles for each declared cred-proxy - route. No-op when the bottle has no tokens.""" - upstreams = plan.cred_proxy_plan.upstreams - if not upstreams: + route. No-op when the bottle has no routes.""" + routes = plan.cred_proxy_plan.routes + if not routes: return bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) git_gate_hosts = {g.UpstreamHost for g in bottle.git} - _provision_npmrc(plan, target, upstreams) - _provision_gitconfig(plan, target, upstreams, git_gate_hosts) - _provision_tea_config(plan, target, upstreams) + _provision_npmrc(plan, target, routes) + _provision_gitconfig(plan, target, routes, git_gate_hosts) + _provision_tea_config(plan, target, routes) # --- npm -------------------------------------------------------------------- -def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str: +def render_npmrc(routes: tuple[CredProxyRoute, ...]) -> str: """Render `~/.npmrc` content. Driven by the `npm-registry` role: finds the (single) route that claims it and writes a registry= line at the proxy. Empty string when no such route exists, so @@ -55,18 +55,18 @@ def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str: npmrc deliberately carries no `_authToken`. The registry alone is enough. Manifest validation enforces that the role is a singleton, so the first match is the only match.""" - for u in upstreams: - if "npm-registry" in u.roles: - return f"registry={cred_proxy_url()}{u.path}\n" + for r in routes: + if "npm-registry" in r.roles: + return f"registry={cred_proxy_url()}{r.path}\n" return "" def _provision_npmrc( plan: DockerBottlePlan, target: str, - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], ) -> None: - content = render_npmrc(upstreams) + content = render_npmrc(routes) if not content: return container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") @@ -88,7 +88,7 @@ def _provision_npmrc( def render_cred_proxy_gitconfig( - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment] ) -> str: """Render the `~/.gitconfig` fragment for cred-proxy insteadOf @@ -105,23 +105,23 @@ def render_cred_proxy_gitconfig( suppressing the rewrite means `git clone https:///...` doesn't have a tempting shortcut that just confuses on push. - The insteadOf left-hand side comes from `upstream` (with a + The insteadOf left-hand side comes from `route.upstream` (with a trailing `/` so insteadOf matches at the directory boundary), so the same renderer handles github.com, gitea.dideric.is, and any future host the user wires up.""" rules: list[str] = [] - for u in upstreams: - if "git-insteadof" not in u.roles: + for r in routes: + if "git-insteadof" not in r.roles: continue # Strip scheme to derive the host for the git-gate overlap # check. urllib.parse-free parse: same shape we accept in # manifest validation. - host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0] + host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0] if host in git_gate_hosts: continue rules.append( - f'[url "{cred_proxy_url()}{u.path}"]\n' - f"\tinsteadOf = {u.upstream}/\n" + f'[url "{cred_proxy_url()}{r.path}"]\n' + f"\tinsteadOf = {r.upstream}/\n" ) if not rules: return "" @@ -136,14 +136,14 @@ def render_cred_proxy_gitconfig( def _provision_gitconfig( plan: DockerBottlePlan, target: str, - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], git_gate_hosts: set[str], ) -> None: """Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs after `provision_git`, so any git-gate rules already live in the file; we append rather than overwrite. Hosts already brokered by git-gate are skipped — git-gate is the canonical git path there.""" - content = render_cred_proxy_gitconfig(upstreams, git_gate_hosts) + content = render_cred_proxy_gitconfig(routes, git_gate_hosts) if not content: return container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") @@ -179,25 +179,25 @@ def _provision_gitconfig( # --- tea -------------------------------------------------------------------- -def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str: +def render_tea_config(routes: tuple[CredProxyRoute, ...]) -> str: """Render `~/.config/tea/config.yml`. Driven by the `tea-login` role: each route that claims it produces one `logins:` entry pointing at the cred-proxy. The proxy substitutes 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 = [u for u in upstreams if "tea-login" in u.roles] + tea_routes = [r for r in routes if "tea-login" in r.roles] if not tea_routes: return "" lines = ["logins:"] - for u in tea_routes: + for r in tea_routes: # Derive a stable login name from the upstream host. The # path may not encode the host (e.g. `/gitea/dideric/` vs # upstream gitea.dideric.is), so we read it off `upstream`. - host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0] + host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0] lines.extend([ f"- name: {host}", - f" url: {cred_proxy_url()}{u.path}", + f" url: {cred_proxy_url()}{r.path}", " token: cred-proxy-placeholder", " default: false", " ssh_host: \"\"", @@ -210,9 +210,9 @@ def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str: def _provision_tea_config( plan: DockerBottlePlan, target: str, - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], ) -> None: - content = render_tea_config(upstreams) + content = render_tea_config(routes) if not content: return container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") @@ -220,7 +220,10 @@ def _provision_tea_config( cfg = plan.stage_dir / "agent_tea_config.yml" cfg.write_text(content) cfg.chmod(0o600) - info(f"writing {container_tea} ({len([u for u in upstreams if u.kind == 'gitea'])} gitea login(s))") + 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)] ) diff --git a/claude_bottle/cred_proxy.py b/claude_bottle/cred_proxy.py index 1bd4492..79ee4e5 100644 --- a/claude_bottle/cred_proxy.py +++ b/claude_bottle/cred_proxy.py @@ -14,8 +14,8 @@ escaping the agent container, the same threshold pipelock and git-gate already rely on. This module defines the abstract proxy (`CredProxy`), its plan -dataclass (`CredProxyPlan`), and the per-route shape -(`CredProxyUpstream`). The sidecar's start/stop lifecycle is backend- +dataclass (`CredProxyPlan`), and the resolved per-route shape +(`CredProxyRoute`). The sidecar's start/stop lifecycle is backend- specific and lives on concrete subclasses (see `claude_bottle/backend/docker/cred_proxy.py`). """ @@ -32,10 +32,15 @@ from .manifest import Bottle @dataclass(frozen=True) -class CredProxyUpstream: - """One route on the cred-proxy sidecar. Maps a path under the - proxy to a real upstream, an auth scheme, an in-container env-var - slot, and optional provisioner roles. +class CredProxyRoute: + """One resolved route on the cred-proxy sidecar. Maps a path + under the proxy to a real upstream, an auth scheme, an + in-container env-var slot, and optional provisioner roles. + + Distinct from `manifest.CredProxyRoute` (the declaration shape + with Capitalize fields): this is the runtime view after the + abstract `CredProxy.prepare` step assigns token slots and + normalizes URLs. Modules that need both alias one on import. `path` is the agent-facing prefix (e.g. `/anthropic/`). `upstream` is the upstream base URL with scheme. `auth_scheme` @@ -46,12 +51,12 @@ class CredProxyUpstream: `token_env` is the env-var name inside the cred-proxy container (e.g. `CRED_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 TokenRef coalesce to one - `token_env` slot. + 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.CRED_PROXY_ROLES`). Each tag drives one agent-side - rewrite when this upstream's dotfile family is written.""" + rewrite when this route's dotfile family is written.""" path: str upstream: str @@ -65,16 +70,16 @@ class CredProxyUpstream: class CredProxyPlan: """Output of CredProxy.prepare; consumed by .start. - The slug + routes_path + upstreams + token_env_map fields are + The slug + routes_path + routes + token_env_map fields are filled at prepare time (host-side, side-effect-free on docker). The network + pipelock fields are populated by the backend's launch step via `dataclasses.replace` once those resources exist. Empty defaults are sentinels meaning "not yet set"; `.start` validates that they are populated. - `token_env_map` is `{: }`. - The backend's start step reads `os.environ[TokenRef]` and forwards - the value into the cred-proxy container's environ under + `token_env_map` is `{: }`. + The backend's start step reads `os.environ[token_ref]` and + forwards the value into the cred-proxy container's environ under `token_env`. The plan itself never holds token values — secrets never land in a dataclass that might be logged. @@ -88,7 +93,7 @@ class CredProxyPlan: slug: str routes_path: Path - upstreams: tuple[CredProxyUpstream, ...] + routes: tuple[CredProxyRoute, ...] token_env_map: dict[str, str] internal_network: str = "" egress_network: str = "" @@ -96,28 +101,29 @@ class CredProxyPlan: pipelock_proxy_url: str = "" -def cred_proxy_upstreams_for_bottle( +def cred_proxy_routes_for_bottle( bottle: Bottle, -) -> tuple[CredProxyUpstream, ...]: - """Lift each `bottle.cred_proxy.routes[]` entry into a - CredProxyUpstream. Order is preserved so route lookup is stable. +) -> tuple[CredProxyRoute, ...]: + """Lift each `bottle.cred_proxy.routes[]` manifest entry into a + resolved CredProxyRoute. Order is preserved so route lookup at + the proxy is stable. - Token-env slots are assigned per distinct TokenRef: the first - route with TokenRef "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a second - route with the same TokenRef shares slot 0. The launch step - forwards each TokenRef's value from the host environ into the - sidecar's environ under the matching slot name once. + Token-env slots are assigned per distinct `token_ref`: the first + route with `token_ref` "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a + second route with the same `token_ref` shares slot 0. The launch + step forwards each `token_ref`'s value from the host environ into + the sidecar's environ under the matching slot name once. Manifest validation already enforced uniqueness rules (no duplicate paths, singleton-role enforcement).""" - out: list[CredProxyUpstream] = [] + out: list[CredProxyRoute] = [] slot_for_token: dict[str, str] = {} for r in bottle.cred_proxy.routes: token_env = slot_for_token.get(r.TokenRef) if token_env is None: token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}" slot_for_token[r.TokenRef] = token_env - out.append(CredProxyUpstream( + out.append(CredProxyRoute( path=r.Path, upstream=r.Upstream.rstrip("/"), auth_scheme=r.AuthScheme, @@ -129,27 +135,27 @@ def cred_proxy_upstreams_for_bottle( def cred_proxy_token_env_map( - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], ) -> dict[str, str]: - """Collapse the upstream list into `{token_env: TokenRef}`. Two + """Collapse the route list into `{token_env: token_ref}`. Two routes that share a token (gh-api + gh-git) coalesce; the result is the set of env vars the backend's start step must forward into the sidecar's environ.""" out: dict[str, str] = {} - for u in upstreams: - existing = out.get(u.token_env) - if existing is not None and existing != u.token_ref: + for r in routes: + existing = out.get(r.token_env) + if existing is not None and existing != r.token_ref: die( - f"cred-proxy plan conflict: {u.token_env} maps to both " - f"{existing!r} and {u.token_ref!r}. Two routes sharing a " + f"cred-proxy plan conflict: {r.token_env} maps to both " + f"{existing!r} and {r.token_ref!r}. Two routes sharing a " f"token slot must reference the same host env var." ) - out[u.token_env] = u.token_ref + out[r.token_env] = r.token_ref return out def cred_proxy_render_routes( - upstreams: tuple[CredProxyUpstream, ...], + routes: tuple[CredProxyRoute, ...], ) -> str: """Serialize the route table for the cred-proxy server to read. JSON, no token values, no host env-var names — the only thing @@ -159,12 +165,12 @@ def cred_proxy_render_routes( payload = { "routes": [ { - "path": u.path, - "upstream": u.upstream, - "auth_scheme": u.auth_scheme, - "token_env": u.token_env, + "path": r.path, + "upstream": r.upstream, + "auth_scheme": r.auth_scheme, + "token_env": r.token_env, } - for u in upstreams + for r in routes ], } return json.dumps(payload, indent=2, sort_keys=False) + "\n" @@ -201,29 +207,30 @@ def cred_proxy_resolve_token_values( class CredProxy(ABC): """The per-bottle credential proxy. Encapsulates the host-side - prepare (upstream lift + routes.json render + token-env-map + prepare (route lift + routes.json render + token-env-map derivation); the sidecar's start/stop lifecycle is backend- specific and lives on concrete subclasses.""" def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> CredProxyPlan: - """Lift `bottle.tokens` into the upstream table, render the - routes.json (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 forward values from the - host's environ into the sidecar's environ. + """Lift `bottle.cred_proxy.routes` into resolved routes, + render the routes.json (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 + forward values from the host's environ into the sidecar's + environ. Returned plan is incomplete: the launch step must fill `internal_network` / `egress_network` via `dataclasses.replace` before passing it to `.start`.""" - upstreams = cred_proxy_upstreams_for_bottle(bottle) + routes = cred_proxy_routes_for_bottle(bottle) routes_path = stage_dir / "cred_proxy_routes.json" - routes_path.write_text(cred_proxy_render_routes(upstreams)) + routes_path.write_text(cred_proxy_render_routes(routes)) routes_path.chmod(0o600) return CredProxyPlan( slug=slug, routes_path=routes_path, - upstreams=upstreams, - token_env_map=cred_proxy_token_env_map(upstreams), + routes=routes, + token_env_map=cred_proxy_token_env_map(routes), ) @abstractmethod @@ -242,9 +249,9 @@ class CredProxy(ABC): __all__ = [ "CredProxy", "CredProxyPlan", - "CredProxyUpstream", + "CredProxyRoute", "cred_proxy_render_routes", "cred_proxy_resolve_token_values", + "cred_proxy_routes_for_bottle", "cred_proxy_token_env_map", - "cred_proxy_upstreams_for_bottle", ] diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index dc332fe..f5dba36 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -608,8 +608,9 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]: def _parse_https_host(url: str, label: str) -> str: """Extract the host from an `https://host[:port][/path]` URL. Dies if `url` is not an https:// URL or the host segment is empty. - Used to derive `TokenEntry.UpstreamHost` from a gitea Url so the - cross-validator can spot collisions with `bottle.git` hosts.""" + Used to derive `CredProxyRoute.UpstreamHost` from a route's + `upstream` so pipelock's allowlist (and the provisioner's git-gate + overlap check) can match on host alone.""" if not url.startswith("https://"): die(f"{label} must be an https:// URL (was {url!r})") rest = url[len("https://"):] diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 3ae11e0..52f5b23 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -74,7 +74,7 @@ def pipelock_token_hosts(bottle: Bottle) -> list[str]: def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: """Deduplicated union of: baked-in defaults, bottle.egress.allowlist, - and the cred-proxy upstream hosts derived from bottle.tokens. + and the cred-proxy upstream hosts derived from bottle.cred_proxy.routes. Sorted for stability. Git upstreams declared in `bottle.git` do NOT contribute here — git traffic flows through the per-agent git-gate sidecar (PRD 0008), not pipelock.""" diff --git a/tests/integration/test_cred_proxy_sidecar.py b/tests/integration/test_cred_proxy_sidecar.py index 7e1d468..c407380 100644 --- a/tests/integration/test_cred_proxy_sidecar.py +++ b/tests/integration/test_cred_proxy_sidecar.py @@ -11,7 +11,6 @@ egress net. cred-proxy straddles both. from __future__ import annotations -import dataclasses import json import os import shutil @@ -32,8 +31,6 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from claude_bottle.cred_proxy import CredProxy -from claude_bottle.manifest import Manifest from tests._docker import skip_unless_docker @@ -43,24 +40,6 @@ FAKE_UPSTREAM_HOST = "fake-upstream" FAKE_UPSTREAM_PORT = "8080" -def _bottle(tokens): - return Manifest.from_json_obj({ - "bottles": {"dev": {"tokens": tokens}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - - -class _StubCredProxy(CredProxy): - """CredProxy.prepare's render uses the Kind defaults, but the - integration test needs the cred-proxy to forward to the fake - upstream — not api.anthropic.com / github.com / npmjs.org. We - pass a one-route plan in directly via DockerCredProxy.start - rather than going through the manifest path.""" - - def start(self, plan): raise NotImplementedError - def stop(self, target): return None - - def _make_routes_json(upstream_host: str, upstream_port: str) -> str: payload = { "routes": [ @@ -140,12 +119,12 @@ class TestCredProxySidecar(unittest.TestCase): def _start_cred_proxy_via_production_code(self) -> str: """Run DockerCredProxy.start with a plan that points at the - fake upstream. We bypass the manifest path (which fixes - upstreams by Kind) by handing .start an already-rendered - routes.json.""" + fake upstream. We bypass the manifest path so we can route + the proxy at a test-only upstream (the fake-upstream + container) without going through the parser.""" from claude_bottle.cred_proxy import ( CredProxyPlan, - CredProxyUpstream, + CredProxyRoute, ) routes_path = self.work_dir / "routes.json" routes_path.write_text(_make_routes_json(FAKE_UPSTREAM_HOST, FAKE_UPSTREAM_PORT)) @@ -153,7 +132,7 @@ class TestCredProxySidecar(unittest.TestCase): plan = CredProxyPlan( slug=self.slug, routes_path=routes_path, - upstreams=(CredProxyUpstream( + routes=(CredProxyRoute( path="/fake/", upstream=f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}", auth_scheme="Bearer", diff --git a/tests/unit/test_cred_proxy.py b/tests/unit/test_cred_proxy.py index d238ab8..b62cd7c 100644 --- a/tests/unit/test_cred_proxy.py +++ b/tests/unit/test_cred_proxy.py @@ -1,4 +1,4 @@ -"""Unit: CredProxy upstream lift + routes.json render + token resolution +"""Unit: CredProxy route lift + routes.json render + token resolution (PRD 0010).""" import json @@ -8,7 +8,7 @@ from claude_bottle.cred_proxy import ( cred_proxy_render_routes, cred_proxy_resolve_token_values, cred_proxy_token_env_map, - cred_proxy_upstreams_for_bottle, + cred_proxy_routes_for_bottle, ) from claude_bottle.log import Die from claude_bottle.manifest import Manifest @@ -28,7 +28,7 @@ class TestUpstreamLift(unittest.TestCase): "auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN", "role": "anthropic-base-url"}, ]) - upstreams = cred_proxy_upstreams_for_bottle(b) + upstreams = cred_proxy_routes_for_bottle(b) self.assertEqual(1, len(upstreams)) u = upstreams[0] self.assertEqual("/anthropic/", u.path) @@ -47,7 +47,7 @@ class TestUpstreamLift(unittest.TestCase): "auth_scheme": "Bearer", "token_ref": "GH_PAT", "role": "git-insteadof"}, ]) - upstreams = cred_proxy_upstreams_for_bottle(b) + upstreams = cred_proxy_routes_for_bottle(b) self.assertEqual(2, len(upstreams)) self.assertEqual({"CRED_PROXY_TOKEN_0"}, {u.token_env for u in upstreams}) @@ -61,7 +61,7 @@ class TestUpstreamLift(unittest.TestCase): {"path": "/c/", "upstream": "https://c.example", "auth_scheme": "Bearer", "token_ref": "T1"}, ]) - upstreams = cred_proxy_upstreams_for_bottle(b) + upstreams = cred_proxy_routes_for_bottle(b) # T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0. self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env) self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env) @@ -73,7 +73,7 @@ class TestUpstreamLift(unittest.TestCase): "auth_scheme": "token", "token_ref": "T"}, ]) self.assertEqual("https://gitea.dideric.is", - cred_proxy_upstreams_for_bottle(b)[0].upstream) + cred_proxy_routes_for_bottle(b)[0].upstream) def test_roles_list_passes_through(self): b = _bottle([ @@ -82,11 +82,11 @@ class TestUpstreamLift(unittest.TestCase): "role": ["git-insteadof", "tea-login"]}, ]) self.assertEqual(("git-insteadof", "tea-login"), - cred_proxy_upstreams_for_bottle(b)[0].roles) + cred_proxy_routes_for_bottle(b)[0].roles) def test_empty_routes_yields_empty_upstreams(self): b = _bottle([]) - self.assertEqual((), cred_proxy_upstreams_for_bottle(b)) + self.assertEqual((), cred_proxy_routes_for_bottle(b)) class TestTokenEnvMap(unittest.TestCase): @@ -97,7 +97,7 @@ class TestTokenEnvMap(unittest.TestCase): {"path": "/b/", "upstream": "https://b.example", "auth_scheme": "Bearer", "token_ref": "B"}, ]) - m = cred_proxy_token_env_map(cred_proxy_upstreams_for_bottle(b)) + m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b)) self.assertEqual({"CRED_PROXY_TOKEN_0": "A", "CRED_PROXY_TOKEN_1": "B"}, m) @@ -108,7 +108,7 @@ class TestTokenEnvMap(unittest.TestCase): {"path": "/gh-git/", "upstream": "https://github.com", "auth_scheme": "Bearer", "token_ref": "GH"}, ]) - m = cred_proxy_token_env_map(cred_proxy_upstreams_for_bottle(b)) + m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b)) self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m) @@ -120,7 +120,7 @@ class TestRoutesRender(unittest.TestCase): {"path": "/gitea/x/", "upstream": "https://gitea.dideric.is", "auth_scheme": "token", "token_ref": "GITEA_TOKEN"}, ]) - rendered = cred_proxy_render_routes(cred_proxy_upstreams_for_bottle(b)) + rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b)) payload = json.loads(rendered) self.assertEqual(["routes"], list(payload.keys())) self.assertEqual(2, len(payload["routes"])) @@ -134,7 +134,7 @@ class TestRoutesRender(unittest.TestCase): # or the host-side TokenRef name. b = _bottle([{"path": "/x/", "upstream": "https://x.example", "auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"}]) - rendered = cred_proxy_render_routes(cred_proxy_upstreams_for_bottle(b)) + rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b)) self.assertNotIn("GITHUB_TOKEN", rendered) def test_empty_upstreams_renders_empty_routes_array(self): diff --git a/tests/unit/test_docker_cred_proxy.py b/tests/unit/test_docker_cred_proxy.py index d1c6c42..69b3cee 100644 --- a/tests/unit/test_docker_cred_proxy.py +++ b/tests/unit/test_docker_cred_proxy.py @@ -15,7 +15,7 @@ from claude_bottle.backend.docker.cred_proxy import ( cred_proxy_container_name, cred_proxy_url, ) -from claude_bottle.cred_proxy import CredProxyPlan, CredProxyUpstream +from claude_bottle.cred_proxy import CredProxyPlan, CredProxyRoute from claude_bottle.log import Die @@ -23,7 +23,7 @@ def _empty_plan(**overrides): base = { "slug": "demo", "routes_path": Path("/nonexistent"), - "upstreams": (), + "routes": (), "token_env_map": {}, "internal_network": "", "egress_network": "", @@ -56,17 +56,17 @@ class TestStartGuards(unittest.TestCase): self.proxy.start(_empty_plan()) def test_missing_internal_network_dies(self): - upstream = CredProxyUpstream( + upstream = CredProxyRoute( path="/anthropic/", upstream="https://api.anthropic.com", auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0", token_ref="T", ) with self.assertRaises(Die): - self.proxy.start(_empty_plan(upstreams=(upstream,))) + self.proxy.start(_empty_plan(routes=(upstream,))) def test_missing_routes_file_dies(self): - upstream = CredProxyUpstream( + upstream = CredProxyRoute( path="/anthropic/", upstream="https://api.anthropic.com", auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0", @@ -74,7 +74,7 @@ class TestStartGuards(unittest.TestCase): ) with self.assertRaises(Die): self.proxy.start(_empty_plan( - upstreams=(upstream,), + routes=(upstream,), internal_network="net-x", egress_network="egress-x", routes_path=Path("/tmp/cred-proxy-test-does-not-exist.json"), @@ -83,7 +83,7 @@ class TestStartGuards(unittest.TestCase): def test_pipelock_url_without_ca_dies(self): # URL set + CA path empty/missing is a wiring bug: either both # populated (production) or both empty (test escape hatch). - upstream = CredProxyUpstream( + upstream = CredProxyRoute( path="/anthropic/", upstream="https://api.anthropic.com", auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0", @@ -92,7 +92,7 @@ class TestStartGuards(unittest.TestCase): with tempfile.NamedTemporaryFile() as routes: with self.assertRaises(Die): self.proxy.start(_empty_plan( - upstreams=(upstream,), + routes=(upstream,), internal_network="net-x", egress_network="egress-x", routes_path=Path(routes.name), diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py index 0743974..29125aa 100644 --- a/tests/unit/test_pipelock_allowlist.py +++ b/tests/unit/test_pipelock_allowlist.py @@ -1,7 +1,7 @@ """Unit: pipelock_effective_allowlist — the union of baked-in defaults, bottle.egress.allowlist, and cred-proxy upstream hosts derived from -bottle.tokens (PRD 0010). Git upstreams declared in bottle.git do not -contribute here; they flow through the per-agent git-gate (PRD 0008).""" +bottle.cred_proxy.routes (PRD 0010). Git upstreams declared in bottle.git +do not contribute here; they flow through the per-agent git-gate (PRD 0008).""" import unittest diff --git a/tests/unit/test_provision_cred_proxy.py b/tests/unit/test_provision_cred_proxy.py index d58735a..6fc026a 100644 --- a/tests/unit/test_provision_cred_proxy.py +++ b/tests/unit/test_provision_cred_proxy.py @@ -10,7 +10,7 @@ from claude_bottle.backend.docker.provision.cred_proxy import ( render_npmrc, render_tea_config, ) -from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle +from claude_bottle.cred_proxy import cred_proxy_routes_for_bottle from claude_bottle.manifest import Manifest @@ -22,7 +22,7 @@ def _bottle(routes): def _upstreams(routes): - return cred_proxy_upstreams_for_bottle(_bottle(routes)) + return cred_proxy_routes_for_bottle(_bottle(routes)) class TestRenderNpmrc(unittest.TestCase):