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:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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([{
|
||||||
|
|||||||
@@ -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"}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user