f4452b391d
The agent's HTTP_PROXY env points at pipelock, so an ANTHROPIC_BASE_URL like http://cred-proxy:9099/anthropic doesn't short-circuit through Docker's embedded DNS — it gets forwarded through pipelock, which then checks its api_allowlist for the hostname `cred-proxy` and 403's because the name isn't there. The agent surfaces the failure as "API Error: 403 blocked: domain not in allowlist: cred-proxy" on Claude's first call. Fix: pipelock_effective_allowlist auto-adds CRED_PROXY_HOSTNAME when bottle.cred_proxy.routes is non-empty (i.e., when the sidecar will actually be running and reachable). Move CRED_PROXY_HOSTNAME from backend/docker/cred_proxy.py to the backend-agnostic claude_bottle/cred_proxy.py so pipelock can reference it without a layering violation; the docker concrete imports it from the same place.
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle
|
|
cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare
|
|
step (route lift + routes.json render + token-env-map derivation)
|
|
from `CredProxy`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from ...cred_proxy import (
|
|
CRED_PROXY_HOSTNAME,
|
|
CredProxy,
|
|
CredProxyPlan,
|
|
cred_proxy_resolve_token_values,
|
|
)
|
|
from ...log import die, info, warn
|
|
from . import util as docker_mod
|
|
|
|
|
|
CRED_PROXY_IMAGE = os.environ.get(
|
|
"CLAUDE_BOTTLE_CRED_PROXY_IMAGE",
|
|
"claude-bottle-cred-proxy:latest",
|
|
)
|
|
|
|
CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy"
|
|
|
|
# Listening port inside the sidecar. The agent dials cred-proxy on
|
|
# this port; surfaced as a constant so the provisioner and tests can
|
|
# both reference it.
|
|
CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099"))
|
|
|
|
# In-container path the proxy server reads its route table from.
|
|
# Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the
|
|
# file directly.
|
|
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
|
|
|
|
# In-container path for the per-bottle pipelock CA. Alpine's
|
|
# update-ca-certificates picks anything ending in `.crt` under
|
|
# /usr/local/share/ca-certificates/ and folds it into the system
|
|
# trust store at boot — so cred-proxy's HTTPS client trusts
|
|
# pipelock's bumped certs when pipelock MITMs the outbound leg.
|
|
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER = "/usr/local/share/ca-certificates/pipelock.crt"
|
|
|
|
# Repo root, for `docker build` context. Resolved from this file's
|
|
# location: claude_bottle/backend/docker/cred_proxy.py → repo root.
|
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
|
|
|
|
def cred_proxy_container_name(slug: str) -> str:
|
|
return f"claude-bottle-cred-proxy-{slug}"
|
|
|
|
|
|
def cred_proxy_url() -> str:
|
|
"""Base URL the agent dials. Stable across bottles because the
|
|
sidecar attaches `--network-alias cred-proxy` on the internal
|
|
network; the container name (which carries the slug) is not
|
|
referenced by agent-side config."""
|
|
return f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}"
|
|
|
|
|
|
def build_cred_proxy_image() -> None:
|
|
"""Build the cred-proxy image from `Dockerfile.cred-proxy`.
|
|
Called by `DockerCredProxy.start`; exposed at module level so
|
|
integration tests can build it without running the full launch
|
|
pipeline."""
|
|
docker_mod.build_image(CRED_PROXY_IMAGE, _REPO_DIR, dockerfile=CRED_PROXY_DOCKERFILE)
|
|
|
|
|
|
class DockerCredProxy(CredProxy):
|
|
"""Brings the cred-proxy sidecar up and down via Docker."""
|
|
|
|
def start(self, plan: CredProxyPlan) -> str:
|
|
"""Boot the cred-proxy sidecar:
|
|
1. Resolve every host TokenRef env var into a concrete
|
|
value. Fails early if any are unset.
|
|
2. Build the cred-proxy image (no-op when cache is hot).
|
|
3. `docker create` on the internal network with
|
|
`--network-alias cred-proxy` and one `-e CRED_PROXY_TOKEN_N`
|
|
flag per route. The values arrive via subprocess env, so
|
|
they never land on argv.
|
|
4. `docker cp` the routes.json into the container.
|
|
5. Attach to the per-agent egress network so the proxy can
|
|
reach the real upstream over HTTPS.
|
|
6. `docker start`.
|
|
Returns the container name (the target passed to `.stop`)."""
|
|
if not plan.routes:
|
|
die("DockerCredProxy.start called with no routes; caller should skip")
|
|
if not plan.internal_network or not plan.egress_network:
|
|
die(
|
|
"DockerCredProxy.start: internal_network / egress_network must be "
|
|
"populated on the plan before start"
|
|
)
|
|
if not plan.routes_path.is_file():
|
|
die(
|
|
f"cred-proxy routes file missing at {plan.routes_path}; "
|
|
f"CredProxy.prepare must run first"
|
|
)
|
|
# pipelock fields are populated by launch.py in production; both
|
|
# must be present (URL + CA) or both absent. Mixing is a wiring
|
|
# bug. Both-absent is supported only as a test escape hatch:
|
|
# the integration tests in tests/integration/ exercise header
|
|
# injection in isolation and do not bring pipelock up.
|
|
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
|
|
if route_via_pipelock:
|
|
if not plan.pipelock_proxy_url:
|
|
die(
|
|
"DockerCredProxy.start: pipelock_ca_host_path is set but "
|
|
"pipelock_proxy_url is empty; populate both or neither."
|
|
)
|
|
if not plan.pipelock_ca_host_path.is_file():
|
|
die(
|
|
f"DockerCredProxy.start: pipelock CA missing at "
|
|
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
|
)
|
|
|
|
# Resolve host env vars into concrete values. This must
|
|
# happen at start time (not prepare) — the values flow into
|
|
# the sidecar's environ via subprocess env. The plan never
|
|
# holds them.
|
|
token_values = cred_proxy_resolve_token_values(plan.token_env_map, dict(os.environ))
|
|
|
|
build_cred_proxy_image()
|
|
|
|
name = cred_proxy_container_name(plan.slug)
|
|
info(f"starting cred-proxy sidecar {name} on network {plan.internal_network}")
|
|
|
|
create_args = [
|
|
"docker", "create",
|
|
"--name", name,
|
|
"--network", plan.internal_network,
|
|
"--network-alias", CRED_PROXY_HOSTNAME,
|
|
]
|
|
if route_via_pipelock:
|
|
# Route cred-proxy's outbound HTTPS through pipelock so
|
|
# the egress allowlist + DLP body scanner apply to its
|
|
# traffic. Pipelock MITMs each handshake with the
|
|
# per-bottle CA we docker cp in below.
|
|
create_args.extend([
|
|
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
|
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
|
])
|
|
# One -e flag per token slot; values arrive via subprocess env.
|
|
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
|
# current process env at create time. We pass `env=child_env`
|
|
# to subprocess.run so the value comes from token_values, not
|
|
# the host's os.environ directly — keeps the resolver in one
|
|
# place and lets cred_proxy_resolve_token_values surface
|
|
# missing-env errors with a clear hint.
|
|
for token_env in sorted(plan.token_env_map.keys()):
|
|
create_args.extend(["-e", token_env])
|
|
create_args.append(CRED_PROXY_IMAGE)
|
|
|
|
child_env: dict[str, str] = {**os.environ, **token_values}
|
|
|
|
create_result = subprocess.run(
|
|
create_args, capture_output=True, text=True, env=child_env, check=False,
|
|
)
|
|
if create_result.returncode != 0:
|
|
die(
|
|
f"failed to create cred-proxy sidecar {name}: "
|
|
f"{create_result.stderr.strip()}"
|
|
)
|
|
|
|
cps: list[tuple[str, str, str]] = [
|
|
(str(plan.routes_path), CRED_PROXY_ROUTES_IN_CONTAINER, "routes.json"),
|
|
]
|
|
if route_via_pipelock:
|
|
# CA must land BEFORE `docker start` so the entrypoint's
|
|
# update-ca-certificates picks it up. Docker cp's the
|
|
# file in even on the stopped container — that's the
|
|
# whole reason this works without a custom build step.
|
|
cps.append((
|
|
str(plan.pipelock_ca_host_path),
|
|
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER,
|
|
"pipelock CA",
|
|
))
|
|
for src, dst, label in cps:
|
|
cp_result = subprocess.run(
|
|
["docker", "cp", src, f"{name}:{dst}"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if cp_result.returncode != 0:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
die(
|
|
f"failed to copy {label} into {name}: "
|
|
f"{cp_result.stderr.strip()}"
|
|
)
|
|
|
|
connect_result = subprocess.run(
|
|
["docker", "network", "connect", plan.egress_network, name],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if connect_result.returncode != 0:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
die(
|
|
f"failed to attach cred-proxy sidecar {name} to egress network "
|
|
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
|
)
|
|
|
|
start_result = subprocess.run(
|
|
["docker", "start", name], capture_output=True, text=True, check=False,
|
|
)
|
|
if start_result.returncode != 0:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
die(
|
|
f"failed to start cred-proxy sidecar {name}: "
|
|
f"{start_result.stderr.strip()}"
|
|
)
|
|
|
|
return name
|
|
|
|
def stop(self, target: str) -> None:
|
|
"""Idempotent: missing container is success. `target` is the
|
|
container name returned by `.start`."""
|
|
if subprocess.run(
|
|
["docker", "inspect", target],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode == 0:
|
|
if subprocess.run(
|
|
["docker", "rm", "-f", target],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode != 0:
|
|
warn(
|
|
f"failed to remove cred-proxy sidecar {target}; "
|
|
f"clean up with 'docker rm -f {target}'"
|
|
)
|