feat(manifest): add agent_provider.auth_token for Claude OAuth via egress

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
This commit is contained in:
2026-06-02 01:24:18 +00:00
committed by didericis
parent 952dcd7eec
commit de9bd7eb83
11 changed files with 136 additions and 113 deletions
+17 -15
View File
@@ -33,7 +33,6 @@ class AgentProviderRuntime:
command: str command: str
image: str image: str
dockerfile: str dockerfile: str
auth_role: str
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_args: tuple[str, ...] resume_args: tuple[str, ...]
@@ -73,6 +72,11 @@ class AgentProvisionPlan:
pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps
provider logic out of the egress and pipelock modules — they merge provider logic out of the egress and pipelock modules — they merge
provider routes generically without knowing the provider type. 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 template: str
@@ -87,6 +91,7 @@ class AgentProvisionPlan:
pre_copy: tuple[AgentProvisionCommand, ...] = () pre_copy: tuple[AgentProvisionCommand, ...] = ()
verify: tuple[AgentProvisionCommand, ...] = () verify: tuple[AgentProvisionCommand, ...] = ()
egress_routes: tuple[EgressRoute, ...] = () egress_routes: tuple[EgressRoute, ...] = ()
hidden_env_names: frozenset[str] = field(default_factory=frozenset)
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -98,7 +103,6 @@ _RUNTIMES = {
command="claude", command="claude",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"), dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
auth_role="claude_code_oauth",
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
@@ -109,7 +113,6 @@ _RUNTIMES = {
command="codex", command="codex",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"), dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
auth_role="",
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
@@ -122,13 +125,6 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return _RUNTIMES[template] 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( def agent_provision_plan(
*, *,
template: str, template: str,
@@ -136,14 +132,11 @@ def agent_provision_plan(
state_dir: Path, state_dir: Path,
guest_home: str = "/home/node", guest_home: str = "/home/node",
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
manifest_egress_routes: tuple[EgressRoute, ...] = (),
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
runtime = runtime_for(template) 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 {}) resolved_guest_env = dict(guest_env or {})
env_vars: dict[str, str] = {} env_vars: dict[str, str] = {}
dirs: list[AgentProvisionDir] = [] dirs: list[AgentProvisionDir] = []
@@ -151,6 +144,7 @@ def agent_provision_plan(
pre_copy: list[AgentProvisionCommand] = [] pre_copy: list[AgentProvisionCommand] = []
verify: list[AgentProvisionCommand] = [] verify: list[AgentProvisionCommand] = []
egress_routes: list[EgressRoute] = [] egress_routes: list[EgressRoute] = []
hidden_env_names: frozenset[str] = frozenset()
if template == PROVIDER_CODEX: if template == PROVIDER_CODEX:
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt" 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 " "codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it" "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_OAUTH_TOKEN"] = "egress-placeholder"
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
env_vars["DISABLE_ERROR_REPORTING"] = "1" env_vars["DISABLE_ERROR_REPORTING"] = "1"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
return AgentProvisionPlan( return AgentProvisionPlan(
template=template, template=template,
@@ -217,6 +218,7 @@ def agent_provision_plan(
pre_copy=tuple(pre_copy), pre_copy=tuple(pre_copy),
verify=tuple(verify), verify=tuple(verify),
egress_routes=tuple(egress_routes), egress_routes=tuple(egress_routes),
hidden_env_names=hidden_env_names,
) )
+1 -1
View File
@@ -89,7 +89,7 @@ class DockerBottlePlan(BottlePlan):
| set(self.forwarded_env.keys()) | set(self.forwarded_env.keys())
| set(self.agent_provision.guest_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) print(file=sys.stderr)
+2 -2
View File
@@ -16,7 +16,7 @@ from dataclasses import replace
from pathlib import Path from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for 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 ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...log import die from ...log import die
@@ -178,7 +178,7 @@ def resolve_plan(
state_dir=agent_dir, state_dir=agent_dir,
guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"),
forward_host_credentials=provider.forward_host_credentials, forward_host_credentials=provider.forward_host_credentials,
manifest_egress_routes=egress_manifest_routes(bottle), auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
) )
guest_env = dict(agent_provision.guest_env) guest_env = dict(agent_provision.guest_env)
+6 -10
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from typing import Sequence from typing import Sequence
from ..agent_provider import placeholder_env_for
from ..log import info from ..log import info
@@ -30,16 +29,13 @@ def print_multi(label: str, values: Sequence[str]) -> None:
def visible_agent_env_names( def visible_agent_env_names(
env_names: Sequence[str], *, agent_provider_template: str, env_names: Sequence[str], *, hidden_env_names: frozenset[str],
) -> list[str]: ) -> list[str]:
"""Env names worth showing in launch summaries. """Env names worth showing in launch summaries.
Provider auth placeholders (currently `CLAUDE_CODE_OAUTH_TOKEN`) Provider-injected placeholder env vars are implementation details:
are implementation details: they are non-secret dummy values that they are non-secret dummy values that satisfy provider CLIs while
satisfy the provider CLI while egress injects the real upstream egress injects the real Authorization header. The plan's
Authorization header. Showing them in preflight makes the operator `hidden_env_names` carries exactly which names to suppress.
think a real key is entering the agent, so hide only the active
provider-owned placeholder.
""" """
hidden = {placeholder_env_for(agent_provider_template)} return sorted({name for name in env_names if name and name not in hidden_env_names})
return sorted({name for name in env_names if name and name not in hidden})
@@ -125,7 +125,7 @@ class SmolmachinesBottlePlan(BottlePlan):
set(bottle.env.keys()) set(bottle.env.keys())
| set(self.agent_provision.guest_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 = [ upstreams = [
f"{g.Name}{g.Upstream}" for g in bottle.git f"{g.Name}{g.Upstream}" for g in bottle.git
+1 -2
View File
@@ -16,7 +16,6 @@ from dataclasses import replace
from pathlib import Path from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for from ...agent_provider import agent_provision_plan, runtime_for
from ...egress import egress_manifest_routes
from ...backend import BottleSpec from ...backend import BottleSpec
from ...backend.docker.bottle_state import ( from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
@@ -134,7 +133,7 @@ def resolve_plan(
guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"), guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"),
guest_env=guest_env, guest_env=guest_env,
forward_host_credentials=provider.forward_host_credentials, forward_host_credentials=provider.forward_host_credentials,
manifest_egress_routes=egress_manifest_routes(bottle), auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
) )
merged_guest_env = dict(agent_provision.guest_env) merged_guest_env = dict(agent_provision.guest_env)
+34 -27
View File
@@ -179,40 +179,28 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# a specific named part in the bottle's auth flow"; the launch step # a specific named part in the bottle's auth flow"; the launch step
# acts on the marker. # acts on the marker.
# #
# claude_code_oauth: this route auth-injects on the agent's # codex_auth: placeholder marker for Codex egress-held auth flows.
# claude-code OAuth flow. Triggers prepare.py # Accepted on Codex routes for forward-compatibility;
# to ship a placeholder CLAUDE_CODE_OAUTH_TOKEN # the provisioner does not act on it today.
# 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.
# #
# Routes without a `role` are pure proxy entries: egress # Routes without a `role` are pure proxy entries: egress
# enforces path_allowlist + injects auth on its own, but nothing # enforces path_allowlist + injects auth on its own, but nothing
# special happens on the agent side. # 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({ EGRESS_ROLES = frozenset({
"claude_code_oauth",
"codex_auth", "codex_auth",
}) })
# Singleton roles may appear on at most one route per bottle. Some # Singleton roles may appear on at most one route per bottle.
# roles drive a single provider auth path; two routes claiming one
# marker would leave "which one is canonical?" ambiguous.
EGRESS_SINGLETON_ROLES = frozenset({ EGRESS_SINGLETON_ROLES = frozenset({
"claude_code_oauth",
"codex_auth", "codex_auth",
}) })
PROVIDER_EGRESS_ROLES = { PROVIDER_EGRESS_ROLES = {
"claude": frozenset({"claude_code_oauth"}), "claude": frozenset(),
"codex": frozenset({"codex_auth"}), "codex": frozenset({"codex_auth"}),
} }
@@ -224,20 +212,30 @@ class AgentProvider:
`template` selects a built-in launch/runtime contract. `dockerfile` `template` selects a built-in launch/runtime contract. `dockerfile`
optionally points at a custom agent-image Dockerfile while leaving optionally points at a custom agent-image Dockerfile while leaving
bot-bottle's sidecar infrastructure intact. 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" template: str = "claude"
dockerfile: str = "" dockerfile: str = ""
auth_token: str = ""
forward_host_credentials: bool = False forward_host_credentials: bool = False
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider": def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider") d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d: 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( raise ManifestError(
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; " 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") template = d.get("template", "claude")
if not isinstance(template, str) or not template: 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"bottle '{bottle_name}' agent_provider.dockerfile must be a "
f"string (was {type(dockerfile).__name__})" 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) forward_host_credentials = d.get("forward_host_credentials", False)
if not isinstance(forward_host_credentials, bool): if not isinstance(forward_host_credentials, bool):
raise ManifestError( raise ManifestError(
@@ -270,6 +279,7 @@ class AgentProvider:
return cls( return cls(
template=template, template=template,
dockerfile=dockerfile, dockerfile=dockerfile,
auth_token=auth_token,
forward_host_credentials=forward_host_credentials, forward_host_credentials=forward_host_credentials,
) )
@@ -428,10 +438,7 @@ class EgressRoute:
manifest's `auth` block is omitted both fields are empty strings — manifest's `auth` block is omitted both fields are empty strings —
no Authorization is written, no token forwarded. no Authorization is written, no token forwarded.
`Role` is an optional tuple of named markers (see `Role` is an optional tuple of named markers (see EGRESS_ROLES).
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).
Validation rules (enforced in `from_dict`): Validation rules (enforced in `from_dict`):
- `host` required, non-empty. - `host` required, non-empty.
@@ -1,4 +1,4 @@
# PRD 0029: Codex host credentials through egress # PRD 0029: Provider auth credentials through egress
- **Status:** Draft - **Status:** Draft
- **Author:** didericis-codex - **Author:** didericis-codex
@@ -7,9 +7,12 @@
## Summary ## Summary
Allow Codex bottles to use a host-authorized ChatGPT/device-login Allow provider bottles to inject host credentials into the egress
access token by forwarding it only into the egress sidecar, gated by an sidecar without exposing them to the agent. Codex uses
explicit `agent_provider.forward_host_credentials` manifest flag. `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 ## Problem
@@ -51,8 +54,8 @@ possible, not in the agent.
current access token at launch; operators can restart after host Codex current access token at launch; operators can restart after host Codex
refreshes auth. refreshes auth.
- Copying host `~/.codex/auth.json` credentials into the agent. - Copying host `~/.codex/auth.json` credentials into the agent.
- Allowing arbitrary host credential forwarding. This PRD covers Codex - Allowing arbitrary host credential forwarding beyond the two providers
ChatGPT/device-login credentials only. covered here (Codex ChatGPT/device-login and Claude OAuth).
- Hot-applying new authenticated Codex routes to an existing running - Hot-applying new authenticated Codex routes to an existing running
sidecar. The current hot-apply path cannot safely populate new token sidecar. The current hot-apply path cannot safely populate new token
env slots in an already-running container. 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 - Add `agent_provider.forward_host_credentials` to the bottle manifest
schema, defaulting to `false`. schema, defaulting to `false`.
- Support the flag for `agent_provider.template: codex`. - 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 - Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is
set, otherwise from `~/.codex/auth.json`. set, otherwise from `~/.codex/auth.json`.
- Extract only `tokens.access_token` for egress injection. - Extract only `tokens.access_token` for egress injection.
+10 -14
View File
@@ -13,7 +13,7 @@ from bot_bottle.agent_provider import (
agent_provision_plan, agent_provision_plan,
runtime_for, 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: def _jwt(exp: int) -> str:
@@ -24,14 +24,6 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase): 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): def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = agent_provision_plan(
@@ -79,20 +71,24 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertEqual(1, len(plan.verify)) self.assertEqual(1, len(plan.verify))
self.assertIn("CODEX_HOME=/run/codex-home", plan.verify[0].argv) 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: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = agent_provision_plan(
template="claude", template="claude",
dockerfile="/tmp/Dockerfile.claude", dockerfile="/tmp/Dockerfile.claude",
state_dir=Path(tmp), state_dir=Path(tmp),
manifest_egress_routes=(EgressRoute( auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
host="api.anthropic.com",
roles=("claude_code_oauth",),
),),
) )
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("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["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"])
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) 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): def test_codex_forward_host_credentials_populates_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
+42 -31
View File
@@ -85,6 +85,31 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
"forward_host_credentials": True, "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): class TestPathAllowlist(unittest.TestCase):
def test_optional(self): def test_optional(self):
@@ -179,22 +204,20 @@ class TestRole(unittest.TestCase):
self.assertEqual((), b.egress.routes[0].Role) self.assertEqual((), b.egress.routes[0].Role)
def test_string_normalizes_to_tuple(self): def test_string_normalizes_to_tuple(self):
b = _bottle([{ b = _provider_bottle("codex", [{
"host": "api.anthropic.com", "host": "api.openai.com",
"role": "claude_code_oauth", "role": "codex_auth",
"auth": {"scheme": "Bearer", "token_ref": "T"}, "auth": {"scheme": "Bearer", "token_ref": "T"},
}]) }])
self.assertEqual(("claude_code_oauth",), self.assertEqual(("codex_auth",), b.egress.routes[0].Role)
b.egress.routes[0].Role)
def test_list_supported(self): def test_list_supported(self):
b = _bottle([{ b = _provider_bottle("codex", [{
"host": "api.anthropic.com", "host": "api.openai.com",
"role": ["claude_code_oauth"], "role": ["codex_auth"],
"auth": {"scheme": "Bearer", "token_ref": "T"}, "auth": {"scheme": "Bearer", "token_ref": "T"},
}]) }])
self.assertEqual(("claude_code_oauth",), self.assertEqual(("codex_auth",), b.egress.routes[0].Role)
b.egress.routes[0].Role)
def test_unknown_role_rejected(self): def test_unknown_role_rejected(self):
# The role enum is locked down — typos shouldn't silently # The role enum is locked down — typos shouldn't silently
@@ -202,6 +225,14 @@ class TestRole(unittest.TestCase):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{"host": "x.example", "role": "totally-made-up"}]) _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): def test_non_string_role_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{"host": "x.example", "role": 42}]) _bottle([{"host": "x.example", "role": 42}])
@@ -209,19 +240,7 @@ class TestRole(unittest.TestCase):
def test_list_with_non_string_item_rejected(self): def test_list_with_non_string_item_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{"host": "x.example", _bottle([{"host": "x.example",
"role": ["claude_code_oauth", 42]}]) "role": ["codex_auth", 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"}},
])
def test_codex_auth_role_allowed_for_codex_provider(self): def test_codex_auth_role_allowed_for_codex_provider(self):
b = _provider_bottle("codex", [{ b = _provider_bottle("codex", [{
@@ -231,14 +250,6 @@ class TestRole(unittest.TestCase):
}]) }])
self.assertEqual(("codex_auth",), b.egress.routes[0].Role) 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): def test_codex_role_rejected_for_default_claude_provider(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{ _bottle([{
+4 -4
View File
@@ -8,21 +8,21 @@ from bot_bottle.backend.print_util import visible_agent_env_names
class TestVisibleAgentEnvNames(unittest.TestCase): 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( self.assertEqual(
["CUSTOM", "OPENAI_API_KEY"], ["CUSTOM", "OPENAI_API_KEY"],
visible_agent_env_names( visible_agent_env_names(
["OPENAI_API_KEY", "CUSTOM"], ["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( self.assertEqual(
["CUSTOM", "OPENAI_API_KEY"], ["CUSTOM", "OPENAI_API_KEY"],
visible_agent_env_names( visible_agent_env_names(
["CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CUSTOM"], ["CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CUSTOM"],
agent_provider_template="claude", hidden_env_names=frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}),
), ),
) )