feat(manifest): add agent_provider.auth_token for Claude OAuth via egress
test / unit (pull_request) Successful in 39s
test / integration (pull_request) Successful in 1m0s

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
parent 2eb6e02ee1
commit 200a113cce
11 changed files with 136 additions and 113 deletions
+17 -15
View File
@@ -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,
)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
+6 -10
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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.
@@ -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.
+10 -14
View File
@@ -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:
+42 -31
View File
@@ -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([{
+4 -4
View File
@@ -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"}),
),
)