revert(egress-proxy): drop Role + agent provisioner (keep git-push block)
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m3s

Partial revert of fa06a3a. 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 from fa06a3a:
  - 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:
2026-05-25 15:02:01 -04:00
parent fa06a3a0ab
commit 4abea282e0
9 changed files with 24 additions and 453 deletions
+9 -18
View File
@@ -219,23 +219,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
argv. argv.
Default orchestration: ca → prompt → skills → git → Default orchestration: ca → prompt → skills → git →
egress_proxy → supervise. CA install runs first so the supervise. CA install runs first so the agent's trust store
agent's trust store is rebuilt before anything inside the is rebuilt before anything inside the agent makes a TLS call.
agent makes a TLS call. egress_proxy runs after git because Subclasses typically don't override this; they implement the
its provisioner may layer on top of `~/.gitconfig` entries sub-methods below.
provision_git writes. Subclasses typically don't override
this; they implement the sub-methods below.
PRD 0017: most agent-side rewrites (HTTPS routing) are PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
obsolete because egress-proxy sits on the HTTPS_PROXY path. ~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
The remaining rewrites — npm registry, tea config, on the agent's HTTP_PROXY path so every tool that respects
ANTHROPIC_BASE_URL — exist for tools that need an explicit HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
URL config rather than just respecting HTTPS_PROXY.""" intercepted without per-tool reconfiguration."""
self.provision_ca(plan, target) self.provision_ca(plan, target)
prompt_path = self.provision_prompt(plan, target) prompt_path = self.provision_prompt(plan, target)
self.provision_skills(plan, target) self.provision_skills(plan, target)
self.provision_git(plan, target) self.provision_git(plan, target)
self.provision_egress_proxy(plan, target)
self.provision_supervise(plan, target) self.provision_supervise(plan, target)
return prompt_path return prompt_path
@@ -265,12 +262,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
bottle if the user requested --cwd. No-op otherwise.""" 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: def provision_supervise(self, plan: PlanT, target: str) -> None:
"""Write the in-bottle Claude Code MCP config so the agent """Write the in-bottle Claude Code MCP config so the agent
discovers the per-bottle supervise sidecar (PRD 0013). discovers the per-bottle supervise sidecar (PRD 0013).
-4
View File
@@ -27,7 +27,6 @@ from .egress_proxy import DockerEgressProxy
from .git_gate import DockerGitGate from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy from .pipelock import DockerPipelockProxy
from .provision import ca as _ca from .provision import ca as _ca
from .provision import egress_proxy as _egress_proxy_prov
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import skills as _skills from .provision import skills as _skills
@@ -81,9 +80,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_git(self, plan: DockerBottlePlan, target: str) -> None: def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
_git.provision_git(plan, target) _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: def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
_supervise_prov.provision_supervise(plan, target) _supervise_prov.provision_supervise(plan, target)
+12 -22
View File
@@ -175,31 +175,21 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing # never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ. # mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded) forwarded_env: dict[str, str] = dict(resolved.forwarded)
# Find the (at most one) egress-proxy route claiming the # When the bottle declares an egress-proxy route for the Anthropic
# anthropic-base-url role. Manifest validation enforces the # OAuth flow, claude-code's outbound Authorization gets stripped +
# singleton constraint. The role flips on claude-code's # re-injected by egress-proxy. The agent's environ still needs
# placeholder OAuth token + telemetry-off env vars and pins # *something* claude-code recognises as a credential or it refuses
# ANTHROPIC_BASE_URL at the route's host. Egress-proxy then # to start; ship a non-secret placeholder. The placeholder is not
# strips inbound Authorization on every request and injects # any real `auth.token_ref` value, so leaking it would tell an
# the real one from the route's `auth.token_ref` env var. # attacker only that egress-proxy is in front.
anthropic_route = next( has_anthropic_auth = any(
(r for r in egress_proxy_plan.routes if "anthropic-base-url" in r.roles), r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
None, for r in egress_proxy_plan.routes
) )
if anthropic_route is not None: if has_anthropic_auth:
# 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.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder" forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
# Belt-and-braces: turn off telemetry endpoints (statsig, # 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("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
_write_env_file(resolved, env_file) _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])
+1 -9
View File
@@ -62,19 +62,13 @@ class EgressProxyRoute:
(e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var (e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var
the CLI reads at launch and forwards into the container's environ the CLI reads at launch and forwards into the container's environ
under `token_env`. Routes that share a `token_ref` coalesce to under `token_env`. Routes that share a `token_ref` coalesce to
one `token_env` slot. one `token_env` slot."""
`roles` are the provisioner tags from the manifest route (see
`manifest.EGRESS_PROXY_ROLES`). Each tag drives one agent-side
dotfile / env rewrite at bottle bring-up. The addon never reads
these — they flow through the plan to the host-side provisioner."""
host: str host: str
path_allowlist: tuple[str, ...] = () path_allowlist: tuple[str, ...] = ()
auth_scheme: str = "" auth_scheme: str = ""
token_env: str = "" token_env: str = ""
token_ref: str = "" token_ref: str = ""
roles: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -154,13 +148,11 @@ def egress_proxy_routes_for_bottle(
auth_scheme=r.AuthScheme, auth_scheme=r.AuthScheme,
token_env=token_env, token_env=token_env,
token_ref=r.TokenRef, token_ref=r.TokenRef,
roles=r.Role,
)) ))
else: else:
out.append(EgressProxyRoute( out.append(EgressProxyRoute(
host=r.Host, host=r.Host,
path_allowlist=r.PathAllowlist, path_allowlist=r.PathAllowlist,
roles=r.Role,
)) ))
return tuple(out) return tuple(out)
+2 -89
View File
@@ -129,44 +129,6 @@ class GitEntry:
# token-not-Bearer quirk (go-gitea/gitea#16734). # token-not-Bearer quirk (go-gitea/gitea#16734).
EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token") EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token")
# Agent-side provisioner role tags a route may carry. Each tag drives
# one dotfile / env rewrite at bottle bring-up so tools that need an
# explicit URL config (rather than just respecting HTTPS_PROXY) point
# at the canonical upstream. Egress-proxy is on the agent's HTTP_PROXY
# path, so the canonical URL routes through the proxy automatically —
# the dotfile values are upstream URLs, not proxy URLs.
#
# anthropic-base-url: set ANTHROPIC_BASE_URL=https://<host> in the
# agent's environ (signals claude-code to use
# a non-default Anthropic endpoint; in practice
# the host is api.anthropic.com, so the value
# matches claude-code's default — the marker
# is what drives the placeholder-token +
# telemetry-off env vars).
# npm-registry: write ~/.npmrc `registry=https://<host>/`.
# tea-login: add an entry to ~/.config/tea/config.yml
# (url = https://<host>) so `tea` knows which
# Gitea host to talk to.
#
# Routes without a `role` are pure proxy entries: egress-proxy
# enforces path_allowlist + injects auth, but no agent-side dotfile
# is written. (`git-insteadof` is intentionally absent — egress-proxy
# already 403s HTTPS git push universally; PRD 0017's git story is
# `bottle.git` + git-gate for SSH push.)
EGRESS_PROXY_ROLES = frozenset({
"anthropic-base-url",
"npm-registry",
"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 host goes into ANTHROPIC_BASE_URL?).
EGRESS_PROXY_SINGLETON_ROLES = frozenset({
"anthropic-base-url",
"npm-registry",
})
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressProxyRoute: class EgressProxyRoute:
@@ -181,10 +143,6 @@ class EgressProxyRoute:
manifest's `auth` block is omitted both fields are empty strings — manifest's `auth` block is omitted both fields are empty strings —
no Authorization is written, no token forwarded. no Authorization is written, no token forwarded.
`Role` carries optional provisioner tags (see EGRESS_PROXY_ROLES).
Each tag drives one agent-side dotfile / env rewrite when the
sidecar comes up.
Validation rules (enforced in `from_dict`): Validation rules (enforced in `from_dict`):
- `host` required, non-empty. - `host` required, non-empty.
- `path_allowlist` optional, list of absolute path prefixes. - `path_allowlist` optional, list of absolute path prefixes.
@@ -192,17 +150,12 @@ class EgressProxyRoute:
`token_ref` as non-empty strings; an empty `auth: {}` is an `token_ref` as non-empty strings; an empty `auth: {}` is an
error rather than a synonym for "no auth" (omit `auth` for error rather than a synonym for "no auth" (omit `auth` for
that case). that case).
- `role` optional. String or list of strings drawn from
EGRESS_PROXY_ROLES. Singleton roles (see
EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one
route per bottle.
""" """
Host: str Host: str
PathAllowlist: tuple[str, ...] = () PathAllowlist: tuple[str, ...] = ()
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = ()
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
@@ -273,37 +226,11 @@ class EgressProxyRoute:
auth_scheme = auth_scheme_raw auth_scheme = auth_scheme_raw
token_ref = token_ref_raw token_ref = token_ref_raw
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_roles: 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_roles.append(r)
roles = tuple(collected_roles)
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 EGRESS_PROXY_ROLES:
die(
f"{label} role {r!r} is not one of "
f"{', '.join(sorted(EGRESS_PROXY_ROLES))}"
)
for k in d: for k in d:
if k not in ("host", "path_allowlist", "auth", "role"): if k not in ("host", "path_allowlist", "auth"):
die( die(
f"{label} has unknown key {k!r}; accepted keys are " f"{label} has unknown key {k!r}; accepted keys are "
f"'host', 'path_allowlist', 'auth', 'role'" f"'host', 'path_allowlist', 'auth'"
) )
return cls( return cls(
@@ -311,7 +238,6 @@ class EgressProxyRoute:
PathAllowlist=prefixes, PathAllowlist=prefixes,
AuthScheme=auth_scheme, AuthScheme=auth_scheme,
TokenRef=token_ref, TokenRef=token_ref,
Role=roles,
) )
@@ -789,10 +715,6 @@ def _validate_egress_proxy_routes(
- Hosts must be unique within the bottle. The proxy matches by - Hosts must be unique within the bottle. The proxy matches by
exact-host (v1, prefix matching is on path_allowlist only); exact-host (v1, prefix matching is on path_allowlist only);
duplicate hosts leave the route choice ambiguous. duplicate hosts leave the route choice ambiguous.
- Singleton roles (`anthropic-base-url`, `npm-registry`) may
appear on at most one route — each drives a single agent-side
dotfile/env entry, so two routes claiming the role would make
the choice ambiguous.
No cross-validation against `bottle.git` is performed. git-gate No cross-validation against `bottle.git` is performed. git-gate
(SSH push/fetch) and egress-proxy (HTTPS) broker different (SSH push/fetch) and egress-proxy (HTTPS) broker different
@@ -807,15 +729,6 @@ def _validate_egress_proxy_routes(
f"{r.Host!r}; each host must be unique on the proxy." f"{r.Host!r}; each host must be unique on the proxy."
) )
seen_hosts[key] = None seen_hosts[key] = None
for role in EGRESS_PROXY_SINGLETON_ROLES:
with_role = [r for r in routes if role in r.Role]
if len(with_role) > 1:
hosts = ", ".join(r.Host for r in with_role)
die(
f"bottle '{bottle_name}' egress_proxy.routes has {len(with_role)} "
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single agent-side rewrite — pick one."
)
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
-58
View File
@@ -128,64 +128,6 @@ class TestAuth(unittest.TestCase):
}]) }])
class TestRole(unittest.TestCase):
def test_omitted_means_no_roles(self):
b = _bottle([{"host": "x.example"}])
self.assertEqual((), b.egress_proxy.routes[0].Role)
def test_string_normalizes_to_tuple(self):
b = _bottle([{"host": "api.anthropic.com",
"role": "anthropic-base-url",
"auth": {"scheme": "Bearer", "token_ref": "T"}}])
self.assertEqual(("anthropic-base-url",),
b.egress_proxy.routes[0].Role)
def test_list_supported(self):
b = _bottle([{"host": "registry.npmjs.org",
"role": ["npm-registry"]}])
self.assertEqual(("npm-registry",), b.egress_proxy.routes[0].Role)
def test_unknown_role_rejected(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "role": "git-insteadof"}])
def test_non_string_role_rejected(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "role": 42}])
def test_list_with_non_string_item_rejected(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "role": ["npm-registry", 42]}])
def test_singleton_anthropic_base_url_enforced(self):
with self.assertRaises(Die):
_bottle([
{"host": "api.anthropic.com", "role": "anthropic-base-url",
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "api2.anthropic.example",
"role": "anthropic-base-url",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
def test_singleton_npm_registry_enforced(self):
with self.assertRaises(Die):
_bottle([
{"host": "registry.npmjs.org", "role": "npm-registry"},
{"host": "npm.example", "role": "npm-registry"},
])
def test_tea_login_is_not_singleton(self):
# Multiple Gitea instances on one bottle is a legitimate
# dev setup; tea-login lays one logins[] entry per route.
b = _bottle([
{"host": "gitea.example", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "T1"}},
{"host": "other-gitea.example", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "T2"}},
])
self.assertEqual(2, len(b.egress_proxy.routes))
class TestRouteValidation(unittest.TestCase): class TestRouteValidation(unittest.TestCase):
def test_duplicate_hosts_rejected(self): def test_duplicate_hosts_rejected(self):
# Routes match by exact host; duplicates leave the choice # Routes match by exact host; duplicates leave the choice
-2
View File
@@ -25,7 +25,6 @@ _BOTTLE_DEV = """
egress_proxy: egress_proxy:
routes: routes:
- host: api.anthropic.com - host: api.anthropic.com
role: anthropic-base-url
auth: auth:
scheme: Bearer scheme: Bearer
token_ref: CLAUDE_CODE_OAUTH_TOKEN token_ref: CLAUDE_CODE_OAUTH_TOKEN
@@ -93,7 +92,6 @@ class TestBottleFileParses(_ResolveCase):
self.assertEqual("api.anthropic.com", routes[0].Host) self.assertEqual("api.anthropic.com", routes[0].Host)
self.assertEqual("Bearer", routes[0].AuthScheme) self.assertEqual("Bearer", routes[0].AuthScheme)
self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", routes[0].TokenRef) self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", routes[0].TokenRef)
self.assertEqual(("anthropic-base-url",), routes[0].Role)
self.assertEqual(["example.com"], list(m.bottles["dev"].egress.allowlist)) self.assertEqual(["example.com"], list(m.bottles["dev"].egress.allowlist))
-109
View File
@@ -1,109 +0,0 @@
"""Unit: agent-side provisioning for egress-proxy roles (PRD 0017).
Each role drives one dotfile / env-var rewrite at bottle bring-up.
HTTPS_PROXY routes the canonical URL through egress-proxy, which
injects auth and DLP-scans on the upstream leg."""
import unittest
from claude_bottle.backend.docker.provision.egress_proxy import (
render_npmrc,
render_tea_config,
)
from claude_bottle.egress_proxy import egress_proxy_routes_for_bottle
from claude_bottle.manifest import Manifest
def _routes(manifest_routes):
m = Manifest.from_json_obj({
"bottles": {"dev": {"egress_proxy": {"routes": manifest_routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
return egress_proxy_routes_for_bottle(m.bottles["dev"])
# --- npmrc -----------------------------------------------------------
class TestRenderNpmrc(unittest.TestCase):
def test_canonical_upstream_url(self):
routes = _routes([
{"host": "registry.npmjs.org", "role": "npm-registry",
"auth": {"scheme": "Bearer", "token_ref": "NPM_TOKEN"}},
])
self.assertEqual(
"registry=https://registry.npmjs.org/\n",
render_npmrc(routes),
)
def test_empty_when_no_npm_role(self):
routes = _routes([
{"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"}},
])
self.assertEqual("", render_npmrc(routes))
def test_no_routes_empty(self):
self.assertEqual("", render_npmrc(()))
def test_no_auth_token_in_npmrc(self):
# The proxy injects auth; the npmrc must carry no secret —
# not even `:always-auth=true` lines that would prompt npm
# to wait for credentials. Just the registry URL.
routes = _routes([
{"host": "registry.npmjs.org", "role": "npm-registry",
"auth": {"scheme": "Bearer", "token_ref": "NPM_TOKEN"}},
])
out = render_npmrc(routes)
self.assertNotIn("_authToken", out)
self.assertNotIn("NPM_TOKEN", out)
# --- tea config ------------------------------------------------------
class TestRenderTeaConfig(unittest.TestCase):
def test_single_login(self):
routes = _routes([
{"host": "gitea.dideric.is", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "GITEA_TOKEN"}},
])
out = render_tea_config(routes)
self.assertIn("- name: gitea.dideric.is", out)
self.assertIn("url: https://gitea.dideric.is", out)
self.assertIn("token: egress-proxy-placeholder", out)
def test_multiple_logins_each_get_own_entry(self):
routes = _routes([
{"host": "gitea.a.example", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "T_A"}},
{"host": "gitea.b.example", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "T_B"}},
])
out = render_tea_config(routes)
self.assertIn("- name: gitea.a.example", out)
self.assertIn("- name: gitea.b.example", out)
def test_empty_when_no_tea_role(self):
routes = _routes([
{"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"}},
])
self.assertEqual("", render_tea_config(routes))
def test_no_routes_empty(self):
self.assertEqual("", render_tea_config(()))
def test_no_real_token_in_config(self):
routes = _routes([
{"host": "gitea.dideric.is", "role": "tea-login",
"auth": {"scheme": "token", "token_ref": "GITEA_TOKEN"}},
])
out = render_tea_config(routes)
# GITEA_TOKEN is just the env var name, not the value —
# placeholder-only is the SC.
self.assertIn("egress-proxy-placeholder", out)
if __name__ == "__main__":
unittest.main()