From 8334f5126869bb065875e096e2aff0d56967376d Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 13 May 2026 16:20:42 -0400 Subject: [PATCH] feat(cred_proxy): wire DockerCredProxy through backend (PRD 0010) - DockerBottleBackend instantiates DockerCredProxy alongside pipelock and git-gate; threads it through prepare and launch. - DockerBottlePlan gains cred_proxy_plan; preflight rendering shows the declared kinds + TokenRefs and to_dict emits a cred_proxy array matching the routing table. - prepare.py: when bottle.tokens has an anthropic entry, route the agent at the proxy via ANTHROPIC_BASE_URL, drop the agent-side CLAUDE_CODE_OAUTH_TOKEN forward (the token goes to the sidecar's environ instead, set a non-secret placeholder so claude-code's startup check passes), and default the telemetry-off env vars. - launch.py: bring up the cred-proxy sidecar in ExitStack before the agent container so DNS resolution for `cred-proxy` succeeds on the agent's first call. - backend/__init__.py: add provision_cred_proxy to the provision template (runs after provision_git so it can append to ~/.gitconfig). - bottle_plan _view: env_names is derived from the forwarded_env dict, so the preflight reflects the PRD 0010 switch without ad-hoc branching on spec.forward_oauth_token. --- claude_bottle/backend/__init__.py | 18 ++++++++--- claude_bottle/backend/docker/backend.py | 8 +++++ claude_bottle/backend/docker/bottle_plan.py | 35 +++++++++++++++++++-- claude_bottle/backend/docker/launch.py | 17 ++++++++++ claude_bottle/backend/docker/prepare.py | 32 ++++++++++++++++--- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index ba677b6..cb04496 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -214,15 +214,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): decide whether to add --append-system-prompt-file to claude's argv. - Default orchestration: ca → prompt → skills → git. CA install - runs first so the agent's trust store is rebuilt before - anything inside the agent makes a TLS call. Subclasses - typically don't override this; they implement the sub-methods - below.""" + Default orchestration: ca → prompt → skills → git → + cred_proxy. CA install runs first so the agent's trust store + is rebuilt before anything inside the agent makes a TLS call. + cred_proxy runs last because it appends to ~/.gitconfig (which + provision_git writes). Subclasses typically don't override + this; they implement the sub-methods below.""" self.provision_ca(plan, target) prompt_path = self.provision_prompt(plan, target) self.provision_skills(plan, target) self.provision_git(plan, target) + self.provision_cred_proxy(plan, target) return prompt_path def provision_ca(self, plan: PlanT, target: str) -> None: @@ -251,6 +253,12 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Copy the host's cwd `.git` directory into the running bottle if the user requested --cwd. No-op otherwise.""" + def provision_cred_proxy(self, plan: PlanT, target: str) -> None: + """Drop the cred-proxy agent-side dotfiles (.npmrc, + .gitconfig insteadOf, ~/.config/tea/config.yml) per PRD 0010. + Default impl is a no-op for backends that don't yet support + the cred-proxy sidecar; the Docker backend overrides.""" + @abstractmethod def prepare_cleanup(self) -> CleanupT: """Enumerate orphaned resources from previous bottles. No side diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 55baa8b..3ea0c0e 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -23,9 +23,11 @@ from . import prepare as _prepare from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan +from .cred_proxy import DockerCredProxy from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy from .provision import ca as _ca +from .provision import cred_proxy as _cred_proxy from .provision import git as _git from .provision import prompt as _prompt from .provision import skills as _skills @@ -40,6 +42,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def __init__(self) -> None: self._proxy = DockerPipelockProxy() self._git_gate = DockerGitGate() + self._cred_proxy = DockerCredProxy() def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: return _prepare.resolve_plan( @@ -47,6 +50,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup stage_dir=stage_dir, proxy=self._proxy, git_gate=self._git_gate, + cred_proxy=self._cred_proxy, ) @contextmanager @@ -55,6 +59,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup plan, proxy=self._proxy, git_gate=self._git_gate, + cred_proxy=self._cred_proxy, provision=self.provision, ) as bottle: yield bottle @@ -71,6 +76,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def provision_git(self, plan: DockerBottlePlan, target: str) -> None: _git.provision_git(plan, target) + def provision_cred_proxy(self, plan: DockerBottlePlan, target: str) -> None: + _cred_proxy.provision_cred_proxy(plan, target) + def prepare_cleanup(self) -> DockerBottleCleanupPlan: return _cleanup.prepare_cleanup() diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index af635de..c3f2af5 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -11,6 +11,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path +from ...cred_proxy import CredProxyPlan from ...git_gate import GitGatePlan from ...log import info from ...manifest import Agent, Bottle @@ -51,6 +52,7 @@ class DockerBottlePlan(BottlePlan): prompt_file: Path proxy_plan: PipelockProxyPlan git_gate_plan: GitGatePlan + cred_proxy_plan: CredProxyPlan allowlist_summary: str use_runsc: bool @@ -59,9 +61,13 @@ class DockerBottlePlan(BottlePlan): manifest = spec.manifest agent = manifest.agents[spec.agent_name] bottle = manifest.bottle_for(spec.agent_name) - env_names = list(bottle.env.keys()) - if spec.forward_oauth_token: - env_names.append("CLAUDE_CODE_OAUTH_TOKEN") + # The agent sees the union of literal env names (rendered into + # --env-file) and forwarded env names (`-e NAME` with the value + # arriving via subprocess env). The forwarded set already + # reflects PRD 0010's switch — when cred-proxy holds the + # anthropic token, CLAUDE_CODE_OAUTH_TOKEN is absent and + # ANTHROPIC_BASE_URL is present. + env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())) return _PlanView( agent=agent, bottle=bottle, @@ -100,6 +106,19 @@ class DockerBottlePlan(BottlePlan): info(f" git gate : {'; '.join(git_lines)}") else: info(" git remotes : (none)") + if self.cred_proxy_plan.upstreams: + kinds: list[str] = [] + seen: set[str] = set() + for u in self.cred_proxy_plan.upstreams: + key = u.kind if u.kind != "gitea" else f"gitea ({u.upstream})" + if key in seen: + continue + seen.add(key) + kinds.append(key) + refs = sorted({u.token_ref for u in self.cred_proxy_plan.upstreams}) + info(f" cred-proxy : {', '.join(kinds)}; tokens: {', '.join(refs)}") + else: + info(" cred-proxy : (none)") info(f" egress : {self.allowlist_summary}") info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)") info( @@ -132,6 +151,16 @@ class DockerBottlePlan(BottlePlan): } for u in self.git_gate_plan.upstreams ], + "cred_proxy": [ + { + "kind": u.kind, + "path": u.path, + "upstream": u.upstream, + "auth_scheme": u.auth_scheme, + "token_ref": u.token_ref, + } + for u in self.cred_proxy_plan.upstreams + ], "egress": { "host_count": len(hosts), "hosts": hosts, diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index c1575bc..a274333 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -22,6 +22,7 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan +from .cred_proxy import DockerCredProxy from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH @@ -37,6 +38,7 @@ def launch( *, proxy: DockerPipelockProxy, git_gate: DockerGitGate, + cred_proxy: DockerCredProxy, provision: Callable[[DockerBottlePlan, str], str | None], ) -> Generator[DockerBottle, None, None]: """Build, launch, and provision a Docker bottle. Teardown on exit. @@ -102,6 +104,21 @@ def launch( git_gate_name = git_gate.start(plan.git_gate_plan) stack.callback(git_gate.stop, git_gate_name) + # Cred-proxy (PRD 0010). One sidecar per bottle when + # bottle.tokens declares any kind. Must come up before the + # agent so DNS resolution for `cred-proxy` succeeds on the + # agent's first call; tokens flow from the host env into the + # sidecar's environ, not the agent's. + if plan.cred_proxy_plan.upstreams: + cred_proxy_plan = dataclasses.replace( + plan.cred_proxy_plan, + internal_network=internal_network, + egress_network=egress_network, + ) + plan = dataclasses.replace(plan, cred_proxy_plan=cred_proxy_plan) + cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan) + stack.callback(cred_proxy.stop, cred_proxy_name) + container = _run_agent_container(plan, internal_network) stack.callback(docker_mod.force_remove_container, container) diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 074d8d7..66d6d76 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -19,6 +19,7 @@ from ...log import die from .. import BottleSpec from . import util as docker_mod from .bottle_plan import DockerBottlePlan +from .cred_proxy import DockerCredProxy, cred_proxy_url from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy @@ -29,6 +30,7 @@ def resolve_plan( stage_dir: Path, proxy: DockerPipelockProxy, git_gate: DockerGitGate, + cred_proxy: DockerCredProxy, ) -> DockerBottlePlan: """Resolve Docker-specific names and write scratch files. Trusts that the agent and its skills/git-gate keys are present — @@ -81,14 +83,35 @@ def resolve_plan( proxy_plan = proxy.prepare(bottle, slug, stage_dir) git_gate_plan = git_gate.prepare(bottle, slug, stage_dir) + cred_proxy_plan = cred_proxy.prepare(bottle, slug, stage_dir) resolved = resolve_env(manifest, spec.agent_name) # Everything that should reach the bottle by-name (so its value - # never lands on argv or in env_file) goes into one dict. The - # rename from CLAUDE_BOTTLE_OAUTH_TOKEN to CLAUDE_CODE_OAUTH_TOKEN - # happens here; nothing mutates the host os.environ. + # never lands on argv or in env_file) goes into one dict. Nothing + # mutates the host os.environ. forwarded_env: dict[str, str] = dict(resolved.forwarded) - if spec.forward_oauth_token: + has_anthropic_token = any(t.Kind == "anthropic" for t in bottle.tokens) + if spec.forward_oauth_token and not has_anthropic_token: + # Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN + # directly. Still the path when bottle.tokens has no anthropic + # entry; the cred-proxy sidecar holds the token otherwise. forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"] + if has_anthropic_token: + # Point claude-code at the cred-proxy. The sidecar holds the + # OAuth token; the agent's environ does not. + forwarded_env["ANTHROPIC_BASE_URL"] = f"{cred_proxy_url()}/anthropic" + # claude-code refuses to start without *some* credential in + # its env. The proxy strips inbound Authorization on every + # request and injects the real one — so a non-secret + # placeholder is sufficient and the SC1 test still holds + # (the placeholder is not a `bottle.tokens[].TokenRef` + # value). The agent cannot exfiltrate this string because + # it carries no meaning to api.anthropic.com. + forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder" + # Belt-and-braces: turn off telemetry endpoints that don't + # route through ANTHROPIC_BASE_URL (statsig, error reporting). + # PRD 0010 open question default. + forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") + forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") _write_env_file(resolved, env_file) prompt_file.write_text(agent.prompt) @@ -109,6 +132,7 @@ def resolve_plan( prompt_file=prompt_file, proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, + cred_proxy_plan=cred_proxy_plan, allowlist_summary=allowlist_summary, use_runsc=use_runsc, )