From f04fbb68a937c55dd00bbb6700adacd1b30cbaf2 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 15:28:11 -0400 Subject: [PATCH] feat(egress-proxy): drive claude-code OAuth placeholder off a role marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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 --- claude_bottle/backend/docker/prepare.py | 17 ++--- claude_bottle/egress_proxy.py | 9 ++- claude_bottle/manifest.py | 80 +++++++++++++++++++++++- tests/unit/test_manifest_egress_proxy.py | 51 +++++++++++++++ 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 52cd8d6..e8c6b3b 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -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: diff --git a/claude_bottle/egress_proxy.py b/claude_bottle/egress_proxy.py index fe4b849..864827f 100644 --- a/claude_bottle/egress_proxy.py +++ b/claude_bottle/egress_proxy.py @@ -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) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index dfea962..794e1b7 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -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: diff --git a/tests/unit/test_manifest_egress_proxy.py b/tests/unit/test_manifest_egress_proxy.py index 4ffa998..2e83cea 100644 --- a/tests/unit/test_manifest_egress_proxy.py +++ b/tests/unit/test_manifest_egress_proxy.py @@ -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