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.
This commit is contained in:
2026-05-13 16:20:42 -04:00
parent b3529b27a5
commit 8334f51268
5 changed files with 98 additions and 12 deletions
+13 -5
View File
@@ -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
+8
View File
@@ -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()
+32 -3
View File
@@ -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,
+17
View File
@@ -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)
+28 -4
View File
@@ -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,
)