feat(cred_proxy): add agent-side provisioner (PRD 0010)
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/<host>/ 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.
This commit is contained in:
@@ -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/<host>/
|
||||
|
||||
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/<host>/. 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/<host>/.
|
||||
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://<host>/ 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])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user