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
+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