70f773ac61
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>
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""Host-side helper to apply a routes.json change to a running
|
|
cred-proxy sidecar (PRD 0014).
|
|
|
|
Used by the supervise dashboard when the operator approves a
|
|
cred-proxy-block proposal (or runs the operator-initiated `routes
|
|
edit <bottle>` verb). Fetches the current routes.json via `docker
|
|
exec cat`, validates the new JSON, writes it into the sidecar via
|
|
`docker cp`, then `docker kill --signal HUP` to make the in-sidecar
|
|
SIGHUP handler (PRD 0014 Phase 1) reload without dropping
|
|
connections.
|
|
|
|
Raises CredProxyApplyError on any failure — the dashboard surfaces
|
|
the message and keeps the proposal pending so the operator can
|
|
retry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
# 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.
|
|
|
|
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:
|
|
"""Read the live routes.json from the running cred-proxy sidecar
|
|
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)
|
|
r = subprocess.run(
|
|
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if r.returncode != 0:
|
|
raise CredProxyApplyError(
|
|
f"could not read routes.json from {container}: "
|
|
f"{(r.stderr or '').strip() or 'container not running?'}"
|
|
)
|
|
return r.stdout
|
|
|
|
|
|
def validate_routes_json(content: str) -> None:
|
|
"""Syntactic check before SIGHUP — the sidecar's reload also
|
|
validates, but failing here keeps the old routes live and gives
|
|
the operator a clearer error than 'reload failed' in the
|
|
sidecar logs."""
|
|
try:
|
|
parsed = json.loads(content)
|
|
except json.JSONDecodeError as e:
|
|
raise CredProxyApplyError(
|
|
f"proposed routes.json is not valid JSON: {e}"
|
|
) from e
|
|
if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list):
|
|
raise CredProxyApplyError(
|
|
"proposed routes.json must be an object with a 'routes' array"
|
|
)
|
|
|
|
|
|
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
|
"""Apply `new_content` to the cred-proxy sidecar for `slug`:
|
|
1. Fetch current routes.json (for the before-diff).
|
|
2. Validate the new JSON.
|
|
3. Write to a temp file, `docker cp` into the sidecar.
|
|
4. `docker kill --signal HUP` so cred-proxy reloads.
|
|
|
|
Returns (before, after) where `after` == `new_content`. Raises
|
|
CredProxyApplyError on any step; the existing routes in the
|
|
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)
|
|
before = fetch_current_routes(slug)
|
|
validate_routes_json(new_content)
|
|
|
|
fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".json")
|
|
try:
|
|
with os.fdopen(fd, "w") as f:
|
|
f.write(new_content)
|
|
cp = subprocess.run(
|
|
["docker", "cp", tmp_path, f"{container}:{CRED_PROXY_ROUTES_IN_CONTAINER}"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if cp.returncode != 0:
|
|
raise CredProxyApplyError(
|
|
f"failed to copy routes.json into {container}: "
|
|
f"{(cp.stderr or '').strip()}"
|
|
)
|
|
sig = subprocess.run(
|
|
["docker", "kill", "--signal", "HUP", container],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if sig.returncode != 0:
|
|
raise CredProxyApplyError(
|
|
f"failed to SIGHUP {container}: "
|
|
f"{(sig.stderr or '').strip()}"
|
|
)
|
|
finally:
|
|
try:
|
|
Path(tmp_path).unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
return before, new_content
|
|
|
|
|
|
__all__ = [
|
|
"CredProxyApplyError",
|
|
"apply_routes_change",
|
|
"fetch_current_routes",
|
|
"validate_routes_json",
|
|
]
|