feat(egress-proxy): cutover from cred-proxy (PRD 0017 chunk 2)
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m3s

Hard cutover. cred-proxy is deleted; egress-proxy is now the agent's
HTTP_PROXY (when routes are declared) with pipelock on its outbound
leg. Two per-bottle CAs are minted: egress-proxy's (agent trust
store) and pipelock's (egress-proxy's outbound trust store).

Manifest:
  - `bottle.cred_proxy` → hard error with a migration recipe.
  - `bottle.egress_proxy` is the new shape (PRD 0017 chunk 1).
  - CredProxy* types + role validators removed.

Wiring:
  - launch.py: `egress_proxy_tls_init` mints the egress-proxy CA
    (cert+key concat for mitmproxy + cert-only for agent trust);
    `DockerEgressProxy.start` docker-cps both CAs in, sets
    `HTTPS_PROXY=pipelock` + `EGRESS_PROXY_UPSTREAM_CA` so mitmdump
    trusts pipelock's MITM. Agent's HTTP_PROXY points at
    egress-proxy when routes exist, else falls back to pipelock
    (no-routes bottles unchanged).
  - prepare.py / backend.py: `cred_proxy` arg → `egress_proxy`;
    sidecar-orphan probe + plan field + dashboard view all
    renamed.
  - provision_ca: selects the egress-proxy CA when present, else
    pipelock's (filename renamed to claude-bottle-mitm-ca.crt).
  - bottle.provision: cred-proxy dotfile rewrites (~/.npmrc,
    ~/.gitconfig insteadOf, tea config) are gone — HTTP_PROXY
    catches everything respecting it.

Pipelock helpers:
  - `pipelock_token_hosts` → `pipelock_route_hosts` (now reading
    egress_proxy.routes).
  - cred-proxy hostname auto-allow → egress-proxy hostname
    auto-allow.
  - Anthropic seed-phrase workaround now triggers when an
    egress_proxy route targets api.anthropic.com (was based on the
    cred-proxy `anthropic-base-url` role).

Dockerfile.egress-proxy:
  - Entrypoint conditionally passes
    `--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA`
    (via the `${VAR:+...}` shell expansion) so standalone runs without
    a mounted pipelock CA still boot.
  - mkdirs `/home/mitmproxy/.mitmproxy` ahead of `docker cp`.

Deleted: claude_bottle/{cred_proxy,cred_proxy_server}.py,
backend/docker/{cred_proxy,provision/cred_proxy}.py,
Dockerfile.cred-proxy, plus the corresponding unit + integration
tests. backend/docker/cred_proxy_apply.py stays as a stub for
chunk 3 to rewrite (its container-name + routes-path constants
are inlined so it survives without the deleted module).

Test changes:
  - test_pipelock_allowlist rewritten against egress-proxy routes
    + the new `pipelock_route_hosts`.
  - test_manifest_md_load + test_pipelock_yaml + test_yaml_subset
    fixtures migrated to the `egress_proxy: { routes: [...] }`
    shape.
  - test_supervise_sidecar's round-trip test switched from
    `dashboard.approve` to `dashboard.reject`: the approval-apply
    path on cred-proxy-block proposals hits a deleted sidecar in
    chunk 2's transitional state. Chunk 3 restores the approval
    test once the remediation flow is retargeted at egress-proxy.

