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