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`.
- **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine`
base, stdlib-only) that holds API tokens declared in
`bottle.tokens`. The agent dials it as plain HTTP at
`http://cred-proxy:9099/<kind>/...`; the proxy strips any
inbound `Authorization` header, injects the configured one using
a token held only in its own container's environ, and forwards
to the real upstream over HTTPS. SSE responses stream back
unbuffered. `ANTHROPIC_BASE_URL`, `~/.npmrc`, `~/.gitconfig`
`insteadOf` rules for `https://github.com/` and any declared
Gitea hosts, and `~/.config/tea/config.yml` all get written to
point at the proxy. The agent's `printenv` shows only those
URLs — none of the real token values. Brought up only when
`bottle.tokens` has entries. Design in
`bottle.cred_proxy.routes`. Each route names a `path`,
`upstream`, `auth_scheme`, and `token_ref` (host env var); the
agent dials `http://cred-proxy:9099<path>...` over plain HTTP
and the proxy strips any inbound `Authorization`, injects
`<auth_scheme> <token>` using the value held only in its own
container's environ, and forwards to the real upstream over
HTTPS. SSE responses stream back unbuffered. The cred-proxy's
outbound HTTPS routes through pipelock (it trusts pipelock's
per-bottle CA), so pipelock's egress allowlist + body scanner
apply to cred-proxy traffic the same way they apply to direct
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`.
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
// sidecar, not the agent. Each entry names the host env var
// (`TokenRef`) the CLI reads at launch time; the value goes
// into the sidecar's environ via `docker create -e`, never
// touches argv or disk. Inside the bottle, the agent's
// ANTHROPIC_BASE_URL / npm registry / git insteadOf rules
// point at the proxy. See `docs/prds/0010-cred-proxy.md`.
"tokens": [
{ "Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN" },
{ "Kind": "github", "TokenRef": "GITHUB_PAT" },
{ "Kind": "npm", "TokenRef": "NPM_TOKEN" }
],
// Routes declared here are held by a per-bottle cred-proxy
// sidecar, not the agent. Each route names a path the agent
// dials, the upstream the proxy forwards to, an auth_scheme,
// and a token_ref (host env var). The value goes into the
// sidecar's environ via `docker create -e`, never touches
// argv or disk. Optional `role` tags drive agent-side
// rewrites: `anthropic-base-url` (sets ANTHROPIC_BASE_URL),
// `npm-registry` (writes ~/.npmrc), `git-insteadof` (writes
// ~/.gitconfig), `tea-login` (writes ~/.config/tea/config.yml).
// See `docs/prds/0010-cred-proxy.md`.
"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
// [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
`CLAUDE_CODE_OAUTH_TOKEN`. Declare an `anthropic` entry in
`bottle.tokens` to route via cred-proxy instead: the token then lives
only in the cred-proxy sidecar's environ, the agent's
`CLAUDE_CODE_OAUTH_TOKEN`. Declare a `bottle.cred_proxy.routes` entry
with `role: "anthropic-base-url"` and `token_ref:
"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
agent does not surface the real token. Either way the value is never
written to disk or placed on argv on the host.
+31 -7
View File
@@ -43,13 +43,37 @@
"GIT_AUTHOR_NAME": "Eric Diderich",
"NODE_ENV": "development"
},
"tokens": [
{ "Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN" },
{ "Kind": "github", "TokenRef": "GH_PAT" },
{ "Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is" },
{ "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": "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:
info(" git remotes : (none)")
if self.cred_proxy_plan.upstreams:
kinds: list[str] = []
seen: set[str] = set()
for u in self.cred_proxy_plan.upstreams:
key = u.kind if u.kind != "gitea" else f"gitea ({u.upstream})"
if key in seen:
continue
seen.add(key)
kinds.append(key)
routes = [f"{u.path}{u.upstream}" for u in self.cred_proxy_plan.upstreams]
refs = sorted({u.token_ref for u in self.cred_proxy_plan.upstreams})
info(f" cred-proxy : {', '.join(kinds)}; tokens: {', '.join(refs)}")
info(f" cred-proxy : {len(routes)} route(s); tokens: {', '.join(refs)}")
for line in routes:
info(f" {line}")
else:
info(" cred-proxy : (none)")
info(f" egress : {self.allowlist_summary}")
@@ -153,11 +148,11 @@ class DockerBottlePlan(BottlePlan):
],
"cred_proxy": [
{
"kind": u.kind,
"path": u.path,
"upstream": u.upstream,
"auth_scheme": u.auth_scheme,
"token_ref": u.token_ref,
"roles": list(u.roles),
}
for u in self.cred_proxy_plan.upstreams
],
+19 -8
View File
@@ -89,21 +89,32 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
has_anthropic_token = any(t.Kind == "anthropic" for t in bottle.tokens)
if spec.forward_oauth_token and not has_anthropic_token:
# Find the (at most one) cred-proxy route claiming the
# anthropic-base-url role. Manifest validation enforces the
# singleton constraint.
anthropic_route = next(
(u for u in cred_proxy_plan.upstreams if "anthropic-base-url" in u.roles),
None,
)
if spec.forward_oauth_token and anthropic_route is None:
# Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN
# directly. Still the path when bottle.tokens has no anthropic
# entry; the cred-proxy sidecar holds the token otherwise.
# directly. Still the path when no cred_proxy.routes entry
# is tagged anthropic-base-url; otherwise the sidecar holds
# the token.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
if has_anthropic_token:
if anthropic_route is not None:
# Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not.
forwarded_env["ANTHROPIC_BASE_URL"] = f"{cred_proxy_url()}/anthropic"
# OAuth token; the agent's environ does not. Strip the
# trailing slash so claude-code's path-join produces e.g.
# http://cred-proxy:9099/anthropic/v1/messages.
forwarded_env["ANTHROPIC_BASE_URL"] = (
f"{cred_proxy_url()}{anthropic_route.path}".rstrip("/")
)
# claude-code refuses to start without *some* credential in
# its env. The proxy strips inbound Authorization on every
# request and injects the real one — so a non-secret
# placeholder is sufficient and the SC1 test still holds
# (the placeholder is not a `bottle.tokens[].TokenRef`
# (the placeholder is not a `cred_proxy.routes[].TokenRef`
# value). The agent cannot exfiltrate this string because
# it carries no meaning to api.anthropic.com.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder"
@@ -46,14 +46,17 @@ def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.npmrc` content. No-op (empty string) when no npm
route is declared, so callers can branch on emptiness.
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
finds the (single) route that claims it and writes a registry=
line at the proxy. Empty string when no such route exists, so
callers can branch on emptiness.
The proxy strips inbound Authorization and injects its own — the
npmrc deliberately carries no `_authToken`. The registry alone
is enough."""
is enough. Manifest validation enforces that the role is a
singleton, so the first match is the only match."""
for u in upstreams:
if u.kind == "npm":
if "npm-registry" in u.roles:
return f"registry={cred_proxy_url()}{u.path}\n"
return ""
@@ -89,40 +92,37 @@ def render_cred_proxy_gitconfig(
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
) -> str:
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
rewrites. Empty string when no github / gitea routes are declared.
rewrites. Driven by the `git-insteadof` role: each route that
claims it produces a `[url "<proxy><path>"] insteadOf =
<upstream>/` block. Empty string when no such route exists.
The rewrite is suppressed for any host that's also declared in
`bottle.git`. git-gate is the canonical git path on those hosts —
its pre-receive runs gitleaks before forwarding the push. A
cred-proxy https://<host>/ rewrite would route HTTPS git ops
around the gate. cred-proxy still refuses smart-HTTP push at
runtime (defense in depth), but suppressing the rewrite means
`git clone https://<host>/...` doesn't have a tempting shortcut
that just confuses on push.
The rewrite is suppressed for any route whose upstream host is
also declared in `bottle.git`. git-gate is the canonical git
path on those hosts — its pre-receive runs gitleaks before
forwarding the push. A cred-proxy `https://<host>/` rewrite
would route HTTPS git ops around the gate. cred-proxy still
refuses smart-HTTP push at runtime (defense in depth), but
suppressing the rewrite means `git clone https://<host>/...`
doesn't have a tempting shortcut that just confuses on push.
github expands to one rewrite (https://github.com/... → /gh-git/...,
the git transport endpoint); /gh-api/ stays unmapped here because
tools call api.github.com directly rather than through git.
Gitea entries get one rewrite per declared host."""
The insteadOf left-hand side comes from `upstream` (with a
trailing `/` so insteadOf matches at the directory boundary),
so the same renderer handles github.com, gitea.dideric.is, and
any future host the user wires up."""
rules: list[str] = []
for u in upstreams:
if u.kind == "github" and u.path == "/gh-git/":
if "github.com" in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}/gh-git/"]\n'
f"\tinsteadOf = https://github.com/\n"
)
elif u.kind == "gitea":
# u.path is /gitea/<host>/; derive the host the same way
# the route table did so we match git_gate's UpstreamHost.
host = u.path[len("/gitea/"):].rstrip("/")
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if "git-insteadof" not in u.roles:
continue
# Strip scheme to derive the host for the git-gate overlap
# check. urllib.parse-free parse: same shape we accept in
# manifest validation.
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if not rules:
return ""
return (
@@ -180,19 +180,21 @@ def _provision_gitconfig(
def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.config/tea/config.yml`. One `logins:` entry per
gitea route, pointing at the cred-proxy. The proxy substitutes
the real token; the value in `token:` here is a placeholder and
is replaced by the proxy on every request, but `tea` won't make
calls without a non-empty token field."""
giteas = [u for u in upstreams if u.kind == "gitea"]
if not giteas:
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
role: each route that claims it produces one `logins:` entry
pointing at the cred-proxy. The proxy substitutes the real
token at request time; the value in `token:` here is a
placeholder. `tea` refuses to make calls without a non-empty
token field, so the placeholder is necessary."""
tea_routes = [u for u in upstreams if "tea-login" in u.roles]
if not tea_routes:
return ""
lines = ["logins:"]
for u in giteas:
# Derive a stable login name from the host (the part of the
# path between /gitea/ and the trailing /).
host = u.path[len("/gitea/"):].rstrip("/")
for u in tea_routes:
# Derive a stable login name from the upstream host. The
# path may not encode the host (e.g. `/gitea/dideric/` vs
# upstream gitea.dideric.is), so we read it off `upstream`.
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
lines.extend([
f"- name: {host}",
f" url: {cred_proxy_url()}{u.path}",
+43 -71
View File
@@ -28,34 +28,37 @@ from dataclasses import dataclass
from pathlib import Path
from .log import die
from .manifest import Bottle, TokenEntry
from .manifest import Bottle
@dataclass(frozen=True)
class CredProxyUpstream:
"""One route on the cred-proxy sidecar. Maps a path under the
proxy to a real upstream, an auth scheme, and the env-var slot
that holds the token inside the proxy container.
proxy to a real upstream, an auth scheme, an in-container env-var
slot, and optional provisioner roles.
`kind` is the originating `TokenEntry.Kind`; `path` is the agent-
facing prefix (e.g. `/anthropic/`); `upstream` is the upstream
base URL with scheme; `auth_scheme` is the literal word that
precedes the token in the injected header (`Bearer` for all kinds
except `gitea`, which uses `token` to sidestep go-gitea/gitea#16734).
`path` is the agent-facing prefix (e.g. `/anthropic/`).
`upstream` is the upstream base URL with scheme. `auth_scheme`
is the literal word that precedes the token in the injected
header (`Bearer` for most upstreams; `token` for Gitea —
sidesteps go-gitea/gitea#16734).
`token_env` is the env-var name inside the cred-proxy container
(e.g. `CRED_PROXY_TOKEN_0`); `token_ref` is the host env var the
CLI reads at launch and forwards into the container's environ
under `token_env`. Two routes that share a TokenRef (the github
Kind expands into two routes — gh-api and gh-git) carry the same
`token_env`."""
under `token_env`. Routes that share a TokenRef coalesce to one
`token_env` slot.
`roles` are the provisioner tags from the manifest route (see
`manifest.CRED_PROXY_ROLES`). Each tag drives one agent-side
rewrite when this upstream's dotfile family is written."""
kind: str
path: str
upstream: str
auth_scheme: str
token_env: str
token_ref: str
roles: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -93,64 +96,35 @@ class CredProxyPlan:
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(
bottle: Bottle,
) -> tuple[CredProxyUpstream, ...]:
"""Lift every `bottle.tokens[]` entry into one or more
CredProxyUpstreams. Order is preserved so route lookup is stable.
Manifest validation already enforced uniqueness rules."""
"""Lift each `bottle.cred_proxy.routes[]` entry into a
CredProxyUpstream. Order is preserved so route lookup is stable.
Token-env slots are assigned per distinct TokenRef: the first
route with TokenRef "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a second
route with the same TokenRef shares slot 0. The launch step
forwards each TokenRef's value from the host environ into the
sidecar's environ under the matching slot name once.
Manifest validation already enforced uniqueness rules (no
duplicate paths, singleton-role enforcement)."""
out: list[CredProxyUpstream] = []
for i, t in enumerate(bottle.tokens):
token_env = f"CRED_PROXY_TOKEN_{i}"
scheme = _KIND_AUTH_SCHEME[t.Kind]
if t.Kind == "gitea":
out.append(CredProxyUpstream(
kind="gitea",
path=cred_proxy_route_path_for_gitea(t.UpstreamHost),
upstream=t.Url.rstrip("/"),
auth_scheme=scheme,
token_env=token_env,
token_ref=t.TokenRef,
))
else:
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,
))
slot_for_token: dict[str, str] = {}
for r in bottle.cred_proxy.routes:
token_env = slot_for_token.get(r.TokenRef)
if token_env is None:
token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}"
slot_for_token[r.TokenRef] = token_env
out.append(CredProxyUpstream(
path=r.Path,
upstream=r.Upstream.rstrip("/"),
auth_scheme=r.AuthScheme,
token_env=token_env,
token_ref=r.TokenRef,
roles=r.Role,
))
return tuple(out)
@@ -212,14 +186,14 @@ def cred_proxy_resolve_token_values(
if value is None:
die(
f"cred-proxy: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding token entry "
f"from bottle.tokens."
f"before launching, or remove the corresponding route from "
f"bottle.cred_proxy.routes."
)
if not value:
die(
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"real value or remove the token entry."
f"real value or remove the route."
)
out[token_env] = value
return out
@@ -269,10 +243,8 @@ __all__ = [
"CredProxy",
"CredProxyPlan",
"CredProxyUpstream",
"TokenEntry",
"cred_proxy_render_routes",
"cred_proxy_resolve_token_values",
"cred_proxy_route_path_for_gitea",
"cred_proxy_token_env_map",
"cred_proxy_upstreams_for_bottle",
]
+170 -111
View File
@@ -5,10 +5,10 @@ Schema (see CLAUDE.md "Intended design"):
{
"bottles": {
"<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... },
"git": [ <git-entry>, ... ],
"tokens": [ <token-entry>, ... ],
"egress": { "allowlist": [ "<hostname>", ... ] }
"env": { "<NAME>": <env-entry>, ... },
"git": [ <git-entry>, ... ],
"cred_proxy": { "routes": [ <route>, ... ] },
"egress": { "allowlist": [ "<hostname>", ... ] }
}
},
"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)
class TokenEntry:
"""One credential the per-bottle cred-proxy sidecar (PRD 0010)
holds and injects on the agent's behalf.
class CredProxyRoute:
"""One route on the per-bottle cred-proxy sidecar (PRD 0010).
`Kind` selects the route handler: `anthropic` / `github` / `npm`
have fixed upstream URLs; `gitea` requires an explicit `Url`
because the upstream is per-instance.
The agent dials `http://cred-proxy:<port><Path>...`; the sidecar
strips any inbound `Authorization` header, injects
`<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
launch time. The value is forwarded into the cred-proxy
container's environ via `docker run -e NAME` — never onto argv,
never into a file. The value does NOT land in the agent's
environ.
`Path` is the agent-facing prefix (must start and end with `/`).
`Upstream` is the upstream base URL (https only) — the request
path after `Path` is appended to it. `AuthScheme` is the literal
word that precedes the token in the injected header (`Bearer` for
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
documented default for the other kinds). It exists so the
cross-validator can spot collisions with `bottle.git` upstreams
without re-parsing URLs at every call site."""
`UpstreamHost` is parsed from `Upstream` for the pipelock allowlist
+ the git-insteadof suppression check."""
Kind: str
Path: str
Upstream: str
AuthScheme: str
TokenRef: str
Url: str = ""
Role: tuple[str, ...] = ()
UpstreamHost: str = ""
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "TokenEntry":
d = _as_json_object(raw, f"bottle '{bottle_name}' tokens[{idx}]")
kind = d.get("Kind")
if not isinstance(kind, str) or not kind:
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "CredProxyRoute":
label = f"bottle '{bottle_name}' cred_proxy.routes[{idx}]"
d = _as_json_object(raw, label)
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(
f"bottle '{bottle_name}' tokens[{idx}] missing required string field "
f"'Kind'"
f"{label} auth_scheme {auth_scheme!r} is not one of "
f"{', '.join(CRED_PROXY_AUTH_SCHEMES)}"
)
if kind not in TOKEN_KINDS:
die(
f"bottle '{bottle_name}' tokens[{idx}] Kind {kind!r} is not one of "
f"{', '.join(TOKEN_KINDS)}"
)
token_ref = d.get("TokenRef")
token_ref = d.get("token_ref")
if not isinstance(token_ref, str) or not token_ref:
die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) missing required "
f"string field 'TokenRef' (name of the host env var to forward)"
f"{label} missing required string field 'token_ref' "
f"(name of the host env var holding the token value)"
)
url_raw = d.get("Url")
if url_raw is None:
url = ""
elif isinstance(url_raw, str):
url = url_raw
role_raw = d.get("role")
roles: tuple[str, ...] = ()
if role_raw is None:
roles = ()
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:
die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) Url must be a string "
f"(was {type(url_raw).__name__})"
f"{label} role must be a string or a list of strings "
f"(was {type(role_raw).__name__})"
)
if kind == "gitea":
if not url:
for r in roles:
if r not in CRED_PROXY_ROLES:
die(
f"bottle '{bottle_name}' tokens[{idx}] (gitea) requires a Url "
f"(the Gitea instance, e.g. https://gitea.dideric.is)"
f"{label} role {r!r} is not one of "
f"{', '.join(sorted(CRED_PROXY_ROLES))}"
)
host = _parse_https_host(
url, f"bottle '{bottle_name}' tokens[{idx}] (gitea) Url"
)
else:
if url:
die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) cannot set Url; "
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)
return cls(
Path=path,
Upstream=upstream,
AuthScheme=auth_scheme,
TokenRef=token_ref,
Role=roles,
UpstreamHost=host,
)
# Hostnames the cred-proxy talks to upstream for the non-gitea kinds.
# Used both for the proxy's route table and for the manifest cross-
# validator that rejects overlap with `bottle.git`.
_TOKEN_DEFAULT_HOST: dict[str, str] = {
"anthropic": "api.anthropic.com",
"github": "github.com",
"npm": "registry.npmjs.org",
}
@dataclass(frozen=True)
class CredProxyConfig:
"""Per-bottle cred-proxy configuration. Today this is just the
route table; the nesting under `cred_proxy:` leaves room for
per-bottle proxy settings (port override, log level, etc.) in
follow-ups."""
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")
@@ -257,7 +317,7 @@ class BottleEgress:
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = ()
tokens: tuple[TokenEntry, ...] = ()
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
egress: BottleEgress = field(default_factory=BottleEgress)
@classmethod
@@ -305,20 +365,19 @@ class Bottle:
)
_validate_unique_git_names(name, git)
tokens: tuple[TokenEntry, ...] = ()
tokens_raw = d.get("tokens")
if tokens_raw is not None:
if not isinstance(tokens_raw, list):
die(
f"bottle '{name}' tokens must be an array "
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)
if "tokens" in d:
die(
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
f"each route now lives under 'cred_proxy.routes' with explicit "
f"path / upstream / auth_scheme / token_ref / role[]. See "
f"docs/prds/0010-cred-proxy.md."
)
_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 = (
@@ -327,7 +386,7 @@ class Bottle:
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)
@@ -561,41 +620,41 @@ def _parse_https_host(url: str, label: str) -> str:
return host
def _validate_tokens(
def _validate_cred_proxy_routes(
bottle_name: str,
tokens: tuple[TokenEntry, ...],
git: tuple[GitEntry, ...],
routes: tuple[CredProxyRoute, ...],
) -> None:
"""Cross-validation for `bottle.tokens`:
"""Cross-validation for `bottle.cred_proxy.routes`:
- At most one entry per Kind, except `gitea` which may have
multiple entries (one per Gitea instance) with distinct Urls.
- Paths must be unique within the bottle (the proxy routes by
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
`bottle.git` entry: the two paths broker different protocols
(git-gate handles SSH push/fetch with an IdentityFile; cred-proxy
handles HTTPS REST API calls with a PAT), so declaring both on
one host is a legitimate dev setup, not a configuration error.
No cross-validation against `bottle.git` is performed. git-gate
(SSH push/fetch) and cred-proxy (HTTPS REST + git smart-HTTP
fetch) broker different protocols; declaring both on the same
host is a legitimate dev setup.
"""
del git # cross-host overlap is intentionally not rejected.
by_kind: dict[str, list[TokenEntry]] = {}
for t in tokens:
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:
seen_paths: dict[str, None] = {}
for r in routes:
if r.Path in seen_paths:
die(
f"bottle '{bottle_name}' tokens has {len(entries)} entries with "
f"Kind {kind!r}; at most one is allowed (gitea is the only Kind "
f"that may have multiple entries)."
f"bottle '{bottle_name}' cred_proxy.routes has duplicate path "
f"{r.Path!r}; each path must be unique on the proxy."
)
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]:
"""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.
These hosts must be on pipelock's allowlist so cred-proxy's
outbound HTTPS traffic can leave the egress network, and on
pipelock's TLS-passthrough list so pipelock does not MITM them —
cred-proxy validates real upstream certs with the system CA store,
so a pipelock-bumped cert would fail trust."""
hosts: set[str] = set()
for t in bottle.tokens:
if t.Kind == "github":
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")
outbound HTTPS traffic can leave the egress network. They are
NOT auto-added to passthrough_domains: cred-proxy's HTTPS client
trusts pipelock's per-bottle CA at runtime (installed via
docker cp + update-ca-certificates in the cred-proxy image),
so pipelock MITMs and body-scans the cred-proxy upstream leg
the same way it does direct agent traffic."""
hosts = {r.UpstreamHost for r in bottle.cred_proxy.routes if r.UpstreamHost}
return sorted(hosts)
+124 -85
View File
@@ -51,12 +51,13 @@ This PRD is the build.
## Goals / Success Criteria
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
`cat /proc/self/environ` from the agent's shell return only
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`
does not list the cred-proxy process; there is no `/proc/<X>`
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
buffering; `anthropic-version`, `anthropic-beta`, and
`X-Claude-Code-Session-Id` headers round-trip untouched.
4. **Git push to declared remotes works.** `git push` against a
`bottle.tokens[].Kind: github` or `gitea` upstream succeeds;
the upstream sees the gate's token, not the agent's.
4. **`tea` / REST API against declared upstreams works.**
`tea pr list` against a route's upstream succeeds; the
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>`
succeeds against the registry pointed at the proxy. A scoped
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,
the proxy strips and replaces with the configured one. A
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
@@ -113,35 +119,49 @@ supported kinds (anthropic, github, gitea, npm):
### In scope
- **Manifest field.** `bottle.tokens: [TokenEntry, ...]`. Each
entry carries `Kind` (`anthropic` | `github` | `gitea` |
`npm`), an optional `Url` (required for `gitea`, defaulted for
the others), and `TokenRef` (the name of a host env var the
CLI resolves at launch time).
- **Manifest field.** `bottle.cred_proxy.routes: [Route, ...]`.
Each route carries `path` (agent-facing prefix), `upstream`
(HTTPS upstream URL), `auth_scheme` (`Bearer` or `token`),
`token_ref` (name of a host env var the CLI resolves at launch
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
bottle's internal docker network with hostname `cred-proxy`,
listening on `0.0.0.0:<PORT>` bound to the internal interface.
No host port published. Holds the tokens in the sidecar
container's environ — never on argv, never written to disk.
Per-`Kind` route handler: inject the right header, forward
over TLS, stream the response back without buffering.
- **Agent-side rewrites.** Provisioner writes:
- `ANTHROPIC_BASE_URL=http://cred-proxy:<PORT>/anthropic` to
the agent's environ
- `~/.npmrc` `registry = http://cred-proxy:<PORT>/npm/`
- `~/.gitconfig` `[url …] insteadOf = …` for each declared
`github` / `gitea` upstream, **except** when a `bottle.git`
entry already brokers the same host. git-gate is the canonical
git path on those hosts — its pre-receive runs gitleaks before
forwarding the push; a cred-proxy `https://<host>/` rewrite
would route HTTPS git ops around the gate, and `git push` over
HTTPS to the same host via cred-proxy carries no gitleaks
equivalent. (cred-proxy independently refuses smart-HTTP push
Per-route handler: inject the configured header, forward over
TLS, stream the response back without buffering.
- **Agent-side rewrites.** A route's `role` (string or list of
strings) drives optional agent-side dotfile/env writes when the
sidecar comes up. Known roles:
- `anthropic-base-url` (singleton): sets
`ANTHROPIC_BASE_URL=http://cred-proxy:<PORT><route.path>` in
the agent's environ. Used for the Anthropic OAuth path.
- `npm-registry` (singleton): writes
`registry=http://cred-proxy:<PORT><route.path>` to `~/.npmrc`.
- `git-insteadof`: writes a `[url "http://cred-proxy:<PORT><route.path>"]
insteadOf = <route.upstream>/` block to `~/.gitconfig`.
Suppressed when `bottle.git` already brokers the same host:
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
suppressing the rewrite means `git clone https://<host>/...`
doesn't have a tempting shortcut that just confuses later.)
- `~/.config/tea/config.yml` with the proxy URL for each
declared `gitea` entry
- `tea-login`: adds a `logins:` entry to
`~/.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` /
`DockerPipelockProxy` in shape: `prepare` is host-side and
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
- **`claude_bottle/manifest.py`** — add `TokenEntry`,
`Bottle.tokens: tuple[TokenEntry, ...] = ()`, parse + validate
(at most one entry per `Kind` except `gitea`, which may
carry multiple Urls).
- **`claude_bottle/backend/docker/prepare.py`** — delete the
`CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` branch
in the agent's forwarded env. The OAuth token is forwarded
into the cred-proxy sidecar's environ at sidecar `docker create`
time instead.
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton-
role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — switch the
agent's OAuth handling: when a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL` (pointing
at the proxy) plus a non-secret placeholder for
`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
`DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` alongside pipelock and git-gate;
the sidecar must be up before the agent container starts so
DNS resolution for `cred-proxy` succeeds on first contact.
start/stop to the `ExitStack` after pipelock and before the
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
on the cred-proxy plan so its outbound HTTPS routes through
pipelock.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
`CredProxyPlan` field; preflight shows kind + ref name +
port + route table.
- **`claude_bottle/pipelock.py`** — drop the `api.anthropic.com`
TLS-MITM branch; the host stays on the allowlist as a plain
HTTPS destination. Confirm the four upstream hosts are
allowlisted by default when `bottle.tokens` declares them.
- **`README.md`** — replace the architecture diagram with the
one above; document the `bottle.tokens` field.
- **`claude-bottle.example.json`** — add a `tokens` array to
one bottle showing each Kind.
- **Tests** — new unit tests for manifest parsing, route table
generation, header injection; new integration tests for the
six success criteria. Delete the bits of `prepare.py` tests
that asserted on `CLAUDE_CODE_OAUTH_TOKEN` landing in the
agent's env.
`cred_proxy_plan` field; preflight shows route count + token
refs + a path→upstream line per route; `to_dict` emits a
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
roles}`.
- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
map). Allowlist auto-includes them; passthrough does not (the
proxy trusts pipelock's CA so MITM works).
- **`README.md`** — architecture diagram includes the cred-proxy
lane; manifest section documents `bottle.cred_proxy.routes`.
- **`claude-bottle.example.json`** — one bottle demonstrates the
four common routes (Anthropic, GitHub, Gitea, npm).
- **Tests** — manifest parsing/validation, route lift + token-env
slot assignment, role-based dispatch in the provisioner,
pipelock allowlist derivation from routes. Integration test
exercises header inject + smart-HTTP push refusal.
### Data model changes
```python
@dataclass(frozen=True)
class TokenEntry:
Kind: Literal["anthropic", "github", "gitea", "npm"]
TokenRef: str # name of host env var
Url: str | None = None # required for gitea; defaulted otherwise
class CredProxyRoute:
Path: str # "/anthropic/" — must start and end with /
Upstream: str # "https://api.anthropic.com" — https only
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)
class Bottle:
...
tokens: tuple[TokenEntry, ...] = ()
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
```
Validation:
- `Kind` must be one of the four supported values.
- `TokenRef` must resolve against `os.environ` at launch (fail
fast with a clear "host env var X is unset" if missing).
- `gitea` entries require `Url`; others fall back to the
documented upstream.
- At most one entry per `Kind` except `gitea`, which may have
multiple distinct `Url`s.
- A `github` or `gitea` token 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
and is not a configuration error.
- `Path` non-empty, starts and ends with `/`; unique across all
routes in a bottle (the proxy routes by longest-prefix match).
- `Upstream` is `https://...` with a non-empty host.
- `AuthScheme` is one of `Bearer`, `token`.
- `TokenRef` non-empty; its value is resolved against
`os.environ` at launch (fail fast with a clear "host env var X
is unset" if missing).
- `Role` items are one of `anthropic-base-url`, `npm-registry`,
`git-insteadof`, `tea-login`. Single string accepted as sugar
for a one-item list.
- Singleton roles (`anthropic-base-url`, `npm-registry`) appear
on at most one route per bottle.
- 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 |
|-----------|----------------|-------------------------|----------------------------|
| anthropic | `/anthropic/` | `api.anthropic.com` | `Authorization: Bearer …` |
| github | `/gh-api/` | `api.github.com` | `Authorization: Bearer …` |
| github | `/gh-git/` | `github.com` | `Authorization: Bearer …` |
| gitea | `/gitea/<Url>` | configured `Url` | `Authorization: token …` |
| npm | `/npm/` | `registry.npmjs.org` | `Authorization: Bearer …` |
| Common upstream | Route |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| Anthropic API | `{path: "/anthropic/", upstream: "https://api.anthropic.com", auth_scheme: "Bearer", token_ref: "…", role: "anthropic-base-url"}` |
| GitHub REST API | `{path: "/gh-api/", upstream: "https://api.github.com", auth_scheme: "Bearer", token_ref: "…"}` |
| GitHub git transport | `{path: "/gh-git/", upstream: "https://github.com", auth_scheme: "Bearer", token_ref: "…", role: "git-insteadof"}` |
| Gitea instance | `{path: "/gitea/<host>/", upstream: "https://<host>", auth_scheme: "token", token_ref: "…", role: ["git-insteadof", "tea-login"]}` |
| 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
sidestep `go-gitea/gitea#16734`. The proxy strips any incoming
@@ -443,11 +481,12 @@ Rejected because:
## Open questions
- **Field name.** `bottle.tokens` is the working name. The
research note used `bottle.forge` for the gitea/github
generalization, but "forge" doesn't fit `anthropic` or
`npm`. Alternatives: `bottle.brokered`, `bottle.upstreams`,
`bottle.cred_proxy`. Default: `bottle.tokens`.
- **~~Field name.~~** Resolved during iteration: routes live at
`bottle.cred_proxy.routes` (the nested object reserves room for
per-bottle proxy settings later). Each route is independent;
no `Kind` enum on the route. A `role` field drives the
optional agent-side rewrites — see "Agent-side rewrites" in
Scope.
- **Python vs Go for the proxy.** Default: Python, revisit
during implementation if SSE pass-through is unreliable.
- **Sidecar image base.** Distroless (smallest, no shell — hardest
@@ -154,7 +154,6 @@ class TestCredProxySidecar(unittest.TestCase):
slug=self.slug,
routes_path=routes_path,
upstreams=(CredProxyUpstream(
kind="fake",
path="/fake/",
upstream=f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}",
auth_scheme="Bearer",
+78 -69
View File
@@ -14,79 +14,77 @@ from claude_bottle.log import Die
from claude_bottle.manifest import Manifest
def _bottle(tokens):
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"tokens": tokens}},
"bottles": {"dev": {"cred_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestUpstreamLift(unittest.TestCase):
def test_anthropic_yields_one_route(self):
b = _bottle([{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}])
def test_single_route_yields_single_upstream(self):
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)
self.assertEqual(1, len(upstreams))
u = upstreams[0]
self.assertEqual("anthropic", u.kind)
self.assertEqual("/anthropic/", u.path)
self.assertEqual("https://api.anthropic.com", u.upstream)
self.assertEqual("Bearer", u.auth_scheme)
self.assertEqual("CRED_PROXY_TOKEN_0", u.token_env)
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):
b = _bottle([{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}])
def test_shared_token_ref_collapses_to_one_slot(self):
# 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)
self.assertEqual(2, len(upstreams))
paths = [u.path for u in upstreams]
self.assertIn("/gh-api/", paths)
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)
self.assertEqual({"CRED_PROXY_TOKEN_0"},
{u.token_env for u in upstreams})
def test_gitea_uses_token_scheme_and_host_path(self):
def test_distinct_token_refs_get_distinct_slots(self):
b = _bottle([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is"},
])
u = cred_proxy_upstreams_for_bottle(b)[0]
self.assertEqual("/gitea/gitea.dideric.is/", u.path)
self.assertEqual("https://gitea.dideric.is", u.upstream)
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"},
{"path": "/a/", "upstream": "https://a.example",
"auth_scheme": "Bearer", "token_ref": "T1"},
{"path": "/b/", "upstream": "https://b.example",
"auth_scheme": "Bearer", "token_ref": "T2"},
{"path": "/c/", "upstream": "https://c.example",
"auth_scheme": "Bearer", "token_ref": "T1"},
])
upstreams = cred_proxy_upstreams_for_bottle(b)
# 1 anthropic + 2 github + 1 gitea + 1 npm = 5 routes
self.assertEqual(5, len(upstreams))
# github shares one token_env across its two routes -> 4 distinct
envs = {u.token_env for u in upstreams}
self.assertEqual({"CRED_PROXY_TOKEN_0", "CRED_PROXY_TOKEN_1",
"CRED_PROXY_TOKEN_2", "CRED_PROXY_TOKEN_3"}, envs)
# T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0.
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env)
self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env)
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[2].token_env)
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([])
self.assertEqual((), cred_proxy_upstreams_for_bottle(b))
@@ -94,43 +92,48 @@ class TestUpstreamLift(unittest.TestCase):
class TestTokenEnvMap(unittest.TestCase):
def test_distinct_envs_yield_full_map(self):
b = _bottle([
{"Kind": "anthropic", "TokenRef": "A"},
{"Kind": "github", "TokenRef": "G"},
{"path": "/a/", "upstream": "https://a.example",
"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))
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):
b = _bottle([{"Kind": "github", "TokenRef": "G"}])
def test_shared_token_ref_yields_one_env(self):
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))
self.assertEqual({"CRED_PROXY_TOKEN_0": "G"}, m)
self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m)
class TestRoutesRender(unittest.TestCase):
def test_renders_json_with_expected_shape(self):
b = _bottle([
{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is"},
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
{"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))
payload = json.loads(rendered)
self.assertEqual(["routes"], list(payload.keys()))
self.assertEqual(2, len(payload["routes"]))
anthropic = payload["routes"][0]
first = payload["routes"][0]
self.assertEqual({"path", "upstream", "auth_scheme", "token_env"},
set(anthropic.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"])
set(first.keys()))
def test_routes_carry_no_token_values_or_host_env_names(self):
# routes.json lives mode-600 in the staging dir and gets
# docker cp'd into the sidecar — it must not leak secret values
# or even the host-side TokenRef name.
b = _bottle([{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}])
# or the host-side TokenRef name.
b = _bottle([{"path": "/x/", "upstream": "https://x.example",
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"}])
rendered = cred_proxy_render_routes(cred_proxy_upstreams_for_bottle(b))
self.assertNotIn("GITHUB_TOKEN", rendered)
@@ -173,7 +176,13 @@ class TestCredProxyPrepare(unittest.TestCase):
def start(self, plan): return ""
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:
stage = Path(td)
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):
upstream = CredProxyUpstream(
kind="anthropic", path="/anthropic/",
path="/anthropic/",
upstream="https://api.anthropic.com",
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
token_ref="T",
@@ -67,7 +67,7 @@ class TestStartGuards(unittest.TestCase):
def test_missing_routes_file_dies(self):
upstream = CredProxyUpstream(
kind="anthropic", path="/anthropic/",
path="/anthropic/",
upstream="https://api.anthropic.com",
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
token_ref="T",
@@ -84,7 +84,7 @@ class TestStartGuards(unittest.TestCase):
# URL set + CA path empty/missing is a wiring bug: either both
# populated (production) or both empty (test escape hatch).
upstream = CredProxyUpstream(
kind="anthropic", path="/anthropic/",
path="/anthropic/",
upstream="https://api.anthropic.com",
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
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
@@ -6,8 +6,8 @@ from claude_bottle.log import Die
from claude_bottle.manifest import Manifest
def _manifest(tokens, git=None):
bottle: dict[str, object] = {"tokens": tokens}
def _manifest(routes, git=None):
bottle: dict[str, object] = {"cred_proxy": {"routes": routes}}
if git is not None:
bottle["git"] = git
return {
@@ -16,177 +16,156 @@ def _manifest(tokens, git=None):
}
class TestTokenEntryParsing(unittest.TestCase):
def test_parses_anthropic_entry(self):
class TestCredProxyRouteParsing(unittest.TestCase):
def test_parses_minimal_route(self):
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
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("anthropic", e.Kind)
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", e.TokenRef)
self.assertEqual("", e.Url)
self.assertEqual("api.anthropic.com", e.UpstreamHost)
routes = m.bottles["dev"].cred_proxy.routes
self.assertEqual(1, len(routes))
r = routes[0]
self.assertEqual("/anthropic/", r.Path)
self.assertEqual("https://api.anthropic.com", r.Upstream)
self.assertEqual("Bearer", r.AuthScheme)
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([
{"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("github", e.Kind)
self.assertEqual("github.com", e.UpstreamHost)
self.assertEqual(("anthropic-base-url",),
m.bottles["dev"].cred_proxy.routes[0].Role)
def test_parses_npm_entry(self):
def test_role_list_supported(self):
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("registry.npmjs.org", e.UpstreamHost)
self.assertEqual(("git-insteadof", "tea-login"),
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([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is"},
{"path": "/gitea/x/", "upstream": "https://gitea.dideric.is:30443",
"auth_scheme": "token", "token_ref": "T"},
]))
e = m.bottles["dev"].tokens[0]
self.assertEqual("gitea", e.Kind)
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)
self.assertEqual("gitea.dideric.is",
m.bottles["dev"].cred_proxy.routes[0].UpstreamHost)
class TestTokenEntryValidation(unittest.TestCase):
def test_unknown_kind_dies(self):
class TestCredProxyRouteValidation(unittest.TestCase):
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):
Manifest.from_json_obj(_manifest([
{"Kind": "aws", "TokenRef": "AWS_TOKEN"},
]))
Manifest.from_json_obj(_manifest([self._route(path=None)]))
def test_missing_kind_dies(self):
def test_path_without_trailing_slash_dies(self):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([
{"TokenRef": "GITHUB_TOKEN"},
]))
Manifest.from_json_obj(_manifest([self._route(path="/no-slash")]))
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):
with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([
{"Kind": "github"},
]))
Manifest.from_json_obj(_manifest([self._route(token_ref=None)]))
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):
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):
Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "http://gitea.dideric.is"},
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"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):
# Url is fixed for anthropic / github / npm — passing one is a
# configuration smell, not an override knob.
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):
def test_multiple_git_insteadof_ok(self):
# git-insteadof is not a singleton role — each route can
# independently rewrite its own host.
m = Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "T1",
"Url": "https://gitea.dideric.is"},
{"Kind": "gitea", "TokenRef": "T2",
"Url": "https://gitea.example.com"},
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH",
"role": "git-insteadof"},
{"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):
Manifest.from_json_obj(_manifest([
{"Kind": "gitea", "TokenRef": "T1",
"Url": "https://gitea.dideric.is"},
{"Kind": "gitea", "TokenRef": "T2",
"Url": "https://gitea.dideric.is"},
]))
Manifest.from_json_obj({
"bottles": {"dev": {"tokens": [
{"Kind": "anthropic", "TokenRef": "T"},
]}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
class TestTokenGitCoexistence(unittest.TestCase):
"""git-gate brokers SSH push/fetch via an IdentityFile; cred-proxy
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):
class TestEmptyCredProxy(unittest.TestCase):
def test_no_cred_proxy_field_yields_empty_routes(self):
m = Manifest.from_json_obj({
"bottles": {"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):
Manifest.from_json_obj({
"bottles": {"dev": {"tokens": "not-a-list"}},
"bottles": {"dev": {"cred_proxy": {"routes": "not-a-list"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
+38 -53
View File
@@ -37,54 +37,43 @@ class TestEffectiveAllowlist(unittest.TestCase):
self.assertEqual(eff, sorted(eff), "sorted")
def _routes(routes):
return {"cred_proxy": {"routes": routes}}
class TestTokenHosts(unittest.TestCase):
def test_github_yields_both_hosts(self):
hosts = pipelock_token_hosts(_bottle({
"tokens": [{"Kind": "github", "TokenRef": "GH"}],
}))
def test_each_route_contributes_its_upstream_host(self):
hosts = pipelock_token_hosts(_bottle(_routes([
{"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)
def test_gitea_yields_configured_host(self):
hosts = pipelock_token_hosts(_bottle({
"tokens": [{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"}],
}))
self.assertEqual(["gitea.dideric.is"], hosts)
def test_dedupe_across_routes(self):
hosts = pipelock_token_hosts(_bottle(_routes([
{"path": "/a/", "upstream": "https://x.example",
"auth_scheme": "Bearer", "token_ref": "T1"},
{"path": "/b/", "upstream": "https://x.example",
"auth_scheme": "Bearer", "token_ref": "T2"},
])))
self.assertEqual(["x.example"], hosts)
def test_npm_yields_registry(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):
def test_no_routes_empty(self):
self.assertEqual([], pipelock_token_hosts(_bottle({})))
class TestAllowlistWithTokens(unittest.TestCase):
def test_token_hosts_added_to_allowlist(self):
eff = pipelock_effective_allowlist(_bottle({
"tokens": [
{"Kind": "npm", "TokenRef": "N"},
{"Kind": "github", "TokenRef": "G"},
],
}))
def test_route_hosts_added_to_allowlist(self):
eff = pipelock_effective_allowlist(_bottle(_routes([
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer", "token_ref": "N"},
{"path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "G"},
])))
self.assertIn("registry.npmjs.org", 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):
@@ -92,21 +81,17 @@ class TestTlsPassthrough(unittest.TestCase):
passthrough = pipelock_effective_tls_passthrough(_bottle({}))
self.assertEqual(["api.anthropic.com"], passthrough)
def test_token_hosts_NOT_added_to_passthrough(self):
# cred-proxy now trusts pipelock's per-bottle CA (loaded into
# its container's trust store via docker cp + update-ca-
# certificates at start time), so pipelock can MITM the
# cred-proxy -> upstream leg and body-scan it. Auto-adding
# cred-proxy hosts to passthrough would silently disable that
# second scanner for github / gitea / npm.
passthrough = pipelock_effective_tls_passthrough(_bottle({
"tokens": [
{"Kind": "github", "TokenRef": "G"},
{"Kind": "npm", "TokenRef": "N"},
{"Kind": "gitea", "TokenRef": "T",
"Url": "https://gitea.dideric.is"},
],
}))
def test_route_hosts_NOT_added_to_passthrough(self):
# cred-proxy now trusts pipelock's per-bottle CA, so pipelock
# can MITM the cred-proxy -> upstream leg and body-scan it.
# Auto-adding cred-proxy hosts to passthrough would silently
# disable that second scanner.
passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([
{"path": "/gh-api/", "upstream": "https://api.github.com",
"auth_scheme": "Bearer", "token_ref": "G"},
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
"auth_scheme": "Bearer", "token_ref": "N"},
])))
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
def _bottle(tokens):
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"tokens": tokens}},
"bottles": {"dev": {"cred_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
def _upstreams(tokens):
return cred_proxy_upstreams_for_bottle(_bottle(tokens))
def _upstreams(routes):
return cred_proxy_upstreams_for_bottle(_bottle(routes))
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([
{"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([
{"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)
def test_omits_authtoken(self):
# The proxy injects Authorization at request time. The npmrc
# 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.
# The proxy injects Authorization at request time.
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("NPM_TOKEN", out)
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([
{"Kind": "anthropic", "TokenRef": "A"},
{"Kind": "npm", "TokenRef": "N"},
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
"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([
{"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("insteadOf = https://github.com/", out)
def test_gitea_writes_per_host_insteadof(self):
out = render_cred_proxy_gitconfig(_upstreams([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is"},
{"path": "/gitea/dideric/", "upstream": "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)
def test_two_giteas_yield_two_rules(self):
def test_two_routes_yield_two_rules(self):
out = render_cred_proxy_gitconfig(_upstreams([
{"Kind": "gitea", "TokenRef": "G1",
"Url": "https://gitea.dideric.is"},
{"Kind": "gitea", "TokenRef": "G2",
"Url": "https://gitea.example.com"},
{"path": "/gh-git/", "upstream": "https://github.com",
"auth_scheme": "Bearer", "token_ref": "GH",
"role": "git-insteadof"},
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "GT",
"role": "git-insteadof"},
]))
self.assertEqual(2, out.count("insteadOf"))
self.assertIn("gitea.dideric.is/", out)
self.assertIn("gitea.example.com/", out)
self.assertIn("github.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
# canonical git path. The cred-proxy https://github.com/
# rewrite would let the agent push over HTTPS — bypassing
# gitleaks. Suppress it.
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"},
)
self.assertEqual("", out)
def test_gitea_suppressed_when_git_gate_covers_host(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.
def test_partial_suppression_keeps_other_hosts(self):
out = render_cred_proxy_gitconfig(
_upstreams([
{"Kind": "gitea", "TokenRef": "T1",
"Url": "https://gitea.dideric.is"},
{"Kind": "gitea", "TokenRef": "T2",
"Url": "https://gitea.example.com"},
{"path": "/gitea/a/", "upstream": "https://gitea.dideric.is",
"auth_scheme": "token", "token_ref": "T1",
"role": "git-insteadof"},
{"path": "/gitea/b/", "upstream": "https://gitea.example.com",
"auth_scheme": "token", "token_ref": "T2",
"role": "git-insteadof"},
]),
{"gitea.dideric.is"},
)
@@ -119,24 +122,39 @@ class TestRenderGitconfig(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([
{"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([
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
"Url": "https://gitea.dideric.is"},
{"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
"auth_scheme": "token", "token_ref": "GITEA",
"role": "tea-login"},
]))
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("url: http://cred-proxy:9099/gitea/gitea.dideric.is/", 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("url: http://cred-proxy:9099/gitea/dideric/", 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__":