feat(egress-proxy): drive claude-code OAuth placeholder off a role marker
The chunk 2 detection keyed on `token_ref == "CLAUDE_CODE_OAUTH_TOKEN"`,
which broke any bottle whose host env var has a different name (e.g.
`CLAUDE_BOTTLE_OAUTH_TOKEN`). The token_ref is the user's choice —
the placeholder-env trigger shouldn't be locked to one specific
string.
Restoring a minimal `role` marker on `EgressProxyRoute`:
- `EGRESS_PROXY_ROLES = frozenset({"claude_code_oauth"})` — one
marker for now; the field is back so we can grow it.
- `EGRESS_PROXY_SINGLETON_ROLES` — claude_code_oauth is a
singleton (only one route per bottle can carry it).
- `Role: tuple[str, ...]` field on `EgressProxyRoute` (manifest +
runtime), parsed as string or list-of-strings; unknown roles
are rejected so typos can't become silent no-ops.
`prepare.py:has_anthropic_auth` now checks for `"claude_code_oauth"
in r.roles` instead of matching a literal token_ref string. Bottles
can name their host OAuth env var anything; the role marker is what
flips on `CLAUDE_CODE_OAUTH_TOKEN=<placeholder>` and the
telemetry-off env vars on the agent.
Test coverage: 7 new manifest tests (omitted / string / list /
unknown role rejected / non-string rejected / list-item non-string
rejected / singleton enforced).
364 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -175,15 +175,16 @@ 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)
|
||||
# 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.
|
||||
# When the bottle declares an egress-proxy route with the
|
||||
# `claude_code_oauth` role marker, 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 isn't any real token value, so
|
||||
# leaking it would tell an attacker only that egress-proxy is in
|
||||
# front. Manifest validation enforces singleton on this role.
|
||||
has_anthropic_auth = any(
|
||||
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
"claude_code_oauth" in r.roles
|
||||
for r in egress_proxy_plan.routes
|
||||
)
|
||||
if has_anthropic_auth:
|
||||
|
||||
@@ -62,13 +62,18 @@ class EgressProxyRoute:
|
||||
(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
|
||||
under `token_env`. Routes that share a `token_ref` coalesce to
|
||||
one `token_env` slot."""
|
||||
one `token_env` slot.
|
||||
|
||||
`roles` carries the manifest route's optional role markers (see
|
||||
`manifest.EGRESS_PROXY_ROLES`). The launch step reads these for
|
||||
side effects like the claude-code OAuth placeholder env."""
|
||||
|
||||
host: str
|
||||
path_allowlist: tuple[str, ...] = ()
|
||||
auth_scheme: str = ""
|
||||
token_env: str = ""
|
||||
token_ref: str = ""
|
||||
roles: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -148,11 +153,13 @@ def egress_proxy_routes_for_bottle(
|
||||
auth_scheme=r.AuthScheme,
|
||||
token_env=token_env,
|
||||
token_ref=r.TokenRef,
|
||||
roles=r.Role,
|
||||
))
|
||||
else:
|
||||
out.append(EgressProxyRoute(
|
||||
host=r.Host,
|
||||
path_allowlist=r.PathAllowlist,
|
||||
roles=r.Role,
|
||||
))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@@ -129,6 +129,34 @@ class GitEntry:
|
||||
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
||||
EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token")
|
||||
|
||||
# Optional per-route role markers. A role signals "this route plays
|
||||
# a specific named part in the bottle's auth flow"; the launch step
|
||||
# acts on the marker.
|
||||
#
|
||||
# claude_code_oauth: this route auth-injects on the agent's
|
||||
# claude-code OAuth flow. Triggers prepare.py
|
||||
# to ship a placeholder CLAUDE_CODE_OAUTH_TOKEN
|
||||
# to the agent (so claude-code starts) and
|
||||
# disable nonessential-traffic / error-reporting
|
||||
# env vars. Host doesn't matter to the placeholder
|
||||
# logic — declare the role on whichever route
|
||||
# injects the OAuth header.
|
||||
#
|
||||
# Routes without a `role` are pure proxy entries: egress-proxy
|
||||
# enforces path_allowlist + injects auth on its own, but nothing
|
||||
# special happens on the agent side.
|
||||
EGRESS_PROXY_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
})
|
||||
|
||||
# Singleton roles may appear on at most one route per bottle.
|
||||
# claude_code_oauth drives a single placeholder env var; two routes
|
||||
# claiming it would leave "which one is the canonical OAuth route?"
|
||||
# ambiguous for any future role-aware logic.
|
||||
EGRESS_PROXY_SINGLETON_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EgressProxyRoute:
|
||||
@@ -143,6 +171,11 @@ class EgressProxyRoute:
|
||||
manifest's `auth` block is omitted both fields are empty strings —
|
||||
no Authorization is written, no token forwarded.
|
||||
|
||||
`Role` is an optional tuple of named markers (see
|
||||
EGRESS_PROXY_ROLES). The launch step reads these and triggers
|
||||
associated side effects (e.g. the `claude_code_oauth` marker
|
||||
causes prepare.py to set a placeholder OAuth env on the agent).
|
||||
|
||||
Validation rules (enforced in `from_dict`):
|
||||
- `host` required, non-empty.
|
||||
- `path_allowlist` optional, list of absolute path prefixes.
|
||||
@@ -150,12 +183,17 @@ class EgressProxyRoute:
|
||||
`token_ref` as non-empty strings; an empty `auth: {}` is an
|
||||
error rather than a synonym for "no auth" (omit `auth` for
|
||||
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
|
||||
PathAllowlist: tuple[str, ...] = ()
|
||||
AuthScheme: str = ""
|
||||
TokenRef: str = ""
|
||||
Role: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
|
||||
@@ -226,11 +264,37 @@ class EgressProxyRoute:
|
||||
auth_scheme = auth_scheme_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:
|
||||
if k not in ("host", "path_allowlist", "auth"):
|
||||
if k not in ("host", "path_allowlist", "auth", "role"):
|
||||
die(
|
||||
f"{label} has unknown key {k!r}; accepted keys are "
|
||||
f"'host', 'path_allowlist', 'auth'"
|
||||
f"'host', 'path_allowlist', 'auth', 'role'"
|
||||
)
|
||||
|
||||
return cls(
|
||||
@@ -238,6 +302,7 @@ class EgressProxyRoute:
|
||||
PathAllowlist=prefixes,
|
||||
AuthScheme=auth_scheme,
|
||||
TokenRef=token_ref,
|
||||
Role=roles,
|
||||
)
|
||||
|
||||
|
||||
@@ -715,6 +780,8 @@ def _validate_egress_proxy_routes(
|
||||
- Hosts must be unique within the bottle. The proxy matches by
|
||||
exact-host (v1, prefix matching is on path_allowlist only);
|
||||
duplicate hosts leave the route choice ambiguous.
|
||||
- Singleton roles (see EGRESS_PROXY_SINGLETON_ROLES) may appear
|
||||
on at most one route per bottle.
|
||||
|
||||
No cross-validation against `bottle.git` is performed. git-gate
|
||||
(SSH push/fetch) and egress-proxy (HTTPS) broker different
|
||||
@@ -729,6 +796,15 @@ def _validate_egress_proxy_routes(
|
||||
f"{r.Host!r}; each host must be unique on the proxy."
|
||||
)
|
||||
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 launch-step side effect — pick one."
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||
|
||||
@@ -128,6 +128,57 @@ 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": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
self.assertEqual(("claude_code_oauth",),
|
||||
b.egress_proxy.routes[0].Role)
|
||||
|
||||
def test_list_supported(self):
|
||||
b = _bottle([{
|
||||
"host": "api.anthropic.com",
|
||||
"role": ["claude_code_oauth"],
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
self.assertEqual(("claude_code_oauth",),
|
||||
b.egress_proxy.routes[0].Role)
|
||||
|
||||
def test_unknown_role_rejected(self):
|
||||
# The role enum is locked down — typos shouldn't silently
|
||||
# become no-op markers.
|
||||
with self.assertRaises(Die):
|
||||
_bottle([{"host": "x.example", "role": "totally-made-up"}])
|
||||
|
||||
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": ["claude_code_oauth", 42]}])
|
||||
|
||||
def test_singleton_claude_code_oauth_enforced(self):
|
||||
# Two routes both claiming the role would make "which one
|
||||
# drives the placeholder env?" ambiguous.
|
||||
with self.assertRaises(Die):
|
||||
_bottle([
|
||||
{"host": "api.anthropic.com", "role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
|
||||
{"host": "api2.anthropic.example",
|
||||
"role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
||||
])
|
||||
|
||||
|
||||
class TestRouteValidation(unittest.TestCase):
|
||||
def test_duplicate_hosts_rejected(self):
|
||||
# Routes match by exact host; duplicates leave the choice
|
||||
|
||||
Reference in New Issue
Block a user