fix(codex): forward host credentials to api route
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user