376 tests pass (was 427; net delta is removed cred-proxy tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:30:39 -04:00
parent 9e41845a2b
commit 70f773ac61
30 changed files with 573 additions and 3451 deletions
+4 -8
View File
@@ -23,11 +23,10 @@ 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 .egress_proxy import DockerEgressProxy
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
@@ -44,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def __init__(self) -> None:
self._proxy = DockerPipelockProxy()
self._git_gate = DockerGitGate()
self._cred_proxy = DockerCredProxy()
self._egress_proxy = DockerEgressProxy()
self._supervise = DockerSupervise()
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
@@ -53,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
stage_dir=stage_dir,
proxy=self._proxy,
git_gate=self._git_gate,
cred_proxy=self._cred_proxy,
egress_proxy=self._egress_proxy,
supervise=self._supervise,
)
@@ -63,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
plan,
proxy=self._proxy,
git_gate=self._git_gate,
cred_proxy=self._cred_proxy,
egress_proxy=self._egress_proxy,
supervise=self._supervise,
provision=self.provision,
) as bottle:
@@ -81,9 +80,6 @@ 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 provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
_supervise_prov.provision_supervise(plan, target)
+30 -21
View File
@@ -11,7 +11,7 @@ import sys
from dataclasses import dataclass, field
from pathlib import Path
from ...cred_proxy import CredProxyPlan
from ...egress_proxy import EgressProxyPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...manifest import Agent, Bottle
@@ -58,7 +58,7 @@ class DockerBottlePlan(BottlePlan):
prompt_file: Path
proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
cred_proxy_plan: CredProxyPlan
egress_proxy_plan: EgressProxyPlan
# None when bottle.supervise is False. PRD 0013 supervise sidecar
# is opt-in via the manifest's bottle.supervise field.
supervise_plan: SupervisePlan | None
@@ -72,10 +72,11 @@ class DockerBottlePlan(BottlePlan):
bottle = manifest.bottle_for(spec.agent_name)
# 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.
# arriving via subprocess env). The forwarded set holds the
# OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
# interpolations from the manifest; egress-proxy holds upstream
# tokens in its own environ, so no token forwarding from the
# agent to the proxy is needed.
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
return _PlanView(
agent=agent,
@@ -120,16 +121,25 @@ class DockerBottlePlan(BottlePlan):
info(f" git gate : {'; '.join(git_lines)}")
else:
info(" git remotes : (none)")
if self.cred_proxy_plan.routes:
lines = [f"{r.path}{r.upstream}" for r in self.cred_proxy_plan.routes]
refs = sorted({r.token_ref for r in self.cred_proxy_plan.routes})
info(f" cred-proxy : {len(lines)} route(s); tokens: {', '.join(refs)}")
if self.egress_proxy_plan.routes:
lines = []
for r in self.egress_proxy_plan.routes:
paths = (
" " + ",".join(r.path_allowlist) if r.path_allowlist else ""
)
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
lines.append(f"{r.host}{auth}{paths}")
refs = sorted({r.token_ref for r in self.egress_proxy_plan.routes if r.token_ref})
tokens_part = (
f"; tokens: {', '.join(refs)}" if refs else ""
)
info(f" egress-proxy : {len(lines)} route(s){tokens_part}")
for line in lines:
info(f" {line}")
else:
info(" cred-proxy : (none)")
info(" egress-proxy : (none)")
info(f" egress : {self.allowlist_summary}")
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
info(" tls intercept : egress-proxy (per-bottle ephemeral CA, generated at launch)")
if self.supervise_plan is not None:
info(
f" supervise : enabled; queue at {self.supervise_plan.queue_dir}"
@@ -166,23 +176,22 @@ class DockerBottlePlan(BottlePlan):
}
for u in self.git_gate_plan.upstreams
],
"cred_proxy": [
"egress_proxy": [
{
"path": r.path,
"upstream": r.upstream,
"host": r.host,
"path_allowlist": list(r.path_allowlist),
"auth_scheme": r.auth_scheme,
"token_ref": r.token_ref,
"roles": list(r.roles),
}
for r in self.cred_proxy_plan.routes
for r in self.egress_proxy_plan.routes
],
"egress": {
"host_count": len(hosts),
"hosts": hosts,
# PRD 0006: pipelock's `tls_interception` block is on
# for every launched bottle. ca_fingerprint is always
# null at dry-run because the CA doesn't exist yet —
# real launches print the fingerprint to stderr from
# PRD 0017: TLS interception moved from pipelock to
# egress-proxy. ca_fingerprint is always null at
# dry-run because the CA doesn't exist yet — real
# launches print the fingerprint to stderr from
# provision_ca. Reserved field for forward-compat.
"tls_interception": {
"enabled": True,
-250
View File
@@ -1,250 +0,0 @@
"""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}'"
)
@@ -22,15 +22,25 @@ import subprocess
import tempfile
from pathlib import Path
from .cred_proxy import (
CRED_PROXY_ROUTES_IN_CONTAINER,
cred_proxy_container_name,
)
# Constants inlined from the deleted `claude_bottle.backend.docker.
# cred_proxy` module (PRD 0017 chunk 2 cutover). Chunk 3 retargets
# this file at egress-proxy and gets rid of these.
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
def _cred_proxy_container_name(slug: str) -> str:
return f"claude-bottle-cred-proxy-{slug}"
class CredProxyApplyError(RuntimeError):
"""Raised when fetch / apply fails. Caller renders to the
operator; does not crash the dashboard."""
operator; does not crash the dashboard.
PRD 0017 chunk 2 deletes the cred-proxy sidecar; this module's
docker-exec calls now hit a non-existent container and raise
CredProxyApplyError with a "container not running" message,
which the dashboard surfaces to the operator. Chunk 3 retargets
everything at egress-proxy."""
def fetch_current_routes(slug: str) -> str:
@@ -38,7 +48,7 @@ def fetch_current_routes(slug: str) -> str:
for `slug`. Returns the file content as a string. Raises
CredProxyApplyError if the sidecar isn't reachable or the read
fails."""
container = cred_proxy_container_name(slug)
container = _cred_proxy_container_name(slug)
r = subprocess.run(
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
capture_output=True, text=True, check=False,
@@ -80,7 +90,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
sidecar are unchanged if the failure is before docker cp, and
are reverted in spirit if SIGHUP fails (cp landed but reload
didn't fire — caller's next attempt will SIGHUP again)."""
container = cred_proxy_container_name(slug)
container = _cred_proxy_container_name(slug)
before = fetch_current_routes(slug)
validate_routes_json(new_content)
+128 -29
View File
@@ -3,9 +3,9 @@ per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform-
agnostic prepare step (route lift + routes.yaml render + token-env
map derivation) from `EgressProxy`.
Chunk 1 of the PRD: the lifecycle is implemented but not yet called
from `launch.py`. Tests build the image and exercise start/stop
directly. Chunk 2 wires this in alongside the cred-proxy removal."""
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
block remediation flow (PRD 0014)."""
from __future__ import annotations
@@ -24,6 +24,8 @@ from ...log import die, info, warn
from . import util as docker_mod
EGRESS_PROXY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE",
"claude-bottle-egress-proxy:latest",
@@ -32,9 +34,19 @@ EGRESS_PROXY_IMAGE = os.environ.get(
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
# (chunk 2) will resolve to `http://egress-proxy:<port>`.
# resolves to `http://egress-proxy:<port>`.
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM
# file holding BOTH the cert and the private key, concatenated. The
# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream
# leg) is a separate file because pipelock keeps a different CA on
# its end.
EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = (
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
)
# Repo root, for `docker build` context. Resolved from this file's
# location: claude_bottle/backend/docker/egress_proxy.py → repo root.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
@@ -62,6 +74,55 @@ def build_egress_proxy_image() -> None:
)
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
binary's `tls init` subcommand — a known-good RSA CA minter we
already pin and run on this host.
Returns `(mitmproxy_pem, cert_only_pem)`:
- `mitmproxy_pem` is the single-PEM concat (cert + key)
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
- `cert_only_pem` is the cert alone — installed into the agent's
trust store by `provision_ca` so the agent trusts the bumped
CONNECT cert egress-proxy presents.
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
Private keys never leave the host stage dir until
`DockerEgressProxy.start` docker-cps the concat file into the
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
after teardown.
Imported lazily inside the function so test patchers in
pipelock-land don't need to know about us."""
# Local import keeps the module-import graph free of a hard
# pipelock-image dependency at top of file (we don't actually
# need pipelock's *runtime* here, just its tls-init subcommand).
from .pipelock import PIPELOCK_IMAGE
work = stage_dir / "egress-proxy-ca"
work.mkdir(exist_ok=True)
result = subprocess.run(
["docker", "run", "--rm",
"-v", f"{work}:/h",
"-e", "PIPELOCK_HOME=/h",
PIPELOCK_IMAGE, "tls", "init"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
cert = work / "ca.pem"
key = work / "ca-key.pem"
if not cert.is_file() or not key.is_file():
die(f"egress-proxy tls init did not produce ca files in {work}")
cert.chmod(0o600)
# mitmproxy reads cert + key from a single concatenated PEM file.
mitm = work / "mitmproxy-ca.pem"
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
mitm.chmod(0o600)
return (mitm, cert)
class DockerEgressProxy(EgressProxy):
"""Brings the egress-proxy sidecar up and down via Docker."""
@@ -71,13 +132,16 @@ class DockerEgressProxy(EgressProxy):
value. Fails early if any are unset.
2. Build the egress-proxy image (no-op when cache is hot).
3. `docker create` on the internal network with
`--network-alias egress-proxy` and one `-e EGRESS_PROXY_TOKEN_N`
flag per token slot. The values arrive via subprocess env, so
they never land on argv.
4. `docker cp` the routes.yaml into the container.
`--network-alias egress-proxy`, the `HTTPS_PROXY=pipelock`
env (so the upstream leg traverses pipelock), the
`EGRESS_PROXY_UPSTREAM_CA` env pointing at the in-container
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
and one `-e EGRESS_PROXY_TOKEN_N` flag per token slot.
Secret values arrive via subprocess env, never argv.
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
concat), and pipelock CA (cert only) into the container.
5. Attach to the per-agent egress network so the proxy can
reach pipelock (chunk 2 turns this into the pipelock leg
via HTTPS_PROXY).
reach pipelock.
6. `docker start`.
Returns the container name (the target passed to `.stop`)."""
if not plan.routes:
@@ -92,6 +156,27 @@ class DockerEgressProxy(EgressProxy):
f"egress-proxy routes file missing at {plan.routes_path}; "
f"EgressProxy.prepare must run first"
)
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
die(
f"DockerEgressProxy.start: mitmproxy CA missing at "
f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first"
)
# pipelock CA + upstream proxy URL: both must be present (we
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
# upstream leg) or both absent (egress-proxy goes direct, for
# standalone integration tests that don't 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(
"DockerEgressProxy.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"DockerEgressProxy.start: pipelock CA missing at "
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
)
# Resolve host env vars into concrete values. Must happen at
# start time (not prepare) — the values flow into the sidecar's
@@ -111,15 +196,19 @@ class DockerEgressProxy(EgressProxy):
"--network", plan.internal_network,
"--network-alias", EGRESS_PROXY_HOSTNAME,
]
if plan.pipelock_proxy_url:
if route_via_pipelock:
# Route egress-proxy's outbound HTTPS through pipelock so
# the egress allowlist + DLP body scanner apply to its
# traffic on the egress-proxy → upstream leg. Wiring lands
# in chunk 2.
# traffic on the egress-proxy → upstream leg. Pipelock
# MITMs each handshake with its per-bottle CA, which is
# docker-cp'd in below and pointed to via the
# EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally
# adds the matching --set flag).
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",
"-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}",
])
# One -e flag per token slot; values arrive via subprocess env.
# docker create with `-e NAME` (no =VALUE) reads NAME from the
@@ -143,24 +232,34 @@ class DockerEgressProxy(EgressProxy):
f"{create_result.stderr.strip()}"
)
cp_result = subprocess.run(
["docker", "cp", str(plan.routes_path),
f"{name}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cps: list[tuple[Path, str, str]] = [
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
]
if route_via_pipelock:
cps.append((
plan.pipelock_ca_host_path,
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER,
"pipelock CA",
))
for src, dst, label in cps:
cp_result = subprocess.run(
["docker", "cp", str(src), f"{name}:{dst}"],
capture_output=True,
text=True,
check=False,
)
die(
f"failed to copy routes.yaml into {name}: "
f"{cp_result.stderr.strip()}"
)
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],
+55 -28
View File
@@ -24,7 +24,11 @@ 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 .egress_proxy import (
DockerEgressProxy,
egress_proxy_tls_init,
egress_proxy_url,
)
from .git_gate import DockerGitGate
from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
@@ -47,7 +51,7 @@ def launch(
*,
proxy: DockerPipelockProxy,
git_gate: DockerGitGate,
cred_proxy: DockerCredProxy,
egress_proxy: DockerEgressProxy,
supervise: DockerSupervise,
provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]:
@@ -83,19 +87,28 @@ def launch(
# Docker assigns a CIDR to the new internal network. Pipelock's
# SSRF guard otherwise rejects any destination resolving into
# RFC1918 space — which includes the cred-proxy / git-gate /
# pipelock sidecars themselves. Allowlist the bottle's own
# internal subnet so the agent can reach its sidecars via
# pipelock; api_allowlist + body-scanning still apply.
# RFC1918 space — which includes the sibling sidecars
# (egress-proxy → pipelock on the upstream leg, etc.).
# Allowlist the bottle's own internal subnet so internal
# traffic passes through pipelock; api_allowlist + body-scanning
# still apply.
internal_cidr = network_mod.network_inspect_cidr(internal_network)
# Per-bottle ephemeral CA for pipelock's TLS interception
# (PRD 0006). One-shot pipelock container writes ca.pem +
# ca-key.pem under plan.stage_dir; .start docker-cp's them
# into the sidecar. The private key never leaves the host
# stage dir, which start.py's outer finally `shutil.rmtree`s
# after the sidecar is torn down.
# Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two
# separate CAs:
# - pipelock CA: signs MITM certs pipelock presents on the
# egress-proxy → upstream leg.
# - egress-proxy CA: signs MITM certs egress-proxy presents
# to the agent on the agent → egress-proxy leg.
# Both are minted by one-shot pipelock containers (pipelock's
# `tls init` is a known-good RSA CA minter) under stage_dir;
# the .start steps docker-cp the files in. Private keys never
# leave the host stage dir, which start.py's outer finally
# `shutil.rmtree`s after the sidecars are torn down.
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
egress_proxy_ca_host, egress_proxy_ca_cert_only = egress_proxy_tls_init(
plan.stage_dir,
)
# Re-render the pipelock yaml with the SSRF allowlist now that
# we know the internal CIDR. Prepare wrote the yaml without
@@ -141,26 +154,28 @@ 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.cred_proxy.routes is non-empty. Must come up AFTER pipelock
# — cred-proxy routes its outbound HTTPS through pipelock
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
# store) so the egress allowlist + body scanner sit in the
# cred-proxy path too. 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.routes:
cred_proxy_plan = dataclasses.replace(
plan.cred_proxy_plan,
# Egress-proxy (PRD 0017). One sidecar per bottle when
# bottle.egress_proxy.routes is non-empty. Must come up AFTER
# pipelock — egress-proxy routes its outbound HTTPS through
# pipelock (HTTPS_PROXY in environ + the pipelock CA in its
# trust store) so the egress allowlist + body scanner sit on
# the egress-proxy → upstream leg. Must come up BEFORE the
# agent so DNS resolution for `egress-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.egress_proxy_plan.routes:
egress_proxy_plan = dataclasses.replace(
plan.egress_proxy_plan,
internal_network=internal_network,
egress_network=egress_network,
mitmproxy_ca_host_path=egress_proxy_ca_host,
mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only,
pipelock_ca_host_path=ca_cert_host,
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
)
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)
plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan)
egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan)
stack.callback(egress_proxy.stop, egress_proxy_name)
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
# Internal-network only — the sidecar makes no outbound calls.
@@ -208,11 +223,23 @@ def _agent_no_proxy(plan: DockerBottlePlan) -> str:
return ",".join(hosts)
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
"""Pick the proxy URL the agent's HTTP_PROXY env points at. PRD
0017: when an egress-proxy is declared, the agent goes through
egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its
outbound leg). Otherwise the agent talks straight to pipelock —
keeps the network surface minimal for bottles that don't need
path filtering or credential injection."""
if plan.egress_proxy_plan.routes:
return egress_proxy_url()
return pipelock_proxy_url(plan.slug)
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
"""Build the `docker run` argv and execute it, handling name-
conflict races by incrementing the suffix (unless the name was
user-pinned). Returns the resolved container name."""
proxy_url = pipelock_proxy_url(plan.slug)
proxy_url = _agent_proxy_url(plan)
docker_args: list[str] = [
"--rm", "-d",
"--name", plan.container_name,
+27 -43
View File
@@ -15,17 +15,13 @@ from datetime import datetime, timezone
from pathlib import Path
from ... import pipelock
from ...cred_proxy import cred_proxy_render_routes
from ...egress_proxy import egress_proxy_render_routes
from ...env import ResolvedEnv, resolve_env
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_container_name,
cred_proxy_url,
)
from .egress_proxy import DockerEgressProxy, egress_proxy_container_name
from .git_gate import DockerGitGate, git_gate_container_name
from .bottle_state import (
BottleMetadata,
@@ -46,7 +42,7 @@ def resolve_plan(
stage_dir: Path,
proxy: DockerPipelockProxy,
git_gate: DockerGitGate,
cred_proxy: DockerCredProxy,
egress_proxy: DockerEgressProxy,
supervise: DockerSupervise,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
@@ -127,15 +123,15 @@ def resolve_plan(
# surface as a docker-create conflict deep inside launch() with no
# actionable hint. Fail fast here with a cleanup pointer instead.
# Only probe sidecars this launch will actually try to create:
# pipelock always; git-gate when bottle.git is non-empty; cred-proxy
# when bottle.cred_proxy.routes is non-empty.
# pipelock always; git-gate when bottle.git is non-empty;
# egress-proxy when bottle.egress_proxy.routes is non-empty.
sidecar_probes: list[tuple[str, str]] = [
("pipelock", pipelock_container_name(slug)),
]
if bottle.git:
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
if bottle.cred_proxy.routes:
sidecar_probes.append(("cred-proxy", cred_proxy_container_name(slug)))
if bottle.egress_proxy.routes:
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug)))
if bottle.supervise:
sidecar_probes.append(("supervise", supervise_container_name(slug)))
for label, sidecar_name in sidecar_probes:
@@ -154,10 +150,13 @@ 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)
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
supervise_plan = None
if bottle.supervise:
routes_content = cred_proxy_render_routes(cred_proxy_plan.routes) if cred_proxy_plan.routes else ""
routes_content = (
egress_proxy_render_routes(egress_proxy_plan.routes)
if egress_proxy_plan.routes else ""
)
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
# Current Dockerfile for the agent image. Read from the repo
# root; for `--cwd` derived images the base Dockerfile is what
@@ -176,36 +175,21 @@ def resolve_plan(
# 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)
# Find the (at most one) cred-proxy route claiming the
# anthropic-base-url role. Manifest validation enforces the
# singleton constraint. cred-proxy is the only path the Anthropic
# OAuth token reaches the bottle — there is no fallback that
# forwards it into the agent's environ directly. Bottles that
# need claude-code to authenticate must declare an
# anthropic-base-url route.
anthropic_route = next(
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles),
None,
# When the bottle declares an egress-proxy route for the Anthropic
# OAuth flow, claude-code's outbound Authorization gets stripped +
# re-injected by egress-proxy. The agent's environ still needs
# *something* claude-code recognises as a credential or it refuses
# to start; ship a non-secret placeholder. The placeholder is not
# any real `auth.token_ref` value, so leaking it would tell an
# attacker only that egress-proxy is in front.
has_anthropic_auth = any(
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
for r in egress_proxy_plan.routes
)
if anthropic_route is not None:
# Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not. Strip the
# trailing slash so claude-code's path-join produces e.g.
# http://cred-proxy:9099/anthropic/v1/messages.
forwarded_env["ANTHROPIC_BASE_URL"] = (
f"{cred_proxy_url()}{anthropic_route.path}".rstrip("/")
)
# 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 `cred_proxy.routes[].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.
if has_anthropic_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
# Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress-proxy can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
_write_env_file(resolved, env_file)
@@ -229,7 +213,7 @@ def resolve_plan(
prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan,
cred_proxy_plan=cred_proxy_plan,
egress_proxy_plan=egress_proxy_plan,
supervise_plan=supervise_plan,
allowlist_summary=allowlist_summary,
use_runsc=use_runsc,
+45 -21
View File
@@ -1,12 +1,19 @@
"""Install pipelock's per-bottle CA into the agent container's trust
store (PRD 0006).
"""Install the per-bottle MITM CA into the agent container's trust
store.
By the time this provisioner runs, `pipelock_tls_init` has generated
a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar
is up with `tls_interception: { enabled: true }` referencing the
in-container CA paths. This step makes the agent trust certs signed
by that CA so the agent's TLS handshake with the bumped CONNECT
succeeds.
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target:
- Bottle declares `egress_proxy.routes[]` agent's HTTP_PROXY
points at egress-proxy; the cert the agent must trust is the
one egress-proxy mints leaf certs with (the egress-proxy CA).
- No egress_proxy routes agent's HTTP_PROXY points straight at
pipelock; the cert the agent must trust is pipelock's CA (the
pre-cutover behavior).
By the time this provisioner runs, the corresponding `tls_init`
helper has generated the chosen CA under `plan.stage_dir`, and the
sidecar (pipelock or egress-proxy) is up referencing the
in-container CA paths.
Cert lands on Debian's standard source path
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
@@ -27,6 +34,7 @@ from __future__ import annotations
import hashlib
import ssl
import subprocess
from pathlib import Path
from ....log import info
from ..bottle_plan import DockerBottlePlan
@@ -35,26 +43,42 @@ from ..bottle_plan import DockerBottlePlan
# Debian-family path for sources that `update-ca-certificates` reads.
# Bundle path is what the command rebuilds and what every standard
# TLS consumer in the image reads.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-pipelock-ca.crt"
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
"""Copy pipelock's CA cert into the agent, rebuild the trust
bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up."""
container = target
cert_host_path = plan.proxy_plan.ca_cert_host_path
if not cert_host_path or not cert_host_path.is_file():
# Defensive: provision runs after launch wires CA paths
# onto the plan via dataclasses.replace; an empty path here
# would mean that wiring was skipped.
def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
"""Pick the CA cert (and a short label for the log line) that
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
wins when the bottle declares any routes (it sits in front of
pipelock); else pipelock."""
if plan.egress_proxy_plan.routes:
cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file():
from ....log import die
die(
f"egress-proxy CA cert missing at {cert or '(empty)'}; "
f"launch must have called egress_proxy_tls_init and "
f"re-bound the plan before provision"
)
return cert, "egress-proxy"
cert = plan.proxy_plan.ca_cert_host_path
if not cert or not cert.is_file():
from ....log import die
die(
f"pipelock CA cert missing at {cert_host_path or '(empty)'}; "
f"pipelock CA cert missing at {cert or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound "
f"the plan before provision"
)
return cert, "pipelock"
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
"""Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up."""
container = target
cert_host_path, label = _select_ca_cert(plan)
subprocess.run(
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
@@ -76,4 +100,4 @@ def provision_ca(plan: DockerBottlePlan, target: str) -> None:
# fingerprint form. Never the private key.
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
fingerprint = hashlib.sha256(der).hexdigest()
info(f"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
@@ -1,238 +0,0 @@
"""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 CredProxyRoute
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 routes."""
routes = plan.cred_proxy_plan.routes
if not routes:
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, routes)
_provision_gitconfig(plan, target, routes, git_gate_hosts)
_provision_tea_config(plan, target, routes)
# --- npm --------------------------------------------------------------------
def render_npmrc(routes: tuple[CredProxyRoute, ...]) -> 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 r in routes:
if "npm-registry" in r.roles:
return f"registry={cred_proxy_url()}{r.path}\n"
return ""
def _provision_npmrc(
plan: DockerBottlePlan,
target: str,
routes: tuple[CredProxyRoute, ...],
) -> None:
content = render_npmrc(routes)
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(
routes: tuple[CredProxyRoute, ...],
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 `route.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 r in routes:
if "git-insteadof" not in r.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 = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{r.path}"]\n'
f"\tinsteadOf = {r.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,
routes: tuple[CredProxyRoute, ...],
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(routes, 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(routes: tuple[CredProxyRoute, ...]) -> 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 = [r for r in routes if "tea-login" in r.roles]
if not tea_routes:
return ""
lines = ["logins:"]
for r 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 = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
lines.extend([
f"- name: {host}",
f" url: {cred_proxy_url()}{r.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,
routes: tuple[CredProxyRoute, ...],
) -> None:
content = render_tea_config(routes)
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} "
f"({len([r for r in routes if 'tea-login' in r.roles])} tea 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])