fa06a3a0ab
Two related fixes on top of PR #29's chunk-2 cutover: 1. Universal HTTPS git-push block in the egress-proxy addon (`is_git_push_request` in egress_proxy_addon_core, called from the mitmproxy request hook before route matching). 403s any `/git-receive-pack` or `info/refs?service=git-receive-pack` — defense in depth so git-gate (PRD 0008) remains the only outbound path for writes, gitleaks-scanned by its pre-receive. Replicates cred-proxy's `is_git_push_request` behavior. 2. Restored agent-side role provisioner. Brings back `Role` on EgressProxyRoute (manifest + runtime) with three roles — `anthropic-base-url`, `npm-registry`, `tea-login`. Singleton constraint on the first two carries over from cred-proxy. `git-insteadof` is intentionally absent (option 1 above handles the push-bypass concern, and the canonical-URL rewrite has no function when egress-proxy is on HTTPS_PROXY). The provisioner (`backend/docker/provision/egress_proxy.py`): - `~/.npmrc` registry= the canonical upstream URL. - `~/.config/tea/config.yml` logins[] entry per tea-login route. - `ANTHROPIC_BASE_URL` env set in prepare.py based on the anthropic-base-url role (was a token_ref="CLAUDE_CODE_OAUTH_TOKEN" check in this PR's earlier draft — the role marker is cleaner and matches the cred-proxy precedent the user wants kept). All three dotfile values point at canonical upstream URLs; the agent's HTTPS_PROXY=egress-proxy routes them through the proxy automatically. Tests: 11 new role-validation tests, 11 new provisioner-render tests, the chunk-1 manifest fixture exercise role=anthropic-base-url. 400 tests pass (was 376). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
"""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])
|