Files
bot-bottle/claude_bottle/backend/docker/provision/cred_proxy.py
T
didericis fcbbc4484d
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 22s
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.
2026-05-13 21:49:55 -04:00

236 lines
9.3 KiB
Python

"""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
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
git_gate_hosts = {g.UpstreamHost for g in bottle.git}
_provision_npmrc(plan, target, upstreams)
_provision_gitconfig(plan, target, upstreams, git_gate_hosts)
_provision_tea_config(plan, target, upstreams)
# --- npm --------------------------------------------------------------------
def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
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
npmrc deliberately carries no `_authToken`. The registry alone
is enough. Manifest validation enforces that the role is a
singleton, so the first match is the only match."""
for u in upstreams:
if "npm-registry" in u.roles:
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, ...],
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
) -> str:
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
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 route whose upstream host is
also declared in `bottle.git`. git-gate is the canonical git
path on those hosts — its pre-receive runs gitleaks before
forwarding the push. A cred-proxy `https://<host>/` rewrite
would route HTTPS git ops around the gate. cred-proxy still
refuses smart-HTTP push at runtime (defense in depth), but
suppressing the rewrite means `git clone https://<host>/...`
doesn't have a tempting shortcut that just confuses on push.
The insteadOf left-hand side comes from `upstream` (with a
trailing `/` so insteadOf matches at the directory boundary),
so the same renderer handles github.com, gitea.dideric.is, and
any future host the user wires up."""
rules: list[str] = []
for u in upstreams:
if "git-insteadof" not in u.roles:
continue
# Strip scheme to derive the host for the git-gate overlap
# check. urllib.parse-free parse: same shape we accept in
# manifest validation.
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
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:
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, ...],
git_gate_hosts: set[str],
) -> 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. Hosts already brokered by
git-gate are skipped — git-gate is the canonical git path there."""
content = render_cred_proxy_gitconfig(upstreams, git_gate_hosts)
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`. Driven by the `tea-login`
role: each route that claims it produces one `logins:` entry
pointing at the cred-proxy. The proxy substitutes the real
token at request time; the value in `token:` here is a
placeholder. `tea` refuses to make calls without a non-empty
token field, so the placeholder is necessary."""
tea_routes = [u for u in upstreams if "tea-login" in u.roles]
if not tea_routes:
return ""
lines = ["logins:"]
for u in tea_routes:
# Derive a stable login name from the upstream host. The
# path may not encode the host (e.g. `/gitea/dideric/` vs
# upstream gitea.dideric.is), so we read it off `upstream`.
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
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])