refactor(cred_proxy): rename Upstream -> Route, fix tea-login AttributeError
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 25s

Three leftovers from the manifest refactor:

1. provision/cred_proxy.py:223 referenced u.kind == 'gitea' for the
   tea login count — kind was removed from the runtime class, so any
   bottle with a tea-login route raised AttributeError at provision
   time. Switch to `'tea-login' in r.roles`.

2. The runtime class CredProxyUpstream is renamed to CredProxyRoute
   (its data is a route on the proxy, not an "upstream"; the field
   route.upstream is the upstream URL). Module's own naming now
   aligns with manifest.CredProxyRoute and routes.json.

3. cred_proxy_upstreams_for_bottle -> cred_proxy_routes_for_bottle;
   CredProxyPlan.upstreams -> CredProxyPlan.routes; local
   `upstreams` collections become `routes`. Callers in
   backend.py, launch.py, prepare.py, bottle_plan.py,
   provision/cred_proxy.py, and tests updated.

Also strips lingering `bottle.tokens` references from docstrings
(pipelock.py, cred_proxy.py prepare(), manifest._parse_https_host,
test_pipelock_allowlist.py module doc) and removes dead helpers
from the integration test (the _bottle helper used a tokens field
that no longer parses).
This commit is contained in:
2026-05-15 02:39:10 -04:00
parent fcbbc4484d
commit 2990c3c903
13 changed files with 141 additions and 151 deletions
+11 -11
View File
@@ -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),
+3 -3
View File
@@ -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 "
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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:
@@ -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://<host>/...`
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)]
)
+58 -51
View File
@@ -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 `{<token_env in container>: <TokenRef on host>}`.
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 `{<token_env in container>: <token_ref on host>}`.
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",
]
+3 -2
View File
@@ -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://"):]
+1 -1
View File
@@ -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."""
+5 -26
View File
@@ -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",
+12 -12
View File
@@ -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):
+8 -8
View File
@@ -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),
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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):