From b3529b27a556ea6da04048ae2299f47674aa9349 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 13 May 2026 16:11:04 -0400 Subject: [PATCH] feat(cred_proxy): add agent-side provisioner (PRD 0010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provision_cred_proxy(plan, target) drops: - ~/.npmrc with registry= pointing at /npm/ on the proxy - ~/.gitconfig insteadOf rules for github (https://github.com/) and per-gitea hosts, appended after provision_git's git-gate rules - ~/.config/tea/config.yml with a logins: entry per declared gitea URL, pointing at /gitea// on the proxy Renderers are pure and unit-tested. The dispatcher reads plan.cred_proxy_plan.upstreams, which the backend wiring (next commit) populates on DockerBottlePlan. ANTHROPIC_BASE_URL is deliberately *not* a dotfile — it goes into the agent's docker run -e env so claude sees it from process start. --- .../backend/docker/provision/cred_proxy.py | 217 ++++++++++++++++++ tests/unit/test_provision_cred_proxy.py | 109 +++++++++ 2 files changed, 326 insertions(+) create mode 100644 claude_bottle/backend/docker/provision/cred_proxy.py create mode 100644 tests/unit/test_provision_cred_proxy.py diff --git a/claude_bottle/backend/docker/provision/cred_proxy.py b/claude_bottle/backend/docker/provision/cred_proxy.py new file mode 100644 index 0000000..5fecde9 --- /dev/null +++ b/claude_bottle/backend/docker/provision/cred_proxy.py @@ -0,0 +1,217 @@ +"""Cred-proxy provisioning inside a running Docker bottle (PRD 0010). + +Writes the agent-side configuration that points each tool at the +per-bottle cred-proxy sidecar: + + - ~/.npmrc — `registry=` pointing at /npm/ + - ~/.gitconfig (appended) — `insteadOf` rules for the + github / gitea hosts the bottle + declared a token for + - ~/.config/tea/config.yml — per-gitea login pointing at + /gitea// + +The ANTHROPIC_BASE_URL env var is set at `docker run -e` time by the +backend's launch step, not here — it has to be in the agent's environ +before claude starts, and there is no point in writing it to a dotfile +the agent would have to source. See `prepare.py` for that. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from ....cred_proxy import CredProxyUpstream +from ....log import info +from .. import util as docker_mod +from ..bottle_plan import DockerBottlePlan +from ..cred_proxy import cred_proxy_url + + +def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None: + """Drop the agent-side dotfiles for each declared cred-proxy + route. No-op when the bottle has no tokens.""" + upstreams = plan.cred_proxy_plan.upstreams + if not upstreams: + return + _provision_npmrc(plan, target, upstreams) + _provision_gitconfig(plan, target, upstreams) + _provision_tea_config(plan, target, upstreams) + + +# --- npm -------------------------------------------------------------------- + + +def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str: + """Render `~/.npmrc` content. No-op (empty string) when no npm + route is declared, so callers can branch on emptiness. + + The proxy strips inbound Authorization and injects its own — the + npmrc deliberately carries no `_authToken`. The registry alone + is enough.""" + for u in upstreams: + if u.kind == "npm": + return f"registry={cred_proxy_url()}{u.path}\n" + return "" + + +def _provision_npmrc( + plan: DockerBottlePlan, + target: str, + upstreams: tuple[CredProxyUpstream, ...], +) -> None: + content = render_npmrc(upstreams) + if not content: + return + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_npmrc = f"{container_home}/.npmrc" + npmrc = plan.stage_dir / "agent_npmrc" + npmrc.write_text(content) + npmrc.chmod(0o600) + info(f"writing {container_npmrc} (cred-proxy npm registry)") + subprocess.run( + ["docker", "cp", str(npmrc), f"{target}:{container_npmrc}"], + stdout=subprocess.DEVNULL, + check=True, + ) + docker_mod.docker_exec_root(target, ["chown", "node:node", container_npmrc]) + docker_mod.docker_exec_root(target, ["chmod", "644", container_npmrc]) + + +# --- git config ------------------------------------------------------------- + + +def render_cred_proxy_gitconfig(upstreams: tuple[CredProxyUpstream, ...]) -> str: + """Render the `~/.gitconfig` fragment for cred-proxy insteadOf + rewrites. Empty string when no github / gitea routes are declared. + + github expands to two rewrites: https://github.com/... → /gh-git/... + (the git transport endpoint), and the agent's git client reaches + api.github.com over the same proxy via the /gh-api/ route, but + that's used by tools that call the GitHub API directly (gh, tea, + octokit) rather than `git` itself. + + Gitea entries get one rewrite per declared host, pointing at + /gitea//. The path component scopes the credential + so multiple gitea instances coexist on one proxy.""" + rules: list[str] = [] + for u in upstreams: + if u.kind == "github" and u.path == "/gh-git/": + rules.append( + f'[url "{cred_proxy_url()}/gh-git/"]\n' + f"\tinsteadOf = https://github.com/\n" + ) + elif u.kind == "gitea": + # u.upstream is the configured gitea URL (e.g. + # https://gitea.dideric.is) and u.path is /gitea//. + rules.append( + f'[url "{cred_proxy_url()}{u.path}"]\n' + f"\tinsteadOf = {u.upstream}/\n" + ) + if not rules: + return "" + return ( + "# claude-bottle cred-proxy (PRD 0010): rewrite https:/// to\n" + "# the per-bottle cred-proxy sidecar, which holds the upstream\n" + "# credential and injects the Authorization header.\n" + + "".join(rules) + ) + + +def _provision_gitconfig( + plan: DockerBottlePlan, + target: str, + upstreams: tuple[CredProxyUpstream, ...], +) -> None: + """Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs + after `provision_git`, so any git-gate rules already live in the + file; we append rather than overwrite.""" + content = render_cred_proxy_gitconfig(upstreams) + if not content: + return + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_gitconfig = f"{container_home}/.gitconfig" + info(f"appending cred-proxy insteadOf rules to {container_gitconfig}") + # Use `tee -a` over stdin so the content never lands on argv and the + # append is atomic from the agent's perspective. `tee` runs as the + # node user (the default in the container) so ownership is preserved. + result = subprocess.run( + ["docker", "exec", "-i", target, "tee", "-a", container_gitconfig], + input=content, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + # Fall back to root-tee in case ~/.gitconfig didn't exist as the + # node user yet (no git-gate rules were written). The chown + # below makes ownership consistent. + result_root = subprocess.run( + ["docker", "exec", "-i", "-u", "0", target, + "tee", "-a", container_gitconfig], + input=content, + text=True, + capture_output=True, + check=True, + ) + _ = result_root # silence unused + docker_mod.docker_exec_root(target, ["chown", "node:node", container_gitconfig]) + docker_mod.docker_exec_root(target, ["chmod", "644", container_gitconfig]) + + +# --- tea -------------------------------------------------------------------- + + +def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str: + """Render `~/.config/tea/config.yml`. One `logins:` entry per + gitea route, pointing at the cred-proxy. The proxy substitutes + the real token; the value in `token:` here is a placeholder and + is replaced by the proxy on every request, but `tea` won't make + calls without a non-empty token field.""" + giteas = [u for u in upstreams if u.kind == "gitea"] + if not giteas: + return "" + lines = ["logins:"] + for u in giteas: + # Derive a stable login name from the host (the part of the + # path between /gitea/ and the trailing /). + host = u.path[len("/gitea/"):].rstrip("/") + lines.extend([ + f"- name: {host}", + f" url: {cred_proxy_url()}{u.path}", + " token: cred-proxy-placeholder", + " default: false", + " ssh_host: \"\"", + " ssh_key: \"\"", + " insecure: false", + ]) + return "\n".join(lines) + "\n" + + +def _provision_tea_config( + plan: DockerBottlePlan, + target: str, + upstreams: tuple[CredProxyUpstream, ...], +) -> None: + content = render_tea_config(upstreams) + if not content: + return + container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") + container_tea = f"{container_home}/.config/tea/config.yml" + cfg = plan.stage_dir / "agent_tea_config.yml" + cfg.write_text(content) + cfg.chmod(0o600) + info(f"writing {container_tea} ({len([u for u in upstreams if u.kind == 'gitea'])} gitea login(s))") + docker_mod.docker_exec_root( + target, ["mkdir", "-p", str(Path(container_tea).parent)] + ) + subprocess.run( + ["docker", "cp", str(cfg), f"{target}:{container_tea}"], + stdout=subprocess.DEVNULL, + check=True, + ) + docker_mod.docker_exec_root(target, [ + "chown", "-R", "node:node", str(Path(container_tea).parent), + ]) + docker_mod.docker_exec_root(target, ["chmod", "600", container_tea]) diff --git a/tests/unit/test_provision_cred_proxy.py b/tests/unit/test_provision_cred_proxy.py new file mode 100644 index 0000000..dbf7730 --- /dev/null +++ b/tests/unit/test_provision_cred_proxy.py @@ -0,0 +1,109 @@ +"""Unit: cred-proxy agent-side provisioner renderers (PRD 0010). + +The docker cp / docker exec side effects are exercised by integration +tests; these unit tests cover the pure render functions.""" + +import unittest + +from claude_bottle.backend.docker.provision.cred_proxy import ( + render_cred_proxy_gitconfig, + render_npmrc, + render_tea_config, +) +from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle +from claude_bottle.manifest import Manifest + + +def _bottle(tokens): + return Manifest.from_json_obj({ + "bottles": {"dev": {"tokens": tokens}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }).bottles["dev"] + + +def _upstreams(tokens): + return cred_proxy_upstreams_for_bottle(_bottle(tokens)) + + +class TestRenderNpmrc(unittest.TestCase): + def test_empty_when_no_npm_route(self): + self.assertEqual("", render_npmrc(_upstreams([]))) + self.assertEqual("", render_npmrc(_upstreams([ + {"Kind": "anthropic", "TokenRef": "A"}, + ]))) + + def test_writes_registry_line(self): + out = render_npmrc(_upstreams([ + {"Kind": "npm", "TokenRef": "NPM_TOKEN"}, + ])) + 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. + out = render_npmrc(_upstreams([ + {"Kind": "npm", "TokenRef": "NPM_TOKEN"}, + ])) + self.assertNotIn("_authToken", out) + self.assertNotIn("NPM_TOKEN", out) + + +class TestRenderGitconfig(unittest.TestCase): + def test_empty_when_no_github_or_gitea(self): + self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([ + {"Kind": "anthropic", "TokenRef": "A"}, + {"Kind": "npm", "TokenRef": "N"}, + ]))) + + def test_github_writes_https_insteadof(self): + out = render_cred_proxy_gitconfig(_upstreams([ + {"Kind": "github", "TokenRef": "GITHUB_TOKEN"}, + ])) + 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"}, + ])) + self.assertIn('[url "http://cred-proxy:9099/gitea/gitea.dideric.is/"]', out) + self.assertIn("insteadOf = https://gitea.dideric.is/", out) + + def test_two_giteas_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"}, + ])) + self.assertEqual(2, out.count("insteadOf")) + self.assertIn("gitea.dideric.is/", out) + self.assertIn("gitea.example.com/", out) + + +class TestRenderTeaConfig(unittest.TestCase): + def test_empty_when_no_gitea(self): + self.assertEqual("", render_tea_config(_upstreams([ + {"Kind": "github", "TokenRef": "G"}, + ]))) + + def test_single_gitea_login_block(self): + out = render_tea_config(_upstreams([ + {"Kind": "gitea", "TokenRef": "GITEA_TOKEN", + "Url": "https://gitea.dideric.is"}, + ])) + self.assertIn("logins:", out) + 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("token: cred-proxy-placeholder", out) + self.assertNotIn("GITEA_TOKEN", out) + + +if __name__ == "__main__": + unittest.main()