refactor(cred_proxy): flat routes, role-driven provisioning (PRD 0010)
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 22s

Replace bottle.tokens (with Kind enum and hardcoded per-kind
route/auth tables) with bottle.cred_proxy.routes — each route
declares its own path, upstream, auth_scheme, token_ref, and
optional role[]. The manifest is now the source of truth for the
proxy's runtime route table; adding an upstream is a manifest edit,
not a code change.

Agent-side rewrites move from per-kind dispatch to per-role tags
on routes:
  anthropic-base-url -> set ANTHROPIC_BASE_URL=<proxy><path>
  npm-registry       -> write ~/.npmrc registry=
  git-insteadof      -> write ~/.gitconfig [url] insteadOf, keyed
                        off route.upstream (suppressed when
                        bottle.git brokers the same host)
  tea-login          -> add a ~/.config/tea/config.yml login

Roles are a list (string accepted as sugar). A gitea route
typically carries ["git-insteadof", "tea-login"]. Singleton roles
(anthropic-base-url, npm-registry) appear on at most one route.

token_env slots are assigned per distinct TokenRef in declaration
order — two routes sharing a token_ref (e.g. github API + git
endpoints) share a slot.

Drops: TOKEN_KINDS, _KIND_ROUTES, _KIND_AUTH_SCHEME, _TOKEN_DEFAULT_HOST,
cred_proxy_route_path_for_gitea, the kind field on CredProxyUpstream,
and the kind-based hardcoding in pipelock_token_hosts (now derives
from route.UpstreamHost).

