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
|
# 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)
|
||||||
# When the bottle declares an egress-proxy route for the Anthropic
|
# When the bottle declares an egress-proxy route with the
|
||||||
# OAuth flow, claude-code's outbound Authorization gets stripped +
|
# `claude_code_oauth` role marker, claude-code's outbound
|
||||||
# re-injected by egress-proxy. The agent's environ still needs
|
# Authorization gets stripped + re-injected by egress-proxy. The
|
||||||
# *something* claude-code recognises as a credential or it refuses
|
# agent's environ still needs *something* claude-code recognises
|
||||||
# to start; ship a non-secret placeholder. The placeholder is not
|
# as a credential or it refuses to start; ship a non-secret
|
||||||
# any real `auth.token_ref` value, so leaking it would tell an
|
# placeholder. The placeholder isn't any real token value, so
|
||||||
# attacker only that egress-proxy is in front.
|
# leaking it would tell an attacker only that egress-proxy is in
|
||||||
|
# front. Manifest validation enforces singleton on this role.
|
||||||
has_anthropic_auth = any(
|
has_anthropic_auth = any(
|
||||||
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
|
"claude_code_oauth" in r.roles
|
||||||
for r in egress_proxy_plan.routes
|
for r in egress_proxy_plan.routes
|
||||||
)
|
)
|
||||||
if has_anthropic_auth:
|
if has_anthropic_auth:
|
||||||
|
|||||||
@@ -62,13 +62,18 @@ 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` 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
|
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)
|
||||||
@@ -148,11 +153,13 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,34 @@ 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")
|
||||||
|
|
||||||
|
# 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)
|
@dataclass(frozen=True)
|
||||||
class EgressProxyRoute:
|
class EgressProxyRoute:
|
||||||
@@ -143,6 +171,11 @@ 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` 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`):
|
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.
|
||||||
@@ -150,12 +183,17 @@ 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":
|
||||||
@@ -226,11 +264,37 @@ 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"):
|
if k not in ("host", "path_allowlist", "auth", "role"):
|
||||||
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'"
|
f"'host', 'path_allowlist', 'auth', 'role'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -238,6 +302,7 @@ class EgressProxyRoute:
|
|||||||
PathAllowlist=prefixes,
|
PathAllowlist=prefixes,
|
||||||
AuthScheme=auth_scheme,
|
AuthScheme=auth_scheme,
|
||||||
TokenRef=token_ref,
|
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
|
- 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 (see EGRESS_PROXY_SINGLETON_ROLES) may appear
|
||||||
|
on at most one route per bottle.
|
||||||
|
|
||||||
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
|
||||||
@@ -729,6 +796,15 @@ 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 launch-step side effect — 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:
|
||||||
|
|||||||
@@ -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):
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user