diff --git a/README.md b/README.md index 48a8334..2e29903 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 37dfa65..e811cbe 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -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, diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 68863b5..da73dae 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -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: diff --git a/bot_bottle/codex_auth.py b/bot_bottle/codex_auth.py new file mode 100644 index 0000000..707c562 --- /dev/null +++ b/bot_bottle/codex_auth.py @@ -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"] diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 5f93b24..1103d2a 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -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: `). The legacy `bottle.egress.allowlist` - folding is gone — egress is the single allowlist surface.""" - return egress_manifest_routes(bottle) + (`- host: `). 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( diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 6838961..9f69369 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -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) diff --git a/tests/unit/test_codex_auth.py b/tests/unit/test_codex_auth.py new file mode 100644 index 0000000..47dd461 --- /dev/null +++ b/tests/unit/test_codex_auth.py @@ -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() diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 36225ee..aa3a545 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -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() diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index 4bd690c..4d82c6b 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -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"}])