Legacy bottle.tokens manifests now die with a hint pointing at
bottle.cred_proxy.routes + this PRD. Tests rewritten end-to-end.
Docs + example.json + the dev ~/claude-bottle.json updated to match.
This commit is contained in:
2026-05-13 21:49:55 -04:00
parent 27b2d78b11
commit fcbbc4484d
15 changed files with 798 additions and 695 deletions
+5 -10
View File
@@ -107,16 +107,11 @@ class DockerBottlePlan(BottlePlan):
else:
info(" git remotes : (none)")
if self.cred_proxy_plan.upstreams:
kinds: list[str] = []
seen: set[str] = set()
for u in self.cred_proxy_plan.upstreams:
key = u.kind if u.kind != "gitea" else f"gitea ({u.upstream})"
if key in seen:
continue
seen.add(key)
kinds.append(key)
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 : {', '.join(kinds)}; tokens: {', '.join(refs)}")
info(f" cred-proxy : {len(routes)} route(s); tokens: {', '.join(refs)}")
for line in routes:
info(f" {line}")
else:
info(" cred-proxy : (none)")
info(f" egress : {self.allowlist_summary}")
@@ -153,11 +148,11 @@ class DockerBottlePlan(BottlePlan):
],
"cred_proxy": [
{
"kind": u.kind,
"path": u.path,
"upstream": u.upstream,
"auth_scheme": u.auth_scheme,
"token_ref": u.token_ref,
"roles": list(u.roles),
}
for u in self.cred_proxy_plan.upstreams
],
+19 -8
View File
@@ -89,21 +89,32 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
has_anthropic_token = any(t.Kind == "anthropic" for t in bottle.tokens)
if spec.forward_oauth_token and not has_anthropic_token:
# Find the (at most one) cred-proxy route claiming the
# 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),
None,
)
if spec.forward_oauth_token and anthropic_route is None:
# Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN
# directly. Still the path when bottle.tokens has no anthropic
# entry; the cred-proxy sidecar holds the token otherwise.
# directly. Still the path when no cred_proxy.routes entry
# is tagged anthropic-base-url; otherwise the sidecar holds
# the token.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
if has_anthropic_token:
if anthropic_route is not None:
# Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not.
forwarded_env["ANTHROPIC_BASE_URL"] = f"{cred_proxy_url()}/anthropic"
# OAuth token; the agent's environ does not. Strip the
# trailing slash so claude-code's path-join produces e.g.
# http://cred-proxy:9099/anthropic/v1/messages.
forwarded_env["ANTHROPIC_BASE_URL"] = (
f"{cred_proxy_url()}{anthropic_route.path}".rstrip("/")
)
# claude-code refuses to start without *some* credential in
# its env. The proxy strips inbound Authorization on every
# request and injects the real one — so a non-secret
# placeholder is sufficient and the SC1 test still holds
# (the placeholder is not a `bottle.tokens[].TokenRef`
# (the placeholder is not a `cred_proxy.routes[].TokenRef`
# value). The agent cannot exfiltrate this string because
# it carries no meaning to api.anthropic.com.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder"
@@ -46,14 +46,17 @@ def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.npmrc` content. No-op (empty string) when no npm
route is declared, so callers can branch on emptiness.
"""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
callers can branch on emptiness.
The proxy strips inbound Authorization and injects its own — the
npmrc deliberately carries no `_authToken`. The registry alone
is enough."""
is enough. Manifest validation enforces that the role is a
singleton, so the first match is the only match."""
for u in upstreams:
if u.kind == "npm":
if "npm-registry" in u.roles:
return f"registry={cred_proxy_url()}{u.path}\n"
return ""
@@ -89,40 +92,37 @@ def render_cred_proxy_gitconfig(
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
) -> str:
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
rewrites. Empty string when no github / gitea routes are declared.
rewrites. Driven by the `git-insteadof` role: each route that
claims it produces a `[url "<proxy><path>"] insteadOf =
<upstream>/` block. Empty string when no such route exists.
The rewrite is suppressed for any host that's also declared in
`bottle.git`. git-gate is the canonical git path on those hosts —
its pre-receive runs gitleaks before forwarding the push. A
cred-proxy https://<host>/ rewrite would route HTTPS git ops
around the gate. cred-proxy still refuses smart-HTTP push at
runtime (defense in depth), but suppressing the rewrite means
`git clone https://<host>/...` doesn't have a tempting shortcut
that just confuses on push.
The rewrite is suppressed for any route whose upstream host is
also declared in `bottle.git`. git-gate is the canonical git
path on those hosts — its pre-receive runs gitleaks before
forwarding the push. A cred-proxy `https://<host>/` rewrite
would route HTTPS git ops around the gate. cred-proxy still
refuses smart-HTTP push at runtime (defense in depth), but
suppressing the rewrite means `git clone https://<host>/...`
doesn't have a tempting shortcut that just confuses on push.
github expands to one rewrite (https://github.com/... → /gh-git/...,
the git transport endpoint); /gh-api/ stays unmapped here because
tools call api.github.com directly rather than through git.
Gitea entries get one rewrite per declared host."""
The insteadOf left-hand side comes from `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 u.kind == "github" and u.path == "/gh-git/":
if "github.com" in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}/gh-git/"]\n'
f"\tinsteadOf = https://github.com/\n"
)
elif u.kind == "gitea":
# u.path is /gitea/<host>/; derive the host the same way
# the route table did so we match git_gate's UpstreamHost.
host = u.path[len("/gitea/"):].rstrip("/")
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if "git-insteadof" not in u.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]
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if not rules:
return ""
return (
@@ -180,19 +180,21 @@ def _provision_gitconfig(
def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.config/tea/config.yml`. One `logins:` entry per
gitea route, pointing at the cred-proxy. The proxy substitutes
the real token; the value in `token:` here is a placeholder and
is replaced by the proxy on every request, but `tea` won't make
calls without a non-empty token field."""
giteas = [u for u in upstreams if u.kind == "gitea"]
if not giteas:
"""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]
if not tea_routes:
return ""
lines = ["logins:"]
for u in giteas:
# Derive a stable login name from the host (the part of the
# path between /gitea/ and the trailing /).
host = u.path[len("/gitea/"):].rstrip("/")
for u 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]
lines.extend([
f"- name: {host}",
f" url: {cred_proxy_url()}{u.path}",