diff --git a/README.md b/README.md index 2e29903..d688566 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,8 @@ 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 bot-bottle read the host's current Codex ChatGPT access -token and inject it from egress only, opt in explicitly: +token and inject it from egress only for Codex's API calls, opt in +explicitly: ```yaml agent_provider: @@ -373,8 +374,8 @@ launcher reads only `tokens.access_token` from the host's 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. +upgrades `api.openai.com` and `chatgpt.com` to authenticated routes +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 diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 1103d2a..5b5a794 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -31,7 +31,7 @@ from pathlib import Path from .log import die from .manifest import Bottle -CODEX_CHATGPT_HOST = "chatgpt.com" +CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" @@ -181,7 +181,7 @@ def egress_routes_for_bottle( `bottle.egress.routes` as an authenticated route or bare-pass entry (`- 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 + upgrades the Codex API hosts to egress-owned authenticated routes. The legacy `bottle.egress.allowlist` folding is gone — egress is the single allowlist surface.""" routes = list(egress_manifest_routes(bottle)) @@ -191,36 +191,56 @@ def egress_routes_for_bottle( if bottle.agent_provider.template != "codex": return tuple(routes) + for host in CODEX_HOST_CREDENTIAL_HOSTS: + routes = _ensure_codex_host_credential_route(routes, host) + 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 _codex_host_credential_token_env(routes: list[EgressRoute]) -> str: + for route in routes: + if route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF: + return route.token_env + return _next_token_env(routes) + + +def _ensure_codex_host_credential_route( + routes: list[EgressRoute], host: str, +) -> list[EgressRoute]: for idx, route in enumerate(routes): - if route.host.lower() != CODEX_CHATGPT_HOST: + if route.host.lower() != host: continue if route.auth_scheme or route.token_ref: + if ( + route.auth_scheme == "Bearer" + and route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF + ): + return routes die( "codex host credential forwarding conflicts with an " - "authenticated egress route for chatgpt.com. Remove that " + f"authenticated egress route for {host}. 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_env=_codex_host_credential_token_env(routes), token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, roles=route.roles, ) - return tuple(routes) + return routes routes.append(EgressRoute( - host=CODEX_CHATGPT_HOST, + host=host, auth_scheme="Bearer", - token_env=_next_token_env(routes), + token_env=_codex_host_credential_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})}" + return routes def egress_token_env_map( diff --git a/docs/prds/0029-codex-host-credentials-egress.md b/docs/prds/0029-codex-host-credentials-egress.md index a26bb35..b812d15 100644 --- a/docs/prds/0029-codex-host-credentials-egress.md +++ b/docs/prds/0029-codex-host-credentials-egress.md @@ -13,12 +13,13 @@ explicit `agent_provider.forward_host_credentials` manifest flag. ## Problem -Codex bottles can reach `chatgpt.com` after the host is added to egress, -but requests to `chatgpt.com/backend-api/codex/...` still fail with -HTTP 403. The egress proxy strips agent-originated `Authorization` -headers and only re-injects auth for routes that declare an egress-owned -token. A bare `chatgpt.com` route therefore forwards Codex requests -without the ChatGPT bearer token. +Codex bottles can reach OpenAI hosts after they are added to egress, but +requests to Codex's ChatGPT-backed API endpoints still fail with HTTP +403 when the egress route is unauthenticated. The egress proxy strips +agent-originated `Authorization` headers and only re-injects auth for +routes that declare an egress-owned token. Bare `api.openai.com` or +`chatgpt.com` routes therefore forward Codex requests without the +ChatGPT bearer token. Copying `~/.codex/auth.json` into the agent would solve auth but would also put access and refresh material inside the agent sandbox. That cuts @@ -27,8 +28,8 @@ should live in the sidecar boundary when possible, not in the agent. ## Goals / Success Criteria -- A Codex bottle with host ChatGPT auth can call - `chatgpt.com/backend-api/codex/...` through egress. +- A Codex bottle with host ChatGPT auth can call Codex's + `api.openai.com` and `chatgpt.com` endpoints through egress. - Host credential forwarding happens only when the bottle declares `agent_provider.forward_host_credentials: true`. - The agent container does not receive `OPENAI_API_KEY`, @@ -48,9 +49,9 @@ should live in the sidecar boundary when possible, not in the agent. - Copying host `~/.codex/auth.json` into the agent. - Allowing arbitrary host credential forwarding. This PRD covers Codex ChatGPT/device-login credentials only. -- Hot-applying a new authenticated `chatgpt.com` route to an existing - running sidecar. The current hot-apply path cannot safely populate new - token env slots in an already-running container. +- 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. ## Scope @@ -64,8 +65,9 @@ should live in the sidecar boundary when possible, not in the agent. - Extract only `tokens.access_token`. - Validate that `auth_mode` is `chatgpt` and the access token is present, JWT-shaped, and not expired. -- Add or upgrade a `chatgpt.com` egress route to inject that access token - via an `EGRESS_TOKEN_N` sidecar env slot. +- Add or upgrade `api.openai.com` and `chatgpt.com` egress routes to + inject that access token via a shared `EGRESS_TOKEN_N` sidecar env + slot. - Pass the extracted token only into the sidecar compose/run environment, alongside other egress token values. @@ -109,16 +111,21 @@ operator at `codex login --device-auth`, without printing token values. ### Egress route When forwarding host Codex credentials, the effective egress route table -should contain an authenticated `chatgpt.com` route. If the bottle -already declares `chatgpt.com` as a bare-pass route, upgrade it in the -effective route table rather than requiring a duplicate manifest entry. -If the bottle already declares an authenticated `chatgpt.com` route, -fail rather than guessing whether to override operator-provided auth. +should contain authenticated `api.openai.com` and `chatgpt.com` routes. +If the bottle already declares either host as a bare-pass route, upgrade +it in the effective route table rather than requiring a duplicate +manifest entry. If the bottle already declares an authenticated route for +either host, fail rather than guessing whether to override +operator-provided auth, unless that route already uses the synthetic +Codex host credential token reference. The rendered route should look like any other egress-owned auth route: ```yaml routes: + - host: "api.openai.com" + auth_scheme: "Bearer" + token_env: "EGRESS_TOKEN_N" - host: "chatgpt.com" auth_scheme: "Bearer" token_env: "EGRESS_TOKEN_N" @@ -135,7 +142,7 @@ flowchart LR H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"] L -->|access token only| S["egress sidecar env"] A["Codex agent"] -->|HTTPS via proxy, auth stripped| E["egress"] - E -->|Bearer injected from env| C["chatgpt.com"] + E -->|Bearer injected from env| C["api.openai.com / chatgpt.com"] ``` ## Implementation chunks @@ -146,8 +153,8 @@ flowchart LR unit tests. 3. **Host Codex auth reader.** Add a small stdlib-only helper for parsing and validating host Codex auth without printing values. -4. **Effective egress route.** Add/upgrade the `chatgpt.com` route when - the flag is enabled, and add tests for bare route upgrade, +4. **Effective egress route.** Add/upgrade the Codex API routes when the + flag is enabled, and add tests for bare route upgrade, missing-route insertion, and authenticated-route conflict. 5. **Launch wiring.** Pass the host access token into the egress sidecar env for Docker and smolmachines without exposing it to the agent. diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index aa3a545..420d6e6 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -123,13 +123,16 @@ 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): + def test_codex_forward_host_credentials_adds_codex_routes(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(["api.openai.com", "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) + self.assertEqual("Bearer", routes[1].auth_scheme) + self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) + self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[1].token_ref) def test_codex_forward_host_credentials_upgrades_bare_chatgpt_route(self): b = _codex_bottle( @@ -137,12 +140,30 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase): routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}], ) routes = egress_routes_for_bottle(b) - self.assertEqual(1, len(routes)) + self.assertEqual(2, 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) + self.assertEqual("api.openai.com", routes[1].host) + self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) + + def test_codex_forward_host_credentials_accepts_explicit_synthetic_route(self): + b = _codex_bottle( + forward_host_credentials=True, + routes=[{ + "host": "api.openai.com", + "auth": { + "scheme": "Bearer", + "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF, + }, + }], + ) + routes = egress_routes_for_bottle(b) + self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes]) + self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env) + self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env) def test_codex_forward_host_credentials_conflicts_with_authed_route(self): b = _codex_bottle(