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
|
The Codex template expects ChatGPT/device login state instead of an
|
||||||
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
|
`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
|
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
|
```yaml
|
||||||
agent_provider:
|
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
|
to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container does
|
||||||
not receive `auth.json`, refresh tokens, access-token env vars, or
|
not receive `auth.json`, refresh tokens, access-token env vars, or
|
||||||
`OPENAI_API_KEY`. The effective egress table automatically adds or
|
`OPENAI_API_KEY`. The effective egress table automatically adds or
|
||||||
upgrades `chatgpt.com` to an authenticated route when
|
upgrades `api.openai.com` and `chatgpt.com` to authenticated routes
|
||||||
`forward_host_credentials` is true.
|
when `forward_host_credentials` is true.
|
||||||
|
|
||||||
The built-in Codex template uses `Dockerfile.codex`; set
|
The built-in Codex template uses `Dockerfile.codex`; set
|
||||||
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
|
`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 .log import die
|
||||||
from .manifest import Bottle
|
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"
|
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
|
`bottle.egress.routes` as an authenticated route or bare-pass entry
|
||||||
(`- host: <name>`). Codex host-credential forwarding is the
|
(`- host: <name>`). Codex host-credential forwarding is the
|
||||||
provider-owned exception: when explicitly enabled, it adds or
|
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
|
legacy `bottle.egress.allowlist` folding is gone — egress is the
|
||||||
single allowlist surface."""
|
single allowlist surface."""
|
||||||
routes = list(egress_manifest_routes(bottle))
|
routes = list(egress_manifest_routes(bottle))
|
||||||
@@ -191,36 +191,56 @@ def egress_routes_for_bottle(
|
|||||||
if bottle.agent_provider.template != "codex":
|
if bottle.agent_provider.template != "codex":
|
||||||
return tuple(routes)
|
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):
|
for idx, route in enumerate(routes):
|
||||||
if route.host.lower() != CODEX_CHATGPT_HOST:
|
if route.host.lower() != host:
|
||||||
continue
|
continue
|
||||||
if route.auth_scheme or route.token_ref:
|
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(
|
die(
|
||||||
"codex host credential forwarding conflicts with an "
|
"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."
|
"route auth block or disable agent_provider.forward_host_credentials."
|
||||||
)
|
)
|
||||||
routes[idx] = EgressRoute(
|
routes[idx] = EgressRoute(
|
||||||
host=route.host,
|
host=route.host,
|
||||||
path_allowlist=route.path_allowlist,
|
path_allowlist=route.path_allowlist,
|
||||||
auth_scheme="Bearer",
|
auth_scheme="Bearer",
|
||||||
token_env=_next_token_env(routes),
|
token_env=_codex_host_credential_token_env(routes),
|
||||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
roles=route.roles,
|
roles=route.roles,
|
||||||
)
|
)
|
||||||
return tuple(routes)
|
return routes
|
||||||
|
|
||||||
routes.append(EgressRoute(
|
routes.append(EgressRoute(
|
||||||
host=CODEX_CHATGPT_HOST,
|
host=host,
|
||||||
auth_scheme="Bearer",
|
auth_scheme="Bearer",
|
||||||
token_env=_next_token_env(routes),
|
token_env=_codex_host_credential_token_env(routes),
|
||||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
))
|
))
|
||||||
return tuple(routes)
|
return 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(
|
def egress_token_env_map(
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ explicit `agent_provider.forward_host_credentials` manifest flag.
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codex bottles can reach `chatgpt.com` after the host is added to egress,
|
Codex bottles can reach OpenAI hosts after they are added to egress, but
|
||||||
but requests to `chatgpt.com/backend-api/codex/...` still fail with
|
requests to Codex's ChatGPT-backed API endpoints still fail with HTTP
|
||||||
HTTP 403. The egress proxy strips agent-originated `Authorization`
|
403 when the egress route is unauthenticated. The egress proxy strips
|
||||||
headers and only re-injects auth for routes that declare an egress-owned
|
agent-originated `Authorization` headers and only re-injects auth for
|
||||||
token. A bare `chatgpt.com` route therefore forwards Codex requests
|
routes that declare an egress-owned token. Bare `api.openai.com` or
|
||||||
without the ChatGPT bearer token.
|
`chatgpt.com` routes therefore forward Codex requests without the
|
||||||
|
ChatGPT bearer token.
|
||||||
|
|
||||||
Copying `~/.codex/auth.json` into the agent would solve auth but would
|
Copying `~/.codex/auth.json` into the agent would solve auth but would
|
||||||
also put access and refresh material inside the agent sandbox. That cuts
|
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
|
## Goals / Success Criteria
|
||||||
|
|
||||||
- A Codex bottle with host ChatGPT auth can call
|
- A Codex bottle with host ChatGPT auth can call Codex's
|
||||||
`chatgpt.com/backend-api/codex/...` through egress.
|
`api.openai.com` and `chatgpt.com` endpoints through egress.
|
||||||
- Host credential forwarding happens only when the bottle declares
|
- Host credential forwarding happens only when the bottle declares
|
||||||
`agent_provider.forward_host_credentials: true`.
|
`agent_provider.forward_host_credentials: true`.
|
||||||
- The agent container does not receive `OPENAI_API_KEY`,
|
- 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.
|
- Copying host `~/.codex/auth.json` into the agent.
|
||||||
- Allowing arbitrary host credential forwarding. This PRD covers Codex
|
- Allowing arbitrary host credential forwarding. This PRD covers Codex
|
||||||
ChatGPT/device-login credentials only.
|
ChatGPT/device-login credentials only.
|
||||||
- Hot-applying a new authenticated `chatgpt.com` route to an existing
|
- Hot-applying new authenticated Codex routes to an existing running
|
||||||
running sidecar. The current hot-apply path cannot safely populate new
|
sidecar. The current hot-apply path cannot safely populate new token
|
||||||
token env slots in an already-running container.
|
env slots in an already-running container.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -64,8 +65,9 @@ should live in the sidecar boundary when possible, not in the agent.
|
|||||||
- Extract only `tokens.access_token`.
|
- Extract only `tokens.access_token`.
|
||||||
- Validate that `auth_mode` is `chatgpt` and the access token is present,
|
- Validate that `auth_mode` is `chatgpt` and the access token is present,
|
||||||
JWT-shaped, and not expired.
|
JWT-shaped, and not expired.
|
||||||
- Add or upgrade a `chatgpt.com` egress route to inject that access token
|
- Add or upgrade `api.openai.com` and `chatgpt.com` egress routes to
|
||||||
via an `EGRESS_TOKEN_N` sidecar env slot.
|
inject that access token via a shared `EGRESS_TOKEN_N` sidecar env
|
||||||
|
slot.
|
||||||
- Pass the extracted token only into the sidecar compose/run
|
- Pass the extracted token only into the sidecar compose/run
|
||||||
environment, alongside other egress token values.
|
environment, alongside other egress token values.
|
||||||
|
|
||||||
@@ -109,16 +111,21 @@ operator at `codex login --device-auth`, without printing token values.
|
|||||||
### Egress route
|
### Egress route
|
||||||
|
|
||||||
When forwarding host Codex credentials, the effective egress route table
|
When forwarding host Codex credentials, the effective egress route table
|
||||||
should contain an authenticated `chatgpt.com` route. If the bottle
|
should contain authenticated `api.openai.com` and `chatgpt.com` routes.
|
||||||
already declares `chatgpt.com` as a bare-pass route, upgrade it in the
|
If the bottle already declares either host as a bare-pass route, upgrade
|
||||||
effective route table rather than requiring a duplicate manifest entry.
|
it in the effective route table rather than requiring a duplicate
|
||||||
If the bottle already declares an authenticated `chatgpt.com` route,
|
manifest entry. If the bottle already declares an authenticated route for
|
||||||
fail rather than guessing whether to override operator-provided auth.
|
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:
|
The rendered route should look like any other egress-owned auth route:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
routes:
|
routes:
|
||||||
|
- host: "api.openai.com"
|
||||||
|
auth_scheme: "Bearer"
|
||||||
|
token_env: "EGRESS_TOKEN_N"
|
||||||
- host: "chatgpt.com"
|
- host: "chatgpt.com"
|
||||||
auth_scheme: "Bearer"
|
auth_scheme: "Bearer"
|
||||||
token_env: "EGRESS_TOKEN_N"
|
token_env: "EGRESS_TOKEN_N"
|
||||||
@@ -135,7 +142,7 @@ flowchart LR
|
|||||||
H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"]
|
H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"]
|
||||||
L -->|access token only| S["egress sidecar env"]
|
L -->|access token only| S["egress sidecar env"]
|
||||||
A["Codex agent"] -->|HTTPS via proxy, auth stripped| E["egress"]
|
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
|
## Implementation chunks
|
||||||
@@ -146,8 +153,8 @@ flowchart LR
|
|||||||
unit tests.
|
unit tests.
|
||||||
3. **Host Codex auth reader.** Add a small stdlib-only helper for
|
3. **Host Codex auth reader.** Add a small stdlib-only helper for
|
||||||
parsing and validating host Codex auth without printing values.
|
parsing and validating host Codex auth without printing values.
|
||||||
4. **Effective egress route.** Add/upgrade the `chatgpt.com` route when
|
4. **Effective egress route.** Add/upgrade the Codex API routes when the
|
||||||
the flag is enabled, and add tests for bare route upgrade,
|
flag is enabled, and add tests for bare route upgrade,
|
||||||
missing-route insertion, and authenticated-route conflict.
|
missing-route insertion, and authenticated-route conflict.
|
||||||
5. **Launch wiring.** Pass the host access token into the egress sidecar
|
5. **Launch wiring.** Pass the host access token into the egress sidecar
|
||||||
env for Docker and smolmachines without exposing it to the agent.
|
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)]
|
effective = [r.host for r in egress_routes_for_bottle(b)]
|
||||||
self.assertEqual(["x.example"], effective)
|
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=[])
|
b = _codex_bottle(forward_host_credentials=True, routes=[])
|
||||||
routes = egress_routes_for_bottle(b)
|
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("Bearer", routes[0].auth_scheme)
|
||||||
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||||
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
|
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):
|
def test_codex_forward_host_credentials_upgrades_bare_chatgpt_route(self):
|
||||||
b = _codex_bottle(
|
b = _codex_bottle(
|
||||||
@@ -137,12 +140,30 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase):
|
|||||||
routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}],
|
routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}],
|
||||||
)
|
)
|
||||||
routes = egress_routes_for_bottle(b)
|
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("chatgpt.com", routes[0].host)
|
||||||
self.assertEqual("Bearer", routes[0].auth_scheme)
|
self.assertEqual("Bearer", routes[0].auth_scheme)
|
||||||
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||||
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
|
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
|
||||||
self.assertEqual(("/backend-api/",), routes[0].path_allowlist)
|
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):
|
def test_codex_forward_host_credentials_conflicts_with_authed_route(self):
|
||||||
b = _codex_bottle(
|
b = _codex_bottle(
|
||||||
|
|||||||
Reference in New Issue
Block a user