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:
@@ -214,15 +214,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
decide whether to add --append-system-prompt-file to claude's
|
decide whether to add --append-system-prompt-file to claude's
|
||||||
argv.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → git. CA install
|
Default orchestration: ca → prompt → skills → git →
|
||||||
runs first so the agent's trust store is rebuilt before
|
cred_proxy. CA install runs first so the agent's trust store
|
||||||
anything inside the agent makes a TLS call. Subclasses
|
is rebuilt before anything inside the agent makes a TLS call.
|
||||||
typically don't override this; they implement the sub-methods
|
cred_proxy runs last because it appends to ~/.gitconfig (which
|
||||||
below."""
|
provision_git writes). Subclasses typically don't override
|
||||||
|
this; they implement the sub-methods below."""
|
||||||
self.provision_ca(plan, target)
|
self.provision_ca(plan, target)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, target)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, target)
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, target)
|
||||||
|
self.provision_cred_proxy(plan, target)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
def provision_ca(self, plan: PlanT, target: str) -> None:
|
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
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
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
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
"""Enumerate orphaned resources from previous bottles. No side
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ from . import prepare as _prepare
|
|||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .cred_proxy import DockerCredProxy
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
|
from .provision import cred_proxy as _cred_proxy
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
from .provision import prompt as _prompt
|
||||||
from .provision import skills as _skills
|
from .provision import skills as _skills
|
||||||
@@ -40,6 +42,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._git_gate = DockerGitGate()
|
self._git_gate = DockerGitGate()
|
||||||
|
self._cred_proxy = DockerCredProxy()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
return _prepare.resolve_plan(
|
return _prepare.resolve_plan(
|
||||||
@@ -47,6 +50,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
|
cred_proxy=self._cred_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -55,6 +59,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
plan,
|
plan,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
|
cred_proxy=self._cred_proxy,
|
||||||
provision=self.provision,
|
provision=self.provision,
|
||||||
) as bottle:
|
) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
@@ -71,6 +76,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_git.provision_git(plan, target)
|
_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:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...cred_proxy import CredProxyPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
@@ -51,6 +52,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
|
cred_proxy_plan: CredProxyPlan
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
|
||||||
@@ -59,9 +61,13 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
env_names = list(bottle.env.keys())
|
# The agent sees the union of literal env names (rendered into
|
||||||
if spec.forward_oauth_token:
|
# --env-file) and forwarded env names (`-e NAME` with the value
|
||||||
env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
|
# 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(
|
return _PlanView(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
bottle=bottle,
|
bottle=bottle,
|
||||||
@@ -100,6 +106,19 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
info(f" git gate : {'; '.join(git_lines)}")
|
info(f" git gate : {'; '.join(git_lines)}")
|
||||||
else:
|
else:
|
||||||
info(" git remotes : (none)")
|
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(f" egress : {self.allowlist_summary}")
|
||||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
||||||
info(
|
info(
|
||||||
@@ -132,6 +151,16 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
}
|
}
|
||||||
for u in self.git_gate_plan.upstreams
|
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": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from . import network as network_mod
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .cred_proxy import DockerCredProxy
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
@@ -37,6 +38,7 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
|
cred_proxy: DockerCredProxy,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
"""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)
|
git_gate_name = git_gate.start(plan.git_gate_plan)
|
||||||
stack.callback(git_gate.stop, git_gate_name)
|
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)
|
container = _run_agent_container(plan, internal_network)
|
||||||
stack.callback(docker_mod.force_remove_container, container)
|
stack.callback(docker_mod.force_remove_container, container)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ...log import die
|
|||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .cred_proxy import DockerCredProxy, cred_proxy_url
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ def resolve_plan(
|
|||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
|
cred_proxy: DockerCredProxy,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/git-gate keys are present —
|
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)
|
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||||
git_gate_plan = git_gate.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)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# Everything that should reach the bottle by-name (so its value
|
# Everything that should reach the bottle by-name (so its value
|
||||||
# never lands on argv or in env_file) goes into one dict. The
|
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||||
# rename from CLAUDE_BOTTLE_OAUTH_TOKEN to CLAUDE_CODE_OAUTH_TOKEN
|
# mutates the host os.environ.
|
||||||
# happens here; nothing mutates the host os.environ.
|
|
||||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
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"]
|
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)
|
_write_env_file(resolved, env_file)
|
||||||
prompt_file.write_text(agent.prompt)
|
prompt_file.write_text(agent.prompt)
|
||||||
|
|
||||||
@@ -109,6 +132,7 @@ def resolve_plan(
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
|
cred_proxy_plan=cred_proxy_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user