fix(codex): forward host credentials to api route

This commit is contained in:
2026-05-29 03:34:11 -04:00
committed by didericis
parent 711cb9c194
commit 62dd7b2aa5
4 changed files with 89 additions and 40 deletions
+4 -3
View File
@@ -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
+33 -13
View File
@@ -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: <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
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(
+28 -21
View File
@@ -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.
+24 -3
View File
@@ -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(