feat(codex): inject host credentials via egress

This commit is contained in:
2026-05-29 03:21:43 -04:00
committed by didericis
parent 0b80ffb16a
commit 711cb9c194
9 changed files with 378 additions and 12 deletions
+15 -2
View File
@@ -352,10 +352,14 @@ auth through egress and gitea.dideric.is over SSH.
For a Codex-backed base bottle, set `agent_provider.template: codex`.
The Codex template expects ChatGPT/device login state instead of an
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
agent. To let headless device-code login request a user code, add an
unauthenticated egress route for the device-auth endpoint:
agent. To let bot-bottle read the host's current Codex ChatGPT access
token and inject it from egress only, opt in explicitly:
```yaml
agent_provider:
template: codex
forward_host_credentials: true
egress:
routes:
- host: auth.openai.com
@@ -363,6 +367,15 @@ egress:
- /api/accounts/deviceauth/
```
Run `codex login --device-auth` on the host before launch. The
launcher reads only `tokens.access_token` from the host's
`~/.codex/auth.json`, verifies it is fresh ChatGPT auth, and passes it
to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container does
not receive `auth.json`, refresh tokens, access-token env vars, or
`OPENAI_API_KEY`. The effective egress table automatically adds or
upgrades `chatgpt.com` to an authenticated route when
`forward_host_credentials` is true.
The built-in Codex template uses `Dockerfile.codex`; set
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
while keeping the bot-bottle sidecars in place.
+12 -1
View File
@@ -42,7 +42,11 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
from ...egress import egress_resolve_token_values
from ...codex_auth import codex_host_access_token
from ...egress import (
CODEX_HOST_CREDENTIAL_TOKEN_REF,
egress_resolve_token_values,
)
from ...log import info
from . import network as network_mod
from . import util as docker_mod
@@ -181,6 +185,13 @@ def launch(
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, dict(os.environ),
)
if plan.spec.manifest.bottle_for(
plan.spec.agent_name,
).agent_provider.forward_host_credentials:
access_token = codex_host_access_token(dict(os.environ))
for token_env, token_ref in plan.egress_plan.token_env_map.items():
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
token_values[token_env] = access_token
compose_env: dict[str, str] = {
**os.environ,
**plan.forwarded_env,
+16 -2
View File
@@ -26,7 +26,12 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
from ...codex_auth import codex_host_access_token
from ...egress import (
CODEX_HOST_CREDENTIAL_TOKEN_REF,
EGRESS_ROUTES_IN_CONTAINER,
egress_resolve_token_values,
)
from ...pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
@@ -423,7 +428,16 @@ def _resolve_token_env(
ep = plan.egress_plan
if not ep.routes:
return {}
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
env = dict(host_env)
token_values = egress_resolve_token_values(ep.token_env_map, env)
if plan.spec.manifest.bottle_for(
plan.spec.agent_name,
).agent_provider.forward_host_credentials:
access_token = codex_host_access_token(env)
for token_env, token_ref in ep.token_env_map.items():
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
token_values[token_env] = access_token
return token_values
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
+96
View File
@@ -0,0 +1,96 @@
"""Host Codex auth helpers.
Reads the host's Codex ChatGPT/device-login auth state and returns only
the short-lived access token needed by egress. This module deliberately
does not expose refresh tokens or raw auth payloads.
"""
from __future__ import annotations
import base64
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from .log import die
from .util import expand_tilde
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env
home = env.get("CODEX_HOME")
if home:
return Path(expand_tilde(home)) / "auth.json"
return Path.home() / ".codex" / "auth.json"
def codex_host_access_token(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
path = codex_auth_path(host_env)
if not path.is_file():
die(
f"codex host credentials: auth file missing at {path}. "
"Run `codex login --device-auth` on the host or disable "
"agent_provider.forward_host_credentials."
)
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"codex host credentials: {path} must contain a JSON object")
if raw.get("auth_mode") != "chatgpt":
die(
"codex host credentials: host Codex auth is not ChatGPT/device "
"auth. Run `codex login --device-auth` on the host."
)
tokens = raw.get("tokens")
if not isinstance(tokens, dict):
die(f"codex host credentials: {path} is missing tokens")
access = tokens.get("access_token")
if not isinstance(access, str) or not access:
die(
f"codex host credentials: {path} is missing tokens.access_token. "
"Run `codex login --device-auth` on the host."
)
exp = _jwt_exp(access)
if exp is None:
die("codex host credentials: tokens.access_token is not a JWT with exp")
check_now = now or datetime.now(timezone.utc)
if exp <= check_now:
die(
"codex host credentials: host Codex access token is expired. "
"Run `codex login --device-auth` on the host and restart the bottle."
)
return access
def _jwt_exp(token: str) -> datetime | None:
parts = token.split(".")
if len(parts) < 2:
return None
try:
payload = json.loads(_b64url_decode(parts[1]))
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return None
return datetime.fromtimestamp(exp, timezone.utc)
def _b64url_decode(value: str) -> str:
padded = value + ("=" * (-len(value) % 4))
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
__all__ = ["codex_auth_path", "codex_host_access_token"]
+48 -4
View File
@@ -31,6 +31,9 @@ from pathlib import Path
from .log import die
from .manifest import Bottle
CODEX_CHATGPT_HOST = "chatgpt.com"
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
# DNS name agents will dial for the per-bottle egress sidecar.
# Backend-agnostic by contract: every concrete backend (Docker today,
@@ -174,11 +177,50 @@ def egress_routes_for_bottle(
"""Effective egress routes. This is what gets rendered into
routes.yaml + what the addon enforces.
Operators that want to allow a host declare it directly in
Operators that want to allow a host usually declare it directly in
`bottle.egress.routes` as an authenticated route or bare-pass entry
(`- host: <name>`). The legacy `bottle.egress.allowlist`
folding is gone egress is the single allowlist surface."""
return egress_manifest_routes(bottle)
(`- host: <name>`). Codex host-credential forwarding is the
provider-owned exception: when explicitly enabled, it adds or
upgrades `chatgpt.com` to an egress-owned authenticated route. The
legacy `bottle.egress.allowlist` folding is gone egress is the
single allowlist surface."""
routes = list(egress_manifest_routes(bottle))
if not bottle.agent_provider.forward_host_credentials:
return tuple(routes)
if bottle.agent_provider.template != "codex":
return tuple(routes)
for idx, route in enumerate(routes):
if route.host.lower() != CODEX_CHATGPT_HOST:
continue
if route.auth_scheme or route.token_ref:
die(
"codex host credential forwarding conflicts with an "
"authenticated egress route for chatgpt.com. Remove that "
"route auth block or disable agent_provider.forward_host_credentials."
)
routes[idx] = EgressRoute(
host=route.host,
path_allowlist=route.path_allowlist,
auth_scheme="Bearer",
token_env=_next_token_env(routes),
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
roles=route.roles,
)
return tuple(routes)
routes.append(EgressRoute(
host=CODEX_CHATGPT_HOST,
auth_scheme="Bearer",
token_env=_next_token_env(routes),
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
))
return tuple(routes)
def _next_token_env(routes: list[EgressRoute]) -> str:
return f"EGRESS_TOKEN_{len({r.token_env for r in routes if r.token_env})}"
def egress_token_env_map(
@@ -251,6 +293,8 @@ def egress_resolve_token_values(
a sealed mapping without touching `os.environ`."""
out: dict[str, str] = {}
for token_env, token_ref in token_env_map.items():
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
continue
value = host_env.get(token_ref)
if value is None:
die(
+19 -3
View File
@@ -228,15 +228,16 @@ class AgentProvider:
template: str = "claude"
dockerfile: 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"}:
if k not in {"template", "dockerfile", "forward_host_credentials"}:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
f"allowed: template, dockerfile"
f"allowed: template, dockerfile, forward_host_credentials"
)
template = d.get("template", "claude")
if not isinstance(template, str) or not template:
@@ -255,7 +256,22 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
f"string (was {type(dockerfile).__name__})"
)
return cls(template=template, dockerfile=dockerfile)
forward_host_credentials = d.get("forward_host_credentials", False)
if not isinstance(forward_host_credentials, bool):
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"must be a boolean (was {type(forward_host_credentials).__name__})"
)
if forward_host_credentials and template != "codex":
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is currently only supported for template 'codex'"
)
return cls(
template=template,
dockerfile=dockerfile,
forward_host_credentials=forward_host_credentials,
)
@dataclass(frozen=True)
+83
View File
@@ -0,0 +1,83 @@
"""Unit: host Codex auth extraction."""
from __future__ import annotations
import base64
import json
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from bot_bottle.codex_auth import codex_auth_path, codex_host_access_token
from bot_bottle.log import Die
def _jwt(exp: int) -> str:
def enc(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{enc({'alg': 'none'})}.{enc({'exp': exp})}.sig"
class TestCodexHostAccessToken(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="cb-codex-auth.")
self.home = Path(self.tmp.name)
self.auth_path = self.home / "auth.json"
def tearDown(self):
self.tmp.cleanup()
def _write(self, payload: dict) -> None:
self.auth_path.write_text(json.dumps(payload))
def test_auth_path_uses_codex_home(self):
self.assertEqual(
self.auth_path,
codex_auth_path({"CODEX_HOME": str(self.home)}),
)
def test_returns_fresh_chatgpt_access_token(self):
token = _jwt(2000000000)
self._write({
"auth_mode": "chatgpt",
"tokens": {"access_token": token, "refresh_token": "hidden"},
})
out = codex_host_access_token(
{"CODEX_HOME": str(self.home)},
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(token, out)
def test_missing_auth_file_dies(self):
with self.assertRaises(Die):
codex_host_access_token({"CODEX_HOME": str(self.home)})
def test_non_chatgpt_auth_dies(self):
self._write({"auth_mode": "api_key", "tokens": {"access_token": _jwt(2)}})
with self.assertRaises(Die):
codex_host_access_token({"CODEX_HOME": str(self.home)})
def test_expired_token_dies(self):
self._write({
"auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(1000)},
})
with self.assertRaises(Die):
codex_host_access_token(
{"CODEX_HOME": str(self.home)},
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def test_non_jwt_token_dies(self):
self._write({
"auth_mode": "chatgpt",
"tokens": {"access_token": "not-a-jwt"},
})
with self.assertRaises(Die):
codex_host_access_token({"CODEX_HOME": str(self.home)})
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -4,6 +4,7 @@ resolution (PRD 0017)."""
import unittest
from bot_bottle.egress import (
CODEX_HOST_CREDENTIAL_TOKEN_REF,
egress_manifest_routes,
egress_render_routes,
egress_resolve_token_values,
@@ -22,6 +23,21 @@ def _bottle(routes):
}).bottles["dev"]
def _codex_bottle(*, forward_host_credentials: bool, routes):
return Manifest.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {
"template": "codex",
"forward_host_credentials": forward_host_credentials,
},
"egress": {"routes": routes},
}
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestRoutesForBottle(unittest.TestCase):
def test_authenticated_route_gets_slot(self):
b = _bottle([{
@@ -107,6 +123,38 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase):
effective = [r.host for r in egress_routes_for_bottle(b)]
self.assertEqual(["x.example"], effective)
def test_codex_forward_host_credentials_adds_chatgpt_route(self):
b = _codex_bottle(forward_host_credentials=True, routes=[])
routes = egress_routes_for_bottle(b)
self.assertEqual(["chatgpt.com"], [r.host for r in routes])
self.assertEqual("Bearer", routes[0].auth_scheme)
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
def test_codex_forward_host_credentials_upgrades_bare_chatgpt_route(self):
b = _codex_bottle(
forward_host_credentials=True,
routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}],
)
routes = egress_routes_for_bottle(b)
self.assertEqual(1, len(routes))
self.assertEqual("chatgpt.com", routes[0].host)
self.assertEqual("Bearer", routes[0].auth_scheme)
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
self.assertEqual(("/backend-api/",), routes[0].path_allowlist)
def test_codex_forward_host_credentials_conflicts_with_authed_route(self):
b = _codex_bottle(
forward_host_credentials=True,
routes=[{
"host": "chatgpt.com",
"auth": {"scheme": "Bearer", "token_ref": "OTHER"},
}],
)
with self.assertRaises(Die):
egress_routes_for_bottle(b)
class TestTokenEnvMap(unittest.TestCase):
def test_only_authenticated_routes_contribute(self):
@@ -217,6 +265,13 @@ class TestResolveTokenValues(unittest.TestCase):
{"GH_PAT": ""},
)
def test_codex_host_credential_ref_is_resolved_by_launch(self):
out = egress_resolve_token_values(
{"EGRESS_TOKEN_0": CODEX_HOST_CREDENTIAL_TOKEN_REF},
{},
)
self.assertEqual({}, out)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -29,6 +29,13 @@ def _provider_bottle(provider, routes):
}).bottles["dev"]
def _provider_config_bottle(agent_provider):
return Manifest.from_json_obj({
"bottles": {"dev": {"agent_provider": agent_provider}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestMinimalRoute(unittest.TestCase):
def test_host_only(self):
b = _bottle([{"host": "api.example.com"}])
@@ -52,6 +59,33 @@ class TestMinimalRoute(unittest.TestCase):
_bottle([{"host": "x.example", "wat": "yes"}])
class TestAgentProviderHostCredentials(unittest.TestCase):
def test_forward_host_credentials_defaults_false(self):
b = _provider_config_bottle({"template": "codex"})
self.assertFalse(b.agent_provider.forward_host_credentials)
def test_forward_host_credentials_allowed_for_codex(self):
b = _provider_config_bottle({
"template": "codex",
"forward_host_credentials": True,
})
self.assertTrue(b.agent_provider.forward_host_credentials)
def test_forward_host_credentials_must_be_boolean(self):
with self.assertRaises(ManifestError):
_provider_config_bottle({
"template": "codex",
"forward_host_credentials": "yes",
})
def test_forward_host_credentials_rejected_for_claude(self):
with self.assertRaises(ManifestError):
_provider_config_bottle({
"template": "claude",
"forward_host_credentials": True,
})
class TestPathAllowlist(unittest.TestCase):
def test_optional(self):
b = _bottle([{"host": "x.example"}])