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
+46 -26
View File
@@ -138,17 +138,23 @@ host.
`docs/prds/0008-git-gate.md`. `docs/prds/0008-git-gate.md`.
- **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine` - **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine`
base, stdlib-only) that holds API tokens declared in base, stdlib-only) that holds API tokens declared in
`bottle.tokens`. The agent dials it as plain HTTP at `bottle.cred_proxy.routes`. Each route names a `path`,
`http://cred-proxy:9099/<kind>/...`; the proxy strips any `upstream`, `auth_scheme`, and `token_ref` (host env var); the
inbound `Authorization` header, injects the configured one using agent dials `http://cred-proxy:9099<path>...` over plain HTTP
a token held only in its own container's environ, and forwards and the proxy strips any inbound `Authorization`, injects
to the real upstream over HTTPS. SSE responses stream back `<auth_scheme> <token>` using the value held only in its own
unbuffered. `ANTHROPIC_BASE_URL`, `~/.npmrc`, `~/.gitconfig` container's environ, and forwards to the real upstream over
`insteadOf` rules for `https://github.com/` and any declared HTTPS. SSE responses stream back unbuffered. The cred-proxy's
Gitea hosts, and `~/.config/tea/config.yml` all get written to outbound HTTPS routes through pipelock (it trusts pipelock's
point at the proxy. The agent's `printenv` shows only those per-bottle CA), so pipelock's egress allowlist + body scanner
URLs — none of the real token values. Brought up only when apply to cred-proxy traffic the same way they apply to direct
`bottle.tokens` has entries. Design in agent traffic. Smart-HTTP push paths (`/git-receive-pack`,
`/info/refs?service=git-receive-pack`) are refused at the
proxy — push must go through `bottle.git` / git-gate where
gitleaks runs. Optional per-route `role` tags drive agent-side
rewrites: `anthropic-base-url`, `npm-registry`, `git-insteadof`,
`tea-login`. The agent's `printenv` shows only proxy URLs —
none of the real token values. Design in
`docs/prds/0010-cred-proxy.md`. `docs/prds/0010-cred-proxy.md`.
When the agent exits, `cli.py` tears down every sidecar that was When the agent exits, `cli.py` tears down every sidecar that was
@@ -193,18 +199,31 @@ project entries overriding home entries on key conflict).
} }
], ],
// Tokens declared here are held by a per-bottle cred-proxy // Routes declared here are held by a per-bottle cred-proxy
// sidecar, not the agent. Each entry names the host env var // sidecar, not the agent. Each route names a path the agent
// (`TokenRef`) the CLI reads at launch time; the value goes // dials, the upstream the proxy forwards to, an auth_scheme,
// into the sidecar's environ via `docker create -e`, never // and a token_ref (host env var). The value goes into the
// touches argv or disk. Inside the bottle, the agent's // sidecar's environ via `docker create -e`, never touches
// ANTHROPIC_BASE_URL / npm registry / git insteadOf rules // argv or disk. Optional `role` tags drive agent-side
// point at the proxy. See `docs/prds/0010-cred-proxy.md`. // rewrites: `anthropic-base-url` (sets ANTHROPIC_BASE_URL),
"tokens": [ // `npm-registry` (writes ~/.npmrc), `git-insteadof` (writes
{ "Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN" }, // ~/.gitconfig), `tea-login` (writes ~/.config/tea/config.yml).
{ "Kind": "github", "TokenRef": "GITHUB_PAT" }, // See `docs/prds/0010-cred-proxy.md`.
{ "Kind": "npm", "TokenRef": "NPM_TOKEN" } "cred_proxy": {
], "routes": [
{ "path": "/anthropic/", "upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
"role": "anthropic-base-url" },
{ "path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "GITHUB_PAT" },
{ "path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GITHUB_PAT",
"role": "git-insteadof" },
{ "path": "/npm/", "upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
"role": "npm-registry" }
]
},
// Egress is forced through a per-agent // Egress is forced through a per-agent
// [pipelock](https://github.com/luckyPipewrench/pipelock) sidecar // [pipelock](https://github.com/luckyPipewrench/pipelock) sidecar
@@ -266,9 +285,10 @@ export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
``` ```
By default `cli.py` forwards the token into the agent container as By default `cli.py` forwards the token into the agent container as
`CLAUDE_CODE_OAUTH_TOKEN`. Declare an `anthropic` entry in `CLAUDE_CODE_OAUTH_TOKEN`. Declare a `bottle.cred_proxy.routes` entry
`bottle.tokens` to route via cred-proxy instead: the token then lives with `role: "anthropic-base-url"` and `token_ref:
only in the cred-proxy sidecar's environ, the agent's "CLAUDE_BOTTLE_OAUTH_TOKEN"` to route via cred-proxy instead: the
token then lives only in the cred-proxy sidecar's environ, the agent's
`ANTHROPIC_BASE_URL` points at the proxy, and `printenv` inside the `ANTHROPIC_BASE_URL` points at the proxy, and `printenv` inside the
agent does not surface the real token. Either way the value is never agent does not surface the real token. Either way the value is never
written to disk or placed on argv on the host. written to disk or placed on argv on the host.
+31 -7
View File
@@ -43,13 +43,37 @@
"GIT_AUTHOR_NAME": "Eric Diderich", "GIT_AUTHOR_NAME": "Eric Diderich",
"NODE_ENV": "development" "NODE_ENV": "development"
}, },
"tokens": [ "cred_proxy": {
{ "Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN" }, "routes": [
{ "Kind": "github", "TokenRef": "GH_PAT" }, { "path": "/anthropic/",
{ "Kind": "gitea", "TokenRef": "GITEA_TOKEN", "upstream": "https://api.anthropic.com",
"Url": "https://gitea.dideric.is" }, "auth_scheme": "Bearer",
{ "Kind": "npm", "TokenRef": "NPM_TOKEN" } "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
] "role": "anthropic-base-url" },
{ "path": "/gh-api/",
"upstream": "https://api.github.com",
"auth_scheme": "Bearer",
"token_ref": "GH_PAT" },
{ "path": "/gh-git/",
"upstream": "https://github.com",
"auth_scheme": "Bearer",
"token_ref": "GH_PAT",
"role": "git-insteadof" },
{ "path": "/gitea/dideric/",
"upstream": "https://gitea.dideric.is",
"auth_scheme": "token",
"token_ref": "GITEA_TOKEN",
"role": ["git-insteadof", "tea-login"] },
{ "path": "/npm/",
"upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer",
"token_ref": "NPM_TOKEN",
"role": "npm-registry" }
]
}
} }
}, },
+5 -10
View File
@@ -107,16 +107,11 @@ class DockerBottlePlan(BottlePlan):
else: else:
info(" git remotes : (none)") info(" git remotes : (none)")
if self.cred_proxy_plan.upstreams: if self.cred_proxy_plan.upstreams:
kinds: list[str] = [] routes = [f"{u.path}{u.upstream}" for u in self.cred_proxy_plan.upstreams]
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)
refs = sorted({u.token_ref 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: else:
info(" cred-proxy : (none)") info(" cred-proxy : (none)")
info(f" egress : {self.allowlist_summary}") info(f" egress : {self.allowlist_summary}")
@@ -153,11 +148,11 @@ class DockerBottlePlan(BottlePlan):
], ],
"cred_proxy": [ "cred_proxy": [
{ {
"kind": u.kind,
"path": u.path, "path": u.path,
"upstream": u.upstream, "upstream": u.upstream,
"auth_scheme": u.auth_scheme, "auth_scheme": u.auth_scheme,
"token_ref": u.token_ref, "token_ref": u.token_ref,
"roles": list(u.roles),
} }
for u in self.cred_proxy_plan.upstreams 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 # never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ. # mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded) forwarded_env: dict[str, str] = dict(resolved.forwarded)
has_anthropic_token = any(t.Kind == "anthropic" for t in bottle.tokens) # Find the (at most one) cred-proxy route claiming the
if spec.forward_oauth_token and not has_anthropic_token: # 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 # Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN
# directly. Still the path when bottle.tokens has no anthropic # directly. Still the path when no cred_proxy.routes entry
# entry; the cred-proxy sidecar holds the token otherwise. # is tagged anthropic-base-url; otherwise the sidecar holds
# the token.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_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 # Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not. # OAuth token; the agent's environ does not. Strip the
forwarded_env["ANTHROPIC_BASE_URL"] = f"{cred_proxy_url()}/anthropic" # 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 # claude-code refuses to start without *some* credential in
# its env. The proxy strips inbound Authorization on every # its env. The proxy strips inbound Authorization on every
# request and injects the real one — so a non-secret # request and injects the real one — so a non-secret
# placeholder is sufficient and the SC1 test still holds # 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 # value). The agent cannot exfiltrate this string because
# it carries no meaning to api.anthropic.com. # it carries no meaning to api.anthropic.com.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder" 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: def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.npmrc` content. No-op (empty string) when no npm """Render `~/.npmrc` content. Driven by the `npm-registry` role:
route is declared, so callers can branch on emptiness. 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 The proxy strips inbound Authorization and injects its own — the
npmrc deliberately carries no `_authToken`. The registry alone 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: for u in upstreams:
if u.kind == "npm": if "npm-registry" in u.roles:
return f"registry={cred_proxy_url()}{u.path}\n" return f"registry={cred_proxy_url()}{u.path}\n"
return "" return ""
@@ -89,40 +92,37 @@ def render_cred_proxy_gitconfig(
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
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 The rewrite is suppressed for any route whose upstream host is
`bottle.git`. git-gate is the canonical git path on those hosts — also declared in `bottle.git`. git-gate is the canonical git
its pre-receive runs gitleaks before forwarding the push. A path on those hosts — its pre-receive runs gitleaks before
cred-proxy https://<host>/ rewrite would route HTTPS git ops forwarding the push. A cred-proxy `https://<host>/` rewrite
around the gate. cred-proxy still refuses smart-HTTP push at would route HTTPS git ops around the gate. cred-proxy still
runtime (defense in depth), but suppressing the rewrite means refuses smart-HTTP push at runtime (defense in depth), but
`git clone https://<host>/...` doesn't have a tempting shortcut suppressing the rewrite means `git clone https://<host>/...`
that just confuses on push. doesn't have a tempting shortcut that just confuses on push.
github expands to one rewrite (https://github.com/... → /gh-git/..., The insteadOf left-hand side comes from `upstream` (with a
the git transport endpoint); /gh-api/ stays unmapped here because trailing `/` so insteadOf matches at the directory boundary),
tools call api.github.com directly rather than through git. so the same renderer handles github.com, gitea.dideric.is, and
Gitea entries get one rewrite per declared host.""" any future host the user wires up."""
rules: list[str] = [] rules: list[str] = []
for u in upstreams: for u in upstreams:
if u.kind == "github" and u.path == "/gh-git/": if "git-insteadof" not in u.roles:
if "github.com" in git_gate_hosts: continue
continue # Strip scheme to derive the host for the git-gate overlap
rules.append( # check. urllib.parse-free parse: same shape we accept in
f'[url "{cred_proxy_url()}/gh-git/"]\n' # manifest validation.
f"\tinsteadOf = https://github.com/\n" host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
) if host in git_gate_hosts:
elif u.kind == "gitea": continue
# u.path is /gitea/<host>/; derive the host the same way rules.append(
# the route table did so we match git_gate's UpstreamHost. f'[url "{cred_proxy_url()}{u.path}"]\n'
host = u.path[len("/gitea/"):].rstrip("/") f"\tinsteadOf = {u.upstream}/\n"
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: if not rules:
return "" return ""
return ( return (
@@ -180,19 +180,21 @@ def _provision_gitconfig(
def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str: def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.config/tea/config.yml`. One `logins:` entry per """Render `~/.config/tea/config.yml`. Driven by the `tea-login`
gitea route, pointing at the cred-proxy. The proxy substitutes role: each route that claims it produces one `logins:` entry
the real token; the value in `token:` here is a placeholder and pointing at the cred-proxy. The proxy substitutes the real
is replaced by the proxy on every request, but `tea` won't make token at request time; the value in `token:` here is a
calls without a non-empty token field.""" placeholder. `tea` refuses to make calls without a non-empty
giteas = [u for u in upstreams if u.kind == "gitea"] token field, so the placeholder is necessary."""
if not giteas: tea_routes = [u for u in upstreams if "tea-login" in u.roles]
if not tea_routes:
return "" return ""
lines = ["logins:"] lines = ["logins:"]
for u in giteas: for u in tea_routes:
# Derive a stable login name from the host (the part of the # Derive a stable login name from the upstream host. The
# path between /gitea/ and the trailing /). # path may not encode the host (e.g. `/gitea/dideric/` vs
host = u.path[len("/gitea/"):].rstrip("/") # upstream gitea.dideric.is), so we read it off `upstream`.
host = u.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()}{u.path}",
+43 -71
View File
@@ -28,34 +28,37 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .log import die from .log import die
from .manifest import Bottle, TokenEntry from .manifest import Bottle
@dataclass(frozen=True) @dataclass(frozen=True)
class CredProxyUpstream: class CredProxyUpstream:
"""One route on the cred-proxy sidecar. Maps a path under the """One route on the cred-proxy sidecar. Maps a path under the
proxy to a real upstream, an auth scheme, and the env-var slot proxy to a real upstream, an auth scheme, an in-container env-var
that holds the token inside the proxy container. slot, and optional provisioner roles.
`kind` is the originating `TokenEntry.Kind`; `path` is the agent- `path` is the agent-facing prefix (e.g. `/anthropic/`).
facing prefix (e.g. `/anthropic/`); `upstream` is the upstream `upstream` is the upstream base URL with scheme. `auth_scheme`
base URL with scheme; `auth_scheme` is the literal word that is the literal word that precedes the token in the injected
precedes the token in the injected header (`Bearer` for all kinds header (`Bearer` for most upstreams; `token` for Gitea —
except `gitea`, which uses `token` to sidestep go-gitea/gitea#16734). sidesteps go-gitea/gitea#16734).
`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`. Two routes that share a TokenRef (the github under `token_env`. Routes that share a TokenRef coalesce to one
Kind expands into two routes — gh-api and gh-git) carry the same `token_env` slot.
`token_env`."""
`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."""
kind: str
path: str path: str
upstream: str upstream: str
auth_scheme: str auth_scheme: str
token_env: str token_env: str
token_ref: str token_ref: str
roles: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -93,64 +96,35 @@ class CredProxyPlan:
pipelock_proxy_url: str = "" pipelock_proxy_url: str = ""
# Hardcoded upstream URLs for the non-gitea Kinds. Gitea's URL is per-
# entry (`TokenEntry.Url`).
_KIND_ROUTES: dict[str, tuple[tuple[str, str], ...]] = {
# kind -> ((path, upstream), ...) — a Kind can produce multiple
# routes; today only `github` does (api + git endpoints).
"anthropic": (("/anthropic/", "https://api.anthropic.com"),),
"github": (
("/gh-api/", "https://api.github.com"),
("/gh-git/", "https://github.com"),
),
"npm": (("/npm/", "https://registry.npmjs.org"),),
}
# Per-Kind auth header value prefix. Gitea uses `token` (not Bearer);
# everyone else uses Bearer.
_KIND_AUTH_SCHEME: dict[str, str] = {
"anthropic": "Bearer",
"github": "Bearer",
"gitea": "token",
"npm": "Bearer",
}
def cred_proxy_route_path_for_gitea(host: str) -> str:
"""Agent-facing path for a single Gitea instance. The host segment
disambiguates routes when multiple gitea entries are declared."""
return f"/gitea/{host}/"
def cred_proxy_upstreams_for_bottle( def cred_proxy_upstreams_for_bottle(
bottle: Bottle, bottle: Bottle,
) -> tuple[CredProxyUpstream, ...]: ) -> tuple[CredProxyUpstream, ...]:
"""Lift every `bottle.tokens[]` entry into one or more """Lift each `bottle.cred_proxy.routes[]` entry into a
CredProxyUpstreams. Order is preserved so route lookup is stable. CredProxyUpstream. Order is preserved so route lookup is stable.
Manifest validation already enforced uniqueness rules."""
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.
Manifest validation already enforced uniqueness rules (no
duplicate paths, singleton-role enforcement)."""
out: list[CredProxyUpstream] = [] out: list[CredProxyUpstream] = []
for i, t in enumerate(bottle.tokens): slot_for_token: dict[str, str] = {}
token_env = f"CRED_PROXY_TOKEN_{i}" for r in bottle.cred_proxy.routes:
scheme = _KIND_AUTH_SCHEME[t.Kind] token_env = slot_for_token.get(r.TokenRef)
if t.Kind == "gitea": if token_env is None:
out.append(CredProxyUpstream( token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}"
kind="gitea", slot_for_token[r.TokenRef] = token_env
path=cred_proxy_route_path_for_gitea(t.UpstreamHost), out.append(CredProxyUpstream(
upstream=t.Url.rstrip("/"), path=r.Path,
auth_scheme=scheme, upstream=r.Upstream.rstrip("/"),
token_env=token_env, auth_scheme=r.AuthScheme,
token_ref=t.TokenRef, token_env=token_env,
)) token_ref=r.TokenRef,
else: roles=r.Role,
for path, upstream in _KIND_ROUTES[t.Kind]: ))
out.append(CredProxyUpstream(
kind=t.Kind,
path=path,
upstream=upstream,
auth_scheme=scheme,
token_env=token_env,
token_ref=t.TokenRef,
))
return tuple(out) return tuple(out)
@@ -212,14 +186,14 @@ def cred_proxy_resolve_token_values(
if value is None: if value is None:
die( die(
f"cred-proxy: host env var '{token_ref}' is unset. Set it " f"cred-proxy: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding token entry " f"before launching, or remove the corresponding route from "
f"from bottle.tokens." f"bottle.cred_proxy.routes."
) )
if not value: if not value:
die( die(
f"cred-proxy: host env var '{token_ref}' is empty. The " f"cred-proxy: host env var '{token_ref}' is empty. The "
f"cred-proxy will not inject an empty token; set it to the " f"cred-proxy will not inject an empty token; set it to the "
f"real value or remove the token entry." f"real value or remove the route."
) )
out[token_env] = value out[token_env] = value
return out return out
@@ -269,10 +243,8 @@ __all__ = [
"CredProxy", "CredProxy",
"CredProxyPlan", "CredProxyPlan",
"CredProxyUpstream", "CredProxyUpstream",
"TokenEntry",
"cred_proxy_render_routes", "cred_proxy_render_routes",
"cred_proxy_resolve_token_values", "cred_proxy_resolve_token_values",
"cred_proxy_route_path_for_gitea",
"cred_proxy_token_env_map", "cred_proxy_token_env_map",
"cred_proxy_upstreams_for_bottle", "cred_proxy_upstreams_for_bottle",
] ]
+170 -111
View File
@@ -5,10 +5,10 @@ Schema (see CLAUDE.md "Intended design"):
{ {
"bottles": { "bottles": {
"<bottle-name>": { "<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... }, "env": { "<NAME>": <env-entry>, ... },
"git": [ <git-entry>, ... ], "git": [ <git-entry>, ... ],
"tokens": [ <token-entry>, ... ], "cred_proxy": { "routes": [ <route>, ... ] },
"egress": { "allowlist": [ "<hostname>", ... ] } "egress": { "allowlist": [ "<hostname>", ... ] }
} }
}, },
"agents": { "agents": {
@@ -114,92 +114,152 @@ class GitEntry:
) )
TOKEN_KINDS = ("anthropic", "github", "gitea", "npm") CRED_PROXY_AUTH_SCHEMES = ("Bearer", "token")
# Provisioner role tags a route may carry. Each tag drives one
# agent-side rewrite when the cred-proxy sidecar comes up.
# anthropic-base-url: set ANTHROPIC_BASE_URL=<proxy><path>
# npm-registry: write ~/.npmrc registry= <proxy><path>
# git-insteadof: write ~/.gitconfig [url "<proxy><path>"]
# insteadOf = <route.upstream>/
# tea-login: add an entry to ~/.config/tea/config.yml
# (login url = <proxy><path>)
# Routes without a `role` are pure proxy entries with no agent-side
# rewrite — useful for upstreams whose tools the user wires up by
# hand.
CRED_PROXY_ROLES = frozenset({
"anthropic-base-url",
"npm-registry",
"git-insteadof",
"tea-login",
})
# Roles whose semantics imply a single route can carry them. A second
# route claiming the same role would make the provisioner's choice
# ambiguous (which path goes into ANTHROPIC_BASE_URL?).
CRED_PROXY_SINGLETON_ROLES = frozenset({
"anthropic-base-url",
"npm-registry",
})
@dataclass(frozen=True) @dataclass(frozen=True)
class TokenEntry: class CredProxyRoute:
"""One credential the per-bottle cred-proxy sidecar (PRD 0010) """One route on the per-bottle cred-proxy sidecar (PRD 0010).
holds and injects on the agent's behalf.
`Kind` selects the route handler: `anthropic` / `github` / `npm` The agent dials `http://cred-proxy:<port><Path>...`; the sidecar
have fixed upstream URLs; `gitea` requires an explicit `Url` strips any inbound `Authorization` header, injects
because the upstream is per-instance. `<AuthScheme> <token>` using the value of the host env var named
by `TokenRef`, and forwards the rest of the request to `Upstream`.
`TokenRef` is the name of the host env var the CLI resolves at `Path` is the agent-facing prefix (must start and end with `/`).
launch time. The value is forwarded into the cred-proxy `Upstream` is the upstream base URL (https only) the request
container's environ via `docker run -e NAME` — never onto argv, path after `Path` is appended to it. `AuthScheme` is the literal
never into a file. The value does NOT land in the agent's word that precedes the token in the injected header (`Bearer` for
environ. most upstreams, `token` for Gitea sidesteps go-gitea/gitea#16734).
`TokenRef` names the host env var holding the credential value;
the CLI reads it at launch and forwards into the sidecar's environ.
`Role` carries optional provisioner tags (see CRED_PROXY_ROLES).
`UpstreamHost` is parsed from `Url` for `gitea` entries (or the `UpstreamHost` is parsed from `Upstream` for the pipelock allowlist
documented default for the other kinds). It exists so the + the git-insteadof suppression check."""
cross-validator can spot collisions with `bottle.git` upstreams
without re-parsing URLs at every call site."""
Kind: str Path: str
Upstream: str
AuthScheme: str
TokenRef: str TokenRef: str
Url: str = "" Role: tuple[str, ...] = ()
UpstreamHost: str = "" UpstreamHost: str = ""
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "TokenEntry": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "CredProxyRoute":
d = _as_json_object(raw, f"bottle '{bottle_name}' tokens[{idx}]") label = f"bottle '{bottle_name}' cred_proxy.routes[{idx}]"
kind = d.get("Kind") d = _as_json_object(raw, label)
if not isinstance(kind, str) or not kind: path = d.get("path")
if not isinstance(path, str) or not path:
die(f"{label} missing required string field 'path'")
if not (path.startswith("/") and path.endswith("/")):
die(f"{label} path {path!r} must start and end with '/'")
upstream = d.get("upstream")
if not isinstance(upstream, str) or not upstream:
die(f"{label} missing required string field 'upstream'")
host = _parse_https_host(upstream, f"{label} upstream")
auth_scheme = d.get("auth_scheme")
if not isinstance(auth_scheme, str) or not auth_scheme:
die(f"{label} missing required string field 'auth_scheme'")
if auth_scheme not in CRED_PROXY_AUTH_SCHEMES:
die( die(
f"bottle '{bottle_name}' tokens[{idx}] missing required string field " f"{label} auth_scheme {auth_scheme!r} is not one of "
f"'Kind'" f"{', '.join(CRED_PROXY_AUTH_SCHEMES)}"
) )
if kind not in TOKEN_KINDS: token_ref = d.get("token_ref")
die(
f"bottle '{bottle_name}' tokens[{idx}] Kind {kind!r} is not one of "
f"{', '.join(TOKEN_KINDS)}"
)
token_ref = d.get("TokenRef")
if not isinstance(token_ref, str) or not token_ref: if not isinstance(token_ref, str) or not token_ref:
die( die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) missing required " f"{label} missing required string field 'token_ref' "
f"string field 'TokenRef' (name of the host env var to forward)" f"(name of the host env var holding the token value)"
) )
url_raw = d.get("Url") role_raw = d.get("role")
if url_raw is None: roles: tuple[str, ...] = ()
url = "" if role_raw is None:
elif isinstance(url_raw, str): roles = ()
url = url_raw elif isinstance(role_raw, str):
roles = (role_raw,)
elif isinstance(role_raw, list):
role_list = cast(list[object], role_raw)
collected: list[str] = []
for r in role_list:
if not isinstance(r, str):
die(f"{label} role items must be strings (got {type(r).__name__})")
collected.append(r)
roles = tuple(collected)
else: else:
die( die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) Url must be a string " f"{label} role must be a string or a list of strings "
f"(was {type(url_raw).__name__})" f"(was {type(role_raw).__name__})"
) )
if kind == "gitea": for r in roles:
if not url: if r not in CRED_PROXY_ROLES:
die( die(
f"bottle '{bottle_name}' tokens[{idx}] (gitea) requires a Url " f"{label} role {r!r} is not one of "
f"(the Gitea instance, e.g. https://gitea.dideric.is)" f"{', '.join(sorted(CRED_PROXY_ROLES))}"
) )
host = _parse_https_host( return cls(
url, f"bottle '{bottle_name}' tokens[{idx}] (gitea) Url" Path=path,
) Upstream=upstream,
else: AuthScheme=auth_scheme,
if url: TokenRef=token_ref,
die( Role=roles,
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) cannot set Url; " UpstreamHost=host,
f"the upstream for this Kind is fixed by cred-proxy. Drop the " )
f"'Url' field."
)
host = _TOKEN_DEFAULT_HOST[kind]
return cls(Kind=kind, TokenRef=token_ref, Url=url, UpstreamHost=host)
# Hostnames the cred-proxy talks to upstream for the non-gitea kinds. @dataclass(frozen=True)
# Used both for the proxy's route table and for the manifest cross- class CredProxyConfig:
# validator that rejects overlap with `bottle.git`. """Per-bottle cred-proxy configuration. Today this is just the
_TOKEN_DEFAULT_HOST: dict[str, str] = { route table; the nesting under `cred_proxy:` leaves room for
"anthropic": "api.anthropic.com", per-bottle proxy settings (port override, log level, etc.) in
"github": "github.com", follow-ups."""
"npm": "registry.npmjs.org",
} routes: tuple[CredProxyRoute, ...] = ()
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "CredProxyConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' cred_proxy")
routes_raw = d.get("routes")
routes: tuple[CredProxyRoute, ...] = ()
if routes_raw is not None:
if not isinstance(routes_raw, list):
die(
f"bottle '{bottle_name}' cred_proxy.routes must be an array "
f"(was {type(routes_raw).__name__})"
)
routes_list = cast(list[object], routes_raw)
routes = tuple(
CredProxyRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list)
)
_validate_cred_proxy_routes(bottle_name, routes)
return cls(routes=routes)
DLP_ACTIONS = ("block", "warn") DLP_ACTIONS = ("block", "warn")
@@ -257,7 +317,7 @@ class BottleEgress:
class Bottle: class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
tokens: tuple[TokenEntry, ...] = () cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
egress: BottleEgress = field(default_factory=BottleEgress) egress: BottleEgress = field(default_factory=BottleEgress)
@classmethod @classmethod
@@ -305,20 +365,19 @@ class Bottle:
) )
_validate_unique_git_names(name, git) _validate_unique_git_names(name, git)
tokens: tuple[TokenEntry, ...] = () if "tokens" in d:
tokens_raw = d.get("tokens") die(
if tokens_raw is not None: f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
if not isinstance(tokens_raw, list): f"each route now lives under 'cred_proxy.routes' with explicit "
die( f"path / upstream / auth_scheme / token_ref / role[]. See "
f"bottle '{name}' tokens must be an array " f"docs/prds/0010-cred-proxy.md."
f"(was {type(tokens_raw).__name__})"
)
tokens_list = cast(list[object], tokens_raw)
tokens = tuple(
TokenEntry.from_dict(name, i, entry)
for i, entry in enumerate(tokens_list)
) )
_validate_tokens(name, tokens, git)
cred_proxy = (
CredProxyConfig.from_dict(name, d["cred_proxy"])
if "cred_proxy" in d
else CredProxyConfig()
)
egress_raw = d.get("egress") egress_raw = d.get("egress")
egress = ( egress = (
@@ -327,7 +386,7 @@ class Bottle:
else BottleEgress() else BottleEgress()
) )
return cls(env=env, git=git, tokens=tokens, egress=egress) return cls(env=env, git=git, cred_proxy=cred_proxy, egress=egress)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -561,41 +620,41 @@ def _parse_https_host(url: str, label: str) -> str:
return host return host
def _validate_tokens( def _validate_cred_proxy_routes(
bottle_name: str, bottle_name: str,
tokens: tuple[TokenEntry, ...], routes: tuple[CredProxyRoute, ...],
git: tuple[GitEntry, ...],
) -> None: ) -> None:
"""Cross-validation for `bottle.tokens`: """Cross-validation for `bottle.cred_proxy.routes`:
- At most one entry per Kind, except `gitea` which may have - Paths must be unique within the bottle (the proxy routes by
multiple entries (one per Gitea instance) with distinct Urls. longest-prefix match; duplicate paths leave the choice
undefined).
- Singleton roles (`anthropic-base-url`, `npm-registry`) may
appear on at most one route the provisioner uses them to
write a single dotfile entry, so two routes claiming the role
would make the choice ambiguous.
A `github` or `gitea` token MAY name the same host as a No cross-validation against `bottle.git` is performed. git-gate
`bottle.git` entry: the two paths broker different protocols (SSH push/fetch) and cred-proxy (HTTPS REST + git smart-HTTP
(git-gate handles SSH push/fetch with an IdentityFile; cred-proxy fetch) broker different protocols; declaring both on the same
handles HTTPS REST API calls with a PAT), so declaring both on host is a legitimate dev setup.
one host is a legitimate dev setup, not a configuration error.
""" """
del git # cross-host overlap is intentionally not rejected. seen_paths: dict[str, None] = {}
by_kind: dict[str, list[TokenEntry]] = {} for r in routes:
for t in tokens: if r.Path in seen_paths:
by_kind.setdefault(t.Kind, []).append(t)
for kind, entries in by_kind.items():
if kind == "gitea":
seen: dict[str, None] = {}
for e in entries:
if e.Url in seen:
die(
f"bottle '{bottle_name}' tokens has duplicate gitea Url "
f"{e.Url!r}; one entry per Gitea instance."
)
seen[e.Url] = None
elif len(entries) > 1:
die( die(
f"bottle '{bottle_name}' tokens has {len(entries)} entries with " f"bottle '{bottle_name}' cred_proxy.routes has duplicate path "
f"Kind {kind!r}; at most one is allowed (gitea is the only Kind " f"{r.Path!r}; each path must be unique on the proxy."
f"that may have multiple entries)." )
seen_paths[r.Path] = None
for role in CRED_PROXY_SINGLETON_ROLES:
with_role = [r for r in routes if role in r.Role]
if len(with_role) > 1:
paths = ", ".join(r.Path for r in with_role)
die(
f"bottle '{bottle_name}' cred_proxy.routes has {len(with_role)} "
f"routes with role {role!r} (paths: {paths}); this role drives a "
f"single agent-side rewrite — pick one."
) )
+9 -18
View File
@@ -57,27 +57,18 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
def pipelock_token_hosts(bottle: Bottle) -> list[str]: def pipelock_token_hosts(bottle: Bottle) -> list[str]:
"""Hostnames the cred-proxy sidecar (PRD 0010) talks to upstream """Hostnames the cred-proxy sidecar (PRD 0010) talks to upstream
on the agent's behalf. Derived from `bottle.tokens[]`. Returned on the agent's behalf. Derived from each route's
`upstream.UpstreamHost` in `bottle.cred_proxy.routes`. Returned
sorted+deduped. sorted+deduped.
These hosts must be on pipelock's allowlist so cred-proxy's These hosts must be on pipelock's allowlist so cred-proxy's
outbound HTTPS traffic can leave the egress network, and on outbound HTTPS traffic can leave the egress network. They are
pipelock's TLS-passthrough list so pipelock does not MITM them — NOT auto-added to passthrough_domains: cred-proxy's HTTPS client
cred-proxy validates real upstream certs with the system CA store, trusts pipelock's per-bottle CA at runtime (installed via
so a pipelock-bumped cert would fail trust.""" docker cp + update-ca-certificates in the cred-proxy image),
hosts: set[str] = set() so pipelock MITMs and body-scans the cred-proxy upstream leg
for t in bottle.tokens: the same way it does direct agent traffic."""
if t.Kind == "github": hosts = {r.UpstreamHost for r in bottle.cred_proxy.routes if r.UpstreamHost}
hosts.add("api.github.com")
hosts.add("github.com")
elif t.Kind == "gitea":
if t.UpstreamHost:
hosts.add(t.UpstreamHost)
elif t.Kind == "npm":
hosts.add("registry.npmjs.org")
elif t.Kind == "anthropic":
# Already on DEFAULT_ALLOWLIST + DEFAULT_TLS_PASSTHROUGH.
hosts.add("api.anthropic.com")
return sorted(hosts) return sorted(hosts)
+124 -85
View File
@@ -51,12 +51,13 @@ This PRD is the build.
## Goals / Success Criteria ## Goals / Success Criteria
Each test runs inside a bottle whose manifest declares the four Each test runs inside a bottle whose manifest declares the four
supported kinds (anthropic, github, gitea, npm): common upstreams (Anthropic, GitHub, Gitea, npm) as
`bottle.cred_proxy.routes` entries:
1. **No plaintext tokens in the agent's environ.** `printenv` and 1. **No plaintext tokens in the agent's environ.** `printenv` and
`cat /proc/self/environ` from the agent's shell return only `cat /proc/self/environ` from the agent's shell return only
URLs pointing at `cred-proxy:<PORT>/...`. None of the URLs pointing at `cred-proxy:<PORT>/...`. None of the
`bottle.tokens[].TokenRef` values appear. `cred_proxy.routes[].token_ref` host env-var values appear.
2. **Container boundary holds.** From the agent's shell, `ps aux` 2. **Container boundary holds.** From the agent's shell, `ps aux`
does not list the cred-proxy process; there is no `/proc/<X>` does not list the cred-proxy process; there is no `/proc/<X>`
entry for it to read. The sidecar's hostname (`cred-proxy`) entry for it to read. The sidecar's hostname (`cred-proxy`)
@@ -67,9 +68,11 @@ supported kinds (anthropic, github, gitea, npm):
`cred-proxy:<PORT>/anthropic`. SSE chunks arrive without `cred-proxy:<PORT>/anthropic`. SSE chunks arrive without
buffering; `anthropic-version`, `anthropic-beta`, and buffering; `anthropic-version`, `anthropic-beta`, and
`X-Claude-Code-Session-Id` headers round-trip untouched. `X-Claude-Code-Session-Id` headers round-trip untouched.
4. **Git push to declared remotes works.** `git push` against a 4. **`tea` / REST API against declared upstreams works.**
`bottle.tokens[].Kind: github` or `gitea` upstream succeeds; `tea pr list` against a route's upstream succeeds; the
the upstream sees the gate's token, not the agent's. upstream sees the proxy-injected token, not the agent's.
`git push` is *not* on the cred-proxy path — that goes
through `bottle.git` / git-gate (where gitleaks runs).
5. **npm install works.** `npm install <public-package>` 5. **npm install works.** `npm install <public-package>`
succeeds against the registry pointed at the proxy. A scoped succeeds against the registry pointed at the proxy. A scoped
install that requires the token (e.g. against a private install that requires the token (e.g. against a private
@@ -78,7 +81,10 @@ supported kinds (anthropic, github, gitea, npm):
If the agent tries to send its own `Authorization: …` header, If the agent tries to send its own `Authorization: …` header,
the proxy strips and replaces with the configured one. A the proxy strips and replaces with the configured one. A
manifest token revoked at the upstream produces a 401 to the manifest token revoked at the upstream produces a 401 to the
agent, not a 5xx. agent, not a 5xx. Git smart-HTTP push paths
(`/git-receive-pack`, `/info/refs?service=git-receive-pack`)
return 403 unconditionally — push must go through git-gate's
gitleaks-scanned SSH path.
## Non-goals ## Non-goals
@@ -113,35 +119,49 @@ supported kinds (anthropic, github, gitea, npm):
### In scope ### In scope
- **Manifest field.** `bottle.tokens: [TokenEntry, ...]`. Each - **Manifest field.** `bottle.cred_proxy.routes: [Route, ...]`.
entry carries `Kind` (`anthropic` | `github` | `gitea` | Each route carries `path` (agent-facing prefix), `upstream`
`npm`), an optional `Url` (required for `gitea`, defaulted for (HTTPS upstream URL), `auth_scheme` (`Bearer` or `token`),
the others), and `TokenRef` (the name of a host env var the `token_ref` (name of a host env var the CLI resolves at launch
CLI resolves at launch time). time), and an optional `role` (string or list of strings — see
"Agent-side rewrites" below). Routes are independent — there is
no `Kind` enum or per-kind hardcoded path/upstream mapping; the
manifest is the source of truth for the proxy's runtime route
table.
- **cred-proxy sidecar.** Runs as its own container on the - **cred-proxy sidecar.** Runs as its own container on the
bottle's internal docker network with hostname `cred-proxy`, bottle's internal docker network with hostname `cred-proxy`,
listening on `0.0.0.0:<PORT>` bound to the internal interface. listening on `0.0.0.0:<PORT>` bound to the internal interface.
No host port published. Holds the tokens in the sidecar No host port published. Holds the tokens in the sidecar
container's environ — never on argv, never written to disk. container's environ — never on argv, never written to disk.
Per-`Kind` route handler: inject the right header, forward Per-route handler: inject the configured header, forward over
over TLS, stream the response back without buffering. TLS, stream the response back without buffering.
- **Agent-side rewrites.** Provisioner writes: - **Agent-side rewrites.** A route's `role` (string or list of
- `ANTHROPIC_BASE_URL=http://cred-proxy:<PORT>/anthropic` to strings) drives optional agent-side dotfile/env writes when the
the agent's environ sidecar comes up. Known roles:
- `~/.npmrc` `registry = http://cred-proxy:<PORT>/npm/` - `anthropic-base-url` (singleton): sets
- `~/.gitconfig` `[url …] insteadOf = …` for each declared `ANTHROPIC_BASE_URL=http://cred-proxy:<PORT><route.path>` in
`github` / `gitea` upstream, **except** when a `bottle.git` the agent's environ. Used for the Anthropic OAuth path.
entry already brokers the same host. git-gate is the canonical - `npm-registry` (singleton): writes
git path on those hosts — its pre-receive runs gitleaks before `registry=http://cred-proxy:<PORT><route.path>` to `~/.npmrc`.
forwarding the push; a cred-proxy `https://<host>/` rewrite - `git-insteadof`: writes a `[url "http://cred-proxy:<PORT><route.path>"]
would route HTTPS git ops around the gate, and `git push` over insteadOf = <route.upstream>/` block to `~/.gitconfig`.
HTTPS to the same host via cred-proxy carries no gitleaks Suppressed when `bottle.git` already brokers the same host:
equivalent. (cred-proxy independently refuses smart-HTTP push git-gate is the canonical git path there — its pre-receive
runs gitleaks before forwarding pushes; a cred-proxy
`https://<host>/` rewrite would route HTTPS git ops around
the gate. (cred-proxy independently refuses smart-HTTP push
paths at runtime — see "Smart-HTTP push refused" below — but paths at runtime — see "Smart-HTTP push refused" below — but
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 later.) doesn't have a tempting shortcut that just confuses later.)
- `~/.config/tea/config.yml` with the proxy URL for each - `tea-login`: adds a `logins:` entry to
declared `gitea` entry `~/.config/tea/config.yml` pointing at the proxy. Used for
Gitea instances; combine with `git-insteadof` for full agent
coverage.
Routes without a `role` are pure proxy entries — the proxy
handles them at runtime, but no agent-side rewrite happens. The
singleton roles must appear on at most one route per bottle
(manifest validation enforces this).
- **Sidecar lifecycle.** Mirrors `DockerGitGate` / - **Sidecar lifecycle.** Mirrors `DockerGitGate` /
`DockerPipelockProxy` in shape: `prepare` is host-side and `DockerPipelockProxy` in shape: `prepare` is host-side and
side-effect-free; `start` does `docker create` + `docker start` side-effect-free; `start` does `docker create` + `docker start`
@@ -282,80 +302,98 @@ Why the agent can't reach the sidecar's environ:
### Existing code touched ### Existing code touched
- **`claude_bottle/manifest.py`** — add `TokenEntry`, - **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
`Bottle.tokens: tuple[TokenEntry, ...] = ()`, parse + validate `CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
(at most one entry per `Kind` except `gitea`, which may + validate route shape, role enum, path uniqueness, singleton-
carry multiple Urls). role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — delete the - **`claude_bottle/backend/docker/prepare.py`** — switch the
`CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` branch agent's OAuth handling: when a route claims the
in the agent's forwarded env. The OAuth token is forwarded `anthropic-base-url` role, write `ANTHROPIC_BASE_URL` (pointing
into the cred-proxy sidecar's environ at sidecar `docker create` at the proxy) plus a non-secret placeholder for
time instead. `CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start
otherwise; the proxy strips & replaces on every request).
When no such route exists, fall back to the pre-PRD-0010 path
(forward `CLAUDE_BOTTLE_OAUTH_TOKEN` as `CLAUDE_CODE_OAUTH_TOKEN`).
- **`claude_bottle/backend/docker/backend.py`** — instantiate - **`claude_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and `DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop` `DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`. through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy - **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` alongside pipelock and git-gate; start/stop to the `ExitStack` after pipelock and before the
the sidecar must be up before the agent container starts so agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
DNS resolution for `cred-proxy` succeeds on first contact. on the cred-proxy plan so its outbound HTTPS routes through
pipelock.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new - **`claude_bottle/backend/docker/bottle_plan.py`** — new
`CredProxyPlan` field; preflight shows kind + ref name + `cred_proxy_plan` field; preflight shows route count + token
port + route table. refs + a path→upstream line per route; `to_dict` emits a
- **`claude_bottle/pipelock.py`** — drop the `api.anthropic.com` `cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
TLS-MITM branch; the host stays on the allowlist as a plain roles}`.
HTTPS destination. Confirm the four upstream hosts are - **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
allowlisted by default when `bottle.tokens` declares them. from each route's `UpstreamHost` (not a hardcoded Kind→hosts
- **`README.md`** — replace the architecture diagram with the map). Allowlist auto-includes them; passthrough does not (the
one above; document the `bottle.tokens` field. proxy trusts pipelock's CA so MITM works).
- **`claude-bottle.example.json`** — add a `tokens` array to - **`README.md`** — architecture diagram includes the cred-proxy
one bottle showing each Kind. lane; manifest section documents `bottle.cred_proxy.routes`.
- **Tests** — new unit tests for manifest parsing, route table - **`claude-bottle.example.json`** — one bottle demonstrates the
generation, header injection; new integration tests for the four common routes (Anthropic, GitHub, Gitea, npm).
six success criteria. Delete the bits of `prepare.py` tests - **Tests** — manifest parsing/validation, route lift + token-env
that asserted on `CLAUDE_CODE_OAUTH_TOKEN` landing in the slot assignment, role-based dispatch in the provisioner,
agent's env. pipelock allowlist derivation from routes. Integration test
exercises header inject + smart-HTTP push refusal.
### Data model changes ### Data model changes
```python ```python
@dataclass(frozen=True) @dataclass(frozen=True)
class TokenEntry: class CredProxyRoute:
Kind: Literal["anthropic", "github", "gitea", "npm"] Path: str # "/anthropic/" — must start and end with /
TokenRef: str # name of host env var Upstream: str # "https://api.anthropic.com" — https only
Url: str | None = None # required for gitea; defaulted otherwise AuthScheme: str # "Bearer" or "token"
TokenRef: str # name of host env var
Role: tuple[str, ...] = () # provisioner tags; see CRED_PROXY_ROLES
UpstreamHost: str = "" # derived from Upstream
@dataclass(frozen=True)
class CredProxyConfig:
routes: tuple[CredProxyRoute, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class Bottle:
... ...
tokens: tuple[TokenEntry, ...] = () cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
``` ```
Validation: Validation:
- `Kind` must be one of the four supported values. - `Path` non-empty, starts and ends with `/`; unique across all
- `TokenRef` must resolve against `os.environ` at launch (fail routes in a bottle (the proxy routes by longest-prefix match).
fast with a clear "host env var X is unset" if missing). - `Upstream` is `https://...` with a non-empty host.
- `gitea` entries require `Url`; others fall back to the - `AuthScheme` is one of `Bearer`, `token`.
documented upstream. - `TokenRef` non-empty; its value is resolved against
- At most one entry per `Kind` except `gitea`, which may have `os.environ` at launch (fail fast with a clear "host env var X
multiple distinct `Url`s. is unset" if missing).
- A `github` or `gitea` token MAY name the same host as a - `Role` items are one of `anthropic-base-url`, `npm-registry`,
`bottle.git` entry. The two paths broker different protocols — `git-insteadof`, `tea-login`. Single string accepted as sugar
git-gate holds an SSH `IdentityFile` for push/fetch and runs for a one-item list.
gitleaks; cred-proxy holds a PAT for HTTPS REST API calls (`tea`, - Singleton roles (`anthropic-base-url`, `npm-registry`) appear
`gh`, octokit). The common dev setup uses both on the same host on at most one route per bottle.
and is not a configuration error. - A route MAY name the same host as a `bottle.git` entry. The
two paths broker different protocols — git-gate holds an SSH
`IdentityFile` for push/fetch and runs gitleaks; cred-proxy
holds a PAT for HTTPS REST API calls (`tea`, `gh`, octokit).
The common dev setup uses both on the same host. The
provisioner's `git-insteadof` role is suppressed in that case
(see Agent-side rewrites).
### Routing table ### Example routes
| Kind | Proxy path | Upstream | Header | | Common upstream | Route |
|-----------|----------------|-------------------------|----------------------------| |------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| anthropic | `/anthropic/` | `api.anthropic.com` | `Authorization: Bearer …` | | Anthropic API | `{path: "/anthropic/", upstream: "https://api.anthropic.com", auth_scheme: "Bearer", token_ref: "…", role: "anthropic-base-url"}` |
| github | `/gh-api/` | `api.github.com` | `Authorization: Bearer …` | | GitHub REST API | `{path: "/gh-api/", upstream: "https://api.github.com", auth_scheme: "Bearer", token_ref: "…"}` |
| github | `/gh-git/` | `github.com` | `Authorization: Bearer …` | | GitHub git transport | `{path: "/gh-git/", upstream: "https://github.com", auth_scheme: "Bearer", token_ref: "…", role: "git-insteadof"}` |
| gitea | `/gitea/<Url>` | configured `Url` | `Authorization: token …` | | Gitea instance | `{path: "/gitea/<host>/", upstream: "https://<host>", auth_scheme: "token", token_ref: "…", role: ["git-insteadof", "tea-login"]}` |
| npm | `/npm/` | `registry.npmjs.org` | `Authorization: Bearer …` | | npm registry | `{path: "/npm/", upstream: "https://registry.npmjs.org", auth_scheme: "Bearer", token_ref: "…", role: "npm-registry"}` |
Gitea uses `Authorization: token` rather than `Bearer` to Gitea uses `Authorization: token` rather than `Bearer` to
sidestep `go-gitea/gitea#16734`. The proxy strips any incoming sidestep `go-gitea/gitea#16734`. The proxy strips any incoming
@@ -443,11 +481,12 @@ Rejected because:
## Open questions ## Open questions
- **Field name.** `bottle.tokens` is the working name. The - **~~Field name.~~** Resolved during iteration: routes live at
research note used `bottle.forge` for the gitea/github `bottle.cred_proxy.routes` (the nested object reserves room for
generalization, but "forge" doesn't fit `anthropic` or per-bottle proxy settings later). Each route is independent;
`npm`. Alternatives: `bottle.brokered`, `bottle.upstreams`, no `Kind` enum on the route. A `role` field drives the
`bottle.cred_proxy`. Default: `bottle.tokens`. optional agent-side rewrites — see "Agent-side rewrites" in
Scope.
- **Python vs Go for the proxy.** Default: Python, revisit - **Python vs Go for the proxy.** Default: Python, revisit
during implementation if SSE pass-through is unreliable. during implementation if SSE pass-through is unreliable.
- **Sidecar image base.** Distroless (smallest, no shell — hardest - **Sidecar image base.** Distroless (smallest, no shell — hardest
@@ -154,7 +154,6 @@ class TestCredProxySidecar(unittest.TestCase):
slug=self.slug, slug=self.slug,
routes_path=routes_path, routes_path=routes_path,
upstreams=(CredProxyUpstream( upstreams=(CredProxyUpstream(
kind="fake",
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",
+78 -69
View File
@@ -14,79 +14,77 @@ from claude_bottle.log import Die
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
def _bottle(tokens): def _bottle(routes):
return Manifest.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {"tokens": tokens}}, "bottles": {"dev": {"cred_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
class TestUpstreamLift(unittest.TestCase): class TestUpstreamLift(unittest.TestCase):
def test_anthropic_yields_one_route(self): def test_single_route_yields_single_upstream(self):
b = _bottle([{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}]) b = _bottle([
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
"role": "anthropic-base-url"},
])
upstreams = cred_proxy_upstreams_for_bottle(b) upstreams = cred_proxy_upstreams_for_bottle(b)
self.assertEqual(1, len(upstreams)) self.assertEqual(1, len(upstreams))
u = upstreams[0] u = upstreams[0]
self.assertEqual("anthropic", u.kind)
self.assertEqual("/anthropic/", u.path) self.assertEqual("/anthropic/", u.path)
self.assertEqual("https://api.anthropic.com", u.upstream) self.assertEqual("https://api.anthropic.com", u.upstream)
self.assertEqual("Bearer", u.auth_scheme) self.assertEqual("Bearer", u.auth_scheme)
self.assertEqual("CRED_PROXY_TOKEN_0", u.token_env) self.assertEqual("CRED_PROXY_TOKEN_0", u.token_env)
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", u.token_ref) self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", u.token_ref)
self.assertEqual(("anthropic-base-url",), u.roles)
def test_github_yields_two_routes_sharing_token_env(self): def test_shared_token_ref_collapses_to_one_slot(self):
b = _bottle([{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}]) # Two github routes share GH_PAT — they share token_env.
b = _bottle([
{"path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "GH_PAT"},
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH_PAT",
"role": "git-insteadof"},
])
upstreams = cred_proxy_upstreams_for_bottle(b) upstreams = cred_proxy_upstreams_for_bottle(b)
self.assertEqual(2, len(upstreams)) self.assertEqual(2, len(upstreams))
paths = [u.path for u in upstreams] self.assertEqual({"CRED_PROXY_TOKEN_0"},
self.assertIn("/gh-api/", paths) {u.token_env for u in upstreams})
self.assertIn("/gh-git/", paths)
self.assertEqual({"CRED_PROXY_TOKEN_0"}, {u.token_env for u in upstreams})
for u in upstreams:
self.assertEqual("Bearer", u.auth_scheme)
self.assertEqual("GITHUB_TOKEN", u.token_ref)
def test_gitea_uses_token_scheme_and_host_path(self): def test_distinct_token_refs_get_distinct_slots(self):
b = _bottle([ b = _bottle([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", {"path": "/a/", "upstream": "https://a.example",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "Bearer", "token_ref": "T1"},
]) {"path": "/b/", "upstream": "https://b.example",
u = cred_proxy_upstreams_for_bottle(b)[0] "auth_scheme": "Bearer", "token_ref": "T2"},
self.assertEqual("/gitea/gitea.dideric.is/", u.path) {"path": "/c/", "upstream": "https://c.example",
self.assertEqual("https://gitea.dideric.is", u.upstream) "auth_scheme": "Bearer", "token_ref": "T1"},
self.assertEqual("token", u.auth_scheme)
def test_gitea_url_trailing_slash_stripped(self):
b = _bottle([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is/"},
])
u = cred_proxy_upstreams_for_bottle(b)[0]
self.assertEqual("https://gitea.dideric.is", u.upstream)
def test_npm_yields_one_route(self):
b = _bottle([{"Kind": "npm", "TokenRef": "NPM_TOKEN"}])
u = cred_proxy_upstreams_for_bottle(b)[0]
self.assertEqual("/npm/", u.path)
self.assertEqual("https://registry.npmjs.org", u.upstream)
def test_four_kinds_get_distinct_token_envs(self):
b = _bottle([
{"Kind": "anthropic", "TokenRef": "A"},
{"Kind": "github", "TokenRef": "G"},
{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"},
{"Kind": "npm", "TokenRef": "N"},
]) ])
upstreams = cred_proxy_upstreams_for_bottle(b) upstreams = cred_proxy_upstreams_for_bottle(b)
# 1 anthropic + 2 github + 1 gitea + 1 npm = 5 routes # T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0.
self.assertEqual(5, len(upstreams)) self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env)
# github shares one token_env across its two routes -> 4 distinct self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env)
envs = {u.token_env for u in upstreams} self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[2].token_env)
self.assertEqual({"CRED_PROXY_TOKEN_0", "CRED_PROXY_TOKEN_1",
"CRED_PROXY_TOKEN_2", "CRED_PROXY_TOKEN_3"}, envs)
def test_empty_tokens_yields_empty_upstreams(self): def test_upstream_trailing_slash_stripped(self):
b = _bottle([
{"path": "/x/", "upstream": "https://gitea.dideric.is/",
"auth_scheme": "token", "token_ref": "T"},
])
self.assertEqual("https://gitea.dideric.is",
cred_proxy_upstreams_for_bottle(b)[0].upstream)
def test_roles_list_passes_through(self):
b = _bottle([
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "T",
"role": ["git-insteadof", "tea-login"]},
])
self.assertEqual(("git-insteadof", "tea-login"),
cred_proxy_upstreams_for_bottle(b)[0].roles)
def test_empty_routes_yields_empty_upstreams(self):
b = _bottle([]) b = _bottle([])
self.assertEqual((), cred_proxy_upstreams_for_bottle(b)) self.assertEqual((), cred_proxy_upstreams_for_bottle(b))
@@ -94,43 +92,48 @@ class TestUpstreamLift(unittest.TestCase):
class TestTokenEnvMap(unittest.TestCase): class TestTokenEnvMap(unittest.TestCase):
def test_distinct_envs_yield_full_map(self): def test_distinct_envs_yield_full_map(self):
b = _bottle([ b = _bottle([
{"Kind": "anthropic", "TokenRef": "A"}, {"path": "/a/", "upstream": "https://a.example",
{"Kind": "github", "TokenRef": "G"}, "auth_scheme": "Bearer", "token_ref": "A"},
{"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_upstreams_for_bottle(b))
self.assertEqual({"CRED_PROXY_TOKEN_0": "A", self.assertEqual({"CRED_PROXY_TOKEN_0": "A",
"CRED_PROXY_TOKEN_1": "G"}, m) "CRED_PROXY_TOKEN_1": "B"}, m)
def test_github_two_routes_coalesce_to_one_env(self): def test_shared_token_ref_yields_one_env(self):
b = _bottle([{"Kind": "github", "TokenRef": "G"}]) b = _bottle([
{"path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "GH"},
{"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_upstreams_for_bottle(b))
self.assertEqual({"CRED_PROXY_TOKEN_0": "G"}, m) self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m)
class TestRoutesRender(unittest.TestCase): class TestRoutesRender(unittest.TestCase):
def test_renders_json_with_expected_shape(self): def test_renders_json_with_expected_shape(self):
b = _bottle([ b = _bottle([
{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}, {"path": "/anthropic/", "upstream": "https://api.anthropic.com",
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", "auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
"Url": "https://gitea.dideric.is"}, {"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_upstreams_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"]))
anthropic = payload["routes"][0] first = payload["routes"][0]
self.assertEqual({"path", "upstream", "auth_scheme", "token_env"}, self.assertEqual({"path", "upstream", "auth_scheme", "token_env"},
set(anthropic.keys())) set(first.keys()))
self.assertEqual("/anthropic/", anthropic["path"])
self.assertEqual("https://api.anthropic.com", anthropic["upstream"])
self.assertEqual("Bearer", anthropic["auth_scheme"])
self.assertEqual("CRED_PROXY_TOKEN_0", anthropic["token_env"])
def test_routes_carry_no_token_values_or_host_env_names(self): def test_routes_carry_no_token_values_or_host_env_names(self):
# routes.json lives mode-600 in the staging dir and gets # routes.json lives mode-600 in the staging dir and gets
# docker cp'd into the sidecar — it must not leak secret values # docker cp'd into the sidecar — it must not leak secret values
# or even the host-side TokenRef name. # or the host-side TokenRef name.
b = _bottle([{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}]) 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_upstreams_for_bottle(b))
self.assertNotIn("GITHUB_TOKEN", rendered) self.assertNotIn("GITHUB_TOKEN", rendered)
@@ -173,7 +176,13 @@ class TestCredProxyPrepare(unittest.TestCase):
def start(self, plan): return "" def start(self, plan): return ""
def stop(self, target): return None def stop(self, target): return None
b = _bottle([{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}]) b = _bottle([
{"path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"},
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN",
"role": "git-insteadof"},
])
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
stage = Path(td) stage = Path(td)
plan = StubCredProxy().prepare(b, "test-slug", stage) plan = StubCredProxy().prepare(b, "test-slug", stage)
+3 -3
View File
@@ -57,7 +57,7 @@ class TestStartGuards(unittest.TestCase):
def test_missing_internal_network_dies(self): def test_missing_internal_network_dies(self):
upstream = CredProxyUpstream( upstream = CredProxyUpstream(
kind="anthropic", 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",
@@ -67,7 +67,7 @@ class TestStartGuards(unittest.TestCase):
def test_missing_routes_file_dies(self): def test_missing_routes_file_dies(self):
upstream = CredProxyUpstream( upstream = CredProxyUpstream(
kind="anthropic", 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",
@@ -84,7 +84,7 @@ class TestStartGuards(unittest.TestCase):
# 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 = CredProxyUpstream(
kind="anthropic", 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",
+112 -133
View File
@@ -1,4 +1,4 @@
"""Unit: Bottle.tokens manifest parsing + validation (PRD 0010).""" """Unit: bottle.cred_proxy.routes manifest parsing + validation (PRD 0010)."""
import unittest import unittest
@@ -6,8 +6,8 @@ from claude_bottle.log import Die
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
def _manifest(tokens, git=None): def _manifest(routes, git=None):
bottle: dict[str, object] = {"tokens": tokens} bottle: dict[str, object] = {"cred_proxy": {"routes": routes}}
if git is not None: if git is not None:
bottle["git"] = git bottle["git"] = git
return { return {
@@ -16,177 +16,156 @@ def _manifest(tokens, git=None):
} }
class TestTokenEntryParsing(unittest.TestCase): class TestCredProxyRouteParsing(unittest.TestCase):
def test_parses_anthropic_entry(self): def test_parses_minimal_route(self):
m = Manifest.from_json_obj(_manifest([ m = Manifest.from_json_obj(_manifest([
{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}, {"path": "/anthropic/",
"upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer",
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
])) ]))
entries = m.bottles["dev"].tokens routes = m.bottles["dev"].cred_proxy.routes
self.assertEqual(1, len(entries)) self.assertEqual(1, len(routes))
e = entries[0] r = routes[0]
self.assertEqual("anthropic", e.Kind) self.assertEqual("/anthropic/", r.Path)
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", e.TokenRef) self.assertEqual("https://api.anthropic.com", r.Upstream)
self.assertEqual("", e.Url) self.assertEqual("Bearer", r.AuthScheme)
self.assertEqual("api.anthropic.com", e.UpstreamHost) self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", r.TokenRef)
self.assertEqual((), r.Role)
self.assertEqual("api.anthropic.com", r.UpstreamHost)
def test_parses_github_entry(self): def test_role_string_normalizes_to_tuple(self):
m = Manifest.from_json_obj(_manifest([ m = Manifest.from_json_obj(_manifest([
{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}, {"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer", "token_ref": "T",
"role": "anthropic-base-url"},
])) ]))
e = m.bottles["dev"].tokens[0] self.assertEqual(("anthropic-base-url",),
self.assertEqual("github", e.Kind) m.bottles["dev"].cred_proxy.routes[0].Role)
self.assertEqual("github.com", e.UpstreamHost)
def test_parses_npm_entry(self): def test_role_list_supported(self):
m = Manifest.from_json_obj(_manifest([ m = Manifest.from_json_obj(_manifest([
{"Kind": "npm", "TokenRef": "NPM_TOKEN"}, {"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "T",
"role": ["git-insteadof", "tea-login"]},
])) ]))
e = m.bottles["dev"].tokens[0] self.assertEqual(("git-insteadof", "tea-login"),
self.assertEqual("registry.npmjs.org", e.UpstreamHost) m.bottles["dev"].cred_proxy.routes[0].Role)
def test_parses_gitea_entry_with_url(self): def test_upstream_host_extracted(self):
m = Manifest.from_json_obj(_manifest([ m = Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", {"path": "/gitea/x/", "upstream": "https://gitea.dideric.is:30443",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "token", "token_ref": "T"},
])) ]))
e = m.bottles["dev"].tokens[0] self.assertEqual("gitea.dideric.is",
self.assertEqual("gitea", e.Kind) m.bottles["dev"].cred_proxy.routes[0].UpstreamHost)
self.assertEqual("https://gitea.dideric.is", e.Url)
self.assertEqual("gitea.dideric.is", e.UpstreamHost)
def test_gitea_url_with_port_strips_port_from_host(self):
m = Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is:30009"},
]))
self.assertEqual("gitea.dideric.is", m.bottles["dev"].tokens[0].UpstreamHost)
class TestTokenEntryValidation(unittest.TestCase): class TestCredProxyRouteValidation(unittest.TestCase):
def test_unknown_kind_dies(self): def _route(self, **overrides):
base = {
"path": "/x/",
"upstream": "https://example.com",
"auth_scheme": "Bearer",
"token_ref": "TOK",
}
base.update(overrides)
return base
def test_missing_path_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj(_manifest([self._route(path=None)]))
{"Kind": "aws", "TokenRef": "AWS_TOKEN"},
]))
def test_missing_kind_dies(self): def test_path_without_trailing_slash_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj(_manifest([self._route(path="/no-slash")]))
{"TokenRef": "GITHUB_TOKEN"},
])) def test_path_without_leading_slash_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([self._route(path="no-slash/")]))
def test_missing_upstream_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([self._route(upstream=None)]))
def test_non_https_upstream_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([self._route(upstream="http://x.example")]))
def test_unknown_auth_scheme_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([self._route(auth_scheme="Basic")]))
def test_missing_token_ref_dies(self): def test_missing_token_ref_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj(_manifest([self._route(token_ref=None)]))
{"Kind": "github"},
]))
def test_gitea_without_url_dies(self): def test_unknown_role_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([self._route(role="something-made-up")]))
class TestCredProxyCrossValidation(unittest.TestCase):
def test_duplicate_path_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN"}, {"path": "/x/", "upstream": "https://a.example",
"auth_scheme": "Bearer", "token_ref": "T1"},
{"path": "/x/", "upstream": "https://b.example",
"auth_scheme": "Bearer", "token_ref": "T2"},
])) ]))
def test_gitea_with_non_https_url_dies(self): def test_two_routes_same_anthropic_role_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", {"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"Url": "http://gitea.dideric.is"}, "auth_scheme": "Bearer", "token_ref": "A1",
"role": "anthropic-base-url"},
{"path": "/anthropic-2/", "upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer", "token_ref": "A2",
"role": "anthropic-base-url"},
])) ]))
def test_non_gitea_kind_with_url_dies(self): def test_multiple_git_insteadof_ok(self):
# Url is fixed for anthropic / github / npm — passing one is a # git-insteadof is not a singleton role — each route can
# configuration smell, not an override knob. # independently rewrite its own host.
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([
{"Kind": "github", "TokenRef": "GITHUB_TOKEN",
"Url": "https://api.example.com"},
]))
def test_duplicate_non_gitea_kind_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([
{"Kind": "github", "TokenRef": "A"},
{"Kind": "github", "TokenRef": "B"},
]))
def test_two_gitea_with_distinct_urls_ok(self):
m = Manifest.from_json_obj(_manifest([ m = Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "T1", {"path": "/gh-git/", "upstream": "https://github.com",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "Bearer", "token_ref": "GH",
{"Kind": "gitea", "TokenRef": "T2", "role": "git-insteadof"},
"Url": "https://gitea.example.com"}, {"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "GT",
"role": "git-insteadof"},
])) ]))
self.assertEqual(2, len(m.bottles["dev"].tokens)) self.assertEqual(2, len(m.bottles["dev"].cred_proxy.routes))
def test_two_gitea_with_same_url_dies(self):
class TestLegacyTokensField(unittest.TestCase):
def test_legacy_tokens_field_dies_with_hint(self):
# The PRD-iteration shape ({"tokens": [{Kind: ...}]}) was
# replaced by cred_proxy.routes; old manifests must fail
# loudly with a pointer.
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj({
{"Kind": "gitea", "TokenRef": "T1", "bottles": {"dev": {"tokens": [
"Url": "https://gitea.dideric.is"}, {"Kind": "anthropic", "TokenRef": "T"},
{"Kind": "gitea", "TokenRef": "T2", ]}},
"Url": "https://gitea.dideric.is"}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
])) })
class TestTokenGitCoexistence(unittest.TestCase): class TestEmptyCredProxy(unittest.TestCase):
"""git-gate brokers SSH push/fetch via an IdentityFile; cred-proxy def test_no_cred_proxy_field_yields_empty_routes(self):
brokers HTTPS REST API calls via a PAT. Declaring both on the same
host is the common dev setup (SSH key for git ops, PAT for `tea` /
`gh` API calls), not a configuration error."""
def test_github_token_and_github_git_entry_coexist(self):
m = Manifest.from_json_obj(_manifest(
tokens=[{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}],
git=[{
"Name": "myrepo",
"Upstream": "ssh://git@github.com/me/myrepo.git",
"IdentityFile": "/dev/null",
}],
))
self.assertEqual(1, len(m.bottles["dev"].tokens))
self.assertEqual(1, len(m.bottles["dev"].git))
def test_gitea_token_and_same_host_git_entry_coexist(self):
m = Manifest.from_json_obj(_manifest(
tokens=[{
"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is",
}],
git=[{
"Name": "myrepo",
"Upstream": "ssh://git@gitea.dideric.is:30009/me/myrepo.git",
"IdentityFile": "/dev/null",
}],
))
self.assertEqual("gitea.dideric.is", m.bottles["dev"].tokens[0].UpstreamHost)
self.assertEqual("gitea.dideric.is", m.bottles["dev"].git[0].UpstreamHost)
def test_anthropic_token_and_git_unrelated(self):
# api.anthropic.com isn't a git host; coexistence is trivial.
m = Manifest.from_json_obj(_manifest(
tokens=[{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}],
git=[{
"Name": "myrepo",
"Upstream": "ssh://git@gitea.dideric.is:30009/me/myrepo.git",
"IdentityFile": "/dev/null",
}],
))
self.assertEqual(1, len(m.bottles["dev"].tokens))
class TestEmptyTokensField(unittest.TestCase):
def test_no_tokens_field_yields_empty_tuple(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
self.assertEqual((), m.bottles["dev"].tokens) self.assertEqual((), m.bottles["dev"].cred_proxy.routes)
def test_tokens_array_type_required(self): def test_routes_array_type_required(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"tokens": "not-a-list"}}, "bottles": {"dev": {"cred_proxy": {"routes": "not-a-list"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
+38 -53
View File
@@ -37,54 +37,43 @@ class TestEffectiveAllowlist(unittest.TestCase):
self.assertEqual(eff, sorted(eff), "sorted") self.assertEqual(eff, sorted(eff), "sorted")
def _routes(routes):
return {"cred_proxy": {"routes": routes}}
class TestTokenHosts(unittest.TestCase): class TestTokenHosts(unittest.TestCase):
def test_github_yields_both_hosts(self): def test_each_route_contributes_its_upstream_host(self):
hosts = pipelock_token_hosts(_bottle({ hosts = pipelock_token_hosts(_bottle(_routes([
"tokens": [{"Kind": "github", "TokenRef": "GH"}], {"path": "/gh-api/", "upstream": "https://api.github.com",
})) "auth_scheme": "Bearer", "token_ref": "GH"},
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH"},
])))
self.assertEqual(["api.github.com", "github.com"], hosts) self.assertEqual(["api.github.com", "github.com"], hosts)
def test_gitea_yields_configured_host(self): def test_dedupe_across_routes(self):
hosts = pipelock_token_hosts(_bottle({ hosts = pipelock_token_hosts(_bottle(_routes([
"tokens": [{"Kind": "gitea", "TokenRef": "T", {"path": "/a/", "upstream": "https://x.example",
"Url": "https://gitea.dideric.is"}], "auth_scheme": "Bearer", "token_ref": "T1"},
})) {"path": "/b/", "upstream": "https://x.example",
self.assertEqual(["gitea.dideric.is"], hosts) "auth_scheme": "Bearer", "token_ref": "T2"},
])))
self.assertEqual(["x.example"], hosts)
def test_npm_yields_registry(self): def test_no_routes_empty(self):
hosts = pipelock_token_hosts(_bottle({
"tokens": [{"Kind": "npm", "TokenRef": "N"}],
}))
self.assertEqual(["registry.npmjs.org"], hosts)
def test_anthropic_yields_api_host(self):
hosts = pipelock_token_hosts(_bottle({
"tokens": [{"Kind": "anthropic", "TokenRef": "A"}],
}))
self.assertEqual(["api.anthropic.com"], hosts)
def test_no_tokens_empty(self):
self.assertEqual([], pipelock_token_hosts(_bottle({}))) self.assertEqual([], pipelock_token_hosts(_bottle({})))
class TestAllowlistWithTokens(unittest.TestCase): class TestAllowlistWithTokens(unittest.TestCase):
def test_token_hosts_added_to_allowlist(self): def test_route_hosts_added_to_allowlist(self):
eff = pipelock_effective_allowlist(_bottle({ eff = pipelock_effective_allowlist(_bottle(_routes([
"tokens": [ {"path": "/npm/", "upstream": "https://registry.npmjs.org",
{"Kind": "npm", "TokenRef": "N"}, "auth_scheme": "Bearer", "token_ref": "N"},
{"Kind": "github", "TokenRef": "G"}, {"path": "/gh-api/", "upstream": "https://api.github.com",
], "auth_scheme": "Bearer", "token_ref": "G"},
})) ])))
self.assertIn("registry.npmjs.org", eff) self.assertIn("registry.npmjs.org", eff)
self.assertIn("api.github.com", eff) self.assertIn("api.github.com", eff)
self.assertIn("github.com", eff)
def test_gitea_host_added(self):
eff = pipelock_effective_allowlist(_bottle({
"tokens": [{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"}],
}))
self.assertIn("gitea.dideric.is", eff)
class TestTlsPassthrough(unittest.TestCase): class TestTlsPassthrough(unittest.TestCase):
@@ -92,21 +81,17 @@ class TestTlsPassthrough(unittest.TestCase):
passthrough = pipelock_effective_tls_passthrough(_bottle({})) passthrough = pipelock_effective_tls_passthrough(_bottle({}))
self.assertEqual(["api.anthropic.com"], passthrough) self.assertEqual(["api.anthropic.com"], passthrough)
def test_token_hosts_NOT_added_to_passthrough(self): def test_route_hosts_NOT_added_to_passthrough(self):
# cred-proxy now trusts pipelock's per-bottle CA (loaded into # cred-proxy now trusts pipelock's per-bottle CA, so pipelock
# its container's trust store via docker cp + update-ca- # can MITM the cred-proxy -> upstream leg and body-scan it.
# certificates at start time), so pipelock can MITM the # Auto-adding cred-proxy hosts to passthrough would silently
# cred-proxy -> upstream leg and body-scan it. Auto-adding # disable that second scanner.
# cred-proxy hosts to passthrough would silently disable that passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([
# second scanner for github / gitea / npm. {"path": "/gh-api/", "upstream": "https://api.github.com",
passthrough = pipelock_effective_tls_passthrough(_bottle({ "auth_scheme": "Bearer", "token_ref": "G"},
"tokens": [ {"path": "/npm/", "upstream": "https://registry.npmjs.org",
{"Kind": "github", "TokenRef": "G"}, "auth_scheme": "Bearer", "token_ref": "N"},
{"Kind": "npm", "TokenRef": "N"}, ])))
{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"},
],
}))
self.assertEqual(["api.anthropic.com"], passthrough) self.assertEqual(["api.anthropic.com"], passthrough)
+73 -55
View File
@@ -14,103 +14,106 @@ from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
def _bottle(tokens): def _bottle(routes):
return Manifest.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {"tokens": tokens}}, "bottles": {"dev": {"cred_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
def _upstreams(tokens): def _upstreams(routes):
return cred_proxy_upstreams_for_bottle(_bottle(tokens)) return cred_proxy_upstreams_for_bottle(_bottle(routes))
class TestRenderNpmrc(unittest.TestCase): class TestRenderNpmrc(unittest.TestCase):
def test_empty_when_no_npm_route(self): def test_empty_when_no_role(self):
self.assertEqual("", render_npmrc(_upstreams([]))) self.assertEqual("", render_npmrc(_upstreams([])))
self.assertEqual("", render_npmrc(_upstreams([ self.assertEqual("", render_npmrc(_upstreams([
{"Kind": "anthropic", "TokenRef": "A"}, {"path": "/x/", "upstream": "https://x.example",
"auth_scheme": "Bearer", "token_ref": "T"},
]))) ])))
def test_writes_registry_line(self): def test_writes_registry_line_for_npm_registry_role(self):
out = render_npmrc(_upstreams([ out = render_npmrc(_upstreams([
{"Kind": "npm", "TokenRef": "NPM_TOKEN"}, {"path": "/npm/", "upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
"role": "npm-registry"},
])) ]))
self.assertEqual("registry=http://cred-proxy:9099/npm/\n", out) self.assertEqual("registry=http://cred-proxy:9099/npm/\n", out)
def test_omits_authtoken(self): def test_omits_authtoken(self):
# The proxy injects Authorization at request time. The npmrc # The proxy injects Authorization at request time.
# deliberately carries no _authToken — a stale token there
# would just get stripped, but it also creates the false
# impression that the agent holds a credential.
out = render_npmrc(_upstreams([ out = render_npmrc(_upstreams([
{"Kind": "npm", "TokenRef": "NPM_TOKEN"}, {"path": "/npm/", "upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
"role": "npm-registry"},
])) ]))
self.assertNotIn("_authToken", out) self.assertNotIn("_authToken", out)
self.assertNotIn("NPM_TOKEN", out) self.assertNotIn("NPM_TOKEN", out)
class TestRenderGitconfig(unittest.TestCase): class TestRenderGitconfig(unittest.TestCase):
def test_empty_when_no_github_or_gitea(self): def test_empty_when_no_role(self):
self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([ self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([
{"Kind": "anthropic", "TokenRef": "A"}, {"path": "/anthropic/", "upstream": "https://api.anthropic.com",
{"Kind": "npm", "TokenRef": "N"}, "auth_scheme": "Bearer", "token_ref": "A"},
]))) ])))
def test_github_writes_https_insteadof(self): def test_writes_insteadof_for_git_insteadof_role(self):
out = render_cred_proxy_gitconfig(_upstreams([ out = render_cred_proxy_gitconfig(_upstreams([
{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}, {"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH",
"role": "git-insteadof"},
])) ]))
self.assertIn('[url "http://cred-proxy:9099/gh-git/"]', out) self.assertIn('[url "http://cred-proxy:9099/gh-git/"]', out)
self.assertIn("insteadOf = https://github.com/", out) self.assertIn("insteadOf = https://github.com/", out)
def test_gitea_writes_per_host_insteadof(self): def test_gitea_writes_per_host_insteadof(self):
out = render_cred_proxy_gitconfig(_upstreams([ out = render_cred_proxy_gitconfig(_upstreams([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", {"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "token", "token_ref": "GITEA",
"role": "git-insteadof"},
])) ]))
self.assertIn('[url "http://cred-proxy:9099/gitea/gitea.dideric.is/"]', out) self.assertIn('[url "http://cred-proxy:9099/gitea/dideric/"]', out)
self.assertIn("insteadOf = https://gitea.dideric.is/", out) self.assertIn("insteadOf = https://gitea.dideric.is/", out)
def test_two_giteas_yield_two_rules(self): def test_two_routes_yield_two_rules(self):
out = render_cred_proxy_gitconfig(_upstreams([ out = render_cred_proxy_gitconfig(_upstreams([
{"Kind": "gitea", "TokenRef": "G1", {"path": "/gh-git/", "upstream": "https://github.com",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "Bearer", "token_ref": "GH",
{"Kind": "gitea", "TokenRef": "G2", "role": "git-insteadof"},
"Url": "https://gitea.example.com"}, {"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "GT",
"role": "git-insteadof"},
])) ]))
self.assertEqual(2, out.count("insteadOf")) self.assertEqual(2, out.count("insteadOf"))
self.assertIn("gitea.dideric.is/", out) self.assertIn("github.com", out)
self.assertIn("gitea.example.com/", out) self.assertIn("gitea.example.com", out)
def test_github_suppressed_when_git_gate_covers_host(self): def test_suppressed_when_git_gate_covers_host(self):
# When bottle.git brokers github.com over SSH, git-gate is the # When bottle.git brokers github.com over SSH, git-gate is the
# canonical git path. The cred-proxy https://github.com/ # canonical git path. The cred-proxy https://github.com/
# rewrite would let the agent push over HTTPS — bypassing # rewrite would let the agent push over HTTPS — bypassing
# gitleaks. Suppress it. # gitleaks. Suppress it.
out = render_cred_proxy_gitconfig( out = render_cred_proxy_gitconfig(
_upstreams([{"Kind": "github", "TokenRef": "GH"}]), _upstreams([
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH",
"role": "git-insteadof"},
]),
{"github.com"}, {"github.com"},
) )
self.assertEqual("", out) self.assertEqual("", out)
def test_gitea_suppressed_when_git_gate_covers_host(self): def test_partial_suppression_keeps_other_hosts(self):
out = render_cred_proxy_gitconfig(
_upstreams([{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"}]),
{"gitea.dideric.is"},
)
self.assertEqual("", out)
def test_partial_suppression_keeps_other_giteas(self):
# Two gitea instances; git-gate brokers one. The other still
# gets the cred-proxy rewrite.
out = render_cred_proxy_gitconfig( out = render_cred_proxy_gitconfig(
_upstreams([ _upstreams([
{"Kind": "gitea", "TokenRef": "T1", {"path": "/gitea/a/", "upstream": "https://gitea.dideric.is",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "token", "token_ref": "T1",
{"Kind": "gitea", "TokenRef": "T2", "role": "git-insteadof"},
"Url": "https://gitea.example.com"}, {"path": "/gitea/b/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "T2",
"role": "git-insteadof"},
]), ]),
{"gitea.dideric.is"}, {"gitea.dideric.is"},
) )
@@ -119,24 +122,39 @@ class TestRenderGitconfig(unittest.TestCase):
class TestRenderTeaConfig(unittest.TestCase): class TestRenderTeaConfig(unittest.TestCase):
def test_empty_when_no_gitea(self): def test_empty_when_no_role(self):
self.assertEqual("", render_tea_config(_upstreams([ self.assertEqual("", render_tea_config(_upstreams([
{"Kind": "github", "TokenRef": "G"}, {"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "G"},
]))) ])))
def test_single_gitea_login_block(self): def test_single_login_block(self):
out = render_tea_config(_upstreams([ out = render_tea_config(_upstreams([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN", {"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
"Url": "https://gitea.dideric.is"}, "auth_scheme": "token", "token_ref": "GITEA",
"role": "tea-login"},
])) ]))
self.assertIn("logins:", out) self.assertIn("logins:", out)
# Login name comes from the upstream host, not the path —
# the path may not encode the host.
self.assertIn("- name: gitea.dideric.is", out) self.assertIn("- name: gitea.dideric.is", out)
self.assertIn("url: http://cred-proxy:9099/gitea/gitea.dideric.is/", out) self.assertIn("url: http://cred-proxy:9099/gitea/dideric/", out)
# Placeholder token, not the host env var name (which is not a
# secret but also not useful) or the real value (which the
# provisioner does not have).
self.assertIn("token: cred-proxy-placeholder", out) self.assertIn("token: cred-proxy-placeholder", out)
self.assertNotIn("GITEA_TOKEN", out) self.assertNotIn("GITEA", out)
class TestCombinedRoles(unittest.TestCase):
"""A single gitea route typically carries both `git-insteadof`
and `tea-login` the renderers should each fire independently."""
def test_gitea_route_fires_both_renderers(self):
routes = _upstreams([
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "T",
"role": ["git-insteadof", "tea-login"]},
])
self.assertIn("insteadOf", render_cred_proxy_gitconfig(routes))
self.assertIn("logins:", render_tea_config(routes))
if __name__ == "__main__": if __name__ == "__main__":