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