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:
@@ -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