revert(egress-proxy): drop Role + agent provisioner (keep git-push block)
Partial revert offa06a3a. The role + agent-side provisioner felt overengineered: anthropic-base-url + npm-registry's only realistic host values match the tool defaults, so the role tags drove no-op dotfile writes most of the time. If non-default npm registry / tea config is needed in a future bottle, we can ship it through a more direct mechanism then. What stays fromfa06a3a: - Universal HTTPS git-push block in the egress-proxy addon (`is_git_push_request` in egress_proxy_addon_core, called from the request hook before route matching; 403s git-receive-pack regardless of route). This is the security backstop so git-gate remains the only outbound write path; PR #29 keeps it. What gets reverted: - `Role` field on EgressProxyRoute (manifest + runtime). - `EGRESS_PROXY_ROLES` + `EGRESS_PROXY_SINGLETON_ROLES` constants and singleton-role validation. - `backend/docker/provision/egress_proxy.py` (npmrc + tea config). - `provision_egress_proxy` slot in `BottleBackend.provision`. - `prepare.py`'s role-based ANTHROPIC_BASE_URL detection (back to the token_ref="CLAUDE_CODE_OAUTH_TOKEN" auto-detect). - Manifest + provisioner tests for the above. 355 unit + 24 integration tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -219,23 +219,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
argv.
|
||||
|
||||
Default orchestration: ca → prompt → skills → git →
|
||||
egress_proxy → supervise. CA install runs first so the
|
||||
agent's trust store is rebuilt before anything inside the
|
||||
agent makes a TLS call. egress_proxy runs after git because
|
||||
its provisioner may layer on top of `~/.gitconfig` entries
|
||||
provision_git writes. Subclasses typically don't override
|
||||
this; they implement the sub-methods below.
|
||||
supervise. CA install runs first so the agent's trust store
|
||||
is rebuilt before anything inside the agent makes a TLS call.
|
||||
Subclasses typically don't override this; they implement the
|
||||
sub-methods below.
|
||||
|
||||
PRD 0017: most agent-side rewrites (HTTPS routing) are
|
||||
obsolete because egress-proxy sits on the HTTPS_PROXY path.
|
||||
The remaining rewrites — npm registry, tea config,
|
||||
ANTHROPIC_BASE_URL — exist for tools that need an explicit
|
||||
URL config rather than just respecting HTTPS_PROXY."""
|
||||
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||
on the agent's HTTP_PROXY path so every tool that respects
|
||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||
intercepted without per-tool reconfiguration."""
|
||||
self.provision_ca(plan, target)
|
||||
prompt_path = self.provision_prompt(plan, target)
|
||||
self.provision_skills(plan, target)
|
||||
self.provision_git(plan, target)
|
||||
self.provision_egress_proxy(plan, target)
|
||||
self.provision_supervise(plan, target)
|
||||
return prompt_path
|
||||
|
||||
@@ -265,12 +262,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Copy the host's cwd `.git` directory into the running
|
||||
bottle if the user requested --cwd. No-op otherwise."""
|
||||
|
||||
def provision_egress_proxy(self, plan: PlanT, target: str) -> None:
|
||||
"""Drop the agent-side dotfiles driven by `egress_proxy.routes[].role`
|
||||
per PRD 0017 (~/.npmrc, ~/.config/tea/config.yml). Default
|
||||
impl is a no-op for backends that don't yet support the
|
||||
egress-proxy sidecar; the Docker backend overrides."""
|
||||
|
||||
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
||||
"""Write the in-bottle Claude Code MCP config so the agent
|
||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
||||
|
||||
@@ -27,7 +27,6 @@ from .egress_proxy import DockerEgressProxy
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy
|
||||
from .provision import ca as _ca
|
||||
from .provision import egress_proxy as _egress_proxy_prov
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import skills as _skills
|
||||
@@ -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_egress_proxy(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_egress_proxy_prov.provision_egress_proxy(plan, target)
|
||||
|
||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_supervise_prov.provision_supervise(plan, target)
|
||||
|
||||
|
||||
@@ -175,31 +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) egress-proxy route claiming the
|
||||
# anthropic-base-url role. Manifest validation enforces the
|
||||
# singleton constraint. The role flips on claude-code's
|
||||
# placeholder OAuth token + telemetry-off env vars and pins
|
||||
# ANTHROPIC_BASE_URL at the route's host. Egress-proxy then
|
||||
# strips inbound Authorization on every request and injects
|
||||
# the real one from the route's `auth.token_ref` env var.
|
||||
anthropic_route = next(
|
||||
(r for r in egress_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 canonical Anthropic URL. HTTPS_PROXY
|
||||
# routes the request through egress-proxy, which injects the
|
||||
# real OAuth header from the host env named by the route's
|
||||
# auth.token_ref.
|
||||
forwarded_env["ANTHROPIC_BASE_URL"] = f"https://{anthropic_route.host}"
|
||||
# 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. The agent cannot exfiltrate
|
||||
# this string because it carries no meaning to upstream.
|
||||
if has_anthropic_auth:
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
|
||||
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||
# error reporting) that don't route through ANTHROPIC_BASE_URL.
|
||||
# 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)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Egress-proxy agent-side provisioning (PRD 0017).
|
||||
|
||||
Writes the dotfiles / env-var nudges that point tools needing an
|
||||
explicit URL config at the canonical upstream. The agent's
|
||||
`HTTPS_PROXY=egress-proxy` already catches HTTPS traffic — these
|
||||
provisioners exist for tools that:
|
||||
|
||||
- need an explicit base-URL setting beyond the proxy (claude-code
|
||||
via `ANTHROPIC_BASE_URL`), or
|
||||
- read a per-tool config to know which upstream to talk to (npm's
|
||||
`~/.npmrc`, tea's `~/.config/tea/config.yml`).
|
||||
|
||||
The `ANTHROPIC_BASE_URL` env 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.
|
||||
This module handles the rest: ~/.npmrc and ~/.config/tea/config.yml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ....egress_proxy import EgressProxyRoute
|
||||
from ....log import info
|
||||
from .. import util as docker_mod
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_egress_proxy(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Drop the agent-side dotfiles for each declared egress-proxy
|
||||
route role. No-op when the bottle has no roles to provision."""
|
||||
routes = plan.egress_proxy_plan.routes
|
||||
if not routes:
|
||||
return
|
||||
_provision_npmrc(plan, target, routes)
|
||||
_provision_tea_config(plan, target, routes)
|
||||
|
||||
|
||||
# --- npm --------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_npmrc(routes: tuple[EgressProxyRoute, ...]) -> str:
|
||||
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
|
||||
finds the (single) route that claims it and writes a registry=
|
||||
line pointing at the canonical upstream URL. Empty string when
|
||||
no such route exists, so callers can branch on emptiness.
|
||||
|
||||
Egress-proxy is on the agent's HTTPS_PROXY, so the canonical
|
||||
URL routes through the proxy automatically and gets DLP +
|
||||
path_allowlist + auth-injection. The npmrc deliberately carries
|
||||
no `_authToken` — the proxy strips inbound Authorization and
|
||||
injects the upstream one from the bottle's egress-proxy auth
|
||||
config. 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=https://{r.host}/\n"
|
||||
return ""
|
||||
|
||||
|
||||
def _provision_npmrc(
|
||||
plan: DockerBottlePlan,
|
||||
target: str,
|
||||
routes: tuple[EgressProxyRoute, ...],
|
||||
) -> 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} (egress-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])
|
||||
|
||||
|
||||
# --- tea --------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_tea_config(routes: tuple[EgressProxyRoute, ...]) -> str:
|
||||
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
|
||||
role: each route that claims it produces one `logins:` entry
|
||||
pointing at the canonical Gitea URL. The egress-proxy injects
|
||||
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:
|
||||
lines.extend([
|
||||
f"- name: {r.host}",
|
||||
f" url: https://{r.host}",
|
||||
" token: egress-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[EgressProxyRoute, ...],
|
||||
) -> 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])
|
||||
Reference in New Issue
Block a user