PRD 0029: provision egress routes via AgentProvisionPlan #115
@@ -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(
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
Similarly to codex, we should always include these in egress routes (whether or not auth_token is present), but we only have the egress add the auth token when it's present Similarly to codex, we should always include these in egress routes (whether or not auth_token is present), but we only have the egress add the auth token when it's present
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+34
-27
@@ -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.
|
||||
|
didericis
commented
should remove this/is no longer necessary should remove this/is no longer necessary
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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([{
|
||||
|
||||
@@ -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"}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
This doesn't need to be a separate function, and the organization is wrong/we want the code for a specific provider to be making the decision about placeholder envs (a function which switches on different provider templates is the wrong abstraction)