feat(egress-proxy): cutover from cred-proxy (PRD 0017 chunk 2)
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:
+24
-218
@@ -14,7 +14,6 @@ the system prompt, for bottles the body is human documentation
|
||||
Bottle schema (frontmatter):
|
||||
env: { <NAME>: <env-entry>, ... }
|
||||
git: [ <git-entry>, ... ]
|
||||
cred_proxy: { routes: [ <route>, ... ] } # superseded by egress_proxy (PRD 0017)
|
||||
egress_proxy: { routes: [ <egress-route>, ... ] }
|
||||
egress: { allowlist: [ <hostname>, ... ] }
|
||||
|
||||
@@ -125,154 +124,6 @@ class GitEntry:
|
||||
)
|
||||
|
||||
|
||||
CRED_PROXY_AUTH_SCHEMES = ("Bearer", "token")
|
||||
|
||||
# Provisioner role tags a route may carry. Each tag drives one
|
||||
# agent-side rewrite when the cred-proxy sidecar comes up.
|
||||
# anthropic-base-url: set ANTHROPIC_BASE_URL=<proxy><path>
|
||||
# npm-registry: write ~/.npmrc registry= <proxy><path>
|
||||
# git-insteadof: write ~/.gitconfig [url "<proxy><path>"]
|
||||
# insteadOf = <route.upstream>/
|
||||
# tea-login: add an entry to ~/.config/tea/config.yml
|
||||
# (login url = <proxy><path>)
|
||||
# Routes without a `role` are pure proxy entries with no agent-side
|
||||
# rewrite — useful for upstreams whose tools the user wires up by
|
||||
# hand.
|
||||
CRED_PROXY_ROLES = frozenset({
|
||||
"anthropic-base-url",
|
||||
"npm-registry",
|
||||
"git-insteadof",
|
||||
"tea-login",
|
||||
})
|
||||
|
||||
# Roles whose semantics imply a single route can carry them. A second
|
||||
# route claiming the same role would make the provisioner's choice
|
||||
# ambiguous (which path goes into ANTHROPIC_BASE_URL?).
|
||||
CRED_PROXY_SINGLETON_ROLES = frozenset({
|
||||
"anthropic-base-url",
|
||||
"npm-registry",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredProxyRoute:
|
||||
"""One route on the per-bottle cred-proxy sidecar (PRD 0010).
|
||||
|
||||
The agent dials `http://cred-proxy:<port><Path>...`; the sidecar
|
||||
strips any inbound `Authorization` header, injects
|
||||
`<AuthScheme> <token>` using the value of the host env var named
|
||||
by `TokenRef`, and forwards the rest of the request to `Upstream`.
|
||||
|
||||
`Path` is the agent-facing prefix (must start and end with `/`).
|
||||
`Upstream` is the upstream base URL (https only) — the request
|
||||
path after `Path` is appended to it. `AuthScheme` is the literal
|
||||
word that precedes the token in the injected header (`Bearer` for
|
||||
most upstreams, `token` for Gitea — sidesteps go-gitea/gitea#16734).
|
||||
`TokenRef` names the host env var holding the credential value;
|
||||
the CLI reads it at launch and forwards into the sidecar's environ.
|
||||
`Role` carries optional provisioner tags (see CRED_PROXY_ROLES).
|
||||
|
||||
`UpstreamHost` is parsed from `Upstream` for the pipelock allowlist
|
||||
+ the git-insteadof suppression check."""
|
||||
|
||||
Path: str
|
||||
Upstream: str
|
||||
AuthScheme: str
|
||||
TokenRef: str
|
||||
Role: tuple[str, ...] = ()
|
||||
UpstreamHost: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "CredProxyRoute":
|
||||
label = f"bottle '{bottle_name}' cred_proxy.routes[{idx}]"
|
||||
d = _as_json_object(raw, label)
|
||||
path = d.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
die(f"{label} missing required string field 'path'")
|
||||
if not (path.startswith("/") and path.endswith("/")):
|
||||
die(f"{label} path {path!r} must start and end with '/'")
|
||||
upstream = d.get("upstream")
|
||||
if not isinstance(upstream, str) or not upstream:
|
||||
die(f"{label} missing required string field 'upstream'")
|
||||
host = _parse_https_host(upstream, f"{label} upstream")
|
||||
auth_scheme = d.get("auth_scheme")
|
||||
if not isinstance(auth_scheme, str) or not auth_scheme:
|
||||
die(f"{label} missing required string field 'auth_scheme'")
|
||||
if auth_scheme not in CRED_PROXY_AUTH_SCHEMES:
|
||||
die(
|
||||
f"{label} auth_scheme {auth_scheme!r} is not one of "
|
||||
f"{', '.join(CRED_PROXY_AUTH_SCHEMES)}"
|
||||
)
|
||||
token_ref = d.get("token_ref")
|
||||
if not isinstance(token_ref, str) or not token_ref:
|
||||
die(
|
||||
f"{label} missing required string field 'token_ref' "
|
||||
f"(name of the host env var holding the token value)"
|
||||
)
|
||||
role_raw = d.get("role")
|
||||
roles: tuple[str, ...] = ()
|
||||
if role_raw is None:
|
||||
roles = ()
|
||||
elif isinstance(role_raw, str):
|
||||
roles = (role_raw,)
|
||||
elif isinstance(role_raw, list):
|
||||
role_list = cast(list[object], role_raw)
|
||||
collected: list[str] = []
|
||||
for r in role_list:
|
||||
if not isinstance(r, str):
|
||||
die(f"{label} role items must be strings (got {type(r).__name__})")
|
||||
collected.append(r)
|
||||
roles = tuple(collected)
|
||||
else:
|
||||
die(
|
||||
f"{label} role must be a string or a list of strings "
|
||||
f"(was {type(role_raw).__name__})"
|
||||
)
|
||||
for r in roles:
|
||||
if r not in CRED_PROXY_ROLES:
|
||||
die(
|
||||
f"{label} role {r!r} is not one of "
|
||||
f"{', '.join(sorted(CRED_PROXY_ROLES))}"
|
||||
)
|
||||
return cls(
|
||||
Path=path,
|
||||
Upstream=upstream,
|
||||
AuthScheme=auth_scheme,
|
||||
TokenRef=token_ref,
|
||||
Role=roles,
|
||||
UpstreamHost=host,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredProxyConfig:
|
||||
"""Per-bottle cred-proxy configuration. Today this is just the
|
||||
route table; the nesting under `cred_proxy:` leaves room for
|
||||
per-bottle proxy settings (port override, log level, etc.) in
|
||||
follow-ups."""
|
||||
|
||||
routes: tuple[CredProxyRoute, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "CredProxyConfig":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' cred_proxy")
|
||||
routes_raw = d.get("routes")
|
||||
routes: tuple[CredProxyRoute, ...] = ()
|
||||
if routes_raw is not None:
|
||||
if not isinstance(routes_raw, list):
|
||||
die(
|
||||
f"bottle '{bottle_name}' cred_proxy.routes must be an array "
|
||||
f"(was {type(routes_raw).__name__})"
|
||||
)
|
||||
routes_list = cast(list[object], routes_raw)
|
||||
routes = tuple(
|
||||
CredProxyRoute.from_dict(bottle_name, i, entry)
|
||||
for i, entry in enumerate(routes_list)
|
||||
)
|
||||
_validate_cred_proxy_routes(bottle_name, routes)
|
||||
return cls(routes=routes)
|
||||
|
||||
|
||||
# Auth schemes for the egress-proxy route's optional `auth` block.
|
||||
# Same values cred-proxy accepts today; `token` sidesteps the Gitea
|
||||
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
||||
@@ -480,15 +331,15 @@ class BottleEgress:
|
||||
class Bottle:
|
||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
|
||||
egress_proxy: EgressProxyConfig = field(default_factory=EgressProxyConfig)
|
||||
egress: BottleEgress = field(default_factory=BottleEgress)
|
||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
||||
# the launch step brings up a supervise sidecar that exposes three
|
||||
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
||||
# capability-block) plus mounts the current-config dir read-only
|
||||
# into the agent at /etc/claude-bottle/current-config. False (the
|
||||
# default) skips the sidecar and the mount.
|
||||
# capability-block; the cred-proxy-block tool is renamed and
|
||||
# retargeted at egress-proxy in PRD 0017 chunk 3) plus mounts the
|
||||
# current-config dir read-only into the agent at /etc/claude-bottle/
|
||||
# current-config. False (the default) skips the sidecar and mount.
|
||||
supervise: bool = False
|
||||
|
||||
@classmethod
|
||||
@@ -539,16 +390,25 @@ class Bottle:
|
||||
if "tokens" in d:
|
||||
die(
|
||||
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
|
||||
f"each route now lives under 'cred_proxy.routes' with explicit "
|
||||
f"path / upstream / auth_scheme / token_ref / role[]. See "
|
||||
f"docs/prds/0010-cred-proxy.md."
|
||||
f"each route now lives under 'egress_proxy.routes' with explicit "
|
||||
f"host / path_allowlist / auth. See docs/prds/0017-egress-proxy-via-mitmproxy.md."
|
||||
)
|
||||
|
||||
cred_proxy = (
|
||||
CredProxyConfig.from_dict(name, d["cred_proxy"])
|
||||
if "cred_proxy" in d
|
||||
else CredProxyConfig()
|
||||
)
|
||||
if "cred_proxy" in d:
|
||||
die(
|
||||
f"bottle '{name}' has a 'cred_proxy' field, which has been removed "
|
||||
f"(PRD 0017). Rename to 'egress_proxy' and migrate each route:\n"
|
||||
f" - 'path' + 'upstream' (cred-proxy URL prefix + upstream URL)\n"
|
||||
f" → 'host' (just the upstream hostname)\n"
|
||||
f" - 'auth_scheme' + 'token_ref' (flat)\n"
|
||||
f" → 'auth: {{ scheme, token_ref }}' (nested, optional)\n"
|
||||
f" - 'role' (provisioner dotfile rewrites): drop — egress-proxy "
|
||||
f"is on the agent's HTTP_PROXY path, so dotfile rewrites are no "
|
||||
f"longer needed.\n"
|
||||
f" - 'path_allowlist' (new): optional URL prefix gate for the "
|
||||
f"host.\n"
|
||||
f"See docs/prds/0017-egress-proxy-via-mitmproxy.md."
|
||||
)
|
||||
|
||||
egress_proxy = (
|
||||
EgressProxyConfig.from_dict(name, d["egress_proxy"])
|
||||
@@ -571,8 +431,8 @@ class Bottle:
|
||||
)
|
||||
|
||||
return cls(
|
||||
env=env, git=git, cred_proxy=cred_proxy, egress_proxy=egress_proxy,
|
||||
egress=egress, supervise=supervise_raw,
|
||||
env=env, git=git, egress_proxy=egress_proxy, egress=egress,
|
||||
supervise=supervise_raw,
|
||||
)
|
||||
|
||||
|
||||
@@ -846,60 +706,6 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
||||
return (user, host, port, path)
|
||||
|
||||
|
||||
def _parse_https_host(url: str, label: str) -> str:
|
||||
"""Extract the host from an `https://host[:port][/path]` URL.
|
||||
Dies if `url` is not an https:// URL or the host segment is empty.
|
||||
Used to derive `CredProxyRoute.UpstreamHost` from a route's
|
||||
`upstream` so pipelock's allowlist (and the provisioner's git-gate
|
||||
overlap check) can match on host alone."""
|
||||
if not url.startswith("https://"):
|
||||
die(f"{label} must be an https:// URL (was {url!r})")
|
||||
rest = url[len("https://"):]
|
||||
hostport, _, _ = rest.partition("/")
|
||||
host, _, _port = hostport.partition(":")
|
||||
if not host:
|
||||
die(f"{label} host is empty in {url!r}")
|
||||
return host
|
||||
|
||||
|
||||
def _validate_cred_proxy_routes(
|
||||
bottle_name: str,
|
||||
routes: tuple[CredProxyRoute, ...],
|
||||
) -> None:
|
||||
"""Cross-validation for `bottle.cred_proxy.routes`:
|
||||
|
||||
- Paths must be unique within the bottle (the proxy routes by
|
||||
longest-prefix match; duplicate paths leave the choice
|
||||
undefined).
|
||||
- Singleton roles (`anthropic-base-url`, `npm-registry`) may
|
||||
appear on at most one route — the provisioner uses them to
|
||||
write a single dotfile entry, so two routes claiming the role
|
||||
would make the choice ambiguous.
|
||||
|
||||
No cross-validation against `bottle.git` is performed. git-gate
|
||||
(SSH push/fetch) and cred-proxy (HTTPS REST + git smart-HTTP
|
||||
fetch) broker different protocols; declaring both on the same
|
||||
host is a legitimate dev setup.
|
||||
"""
|
||||
seen_paths: dict[str, None] = {}
|
||||
for r in routes:
|
||||
if r.Path in seen_paths:
|
||||
die(
|
||||
f"bottle '{bottle_name}' cred_proxy.routes has duplicate path "
|
||||
f"{r.Path!r}; each path must be unique on the proxy."
|
||||
)
|
||||
seen_paths[r.Path] = None
|
||||
for role in CRED_PROXY_SINGLETON_ROLES:
|
||||
with_role = [r for r in routes if role in r.Role]
|
||||
if len(with_role) > 1:
|
||||
paths = ", ".join(r.Path for r in with_role)
|
||||
die(
|
||||
f"bottle '{bottle_name}' cred_proxy.routes has {len(with_role)} "
|
||||
f"routes with role {role!r} (paths: {paths}); this role drives a "
|
||||
f"single agent-side rewrite — pick one."
|
||||
)
|
||||
|
||||
|
||||
def _validate_egress_proxy_routes(
|
||||
bottle_name: str,
|
||||
routes: tuple[EgressProxyRoute, ...],
|
||||
@@ -950,7 +756,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
||||
# ghost into an empty config.
|
||||
_BOTTLE_KEYS = frozenset(
|
||||
{"env", "git", "cred_proxy", "egress_proxy", "egress", "supervise"}
|
||||
{"env", "git", "egress_proxy", "egress", "supervise"}
|
||||
)
|
||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
||||
|
||||
Reference in New Issue
Block a user