refactor(cred_proxy): flat routes, role-driven provisioning (PRD 0010)
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:
+124
-85
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user