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:
@@ -14,103 +14,106 @@ from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle
|
||||
from claude_bottle.manifest import Manifest
|
||||
|
||||
|
||||
def _bottle(tokens):
|
||||
def _bottle(routes):
|
||||
return Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"tokens": tokens}},
|
||||
"bottles": {"dev": {"cred_proxy": {"routes": routes}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}).bottles["dev"]
|
||||
|
||||
|
||||
def _upstreams(tokens):
|
||||
return cred_proxy_upstreams_for_bottle(_bottle(tokens))
|
||||
def _upstreams(routes):
|
||||
return cred_proxy_upstreams_for_bottle(_bottle(routes))
|
||||
|
||||
|
||||
class TestRenderNpmrc(unittest.TestCase):
|
||||
def test_empty_when_no_npm_route(self):
|
||||
def test_empty_when_no_role(self):
|
||||
self.assertEqual("", render_npmrc(_upstreams([])))
|
||||
self.assertEqual("", render_npmrc(_upstreams([
|
||||
{"Kind": "anthropic", "TokenRef": "A"},
|
||||
{"path": "/x/", "upstream": "https://x.example",
|
||||
"auth_scheme": "Bearer", "token_ref": "T"},
|
||||
])))
|
||||
|
||||
def test_writes_registry_line(self):
|
||||
def test_writes_registry_line_for_npm_registry_role(self):
|
||||
out = render_npmrc(_upstreams([
|
||||
{"Kind": "npm", "TokenRef": "NPM_TOKEN"},
|
||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
||||
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
|
||||
"role": "npm-registry"},
|
||||
]))
|
||||
self.assertEqual("registry=http://cred-proxy:9099/npm/\n", out)
|
||||
|
||||
def test_omits_authtoken(self):
|
||||
# The proxy injects Authorization at request time. The npmrc
|
||||
# deliberately carries no _authToken — a stale token there
|
||||
# would just get stripped, but it also creates the false
|
||||
# impression that the agent holds a credential.
|
||||
# The proxy injects Authorization at request time.
|
||||
out = render_npmrc(_upstreams([
|
||||
{"Kind": "npm", "TokenRef": "NPM_TOKEN"},
|
||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
||||
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
|
||||
"role": "npm-registry"},
|
||||
]))
|
||||
self.assertNotIn("_authToken", out)
|
||||
self.assertNotIn("NPM_TOKEN", out)
|
||||
|
||||
|
||||
class TestRenderGitconfig(unittest.TestCase):
|
||||
def test_empty_when_no_github_or_gitea(self):
|
||||
def test_empty_when_no_role(self):
|
||||
self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([
|
||||
{"Kind": "anthropic", "TokenRef": "A"},
|
||||
{"Kind": "npm", "TokenRef": "N"},
|
||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
||||
"auth_scheme": "Bearer", "token_ref": "A"},
|
||||
])))
|
||||
|
||||
def test_github_writes_https_insteadof(self):
|
||||
def test_writes_insteadof_for_git_insteadof_role(self):
|
||||
out = render_cred_proxy_gitconfig(_upstreams([
|
||||
{"Kind": "github", "TokenRef": "GITHUB_TOKEN"},
|
||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
||||
"role": "git-insteadof"},
|
||||
]))
|
||||
self.assertIn('[url "http://cred-proxy:9099/gh-git/"]', out)
|
||||
self.assertIn("insteadOf = https://github.com/", out)
|
||||
|
||||
def test_gitea_writes_per_host_insteadof(self):
|
||||
out = render_cred_proxy_gitconfig(_upstreams([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
|
||||
"auth_scheme": "token", "token_ref": "GITEA",
|
||||
"role": "git-insteadof"},
|
||||
]))
|
||||
self.assertIn('[url "http://cred-proxy:9099/gitea/gitea.dideric.is/"]', out)
|
||||
self.assertIn('[url "http://cred-proxy:9099/gitea/dideric/"]', out)
|
||||
self.assertIn("insteadOf = https://gitea.dideric.is/", out)
|
||||
|
||||
def test_two_giteas_yield_two_rules(self):
|
||||
def test_two_routes_yield_two_rules(self):
|
||||
out = render_cred_proxy_gitconfig(_upstreams([
|
||||
{"Kind": "gitea", "TokenRef": "G1",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"Kind": "gitea", "TokenRef": "G2",
|
||||
"Url": "https://gitea.example.com"},
|
||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
||||
"role": "git-insteadof"},
|
||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
||||
"auth_scheme": "token", "token_ref": "GT",
|
||||
"role": "git-insteadof"},
|
||||
]))
|
||||
self.assertEqual(2, out.count("insteadOf"))
|
||||
self.assertIn("gitea.dideric.is/", out)
|
||||
self.assertIn("gitea.example.com/", out)
|
||||
self.assertIn("github.com", out)
|
||||
self.assertIn("gitea.example.com", out)
|
||||
|
||||
def test_github_suppressed_when_git_gate_covers_host(self):
|
||||
def test_suppressed_when_git_gate_covers_host(self):
|
||||
# When bottle.git brokers github.com over SSH, git-gate is the
|
||||
# canonical git path. The cred-proxy https://github.com/
|
||||
# rewrite would let the agent push over HTTPS — bypassing
|
||||
# gitleaks. Suppress it.
|
||||
out = render_cred_proxy_gitconfig(
|
||||
_upstreams([{"Kind": "github", "TokenRef": "GH"}]),
|
||||
_upstreams([
|
||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
||||
"role": "git-insteadof"},
|
||||
]),
|
||||
{"github.com"},
|
||||
)
|
||||
self.assertEqual("", out)
|
||||
|
||||
def test_gitea_suppressed_when_git_gate_covers_host(self):
|
||||
out = render_cred_proxy_gitconfig(
|
||||
_upstreams([{"Kind": "gitea", "TokenRef": "T",
|
||||
"Url": "https://gitea.dideric.is"}]),
|
||||
{"gitea.dideric.is"},
|
||||
)
|
||||
self.assertEqual("", out)
|
||||
|
||||
def test_partial_suppression_keeps_other_giteas(self):
|
||||
# Two gitea instances; git-gate brokers one. The other still
|
||||
# gets the cred-proxy rewrite.
|
||||
def test_partial_suppression_keeps_other_hosts(self):
|
||||
out = render_cred_proxy_gitconfig(
|
||||
_upstreams([
|
||||
{"Kind": "gitea", "TokenRef": "T1",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"Kind": "gitea", "TokenRef": "T2",
|
||||
"Url": "https://gitea.example.com"},
|
||||
{"path": "/gitea/a/", "upstream": "https://gitea.dideric.is",
|
||||
"auth_scheme": "token", "token_ref": "T1",
|
||||
"role": "git-insteadof"},
|
||||
{"path": "/gitea/b/", "upstream": "https://gitea.example.com",
|
||||
"auth_scheme": "token", "token_ref": "T2",
|
||||
"role": "git-insteadof"},
|
||||
]),
|
||||
{"gitea.dideric.is"},
|
||||
)
|
||||
@@ -119,24 +122,39 @@ class TestRenderGitconfig(unittest.TestCase):
|
||||
|
||||
|
||||
class TestRenderTeaConfig(unittest.TestCase):
|
||||
def test_empty_when_no_gitea(self):
|
||||
def test_empty_when_no_role(self):
|
||||
self.assertEqual("", render_tea_config(_upstreams([
|
||||
{"Kind": "github", "TokenRef": "G"},
|
||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
||||
"auth_scheme": "Bearer", "token_ref": "G"},
|
||||
])))
|
||||
|
||||
def test_single_gitea_login_block(self):
|
||||
def test_single_login_block(self):
|
||||
out = render_tea_config(_upstreams([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
|
||||
"auth_scheme": "token", "token_ref": "GITEA",
|
||||
"role": "tea-login"},
|
||||
]))
|
||||
self.assertIn("logins:", out)
|
||||
# Login name comes from the upstream host, not the path —
|
||||
# the path may not encode the host.
|
||||
self.assertIn("- name: gitea.dideric.is", out)
|
||||
self.assertIn("url: http://cred-proxy:9099/gitea/gitea.dideric.is/", out)
|
||||
# Placeholder token, not the host env var name (which is not a
|
||||
# secret but also not useful) or the real value (which the
|
||||
# provisioner does not have).
|
||||
self.assertIn("url: http://cred-proxy:9099/gitea/dideric/", out)
|
||||
self.assertIn("token: cred-proxy-placeholder", out)
|
||||
self.assertNotIn("GITEA_TOKEN", out)
|
||||
self.assertNotIn("GITEA", out)
|
||||
|
||||
|
||||
class TestCombinedRoles(unittest.TestCase):
|
||||
"""A single gitea route typically carries both `git-insteadof`
|
||||
and `tea-login` — the renderers should each fire independently."""
|
||||
|
||||
def test_gitea_route_fires_both_renderers(self):
|
||||
routes = _upstreams([
|
||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
||||
"auth_scheme": "token", "token_ref": "T",
|
||||
"role": ["git-insteadof", "tea-login"]},
|
||||
])
|
||||
self.assertIn("insteadOf", render_cred_proxy_gitconfig(routes))
|
||||
self.assertIn("logins:", render_tea_config(routes))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user