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."""