From b79b49090f39a5b990b5a6feadf768d60d4de599 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 23:27:27 +0000 Subject: [PATCH 1/9] refactor: provision egress routes via AgentProvisionPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove provider-specific branching from egress.py and pipelock.py. Previously, `egress_routes_for_bottle` and `pipelock_effective_tls_passthrough` both contained `template == "codex"` checks — the same pattern the rest of the PR moved out of the backends. Root cause: `EgressRoute` had no `tls_passthrough` field, so pipelock couldn't learn from the synthesised Codex routes that they needed passthrough. Fix: - Add `EgressRoute.tls_passthrough: bool`. `egress_manifest_routes` lifts the existing `pipelock.tls_passthrough` manifest flag here; provider routes set it directly. - Add `AgentProvisionPlan.egress_routes`. `agent_provision_plan` populates it for Codex + `forward_host_credentials`, including `tls_passthrough=True`. - Replace Codex-specific `egress_routes_for_bottle` logic with a generic `_merge_provider_route` helper. Backends call `egress_routes_for_bottle(bottle, plan.egress_routes)`; no provider type checks inside egress or pipelock. - Rewrite `pipelock_effective_tls_passthrough` to read `route.tls_passthrough` from the merged route set instead of re-implementing the provider check. - Both backends now call `agent_provision_plan` before `Egress.prepare` and `PipelockProxy.prepare`, threading `plan.egress_routes` to both. `has_provider_auth` is derived from `egress_manifest_routes` (manifest routes only — provider routes carry no auth roles, so the result is identical). Assisted-by: Claude Code --- bot_bottle/agent_provider.py | 21 +++ bot_bottle/backend/docker/prepare.py | 87 +++++++------ bot_bottle/backend/smolmachines/prepare.py | 47 ++++--- bot_bottle/egress.py | 124 ++++++++++-------- bot_bottle/pipelock.py | 70 +++++----- tests/unit/test_agent_provider.py | 48 ++++++- tests/unit/test_egress.py | 141 +++++++++++++-------- tests/unit/test_pipelock_allowlist.py | 26 ++-- 8 files changed, 350 insertions(+), 214 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index c7b28c4..dbd4a59 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -13,11 +13,17 @@ from pathlib import Path from typing import Literal from .codex_auth import write_codex_dummy_auth_file +from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute PROVIDER_CLAUDE = "claude" PROVIDER_CODEX = "codex" PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX}) + +# Hosts that egress injects the host ChatGPT bearer on when Codex +# forward_host_credentials is enabled. Pipelock must pass these through +# (no TLS MITM) or its header DLP blocks the injected JWT. +CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") PromptMode = Literal["append_file", "read_prompt_file"] @@ -63,6 +69,11 @@ class AgentProvisionPlan: Backends interpret this plan with their own copy/exec primitives. Provider-specific content stays here so future provider plugins can return the same shape without adding backend-plan fields. + + `egress_routes` are provider-declared EgressRoutes that backends + pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps + provider logic out of the egress and pipelock modules — they merge + provider routes generically without knowing the provider type. """ template: str @@ -76,6 +87,7 @@ class AgentProvisionPlan: files: tuple[AgentProvisionFile, ...] = () pre_copy: tuple[AgentProvisionCommand, ...] = () verify: tuple[AgentProvisionCommand, ...] = () + egress_routes: tuple[EgressRoute, ...] = () _REPO_ROOT = Path(__file__).resolve().parent.parent @@ -131,6 +143,7 @@ def agent_provision_plan( files: list[AgentProvisionFile] = [] pre_copy: list[AgentProvisionCommand] = [] verify: list[AgentProvisionCommand] = [] + egress_routes: list[EgressRoute] = [] if template == PROVIDER_CODEX: env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt" @@ -148,6 +161,13 @@ def agent_provision_plan( files.append(AgentProvisionFile(config_file, config_path)) if forward_host_credentials: + for host in CODEX_HOST_CREDENTIAL_HOSTS: + egress_routes.append(EgressRoute( + host=host, + auth_scheme="Bearer", + token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, + tls_passthrough=True, + )) auth_file = state_dir / "codex-auth.json" write_codex_dummy_auth_file(auth_file, host_env or dict(os.environ)) files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json")) @@ -188,6 +208,7 @@ def agent_provision_plan( files=tuple(files), pre_copy=tuple(pre_copy), verify=tuple(verify), + egress_routes=tuple(egress_routes), ) diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index c6a92fa..201f699 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -16,7 +16,7 @@ from dataclasses import replace from pathlib import Path from ...agent_provider import agent_provision_plan, runtime_for -from ...egress import Egress +from ...egress import Egress, egress_manifest_routes from ...env import ResolvedEnv, resolve_env from ...git_gate import GitGate from ...log import die @@ -159,17 +159,57 @@ def resolve_plan( prompt_file.write_text("") prompt_file.chmod(0o600) - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = proxy.prepare(bottle, slug, pipelock_dir) - git_gate_dir = git_gate_state_dir(slug) git_gate_dir.mkdir(parents=True, exist_ok=True) git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) + resolved = resolve_env(manifest, spec.agent_name) + # Everything that should reach the bottle by-name (so its value + # 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) + # Some provider CLIs refuse to start without *some* credential + # env var even when egress will strip + re-inject the real + # Authorization header. For those providers, auth_role names the + # route marker that enables a non-secret placeholder env. Codex is + # intentionally absent here: it should use its device/ChatGPT login + # state, and an OPENAI_API_KEY placeholder would force API-key auth. + has_provider_auth = any( + provider_runtime.auth_role + and provider_runtime.auth_role in r.roles + for r in egress_manifest_routes(bottle) + ) + if has_provider_auth and provider_runtime.placeholder_env: + forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder" + _write_env_file(resolved, env_file) + prompt_file.write_text(agent.prompt) + + use_runsc = docker_mod.runsc_available() + agent_provision = agent_provision_plan( + template=provider.template, + dockerfile=dockerfile_path, + state_dir=agent_dir, + guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), + forward_host_credentials=provider.forward_host_credentials, + has_provider_auth=has_provider_auth, + host_env=dict(os.environ), + ) + guest_env = dict(agent_provision.guest_env) + for key, val in agent_provision.env_vars.items(): + guest_env.setdefault(key, val) + agent_provision = replace(agent_provision, guest_env=guest_env) + + pipelock_dir = pipelock_state_dir(slug) + pipelock_dir.mkdir(parents=True, exist_ok=True) + proxy_plan = proxy.prepare( + bottle, slug, pipelock_dir, agent_provision.egress_routes, + ) + egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = egress.prepare(bottle, slug, egress_dir) + egress_plan = egress.prepare( + bottle, slug, egress_dir, agent_provision.egress_routes, + ) supervise_plan = None if bottle.supervise: @@ -197,41 +237,6 @@ def resolve_plan( slug, supervise_dir, dockerfile_content=dockerfile_content, ) - resolved = resolve_env(manifest, spec.agent_name) - # Everything that should reach the bottle by-name (so its value - # 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) - # Some provider CLIs refuse to start without *some* credential - # env var even when egress will strip + re-inject the real - # Authorization header. For those providers, auth_role names the - # route marker that enables a non-secret placeholder env. Codex is - # intentionally absent here: it should use its device/ChatGPT login - # state, and an OPENAI_API_KEY placeholder would force API-key auth. - has_provider_auth = any( - provider_runtime.auth_role - and provider_runtime.auth_role in r.roles - for r in egress_plan.routes - ) - if has_provider_auth and provider_runtime.placeholder_env: - forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder" - _write_env_file(resolved, env_file) - prompt_file.write_text(agent.prompt) - - use_runsc = docker_mod.runsc_available() - agent_provision = agent_provision_plan( - template=provider.template, - dockerfile=dockerfile_path, - state_dir=agent_dir, - guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), - forward_host_credentials=provider.forward_host_credentials, - has_provider_auth=has_provider_auth, - host_env=dict(os.environ), - ) - guest_env = dict(agent_provision.guest_env) - for key, val in agent_provision.env_vars.items(): - guest_env.setdefault(key, val) - agent_provision = replace(agent_provision, guest_env=guest_env) return DockerBottlePlan( spec=spec, diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index d0a09be..447a927 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -16,6 +16,7 @@ from dataclasses import replace from pathlib import Path from ...agent_provider import agent_provision_plan, runtime_for +from ...egress import egress_manifest_routes from ...backend import BottleSpec from ...backend.docker.bottle_state import ( BottleMetadata, @@ -95,24 +96,10 @@ def resolve_plan( "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", } - # Inner Plans for the four bundle daemons. The ABCs are - # platform-neutral — `.prepare()` writes config files + returns - # a Plan dataclass with no backend-specific assumptions. State - # dirs are still keyed by slug under the docker backend's - # bottle_state layout (shared on-host convention; not a docker - # dependency). - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = PipelockProxy().prepare(bottle, slug, pipelock_dir) - git_gate_dir = git_gate_state_dir(slug) git_gate_dir.mkdir(parents=True, exist_ok=True) git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir) - egress_dir = egress_state_dir(slug) - egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = Egress().prepare(bottle, slug, egress_dir) - # Some provider CLIs refuse to start without *some* credential # env var even when egress will strip + re-inject the real # Authorization header. For those providers, auth_role names the @@ -122,17 +109,11 @@ def resolve_plan( has_provider_auth = any( provider_runtime.auth_role and provider_runtime.auth_role in r.roles - for r in egress_plan.routes + for r in egress_manifest_routes(bottle) ) if has_provider_auth and provider_runtime.placeholder_env: guest_env[provider_runtime.placeholder_env] = "egress-placeholder" - supervise_plan = None - if bottle.supervise: - supervise_dir = supervise_state_dir(slug) - supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = Supervise().prepare(slug, supervise_dir) - # Prompt file is always written (mode 0o600) so the in-VM # path always exists. Content is the agent's `prompt` # field (markdown body) — empty for agents with no prompt. @@ -175,6 +156,30 @@ def resolve_plan( merged_guest_env.setdefault(key, val) agent_provision = replace(agent_provision, guest_env=merged_guest_env) + # Inner Plans for the four bundle daemons. The ABCs are + # platform-neutral — `.prepare()` writes config files + returns + # a Plan dataclass with no backend-specific assumptions. State + # dirs are still keyed by slug under the docker backend's + # bottle_state layout (shared on-host convention; not a docker + # dependency). + pipelock_dir = pipelock_state_dir(slug) + pipelock_dir.mkdir(parents=True, exist_ok=True) + proxy_plan = PipelockProxy().prepare( + bottle, slug, pipelock_dir, agent_provision.egress_routes, + ) + + egress_dir = egress_state_dir(slug) + egress_dir.mkdir(parents=True, exist_ok=True) + egress_plan = Egress().prepare( + bottle, slug, egress_dir, agent_provision.egress_routes, + ) + + supervise_plan = None + if bottle.supervise: + supervise_dir = supervise_state_dir(slug) + supervise_dir.mkdir(parents=True, exist_ok=True) + supervise_plan = Supervise().prepare(slug, supervise_dir) + return SmolmachinesBottlePlan( spec=spec, stage_dir=stage_dir, diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 5b5a794..fb46b84 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -31,7 +31,6 @@ from pathlib import Path from .log import die from .manifest import Bottle -CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" @@ -69,7 +68,13 @@ class EgressRoute: `roles` carries the manifest route's optional role markers (see `manifest.EGRESS_ROLES`). The launch step reads these for - side effects like the claude-code OAuth placeholder env.""" + side effects like the claude-code OAuth placeholder env. + + `tls_passthrough` signals that pipelock must not TLS-MITM this + host — either because the manifest declared `pipelock.tls_passthrough: + true` (lifted in `egress_manifest_routes`) or because a provider + route set it (e.g. egress injects its own Bearer on that host + after the agent boundary and pipelock's header DLP would block it).""" host: str path_allowlist: tuple[str, ...] = () @@ -77,6 +82,7 @@ class EgressRoute: token_env: str = "" token_ref: str = "" roles: tuple[str, ...] = () + tls_passthrough: bool = False @dataclass(frozen=True) @@ -161,84 +167,94 @@ def egress_manifest_routes( token_env=token_env, token_ref=r.TokenRef, roles=r.Role, + tls_passthrough=r.Pipelock.TlsPassthrough, )) else: out.append(EgressRoute( host=r.Host, path_allowlist=r.PathAllowlist, roles=r.Role, + tls_passthrough=r.Pipelock.TlsPassthrough, )) return tuple(out) def egress_routes_for_bottle( bottle: Bottle, + provider_routes: tuple[EgressRoute, ...] = (), ) -> tuple[EgressRoute, ...]: - """Effective egress routes. This is what gets rendered into - routes.yaml + what the addon enforces. + """Effective egress routes for the agent. This is what gets rendered + into routes.yaml and what the addon enforces. - Operators that want to allow a host usually declare it directly in - `bottle.egress.routes` as an authenticated route or bare-pass entry - (`- host: `). Codex host-credential forwarding is the - provider-owned exception: when explicitly enabled, it adds or - upgrades the Codex API hosts to egress-owned authenticated routes. The - legacy `bottle.egress.allowlist` folding is gone — egress is the - single allowlist surface.""" + Merges manifest-declared routes with provider-owned routes. The + manifest is the primary surface; `provider_routes` are synthesised + by `agent_provision_plan` and may add or upgrade manifest entries. + Provider routes that conflict with an existing authenticated manifest + route (different auth scheme or token ref) raise a hard error.""" routes = list(egress_manifest_routes(bottle)) - if not bottle.agent_provider.forward_host_credentials: - return tuple(routes) - - if bottle.agent_provider.template != "codex": - return tuple(routes) - - for host in CODEX_HOST_CREDENTIAL_HOSTS: - routes = _ensure_codex_host_credential_route(routes, host) + for pr in provider_routes: + routes = _merge_provider_route(routes, pr) return tuple(routes) -def _next_token_env(routes: list[EgressRoute]) -> str: +def _find_or_alloc_token_env(routes: list[EgressRoute], token_ref: str) -> str: + """Return the existing token_env slot for `token_ref`, or allocate the next one.""" + for route in routes: + if route.token_ref == token_ref and route.token_env: + return route.token_env return f"EGRESS_TOKEN_{len({r.token_env for r in routes if r.token_env})}" -def _codex_host_credential_token_env(routes: list[EgressRoute]) -> str: - for route in routes: - if route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF: - return route.token_env - return _next_token_env(routes) - - -def _ensure_codex_host_credential_route( - routes: list[EgressRoute], host: str, +def _merge_provider_route( + routes: list[EgressRoute], pr: EgressRoute, ) -> list[EgressRoute]: + """Merge one provider-declared route into the manifest route list. + + Upgrade a bare-pass manifest route to authenticated if the provider + declares auth for that host, or append if the host isn't in the manifest. + Identical auth (same scheme + token_ref) on an existing route is a + no-op, with a tls_passthrough upgrade if the provider route sets it. + Conflicting auth (different scheme or token_ref) dies.""" for idx, route in enumerate(routes): - if route.host.lower() != host: + if route.host.lower() != pr.host.lower(): continue if route.auth_scheme or route.token_ref: - if ( - route.auth_scheme == "Bearer" - and route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF - ): + if route.auth_scheme == pr.auth_scheme and route.token_ref == pr.token_ref: + if pr.tls_passthrough and not route.tls_passthrough: + routes[idx] = EgressRoute( + host=route.host, + path_allowlist=route.path_allowlist, + auth_scheme=route.auth_scheme, + token_env=route.token_env, + token_ref=route.token_ref, + roles=route.roles, + tls_passthrough=True, + ) return routes die( - "codex host credential forwarding conflicts with an " - f"authenticated egress route for {host}. Remove that " - "route auth block or disable agent_provider.forward_host_credentials." + f"provider egress route for {pr.host!r} conflicts with an " + f"authenticated manifest route (different auth scheme or token " + f"ref). Remove the manifest route's auth block or disable the " + f"feature that adds this provider route." ) + token_env = _find_or_alloc_token_env(routes, pr.token_ref) routes[idx] = EgressRoute( host=route.host, path_allowlist=route.path_allowlist, - auth_scheme="Bearer", - token_env=_codex_host_credential_token_env(routes), - token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, + auth_scheme=pr.auth_scheme, + token_env=token_env, + token_ref=pr.token_ref, roles=route.roles, + tls_passthrough=pr.tls_passthrough, ) return routes - + token_env = _find_or_alloc_token_env(routes, pr.token_ref) routes.append(EgressRoute( - host=host, - auth_scheme="Bearer", - token_env=_codex_host_credential_token_env(routes), - token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, + host=pr.host, + auth_scheme=pr.auth_scheme, + token_env=token_env, + token_ref=pr.token_ref, + tls_passthrough=pr.tls_passthrough, )) return routes @@ -338,18 +354,23 @@ class Egress(ABC): sidecar's start/stop lifecycle is backend-specific and lives on concrete subclasses.""" - def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressPlan: - """Lift `bottle.egress.routes` into resolved routes, - render the routes file (mode 600) under `stage_dir`, and + def prepare( + self, + bottle: Bottle, + slug: str, + stage_dir: Path, + provider_routes: tuple[EgressRoute, ...] = (), + ) -> EgressPlan: + """Lift `bottle.egress.routes` + `provider_routes` into resolved + routes, render the routes file (mode 600) under `stage_dir`, and return the plan. Pure host-side, no docker subprocess. The token-env map records the mapping the launch step uses to - forward values from the host's environ into the sidecar's - environ. + forward values from the host's environ into the sidecar's environ. Returned plan is incomplete: the launch step must fill `internal_network` / `egress_network` / `pipelock_proxy_url` via `dataclasses.replace` before passing it to `.start`.""" - routes = egress_routes_for_bottle(bottle) + routes = egress_routes_for_bottle(bottle, provider_routes) routes_path = stage_dir / "egress_routes.yaml" routes_path.write_text(egress_render_routes(routes)) routes_path.chmod(0o600) @@ -361,6 +382,7 @@ class Egress(ABC): ) __all__ = [ + "CODEX_HOST_CREDENTIAL_TOKEN_REF", "EGRESS_HOSTNAME", "EGRESS_ROUTES_IN_CONTAINER", "Egress", diff --git a/bot_bottle/pipelock.py b/bot_bottle/pipelock.py index 57d1481..fcab300 100644 --- a/bot_bottle/pipelock.py +++ b/bot_bottle/pipelock.py @@ -21,11 +21,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from .egress import ( - CODEX_HOST_CREDENTIAL_HOSTS, - EGRESS_HOSTNAME, - egress_routes_for_bottle, -) +from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle from .supervise import SUPERVISE_HOSTNAME from .manifest import Bottle @@ -54,14 +50,17 @@ PIPELOCK_HOSTNAME = "pipelock" # --- Allowlist resolution -------------------------------------------------- -def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: +def pipelock_effective_allowlist( + bottle: Bottle, + provider_routes: tuple[EgressRoute, ...] = (), +) -> list[str]: """Hostnames pipelock allows. Sorted for stability. - Always mirrors `egress_routes_for_bottle(bottle)` — egress is the - single allowlist surface, and pipelock's allowlist is the downstream - copy for defense-in-depth + DLP body scanning. For bottles without - any `egress.routes[]` declared, this is empty except for supervise - sidecar traffic when `supervise: true`. + Always mirrors `egress_routes_for_bottle(bottle, provider_routes)` — + egress is the single allowlist surface, and pipelock's allowlist is + the downstream copy for defense-in-depth + DLP body scanning. For + bottles without any `egress.routes[]` declared, this is empty except + for supervise sidecar traffic when `supervise: true`. The supervise sidecar's hostname is auto-added when supervise is enabled (sibling-sidecar traffic that flows through pipelock @@ -69,7 +68,7 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: `bottle.git` do NOT contribute here — git traffic flows through git-gate (PRD 0008), not pipelock.""" seen: dict[str, None] = {} - for r in egress_routes_for_bottle(bottle): + for r in egress_routes_for_bottle(bottle, provider_routes): if r.host: seen.setdefault(r.host, None) if bottle.supervise: @@ -102,32 +101,23 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool: return False -def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]: +def pipelock_effective_tls_passthrough( + bottle: Bottle, + provider_routes: tuple[EgressRoute, ...] = (), +) -> list[str]: """Hostnames pipelock should pass through (no TLS MITM). - A route opts in with `pipelock.tls_passthrough: true`. This is - useful for provider API routes where egress injects the - Authorization header after the agent boundary; pipelock still - enforces the host allowlist but does not decrypt and scan that - provider request. + A manifest route opts in with `pipelock.tls_passthrough: true` + (lifted into `EgressRoute.tls_passthrough` in `egress_manifest_routes`). + Provider routes that set `tls_passthrough=True` (e.g. Codex credential + routes where egress injects the host bearer after the agent boundary) + are also included. Both arrive via `egress_routes_for_bottle` — no + provider-specific branching needed here. """ seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH} - for route in bottle.egress.routes: - if route.Pipelock.TlsPassthrough: - seen.setdefault(route.Host, None) - # forward_host_credentials makes egress inject the host ChatGPT bearer - # on the Codex API hosts AFTER the agent boundary. Pipelock sits - # downstream of egress and DLP-scans request headers; left to MITM - # these routes it flags the injected JWT as a leaked secret - # ("request header contains secret") and blocks. Pass them through so - # pipelock still enforces the host allowlist on CONNECT but does not - # decrypt + rescan egress-owned auth. The auto-added routes live in - # egress_routes_for_bottle, not bottle.egress.routes, so add the - # hosts explicitly here. - provider = bottle.agent_provider - if provider.forward_host_credentials and provider.template == "codex": - for host in CODEX_HOST_CREDENTIAL_HOSTS: - seen.setdefault(host, None) + for route in egress_routes_for_bottle(bottle, provider_routes): + if route.tls_passthrough: + seen.setdefault(route.host, None) return sorted(seen.keys()) @@ -159,6 +149,7 @@ def pipelock_build_config( ca_cert_path: str = "", ca_key_path: str = "", ssrf_ip_allowlist: tuple[str, ...] = (), + provider_routes: tuple[EgressRoute, ...] = (), ) -> dict[str, object]: """Build the structured pipelock config dict the sidecar will load. @@ -188,7 +179,7 @@ def pipelock_build_config( "version": 1, "mode": "strict", "enforce": True, - "api_allowlist": pipelock_effective_allowlist(bottle), + "api_allowlist": pipelock_effective_allowlist(bottle, provider_routes), "forward_proxy": {"enabled": True}, } if not pipelock_seed_phrase_detection_enabled(bottle): @@ -222,7 +213,7 @@ def pipelock_build_config( "enabled": True, "ca_cert": ca_cert_path, "ca_key": ca_key_path, - "passthrough_domains": pipelock_effective_tls_passthrough(bottle), + "passthrough_domains": pipelock_effective_tls_passthrough(bottle, provider_routes), } effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist( bottle, ssrf_ip_allowlist, @@ -336,7 +327,11 @@ class PipelockProxy: (`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`).""" def prepare( - self, bottle: Bottle, slug: str, stage_dir: Path + self, + bottle: Bottle, + slug: str, + stage_dir: Path, + provider_routes: tuple[EgressRoute, ...] = (), ) -> PipelockProxyPlan: """Write the pipelock yaml config (mode 600) under `stage_dir` and return the plan for launch. Pure host-side, no docker @@ -359,6 +354,7 @@ class PipelockProxy: bottle, ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER, ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER, + provider_routes=provider_routes, ) yaml_path.write_text(pipelock_render_yaml(cfg)) yaml_path.chmod(0o600) diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 404bc65..b6c5588 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -8,7 +8,12 @@ import tempfile import unittest from pathlib import Path -from bot_bottle.agent_provider import agent_provision_plan, runtime_for +from bot_bottle.agent_provider import ( + CODEX_HOST_CREDENTIAL_HOSTS, + agent_provision_plan, + runtime_for, +) +from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF def _jwt(exp: int) -> str: @@ -90,6 +95,47 @@ class TestAgentProviderRuntime(unittest.TestCase): ) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) + def test_codex_forward_host_credentials_populates_egress_routes(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + home = Path(tmp) / "host-codex" + home.mkdir() + (home / "auth.json").write_text(json.dumps({ + "auth_mode": "chatgpt", + "tokens": {"access_token": _jwt(2000000000)}, + })) + plan = agent_provision_plan( + template="codex", + dockerfile="", + state_dir=Path(tmp), + forward_host_credentials=True, + host_env={"CODEX_HOME": str(home)}, + ) + hosts = [r.host for r in plan.egress_routes] + self.assertEqual(sorted(CODEX_HOST_CREDENTIAL_HOSTS), sorted(hosts)) + for r in plan.egress_routes: + self.assertEqual("Bearer", r.auth_scheme) + self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, r.token_ref) + self.assertTrue(r.tls_passthrough) + + def test_codex_without_forward_host_credentials_has_no_egress_routes(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + plan = agent_provision_plan( + template="codex", + dockerfile="", + state_dir=Path(tmp), + forward_host_credentials=False, + ) + self.assertEqual((), plan.egress_routes) + + def test_claude_plan_has_no_egress_routes(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + plan = agent_provision_plan( + template="claude", + dockerfile="", + state_dir=Path(tmp), + ) + self.assertEqual((), plan.egress_routes) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 420d6e6..24ad532 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -5,6 +5,7 @@ import unittest from bot_bottle.egress import ( CODEX_HOST_CREDENTIAL_TOKEN_REF, + EgressRoute, egress_manifest_routes, egress_render_routes, egress_resolve_token_values, @@ -23,19 +24,13 @@ def _bottle(routes): }).bottles["dev"] -def _codex_bottle(*, forward_host_credentials: bool, routes): - return Manifest.from_json_obj({ - "bottles": { - "dev": { - "agent_provider": { - "template": "codex", - "forward_host_credentials": forward_host_credentials, - }, - "egress": {"routes": routes}, - } - }, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] +def _provider_route(host: str, token_ref: str, *, tls_passthrough: bool = False) -> EgressRoute: + return EgressRoute( + host=host, + auth_scheme="Bearer", + token_ref=token_ref, + tls_passthrough=tls_passthrough, + ) class TestRoutesForBottle(unittest.TestCase): @@ -100,9 +95,8 @@ class TestRoutesForBottle(unittest.TestCase): self.assertEqual("", routes[1].token_env) -class TestRoutesForBottleUsesManifestOnly(unittest.TestCase): - """The effective route table is exactly the manifest-declared - routes. Provider defaults are not injected implicitly.""" +class TestRoutesForBottleManifestOnly(unittest.TestCase): + """Without provider routes the effective table is exactly the manifest.""" def test_no_manifest_routes_means_no_effective_routes(self): b = _bottle([]) @@ -123,58 +117,97 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase): effective = [r.host for r in egress_routes_for_bottle(b)] self.assertEqual(["x.example"], effective) - def test_codex_forward_host_credentials_adds_codex_routes(self): - b = _codex_bottle(forward_host_credentials=True, routes=[]) + def test_tls_passthrough_lifted_from_manifest(self): + b = _bottle([{ + "host": "api.openai.com", + "auth": {"scheme": "Bearer", "token_ref": "T"}, + "pipelock": {"tls_passthrough": True}, + }]) routes = egress_routes_for_bottle(b) - self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes]) + self.assertTrue(routes[0].tls_passthrough) + + def test_tls_passthrough_false_by_default(self): + b = _bottle([{"host": "api.github.com"}]) + routes = egress_routes_for_bottle(b) + self.assertFalse(routes[0].tls_passthrough) + + +class TestProviderRouteMerge(unittest.TestCase): + """Provider routes are merged into manifest routes generically.""" + + def test_provider_route_appended_when_not_in_manifest(self): + b = _bottle([]) + pr = _provider_route("api.openai.com", "TOK") + routes = egress_routes_for_bottle(b, (pr,)) + self.assertEqual(1, len(routes)) + self.assertEqual("api.openai.com", routes[0].host) self.assertEqual("Bearer", routes[0].auth_scheme) self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env) - self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref) - self.assertEqual("Bearer", routes[1].auth_scheme) - self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) - self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[1].token_ref) + self.assertEqual("TOK", routes[0].token_ref) - def test_codex_forward_host_credentials_upgrades_bare_chatgpt_route(self): - b = _codex_bottle( - forward_host_credentials=True, - routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}], - ) - routes = egress_routes_for_bottle(b) - self.assertEqual(2, len(routes)) + def test_two_provider_routes_with_same_token_ref_share_slot(self): + b = _bottle([]) + routes = egress_routes_for_bottle(b, ( + _provider_route("api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF), + _provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF), + )) + self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes]) + self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env) + self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) + + def test_provider_route_upgrades_bare_manifest_route(self): + b = _bottle([{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}]) + pr = _provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF) + routes = egress_routes_for_bottle(b, (pr,)) + self.assertEqual(1, len(routes)) self.assertEqual("chatgpt.com", routes[0].host) self.assertEqual("Bearer", routes[0].auth_scheme) self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env) self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref) self.assertEqual(("/backend-api/",), routes[0].path_allowlist) - self.assertEqual("api.openai.com", routes[1].host) - self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) - def test_codex_forward_host_credentials_accepts_explicit_synthetic_route(self): - b = _codex_bottle( - forward_host_credentials=True, - routes=[{ - "host": "api.openai.com", - "auth": { - "scheme": "Bearer", - "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF, - }, - }], - ) - routes = egress_routes_for_bottle(b) - self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes]) + def test_provider_route_noop_when_same_auth_already_in_manifest(self): + b = _bottle([{ + "host": "api.openai.com", + "auth": {"scheme": "Bearer", "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF}, + }]) + pr = _provider_route("api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF) + routes = egress_routes_for_bottle(b, (pr,)) + self.assertEqual(1, len(routes)) self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env) - self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) - def test_codex_forward_host_credentials_conflicts_with_authed_route(self): - b = _codex_bottle( - forward_host_credentials=True, - routes=[{ - "host": "chatgpt.com", - "auth": {"scheme": "Bearer", "token_ref": "OTHER"}, - }], + def test_provider_route_upgrades_tls_passthrough_on_existing_same_auth(self): + b = _bottle([{ + "host": "api.openai.com", + "auth": {"scheme": "Bearer", "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF}, + }]) + pr = _provider_route( + "api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF, tls_passthrough=True, ) + routes = egress_routes_for_bottle(b, (pr,)) + self.assertEqual(1, len(routes)) + self.assertTrue(routes[0].tls_passthrough) + + def test_provider_route_conflicts_with_different_authed_manifest_route(self): + b = _bottle([{ + "host": "chatgpt.com", + "auth": {"scheme": "Bearer", "token_ref": "OTHER"}, + }]) + pr = _provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF) with self.assertRaises(Die): - egress_routes_for_bottle(b) + egress_routes_for_bottle(b, (pr,)) + + def test_provider_route_tls_passthrough_set_on_appended_route(self): + b = _bottle([]) + pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True) + routes = egress_routes_for_bottle(b, (pr,)) + self.assertTrue(routes[0].tls_passthrough) + + def test_provider_route_tls_passthrough_set_on_upgraded_bare_route(self): + b = _bottle([{"host": "api.openai.com"}]) + pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True) + routes = egress_routes_for_bottle(b, (pr,)) + self.assertTrue(routes[0].tls_passthrough) class TestTokenEnvMap(unittest.TestCase): diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py index da72090..e6f6722 100644 --- a/tests/unit/test_pipelock_allowlist.py +++ b/tests/unit/test_pipelock_allowlist.py @@ -5,6 +5,8 @@ git-gate (PRD 0008).""" import unittest +from bot_bottle.agent_provider import CODEX_HOST_CREDENTIAL_HOSTS +from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from bot_bottle.manifest import Manifest from bot_bottle.pipelock import ( pipelock_effective_allowlist, @@ -116,17 +118,23 @@ class TestTlsPassthrough(unittest.TestCase): def test_forward_host_credentials_passes_through_codex_hosts(self): # Egress injects the host bearer on the Codex API hosts; pipelock # must pass them through or its header DLP blocks the injected JWT - # ("request header contains secret"). These routes are auto-added - # (not in bottle.egress.routes), so passthrough is host-derived. - passthrough = pipelock_effective_tls_passthrough(_bottle({ - "agent_provider": { - "template": "codex", - "forward_host_credentials": True, - }, - })) + # ("request header contains secret"). Provider routes carry + # tls_passthrough=True; pipelock reads this via egress_routes_for_bottle. + provider_routes = tuple( + EgressRoute( + host=host, + auth_scheme="Bearer", + token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, + tls_passthrough=True, + ) + for host in CODEX_HOST_CREDENTIAL_HOSTS + ) + passthrough = pipelock_effective_tls_passthrough( + _bottle({}), provider_routes, + ) self.assertEqual(["api.openai.com", "chatgpt.com"], passthrough) - def test_no_codex_passthrough_without_forward_host_credentials(self): + def test_no_codex_passthrough_without_provider_routes(self): passthrough = pipelock_effective_tls_passthrough(_bottle({ "agent_provider": {"template": "codex"}, })) -- 2.52.0 From 0233b481b10e659ec7edfcd53e5ce0c8927ddfe4 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 00:28:57 +0000 Subject: [PATCH 2/9] fix(egress): break circular import with manifest via TYPE_CHECKING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest → agent_provider → egress → manifest created a cycle that caused ImportError on any module import. With from __future__ import annotations already present, Bottle is only needed at type-check time (annotations are lazy strings under PEP 563). Assisted-by: Claude Code --- bot_bottle/egress.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index fb46b84..834a300 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -27,9 +27,12 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from .log import die -from .manifest import Bottle + +if TYPE_CHECKING: + from .manifest import Bottle CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" -- 2.52.0 From 86cfd94b72ce98f2c32a81a370ce643520e62bc4 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 00:39:32 +0000 Subject: [PATCH 3/9] fix(codex): emit passthrough egress routes when not forwarding host credentials When forward_host_credentials is false, Codex bottles should still get tls_passthrough routes for the OpenAI/ChatGPT hosts so that tokens a user sets via `codex login` after launch aren't stripped by pipelock's header DLP. Previously no routes were emitted, which would have blocked those requests entirely once pipelock enforcement tightens. Rename the test to reflect the new expected behavior. Assisted-by: Claude Code --- bot_bottle/agent_provider.py | 14 +++++++------- tests/unit/test_agent_provider.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index dbd4a59..b0e538e 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -160,14 +160,14 @@ def agent_provision_plan( config_file.chmod(0o600) files.append(AgentProvisionFile(config_file, config_path)) + for host in CODEX_HOST_CREDENTIAL_HOSTS: + egress_routes.append(EgressRoute( + host=host, + auth_scheme="Bearer" if forward_host_credentials else "", + token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "", + tls_passthrough=True, + )) if forward_host_credentials: - for host in CODEX_HOST_CREDENTIAL_HOSTS: - egress_routes.append(EgressRoute( - host=host, - auth_scheme="Bearer", - token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, - tls_passthrough=True, - )) auth_file = state_dir / "codex-auth.json" write_codex_dummy_auth_file(auth_file, host_env or dict(os.environ)) files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json")) diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index b6c5588..e8905f2 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -117,7 +117,7 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, r.token_ref) self.assertTrue(r.tls_passthrough) - def test_codex_without_forward_host_credentials_has_no_egress_routes(self): + def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: plan = agent_provision_plan( template="codex", @@ -125,7 +125,14 @@ class TestAgentProviderRuntime(unittest.TestCase): state_dir=Path(tmp), forward_host_credentials=False, ) - self.assertEqual((), plan.egress_routes) + self.assertEqual( + {r.host for r in plan.egress_routes}, + set(CODEX_HOST_CREDENTIAL_HOSTS), + ) + for r in plan.egress_routes: + self.assertEqual("", r.auth_scheme) + self.assertEqual("", r.token_ref) + self.assertTrue(r.tls_passthrough) def test_claude_plan_has_no_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: -- 2.52.0 From 2eb6e02ee1e83b425d7696d6de2948b0a3de6a40 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 00:57:43 +0000 Subject: [PATCH 4/9] refactor(agent): move placeholder env injection into agent_provision_plan The has_provider_auth check and egress-placeholder injection were duplicated in both backends. Move them into agent_provision_plan so the provisioner owns that decision entirely: - Replace has_provider_auth: bool param with manifest_egress_routes, compute has_provider_auth internally from the route roles. - Inject CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder inside the plan when has_provider_auth, alongside the existing nonessential-traffic vars. Backends no longer touch the placeholder env. - Remove placeholder_env from AgentProviderRuntime; expose placeholder_env_for() for print_util's hide-from-summary logic. Assisted-by: Claude Code --- bot_bottle/agent_provider.py | 16 ++++++++++++---- bot_bottle/backend/docker/prepare.py | 15 +-------------- bot_bottle/backend/print_util.py | 4 ++-- bot_bottle/backend/smolmachines/prepare.py | 16 +--------------- tests/unit/test_agent_provider.py | 21 ++++++++++----------- 5 files changed, 26 insertions(+), 46 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index b0e538e..e438ff2 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -34,7 +34,6 @@ class AgentProviderRuntime: image: str dockerfile: str auth_role: str - placeholder_env: str prompt_mode: PromptMode bypass_args: tuple[str, ...] resume_args: tuple[str, ...] @@ -100,7 +99,6 @@ _RUNTIMES = { image="bot-bottle-claude:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.claude"), auth_role="claude_code_oauth", - placeholder_env="CLAUDE_CODE_OAUTH_TOKEN", prompt_mode="append_file", bypass_args=("--dangerously-skip-permissions",), resume_args=("--continue",), @@ -112,7 +110,6 @@ _RUNTIMES = { image="bot-bottle-codex:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.codex"), auth_role="", - placeholder_env="", prompt_mode="read_prompt_file", bypass_args=("--dangerously-bypass-approvals-and-sandbox",), resume_args=("resume", "--last"), @@ -125,6 +122,13 @@ def runtime_for(template: str) -> AgentProviderRuntime: return _RUNTIMES[template] +def placeholder_env_for(template: str) -> str: + """Return the provider auth placeholder env var name, or empty string.""" + if template == PROVIDER_CLAUDE: + return "CLAUDE_CODE_OAUTH_TOKEN" + return "" + + def agent_provision_plan( *, template: str, @@ -133,10 +137,13 @@ def agent_provision_plan( guest_home: str = "/home/node", guest_env: dict[str, str] | None = None, forward_host_credentials: bool = False, - has_provider_auth: bool = False, + manifest_egress_routes: tuple[EgressRoute, ...] = (), host_env: dict[str, str] | None = None, ) -> AgentProvisionPlan: runtime = runtime_for(template) + has_provider_auth = bool(runtime.auth_role) and any( + runtime.auth_role in r.roles for r in manifest_egress_routes + ) resolved_guest_env = dict(guest_env or {}) env_vars: dict[str, str] = {} dirs: list[AgentProvisionDir] = [] @@ -193,6 +200,7 @@ def agent_provision_plan( "guest, but Codex did not accept it" ))) if template == PROVIDER_CLAUDE and has_provider_auth: + env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env_vars["DISABLE_ERROR_REPORTING"] = "1" diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 201f699..9a41825 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -168,19 +168,6 @@ 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) - # Some provider CLIs refuse to start without *some* credential - # env var even when egress will strip + re-inject the real - # Authorization header. For those providers, auth_role names the - # route marker that enables a non-secret placeholder env. Codex is - # intentionally absent here: it should use its device/ChatGPT login - # state, and an OPENAI_API_KEY placeholder would force API-key auth. - has_provider_auth = any( - provider_runtime.auth_role - and provider_runtime.auth_role in r.roles - for r in egress_manifest_routes(bottle) - ) - if has_provider_auth and provider_runtime.placeholder_env: - forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder" _write_env_file(resolved, env_file) prompt_file.write_text(agent.prompt) @@ -191,7 +178,7 @@ def resolve_plan( state_dir=agent_dir, guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), forward_host_credentials=provider.forward_host_credentials, - has_provider_auth=has_provider_auth, + manifest_egress_routes=egress_manifest_routes(bottle), host_env=dict(os.environ), ) guest_env = dict(agent_provision.guest_env) diff --git a/bot_bottle/backend/print_util.py b/bot_bottle/backend/print_util.py index 5e277c8..3fe931a 100644 --- a/bot_bottle/backend/print_util.py +++ b/bot_bottle/backend/print_util.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Sequence -from ..agent_provider import runtime_for +from ..agent_provider import placeholder_env_for from ..log import info @@ -41,5 +41,5 @@ def visible_agent_env_names( think a real key is entering the agent, so hide only the active provider-owned placeholder. """ - hidden = {runtime_for(agent_provider_template).placeholder_env} + hidden = {placeholder_env_for(agent_provider_template)} return sorted({name for name in env_names if name and name not in hidden}) diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 447a927..7f1777b 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -100,20 +100,6 @@ def resolve_plan( git_gate_dir.mkdir(parents=True, exist_ok=True) git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir) - # Some provider CLIs refuse to start without *some* credential - # env var even when egress will strip + re-inject the real - # Authorization header. For those providers, auth_role names the - # route marker that enables a non-secret placeholder env. Codex is - # intentionally absent here: it should use its device/ChatGPT login - # state, and an OPENAI_API_KEY placeholder would force API-key auth. - has_provider_auth = any( - provider_runtime.auth_role - and provider_runtime.auth_role in r.roles - for r in egress_manifest_routes(bottle) - ) - if has_provider_auth and provider_runtime.placeholder_env: - guest_env[provider_runtime.placeholder_env] = "egress-placeholder" - # Prompt file is always written (mode 0o600) so the in-VM # path always exists. Content is the agent's `prompt` # field (markdown body) — empty for agents with no prompt. @@ -148,7 +134,7 @@ def resolve_plan( guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"), guest_env=guest_env, forward_host_credentials=provider.forward_host_credentials, - has_provider_auth=has_provider_auth, + manifest_egress_routes=egress_manifest_routes(bottle), host_env=dict(os.environ), ) merged_guest_env = dict(agent_provision.guest_env) diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index e8905f2..eb64232 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -13,7 +13,7 @@ from bot_bottle.agent_provider import ( agent_provision_plan, runtime_for, ) -from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF +from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute def _jwt(exp: int) -> str: @@ -24,15 +24,13 @@ def _jwt(exp: int) -> str: class TestAgentProviderRuntime(unittest.TestCase): - def test_claude_keeps_oauth_placeholder(self): + def test_claude_has_auth_role(self): runtime = runtime_for("claude") self.assertEqual("claude_code_oauth", runtime.auth_role) - self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", runtime.placeholder_env) - def test_codex_does_not_inject_openai_api_key_placeholder(self): + def test_codex_has_no_auth_role(self): runtime = runtime_for("codex") self.assertEqual("", runtime.auth_role) - self.assertEqual("", runtime.placeholder_env) def test_codex_plan_declares_home_state(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -81,18 +79,19 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual(1, len(plan.verify)) self.assertIn("CODEX_HOME=/run/codex-home", plan.verify[0].argv) - def test_claude_with_provider_auth_disables_nonessential_traffic(self): + def test_claude_with_provider_auth_sets_placeholder_and_disables_nonessential_traffic(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: plan = agent_provision_plan( template="claude", dockerfile="/tmp/Dockerfile.claude", state_dir=Path(tmp), - has_provider_auth=True, + manifest_egress_routes=(EgressRoute( + host="api.anthropic.com", + roles=("claude_code_oauth",), + ),), ) - self.assertEqual( - "1", - plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], - ) + self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"]) + self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) def test_codex_forward_host_credentials_populates_egress_routes(self): -- 2.52.0 From 200a113cce2c3a46a8d8cc0e58f3e531b0b1b0f5 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 01:24:18 +0000 Subject: [PATCH 5/9] feat(manifest): add agent_provider.auth_token for Claude OAuth via egress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can now declare: agent_provider: template: claude auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN and the provisioner injects a provider-owned api.anthropic.com egress route (Bearer, tls_passthrough) rather than requiring a manually declared route with the former claude_code_oauth role. Changes: - Add auth_token field to AgentProvider; validate claude-only. - Remove claude_code_oauth from EGRESS_ROLES / PROVIDER_EGRESS_ROLES. Manifests that declare the role now fail at parse time with "unknown role" — the provisioner owns the route. - agent_provision_plan: replace manifest_egress_routes/has_provider_auth with auth_token; Claude branch injects the api.anthropic.com route, placeholder env, and nonessential-traffic flags when auth_token is set. - Add hidden_env_names: frozenset[str] to AgentProvisionPlan; Claude branch populates it with CLAUDE_CODE_OAUTH_TOKEN. - Remove auth_role from AgentProviderRuntime and placeholder_env_for(). - print_util.visible_agent_env_names: accept hidden_env_names from the plan instead of dispatching on agent_provider_template. - Both backends: drop manifest_egress_routes call, pass auth_token. - PRD 0029 rescoped to cover both Codex and Claude provider auth. Assisted-by: Claude Code --- bot_bottle/agent_provider.py | 32 ++++---- bot_bottle/backend/docker/bottle_plan.py | 2 +- bot_bottle/backend/docker/prepare.py | 4 +- bot_bottle/backend/print_util.py | 16 ++-- .../backend/smolmachines/bottle_plan.py | 2 +- bot_bottle/backend/smolmachines/prepare.py | 3 +- bot_bottle/manifest.py | 61 +++++++++------- .../0029-codex-host-credentials-egress.md | 24 ++++-- tests/unit/test_agent_provider.py | 24 +++--- tests/unit/test_manifest_egress.py | 73 +++++++++++-------- tests/unit/test_print_util.py | 8 +- 11 files changed, 136 insertions(+), 113 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index e438ff2..32874b6 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -33,7 +33,6 @@ class AgentProviderRuntime: command: str image: str dockerfile: str - auth_role: str prompt_mode: PromptMode bypass_args: tuple[str, ...] resume_args: tuple[str, ...] @@ -73,6 +72,11 @@ class AgentProvisionPlan: pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps provider logic out of the egress and pipelock modules — they merge provider routes generically without knowing the provider type. + + `hidden_env_names` is the set of env var names the provider injected + as non-secret placeholders. `print_util.visible_agent_env_names` uses + this to suppress them from the preflight summary so operators don't + mistake them for real credentials. """ template: str @@ -87,6 +91,7 @@ class AgentProvisionPlan: pre_copy: tuple[AgentProvisionCommand, ...] = () verify: tuple[AgentProvisionCommand, ...] = () egress_routes: tuple[EgressRoute, ...] = () + hidden_env_names: frozenset[str] = field(default_factory=frozenset) _REPO_ROOT = Path(__file__).resolve().parent.parent @@ -98,7 +103,6 @@ _RUNTIMES = { command="claude", image="bot-bottle-claude:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.claude"), - auth_role="claude_code_oauth", prompt_mode="append_file", bypass_args=("--dangerously-skip-permissions",), resume_args=("--continue",), @@ -109,7 +113,6 @@ _RUNTIMES = { command="codex", image="bot-bottle-codex:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.codex"), - auth_role="", prompt_mode="read_prompt_file", bypass_args=("--dangerously-bypass-approvals-and-sandbox",), resume_args=("resume", "--last"), @@ -122,13 +125,6 @@ def runtime_for(template: str) -> AgentProviderRuntime: return _RUNTIMES[template] -def placeholder_env_for(template: str) -> str: - """Return the provider auth placeholder env var name, or empty string.""" - if template == PROVIDER_CLAUDE: - return "CLAUDE_CODE_OAUTH_TOKEN" - return "" - - def agent_provision_plan( *, template: str, @@ -136,14 +132,11 @@ def agent_provision_plan( state_dir: Path, guest_home: str = "/home/node", guest_env: dict[str, str] | None = None, + auth_token: str = "", forward_host_credentials: bool = False, - manifest_egress_routes: tuple[EgressRoute, ...] = (), host_env: dict[str, str] | None = None, ) -> AgentProvisionPlan: runtime = runtime_for(template) - has_provider_auth = bool(runtime.auth_role) and any( - runtime.auth_role in r.roles for r in manifest_egress_routes - ) resolved_guest_env = dict(guest_env or {}) env_vars: dict[str, str] = {} dirs: list[AgentProvisionDir] = [] @@ -151,6 +144,7 @@ def agent_provision_plan( pre_copy: list[AgentProvisionCommand] = [] verify: list[AgentProvisionCommand] = [] egress_routes: list[EgressRoute] = [] + hidden_env_names: frozenset[str] = frozenset() if template == PROVIDER_CODEX: env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt" @@ -199,10 +193,17 @@ def agent_provision_plan( "codex host credentials: dummy auth was copied into the " "guest, but Codex did not accept it" ))) - if template == PROVIDER_CLAUDE and has_provider_auth: + if template == PROVIDER_CLAUDE and auth_token: + egress_routes.append(EgressRoute( + host="api.anthropic.com", + auth_scheme="Bearer", + token_ref=auth_token, + tls_passthrough=True, + )) env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env_vars["DISABLE_ERROR_REPORTING"] = "1" + hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) return AgentProvisionPlan( template=template, @@ -217,6 +218,7 @@ def agent_provision_plan( pre_copy=tuple(pre_copy), verify=tuple(verify), egress_routes=tuple(egress_routes), + hidden_env_names=hidden_env_names, ) diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index b461e6c..e6240f1 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -89,7 +89,7 @@ class DockerBottlePlan(BottlePlan): | set(self.forwarded_env.keys()) | set(self.agent_provision.guest_env.keys()) ), - agent_provider_template=self.agent_provider_template, + hidden_env_names=self.agent_provision.hidden_env_names, ) print(file=sys.stderr) diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 9a41825..3ce9c83 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -16,7 +16,7 @@ from dataclasses import replace from pathlib import Path from ...agent_provider import agent_provision_plan, runtime_for -from ...egress import Egress, egress_manifest_routes +from ...egress import Egress from ...env import ResolvedEnv, resolve_env from ...git_gate import GitGate from ...log import die @@ -178,7 +178,7 @@ def resolve_plan( state_dir=agent_dir, guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), forward_host_credentials=provider.forward_host_credentials, - manifest_egress_routes=egress_manifest_routes(bottle), + auth_token=provider.auth_token, host_env=dict(os.environ), ) guest_env = dict(agent_provision.guest_env) diff --git a/bot_bottle/backend/print_util.py b/bot_bottle/backend/print_util.py index 3fe931a..4b4ec3d 100644 --- a/bot_bottle/backend/print_util.py +++ b/bot_bottle/backend/print_util.py @@ -9,7 +9,6 @@ from __future__ import annotations from typing import Sequence -from ..agent_provider import placeholder_env_for from ..log import info @@ -30,16 +29,13 @@ def print_multi(label: str, values: Sequence[str]) -> None: def visible_agent_env_names( - env_names: Sequence[str], *, agent_provider_template: str, + env_names: Sequence[str], *, hidden_env_names: frozenset[str], ) -> list[str]: """Env names worth showing in launch summaries. - Provider auth placeholders (currently `CLAUDE_CODE_OAUTH_TOKEN`) - are implementation details: they are non-secret dummy values that - satisfy the provider CLI while egress injects the real upstream - Authorization header. Showing them in preflight makes the operator - think a real key is entering the agent, so hide only the active - provider-owned placeholder. + Provider-injected placeholder env vars are implementation details: + they are non-secret dummy values that satisfy provider CLIs while + egress injects the real Authorization header. The plan's + `hidden_env_names` carries exactly which names to suppress. """ - hidden = {placeholder_env_for(agent_provider_template)} - return sorted({name for name in env_names if name and name not in hidden}) + return sorted({name for name in env_names if name and name not in hidden_env_names}) diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index d89205d..84ef5d4 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -125,7 +125,7 @@ class SmolmachinesBottlePlan(BottlePlan): set(bottle.env.keys()) | set(self.agent_provision.guest_env.keys()) ), - agent_provider_template=self.agent_provider_template, + hidden_env_names=self.agent_provision.hidden_env_names, ) upstreams = [ f"{g.Name} → {g.Upstream}" for g in bottle.git diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 7f1777b..4746316 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -16,7 +16,6 @@ from dataclasses import replace from pathlib import Path from ...agent_provider import agent_provision_plan, runtime_for -from ...egress import egress_manifest_routes from ...backend import BottleSpec from ...backend.docker.bottle_state import ( BottleMetadata, @@ -134,7 +133,7 @@ def resolve_plan( guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"), guest_env=guest_env, forward_host_credentials=provider.forward_host_credentials, - manifest_egress_routes=egress_manifest_routes(bottle), + auth_token=provider.auth_token, host_env=dict(os.environ), ) merged_guest_env = dict(agent_provision.guest_env) diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 9f69369..38d3386 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -179,40 +179,28 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token") # 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. -# -# codex_auth: placeholder marker reserved for follow-up Codex -# credential-injection work. It is still accepted so -# existing manifests and future egress-held auth flows -# have a stable role name, but it no longer triggers an -# OPENAI_API_KEY placeholder. Codex bottles should prefer -# device/ChatGPT login state today. +# codex_auth: placeholder marker for Codex egress-held auth flows. +# Accepted on Codex routes for forward-compatibility; +# the provisioner does not act on it today. # # Routes without a `role` are pure proxy entries: egress # enforces path_allowlist + injects auth on its own, but nothing # special happens on the agent side. +# +# Note: the former `claude_code_oauth` role has been removed. Claude +# OAuth is now provisioner-owned via `agent_provider.auth_token`; the +# provisioner injects the api.anthropic.com route automatically. EGRESS_ROLES = frozenset({ - "claude_code_oauth", "codex_auth", }) -# Singleton roles may appear on at most one route per bottle. Some -# roles drive a single provider auth path; two routes claiming one -# marker would leave "which one is canonical?" ambiguous. +# Singleton roles may appear on at most one route per bottle. EGRESS_SINGLETON_ROLES = frozenset({ - "claude_code_oauth", "codex_auth", }) PROVIDER_EGRESS_ROLES = { - "claude": frozenset({"claude_code_oauth"}), + "claude": frozenset(), "codex": frozenset({"codex_auth"}), } @@ -224,20 +212,30 @@ class AgentProvider: `template` selects a built-in launch/runtime contract. `dockerfile` optionally points at a custom agent-image Dockerfile while leaving bot-bottle's sidecar infrastructure intact. + + `auth_token` names the host env var that holds the provider's OAuth + token (Claude only). The provisioner injects a provider-owned egress + route for api.anthropic.com that re-injects this token as the Bearer + header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent + so the Claude Code CLI starts. + + `forward_host_credentials` forwards the host Codex auth token into + the egress sidecar (Codex only). """ template: str = "claude" dockerfile: str = "" + auth_token: str = "" forward_host_credentials: bool = False @classmethod def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider": d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider") for k in d: - if k not in {"template", "dockerfile", "forward_host_credentials"}: + if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}: raise ManifestError( f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; " - f"allowed: template, dockerfile, forward_host_credentials" + f"allowed: template, dockerfile, auth_token, forward_host_credentials" ) template = d.get("template", "claude") if not isinstance(template, str) or not template: @@ -256,6 +254,17 @@ class AgentProvider: f"bottle '{bottle_name}' agent_provider.dockerfile must be a " f"string (was {type(dockerfile).__name__})" ) + auth_token = d.get("auth_token", "") + if not isinstance(auth_token, str): + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.auth_token must be a " + f"string (was {type(auth_token).__name__})" + ) + if auth_token and template != "claude": + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.auth_token is only " + f"supported for template 'claude'" + ) forward_host_credentials = d.get("forward_host_credentials", False) if not isinstance(forward_host_credentials, bool): raise ManifestError( @@ -270,6 +279,7 @@ class AgentProvider: return cls( template=template, dockerfile=dockerfile, + auth_token=auth_token, forward_host_credentials=forward_host_credentials, ) @@ -428,10 +438,7 @@ class EgressRoute: 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_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). + `Role` is an optional tuple of named markers (see EGRESS_ROLES). Validation rules (enforced in `from_dict`): - `host` required, non-empty. diff --git a/docs/prds/0029-codex-host-credentials-egress.md b/docs/prds/0029-codex-host-credentials-egress.md index 7c8400d..f3f6a22 100644 --- a/docs/prds/0029-codex-host-credentials-egress.md +++ b/docs/prds/0029-codex-host-credentials-egress.md @@ -1,4 +1,4 @@ -# PRD 0029: Codex host credentials through egress +# PRD 0029: Provider auth credentials through egress - **Status:** Draft - **Author:** didericis-codex @@ -7,9 +7,12 @@ ## Summary -Allow Codex bottles to use a host-authorized ChatGPT/device-login -access token by forwarding it only into the egress sidecar, gated by an -explicit `agent_provider.forward_host_credentials` manifest flag. +Allow provider bottles to inject host credentials into the egress +sidecar without exposing them to the agent. Codex uses +`agent_provider.forward_host_credentials` for ChatGPT/device-login +access tokens. Claude uses `agent_provider.auth_token` to name the host +env var holding its OAuth token, which egress injects on +`api.anthropic.com` requests. ## Problem @@ -51,8 +54,8 @@ possible, not in the agent. current access token at launch; operators can restart after host Codex refreshes auth. - Copying host `~/.codex/auth.json` credentials into the agent. -- Allowing arbitrary host credential forwarding. This PRD covers Codex - ChatGPT/device-login credentials only. +- Allowing arbitrary host credential forwarding beyond the two providers + covered here (Codex ChatGPT/device-login and Claude OAuth). - Hot-applying new authenticated Codex routes to an existing running sidecar. The current hot-apply path cannot safely populate new token env slots in an already-running container. @@ -64,6 +67,15 @@ possible, not in the agent. - Add `agent_provider.forward_host_credentials` to the bottle manifest schema, defaulting to `false`. - Support the flag for `agent_provider.template: codex`. +- Add `agent_provider.auth_token` to the bottle manifest schema. +- Support the field for `agent_provider.template: claude`: the named + host env var is forwarded only into the egress sidecar as the Bearer + token for `api.anthropic.com`, and a placeholder + `CLAUDE_CODE_OAUTH_TOKEN` is set in the agent so the Claude Code CLI + starts without a real credential. +- Remove the `claude_code_oauth` egress route role, which previously + required operators to declare the OAuth route manually. The provisioner + now injects it from `auth_token`. - Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is set, otherwise from `~/.codex/auth.json`. - Extract only `tokens.access_token` for egress injection. diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index eb64232..5b333fb 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -13,7 +13,7 @@ from bot_bottle.agent_provider import ( agent_provision_plan, runtime_for, ) -from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute +from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF def _jwt(exp: int) -> str: @@ -24,14 +24,6 @@ def _jwt(exp: int) -> str: class TestAgentProviderRuntime(unittest.TestCase): - def test_claude_has_auth_role(self): - runtime = runtime_for("claude") - self.assertEqual("claude_code_oauth", runtime.auth_role) - - def test_codex_has_no_auth_role(self): - runtime = runtime_for("codex") - self.assertEqual("", runtime.auth_role) - def test_codex_plan_declares_home_state(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: plan = agent_provision_plan( @@ -79,20 +71,24 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual(1, len(plan.verify)) self.assertIn("CODEX_HOME=/run/codex-home", plan.verify[0].argv) - def test_claude_with_provider_auth_sets_placeholder_and_disables_nonessential_traffic(self): + def test_claude_with_auth_token_injects_provider_route_and_placeholder(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: plan = agent_provision_plan( template="claude", dockerfile="/tmp/Dockerfile.claude", state_dir=Path(tmp), - manifest_egress_routes=(EgressRoute( - host="api.anthropic.com", - roles=("claude_code_oauth",), - ),), + auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", ) + self.assertEqual(1, len(plan.egress_routes)) + route = plan.egress_routes[0] + self.assertEqual("api.anthropic.com", route.host) + self.assertEqual("Bearer", route.auth_scheme) + self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", route.token_ref) + self.assertTrue(route.tls_passthrough) self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"]) self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) + self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names) def test_codex_forward_host_credentials_populates_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index 4d82c6b..1a03d0b 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -85,6 +85,31 @@ class TestAgentProviderHostCredentials(unittest.TestCase): "forward_host_credentials": True, }) + def test_auth_token_defaults_empty(self): + b = _provider_config_bottle({"template": "claude"}) + self.assertEqual("", b.agent_provider.auth_token) + + def test_auth_token_allowed_for_claude(self): + b = _provider_config_bottle({ + "template": "claude", + "auth_token": "BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", + }) + self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", b.agent_provider.auth_token) + + def test_auth_token_must_be_string(self): + with self.assertRaises(ManifestError): + _provider_config_bottle({ + "template": "claude", + "auth_token": 42, + }) + + def test_auth_token_rejected_for_codex(self): + with self.assertRaises(ManifestError): + _provider_config_bottle({ + "template": "codex", + "auth_token": "SOME_TOKEN", + }) + class TestPathAllowlist(unittest.TestCase): def test_optional(self): @@ -179,22 +204,20 @@ class TestRole(unittest.TestCase): self.assertEqual((), b.egress.routes[0].Role) def test_string_normalizes_to_tuple(self): - b = _bottle([{ - "host": "api.anthropic.com", - "role": "claude_code_oauth", + b = _provider_bottle("codex", [{ + "host": "api.openai.com", + "role": "codex_auth", "auth": {"scheme": "Bearer", "token_ref": "T"}, }]) - self.assertEqual(("claude_code_oauth",), - b.egress.routes[0].Role) + self.assertEqual(("codex_auth",), b.egress.routes[0].Role) def test_list_supported(self): - b = _bottle([{ - "host": "api.anthropic.com", - "role": ["claude_code_oauth"], + b = _provider_bottle("codex", [{ + "host": "api.openai.com", + "role": ["codex_auth"], "auth": {"scheme": "Bearer", "token_ref": "T"}, }]) - self.assertEqual(("claude_code_oauth",), - b.egress.routes[0].Role) + self.assertEqual(("codex_auth",), b.egress.routes[0].Role) def test_unknown_role_rejected(self): # The role enum is locked down — typos shouldn't silently @@ -202,6 +225,14 @@ class TestRole(unittest.TestCase): with self.assertRaises(ManifestError): _bottle([{"host": "x.example", "role": "totally-made-up"}]) + def test_claude_code_oauth_role_rejected(self): + # claude_code_oauth was removed; provisioner injects the route + # automatically via agent_provider.auth_token. + with self.assertRaises(ManifestError): + _bottle([{"host": "api.anthropic.com", + "role": "claude_code_oauth", + "auth": {"scheme": "Bearer", "token_ref": "T"}}]) + def test_non_string_role_rejected(self): with self.assertRaises(ManifestError): _bottle([{"host": "x.example", "role": 42}]) @@ -209,19 +240,7 @@ class TestRole(unittest.TestCase): def test_list_with_non_string_item_rejected(self): with self.assertRaises(ManifestError): _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(ManifestError): - _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"}}, - ]) + "role": ["codex_auth", 42]}]) def test_codex_auth_role_allowed_for_codex_provider(self): b = _provider_bottle("codex", [{ @@ -231,14 +250,6 @@ class TestRole(unittest.TestCase): }]) self.assertEqual(("codex_auth",), b.egress.routes[0].Role) - def test_claude_role_rejected_for_codex_provider(self): - with self.assertRaises(ManifestError): - _provider_bottle("codex", [{ - "host": "api.anthropic.com", - "role": "claude_code_oauth", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - }]) - def test_codex_role_rejected_for_default_claude_provider(self): with self.assertRaises(ManifestError): _bottle([{ diff --git a/tests/unit/test_print_util.py b/tests/unit/test_print_util.py index d4bea06..a09a91c 100644 --- a/tests/unit/test_print_util.py +++ b/tests/unit/test_print_util.py @@ -8,21 +8,21 @@ from bot_bottle.backend.print_util import visible_agent_env_names class TestVisibleAgentEnvNames(unittest.TestCase): - def test_codex_shows_openai_api_key_if_user_declares_it(self): + def test_shows_all_when_no_hidden_names(self): self.assertEqual( ["CUSTOM", "OPENAI_API_KEY"], visible_agent_env_names( ["OPENAI_API_KEY", "CUSTOM"], - agent_provider_template="codex", + hidden_env_names=frozenset(), ), ) - def test_hides_only_active_provider_placeholder(self): + def test_hides_provider_placeholder(self): self.assertEqual( ["CUSTOM", "OPENAI_API_KEY"], visible_agent_env_names( ["CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CUSTOM"], - agent_provider_template="claude", + hidden_env_names=frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), ), ) -- 2.52.0 From 962f8144fcfe6e0a808939a8e643806dc45a02c6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 01:34:25 +0000 Subject: [PATCH 6/9] fix(agent): always emit passthrough egress route for api.anthropic.com Mirrors the Codex pattern: Claude always gets a tls_passthrough route for api.anthropic.com so user-set tokens aren't stripped by pipelock, whether or not auth_token is declared. Auth injection (scheme + token_ref) and the placeholder env only apply when auth_token is set. Assisted-by: Claude Code --- bot_bottle/agent_provider.py | 13 +++++++------ tests/unit/test_agent_provider.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 32874b6..80f99d1 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -193,17 +193,18 @@ def agent_provision_plan( "codex host credentials: dummy auth was copied into the " "guest, but Codex did not accept it" ))) - if template == PROVIDER_CLAUDE and auth_token: + if template == PROVIDER_CLAUDE: egress_routes.append(EgressRoute( host="api.anthropic.com", - auth_scheme="Bearer", + auth_scheme="Bearer" if auth_token else "", token_ref=auth_token, tls_passthrough=True, )) - env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" - env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" - env_vars["DISABLE_ERROR_REPORTING"] = "1" - hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) + if auth_token: + env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" + env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + env_vars["DISABLE_ERROR_REPORTING"] = "1" + hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) return AgentProvisionPlan( template=template, diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 5b333fb..8d4f4b6 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -129,14 +129,21 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("", r.token_ref) self.assertTrue(r.tls_passthrough) - def test_claude_plan_has_no_egress_routes(self): + def test_claude_without_auth_token_has_passthrough_egress_route(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: plan = agent_provision_plan( template="claude", dockerfile="", state_dir=Path(tmp), ) - self.assertEqual((), plan.egress_routes) + self.assertEqual(1, len(plan.egress_routes)) + route = plan.egress_routes[0] + self.assertEqual("api.anthropic.com", route.host) + self.assertEqual("", route.auth_scheme) + self.assertEqual("", route.token_ref) + self.assertTrue(route.tls_passthrough) + self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", plan.env_vars) + self.assertEqual(frozenset(), plan.hidden_env_names) if __name__ == "__main__": -- 2.52.0 From 3b96de95ab85704f0693313904173313a26899f9 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 1 Jun 2026 21:41:38 -0400 Subject: [PATCH 7/9] fix(agent): move default claude env vars to the right location --- bot_bottle/agent_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 80f99d1..cd271dc 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -194,6 +194,8 @@ def agent_provision_plan( "guest, but Codex did not accept it" ))) if template == PROVIDER_CLAUDE: + env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + env_vars["DISABLE_ERROR_REPORTING"] = "1" egress_routes.append(EgressRoute( host="api.anthropic.com", auth_scheme="Bearer" if auth_token else "", @@ -202,8 +204,6 @@ def agent_provision_plan( )) if auth_token: env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" - env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" - env_vars["DISABLE_ERROR_REPORTING"] = "1" hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) return AgentProvisionPlan( -- 2.52.0 From 8a038dccebebb1af4e24689b631274d2056b6c06 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 01:50:34 +0000 Subject: [PATCH 8/9] refactor(manifest): remove codex_auth egress role Both provider-owned roles are now gone. Provider auth routes are provisioner-owned (claude: auth_token, codex: forward_host_credentials); the role field and validation plumbing stay for future use but EGRESS_ROLES is empty. Any manifest declaring a role now fails at parse time. Assisted-by: Claude Code --- bot_bottle/manifest.py | 34 +++++-------------- tests/unit/test_manifest_egress.py | 54 ++++-------------------------- 2 files changed, 15 insertions(+), 73 deletions(-) diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 38d3386..5ae00a6 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -175,33 +175,15 @@ class GitEntry: # token-not-Bearer quirk (go-gitea/gitea#16734). EGRESS_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. -# -# codex_auth: placeholder marker for Codex egress-held auth flows. -# Accepted on Codex routes for forward-compatibility; -# the provisioner does not act on it today. -# -# Routes without a `role` are pure proxy entries: egress -# enforces path_allowlist + injects auth on its own, but nothing -# special happens on the agent side. -# -# Note: the former `claude_code_oauth` role has been removed. Claude -# OAuth is now provisioner-owned via `agent_provider.auth_token`; the -# provisioner injects the api.anthropic.com route automatically. -EGRESS_ROLES = frozenset({ - "codex_auth", -}) - -# Singleton roles may appear on at most one route per bottle. -EGRESS_SINGLETON_ROLES = frozenset({ - "codex_auth", -}) - -PROVIDER_EGRESS_ROLES = { +# Per-route role markers. Both former roles (claude_code_oauth, +# codex_auth) have been removed — provider auth is now provisioner-owned +# via agent_provider.auth_token / forward_host_credentials. The field +# and validation plumbing remain for future roles. +EGRESS_ROLES: frozenset[str] = frozenset() +EGRESS_SINGLETON_ROLES: frozenset[str] = frozenset() +PROVIDER_EGRESS_ROLES: dict[str, frozenset[str]] = { "claude": frozenset(), - "codex": frozenset({"codex_auth"}), + "codex": frozenset(), } diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index 1a03d0b..1dba6a5 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -203,35 +203,12 @@ class TestRole(unittest.TestCase): b = _bottle([{"host": "x.example"}]) self.assertEqual((), b.egress.routes[0].Role) - def test_string_normalizes_to_tuple(self): - b = _provider_bottle("codex", [{ - "host": "api.openai.com", - "role": "codex_auth", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - }]) - self.assertEqual(("codex_auth",), b.egress.routes[0].Role) - - def test_list_supported(self): - b = _provider_bottle("codex", [{ - "host": "api.openai.com", - "role": ["codex_auth"], - "auth": {"scheme": "Bearer", "token_ref": "T"}, - }]) - self.assertEqual(("codex_auth",), b.egress.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(ManifestError): - _bottle([{"host": "x.example", "role": "totally-made-up"}]) - - def test_claude_code_oauth_role_rejected(self): - # claude_code_oauth was removed; provisioner injects the route - # automatically via agent_provider.auth_token. - with self.assertRaises(ManifestError): - _bottle([{"host": "api.anthropic.com", - "role": "claude_code_oauth", - "auth": {"scheme": "Bearer", "token_ref": "T"}}]) + def test_any_role_rejected(self): + # All former roles removed; the field is reserved for future use. + for role in ("claude_code_oauth", "codex_auth", "totally-made-up"): + with self.subTest(role=role): + with self.assertRaises(ManifestError): + _bottle([{"host": "x.example", "role": role}]) def test_non_string_role_rejected(self): with self.assertRaises(ManifestError): @@ -239,24 +216,7 @@ class TestRole(unittest.TestCase): def test_list_with_non_string_item_rejected(self): with self.assertRaises(ManifestError): - _bottle([{"host": "x.example", - "role": ["codex_auth", 42]}]) - - def test_codex_auth_role_allowed_for_codex_provider(self): - b = _provider_bottle("codex", [{ - "host": "api.openai.com", - "role": "codex_auth", - "auth": {"scheme": "Bearer", "token_ref": "OPENAI_TOKEN"}, - }]) - self.assertEqual(("codex_auth",), b.egress.routes[0].Role) - - def test_codex_role_rejected_for_default_claude_provider(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "api.openai.com", - "role": "codex_auth", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - }]) + _bottle([{"host": "x.example", "role": ["x", 42]}]) class TestPipelockPolicy(unittest.TestCase): -- 2.52.0 From 650f3aa93ee77ca82895b40360098edf9637dcb6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 01:57:55 +0000 Subject: [PATCH 9/9] refactor(manifest): remove empty EGRESS_ROLES and related plumbing EGRESS_ROLES, EGRESS_SINGLETON_ROLES, and PROVIDER_EGRESS_ROLES were all empty frozensets after the codex_auth and claude_code_oauth roles were removed. Delete the constants and all validation code that iterated over them (the singleton-role loop and provider-role check in _validate_egress_routes, the EGRESS_ROLES membership test in EgressRoute.from_dict). EgressRoute.from_dict now rejects any role string unconditionally; _validate_egress_routes loses its agent_provider_template parameter entirely. Assisted-by: Claude Code --- bot_bottle/egress.py | 5 +-- bot_bottle/manifest.py | 83 +++++++++--------------------------------- 2 files changed, 19 insertions(+), 69 deletions(-) diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 834a300..db58920 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -69,9 +69,8 @@ class EgressRoute: under `token_env`. Routes that share a `token_ref` coalesce to one `token_env` slot. - `roles` carries the manifest route's optional role markers (see - `manifest.EGRESS_ROLES`). The launch step reads these for - side effects like the claude-code OAuth placeholder env. + `roles` carries the manifest route's role tuple (reserved for + future use; always empty today). `tls_passthrough` signals that pipelock must not TLS-MITM this host — either because the manifest declared `pipelock.tls_passthrough: diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 5ae00a6..d876fbd 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -175,17 +175,6 @@ class GitEntry: # token-not-Bearer quirk (go-gitea/gitea#16734). EGRESS_AUTH_SCHEMES = ("Bearer", "token") -# Per-route role markers. Both former roles (claude_code_oauth, -# codex_auth) have been removed — provider auth is now provisioner-owned -# via agent_provider.auth_token / forward_host_credentials. The field -# and validation plumbing remain for future roles. -EGRESS_ROLES: frozenset[str] = frozenset() -EGRESS_SINGLETON_ROLES: frozenset[str] = frozenset() -PROVIDER_EGRESS_ROLES: dict[str, frozenset[str]] = { - "claude": frozenset(), - "codex": frozenset(), -} - @dataclass(frozen=True) class AgentProvider: @@ -420,7 +409,8 @@ class EgressRoute: 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_ROLES). + `Role` is reserved for future use; all role strings are currently + rejected by the validator. Validation rules (enforced in `from_dict`): - `host` required, non-empty. @@ -429,10 +419,7 @@ class EgressRoute: `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_ROLES. Singleton roles (see - EGRESS_SINGLETON_ROLES) may appear on at most one - route per bottle. + - `role` optional, reserved — any non-empty value is rejected. """ Host: str @@ -530,12 +517,11 @@ class EgressRoute: 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_ROLES: - raise ManifestError( - f"{label} role {r!r} is not one of " - f"{', '.join(sorted(EGRESS_ROLES))}" - ) + if roles: + raise ManifestError( + f"{label} role {roles[0]!r} is not accepted; " + f"the 'role' field is reserved for future use" + ) pipelock = ( PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"]) @@ -570,9 +556,7 @@ class EgressConfig: routes: tuple[EgressRoute, ...] = () @classmethod - def from_dict( - cls, bottle_name: str, raw: object, *, agent_provider_template: str = "claude", - ) -> "EgressConfig": + def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig": d = _as_json_object(raw, f"bottle '{bottle_name}' egress") routes_raw = d.get("routes") routes: tuple[EgressRoute, ...] = () @@ -587,9 +571,7 @@ class EgressConfig: EgressRoute.from_dict(bottle_name, i, entry) for i, entry in enumerate(routes_list) ) - _validate_egress_routes( - bottle_name, routes, agent_provider_template=agent_provider_template, - ) + _validate_egress_routes(bottle_name, routes) for k in d: if k != "routes": raise ManifestError( @@ -680,10 +662,7 @@ class Bottle: ) egress = ( - EgressConfig.from_dict( - name, d["egress"], - agent_provider_template=agent_provider.template, - ) + EgressConfig.from_dict(name, d["egress"]) if "egress" in d else EgressConfig() ) @@ -1047,21 +1026,15 @@ def _is_ip_literal(value: str) -> bool: def _validate_egress_routes( bottle_name: str, routes: tuple[EgressRoute, ...], - *, - agent_provider_template: str = "claude", ) -> None: - """Cross-validation for `bottle.egress.routes`: + """Cross-validation for `bottle.egress.routes`: hosts must be unique. - - 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_SINGLETON_ROLES) may appear - on at most one route per bottle. + The proxy matches by exact-host (v1); duplicate hosts leave the + route choice ambiguous so we reject them up front. No cross-validation against `bottle.git` is performed. git-gate - (SSH push/fetch) and egress (HTTPS) broker different - protocols; declaring both for the same host is a legitimate - dev setup.""" + (SSH push/fetch) and egress (HTTPS) broker different protocols; + declaring both for the same host is a legitimate dev setup.""" seen_hosts: dict[str, None] = {} for r in routes: key = r.Host.lower() @@ -1071,25 +1044,6 @@ def _validate_egress_routes( f"{r.Host!r}; each host must be unique on the proxy." ) seen_hosts[key] = None - for role in EGRESS_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) - raise ManifestError( - f"bottle '{bottle_name}' egress.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." - ) - allowed_roles = PROVIDER_EGRESS_ROLES[agent_provider_template] - for route in routes: - for role in route.Role: - if role not in allowed_roles: - raise ManifestError( - f"bottle '{bottle_name}' egress route for host " - f"{route.Host!r} has role {role!r}, but provider " - f"{agent_provider_template!r} only accepts roles " - f"{', '.join(sorted(allowed_roles)) or '(none)'}" - ) def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: @@ -1300,10 +1254,7 @@ def _merge_bottles( merged_supervise = ( child.supervise if "supervise" in child_raw else parent.supervise ) - _validate_egress_routes( - name, merged_egress.routes, - agent_provider_template=merged_agent_provider.template, - ) + _validate_egress_routes(name, merged_egress.routes) return Bottle( env=merged_env, -- 2.52.0