From c8ab90d01d217e2ca508a1675d2ad4bc653de2b1 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 13 May 2026 16:38:36 -0400 Subject: [PATCH] fix(manifest): allow token + git on the same host (PRD 0010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git-gate holds an SSH IdentityFile for push/fetch; cred-proxy holds a PAT for HTTPS REST API calls. The two brokers are orthogonal — the common dev setup names both on the same host (e.g. gitea.dideric.is SSH for push, gitea.dideric.is PAT for `tea pr create`). The original PRD 0010 wording called this a "configuration smell" and rejected it at parse time. That was wrong; this drops the overlap rejection from the validator and updates the PRD prose to match. Tests flip from "rejection" to "coexistence" assertions. --- claude_bottle/manifest.py | 20 ++++------ docs/prds/0010-cred-proxy.md | 12 +++--- tests/unit/test_manifest_tokens.py | 62 ++++++++++++++++-------------- 3 files changed, 46 insertions(+), 48 deletions(-) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 9e35da9..58abc47 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -570,11 +570,14 @@ def _validate_tokens( - At most one entry per Kind, except `gitea` which may have multiple entries (one per Gitea instance) with distinct Urls. - - No overlap with `bottle.git` hosts: a `github` or `gitea` token - whose host matches a `bottle.git` upstream host would put two - credential brokers on the same remote (git-gate's gitleaks- - scanning gate AND cred-proxy's bearer injection). Pick one. + + A `github` or `gitea` token MAY name the same host as a + `bottle.git` entry: the two paths broker different protocols + (git-gate handles SSH push/fetch with an IdentityFile; cred-proxy + handles HTTPS REST API calls with a PAT), so declaring both on + one host is a legitimate dev setup, not a configuration error. """ + del git # cross-host overlap is intentionally not rejected. by_kind: dict[str, list[TokenEntry]] = {} for t in tokens: by_kind.setdefault(t.Kind, []).append(t) @@ -595,15 +598,6 @@ def _validate_tokens( f"that may have multiple entries)." ) - git_hosts = {g.UpstreamHost for g in git} - for t in tokens: - if t.Kind in ("github", "gitea") and t.UpstreamHost in git_hosts: - die( - f"bottle '{bottle_name}' token ({t.Kind}, host {t.UpstreamHost!r}) " - f"overlaps a bottle.git upstream on the same host. git-gate already " - f"brokers this remote; drop the token entry or remove the git entry." - ) - def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: seen: dict[str, None] = {} diff --git a/docs/prds/0010-cred-proxy.md b/docs/prds/0010-cred-proxy.md index 760c2a8..4737051 100644 --- a/docs/prds/0010-cred-proxy.md +++ b/docs/prds/0010-cred-proxy.md @@ -315,12 +315,12 @@ Validation: documented upstream. - At most one entry per `Kind` except `gitea`, which may have multiple distinct `Url`s. -- No silent overlap with `bottle.git` upstreams that already - flow through git-gate; if a `tokens[].Kind: github|gitea` - entry's `Url` collides with a `git[].Upstream`'s host, parse - fails with a "git-gate already brokers this remote, drop one" - hint. (Both paths broker credentials; doubling up is a - configuration smell, not a feature.) +- A `github` or `gitea` token MAY name the same host as a + `bottle.git` entry. The two paths broker different protocols — + git-gate holds an SSH `IdentityFile` for push/fetch and runs + gitleaks; cred-proxy holds a PAT for HTTPS REST API calls (`tea`, + `gh`, octokit). The common dev setup uses both on the same host + and is not a configuration error. ### Routing table diff --git a/tests/unit/test_manifest_tokens.py b/tests/unit/test_manifest_tokens.py index 388c591..d99bf4b 100644 --- a/tests/unit/test_manifest_tokens.py +++ b/tests/unit/test_manifest_tokens.py @@ -129,37 +129,41 @@ class TestTokenEntryValidation(unittest.TestCase): ])) -class TestTokenGitOverlap(unittest.TestCase): - def test_github_token_collides_with_github_git_entry(self): - # bottle.git already brokers github.com via the gate; declaring - # a github token on top would put two credential brokers on - # the same remote. - with self.assertRaises(Die): - 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", - }], - )) +class TestTokenGitCoexistence(unittest.TestCase): + """git-gate brokers SSH push/fetch via an IdentityFile; cred-proxy + brokers HTTPS REST API calls via a PAT. Declaring both on the same + host is the common dev setup (SSH key for git ops, PAT for `tea` / + `gh` API calls), not a configuration error.""" - def test_gitea_token_collides_with_same_host_git_entry(self): - with self.assertRaises(Die): - 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", - }], - )) + 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_anthropic_token_does_not_collide_with_git(self): - # api.anthropic.com isn't a git host; no overlap possible. + 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=[{