# Codex Gateway (Entra JWT → OpenAI)

Dedicated AgentGateway for **OpenAI Codex** (CLI + ChatGPT desktop Codex):
Microsoft Entra ID JWT on the data plane, OpenAI upstream with the Vault-synced
API key. Same pattern as the [Claude Desktop gateway](claude-desktop-gateway.md)
— dedicated Gateway, Strict JWT, reused JWKS + provider secret — but Codex talks
the **Responses API** (`/v1/responses`) and **allows plain HTTP on non-loopback**.

**Related:** [Codex lab page](https://goose.maniak.ai/codex.html)
([`#config`](https://goose.maniak.ai/codex.html#config) ·
[`#links`](https://goose.maniak.ai/codex.html#links)) · sibling open OpenAI path
`:30160/openai` (no Entra, gpt-5.5 pinned, used by kagent) on the
[LLM gateway](https://goose.maniak.ai/llm-gateway.html#endpoints).

Official docs:
[Solo Codex](https://docs.solo.io/agentgateway/latest/integrations/llm-clients/codex/) ·
[agentgateway.dev Codex](https://agentgateway.dev/docs/kubernetes/latest/integrations/llm-clients/codex/) ·
[OpenAI provider](https://docs.solo.io/agentgateway/latest/llm/providers/openai/).
Tested with **codex-cli 0.144.4**.

## Architecture

```
Codex CLI / ChatGPT desktop Codex
  │  Authorization: Bearer <Entra ID token>   (CODEX_AGW_TOKEN)
  │  POST {base}/v1/responses   (base URL MUST end in /v1)
  ▼
optional scripts/codex-lab-proxy.py   (TCP loopback :18790 → lab HTTP NodePort)
  ▼
codex-gateway  (HTTP :31928 / optional HTTPS :31540 on 172.16.10.155)
  │  AgentgatewayPolicy codex-jwt-auth
  │    jwtAuthentication Strict → entra-jwks (login.microsoftonline.com)
  │  HTTPRoute codex  PathPrefix /
  ▼
AgentgatewayBackend openai-codex
  │  provider.openai: {}   (no pinned model — Codex sends it)
  │  injects openai-secret (Vault → ESO)
  ▼
api.openai.com  (/v1/responses, /v1/chat/completions, /v1/models)
```

Codex posts to `{base_url}/responses` where `base_url` **must end in `/v1`**, so
the live path is `{NodePort}/v1/responses`. This gateway matches `/` (not
`/openai`) so that path works at the NodePort root. The kagent OpenAI path
stays on `agentgateway-proxy` at `/openai`.

Codex **allows HTTP off loopback**, so the LAN NodePort is a valid base URL.
The loopback proxy is optional — useful for a stable `127.0.0.1:18790` URL
that does not change if the NodePort moves.

## Recommended lab paths

**LAN (simplest — Codex allows non-loopback HTTP):**

```text
http://172.16.10.155:31928/v1
```

**Loopback (optional, same pattern as Claude Desktop):**

1. Be on the lab LAN or VPN so `172.16.10.155` is reachable.
2. Start the proxy (defaults to HTTP NodePort **31928**):

   ```bash
   python3 scripts/codex-lab-proxy.py
   # or: CODEX_GW_NODEPORT=31928 python3 scripts/codex-lab-proxy.py
   ```

   Listens on `127.0.0.1:18790`, forwards to `172.16.10.155:31928`.

3. Set Codex `base_url` to:

   ```text
   http://127.0.0.1:18790/v1
   ```

Script: [`scripts/codex-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/codex-lab-proxy.py).

### HTTPS self-signed (optional)

Optional advanced base URL: `https://172.16.10.155:31540/v1` (TLS
terminate with `solo-ui-tls`). Trust the lab cert, or use `curl -k` for smoke
tests. Prefer HTTP unless you have a reason to fight OS trust stores.

If the NodePort ever changes, re-confirm with
`kubectl --context maniak-goose -n agentgateway-system get svc codex-gateway`.

## Entra app (already created — do not recreate)

| Field | Value |
|-------|--------|
| App name | `agw-codex` |
| Tenant ID | `8635e970-2205-4189-bc77-77519ff5064f` |
| Client ID (Application ID) | `e862e87d-d284-46a8-bf70-30ac7dba351e` |
| Issuer | `https://login.microsoftonline.com/8635e970-2205-4189-bc77-77519ff5064f/v2.0` |
| Platform | **Mobile and desktop applications** |
| Redirect URI | `http://127.0.0.1/callback` |
| Allow public client flows | **Yes** (`isFallbackPublicClient: true`) |
| Client secret | **none** (public client) |

Gateway JWT `audiences` is this client ID only — that matches **ID-token**
`aud`. Access tokens will fail Strict JWT.

Do not register the redirect under Web. The path must include `/callback`
(`http://127.0.0.1` alone fails with `AADSTS50011`); the port is wildcarded.

## Cluster resources

| Kind | Name | File |
|------|------|------|
| Gateway | `codex-gateway` | [`config/gateway/codex-gateway.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/gateway/codex-gateway.yaml) |
| HTTPRoute | `codex` | [`config/routes/codex-route.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/routes/codex-route.yaml) |
| AgentgatewayBackend | `openai-codex` | [`config/backends/openai-codex.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/backends/openai-codex.yaml) |
| AgentgatewayPolicy | `codex-jwt-auth` | [`config/policies/codex-jwt-auth.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/codex-jwt-auth.yaml) |
| tracing targetRef | Gateway `codex-gateway` | [`config/policies/tracing.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/tracing.yaml) |
| Lab proxy script | — | [`scripts/codex-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/codex-lab-proxy.py) |

**Reused (do not recreate):**

- `AgentgatewayBackend/entra-jwks` — Entra JWKS host
- `Secret/openai-secret` + ExternalSecret → Vault (same key as `/openai`)
- `Secret/solo-ui-tls` — lab self-signed TLS (same Secret as Solo UI `:30443`;
  must live in `agentgateway-system`, same ns as the Gateway)
- `EnterpriseAgentgatewayParameters/agentgateway-proxy-params` — cost catalog

Argo app `agentgateway-config` syncs `config/` recursively — push + sync is enough.
Do **not** attach this route to `agentgateway-proxy` or steal `/openai`.

## Find the NodePort / base URL

Live lab values (Service `codex-gateway`):

| Port | Use |
|------|-----|
| **Lab LAN** | `http://172.16.10.155:31928/v1` |
| **Loopback (optional)** | `http://127.0.0.1:18790/v1` via `scripts/codex-lab-proxy.py` |
| HTTP **`80:31928/TCP`** | Primary lab path + proxy upstream + debug/curl |
| HTTPS **`443:31540/TCP`** | Optional/advanced direct TLS (`solo-ui-tls`) |

Responses path: `{base}/responses` → `…/v1/responses`.

If the NodePort ever changes, re-confirm with:

```bash
kubectl --context maniak-goose -n agentgateway-system get svc codex-gateway
```

Optional discovery one-liner (examples below use **31928** / **31540**):

```bash
HTTP_NP=$(kubectl --context maniak-goose -n agentgateway-system get svc codex-gateway \
  -o jsonpath='{range .spec.ports[*]}{.port}:{.nodePort}{"\n"}{end}' | awk -F: '$1==80{print $2}')
HTTPS_NP=$(kubectl --context maniak-goose -n agentgateway-system get svc codex-gateway \
  -o jsonpath='{range .spec.ports[*]}{.port}:{.nodePort}{"\n"}{end}' | awk -F: '$1==443{print $2}')
echo "LAN:      http://172.16.10.155:${HTTP_NP}/v1"
echo "Loopback: http://127.0.0.1:18790/v1   # CODEX_GW_NODEPORT=${HTTP_NP} python3 scripts/codex-lab-proxy.py"
echo "HTTPS:    https://172.16.10.155:${HTTPS_NP}/v1"
```

## Codex client config

`~/.codex/config.toml` (or a profile file). **`base_url` must end in `/v1`.**
`CODEX_AGW_TOKEN` must be an Entra **ID token** for `agw-codex`.

```toml
model_provider = "agentgateway"

[model_providers.agentgateway]
name = "OpenAI via agentgateway"
base_url = "http://172.16.10.155:31928/v1"
wire_api = "responses"
env_key = "CODEX_AGW_TOKEN"
```

Loopback variant: `base_url = "http://127.0.0.1:18790/v1"`.

### Mint an ID token (`CODEX_AGW_TOKEN`)

Access tokens (`az account get-access-token`) will fail — gateway `aud` is the
app client ID, which matches an **ID token**. Device-code against the public
client (no secret):

```bash
TENANT=8635e970-2205-4189-bc77-77519ff5064f
CLIENT=e862e87d-d284-46a8-bf70-30ac7dba351e

# 1. Start device code
curl -sS -X POST "https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode" \
  -d "client_id=${CLIENT}" \
  -d "scope=openid profile email offline_access"

# 2. Complete the user_code in a browser, then poll:
# DEVICE_CODE=<device_code from step 1>
curl -sS -X POST "https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "client_id=${CLIENT}" \
  -d "device_code=${DEVICE_CODE}"
# → copy id_token (not access_token)

export CODEX_AGW_TOKEN='<id_token>'
```

ID tokens expire in ~1h; `offline_access` yields a refresh_token you can
exchange later.

### Codex CLI (one-shot)

From [Solo Codex docs](https://docs.solo.io/agentgateway/latest/integrations/llm-clients/codex/)
plus `env_key`:

```bash
export AGENTGATEWAY_BASE_URL="http://172.16.10.155:31928/v1"
# or: export AGENTGATEWAY_BASE_URL="http://127.0.0.1:18790/v1"

codex -c 'model_provider="agentgateway"' \
  -c 'model_providers.agentgateway.name="OpenAI via agentgateway"' \
  -c "model_providers.agentgateway.base_url=\"${AGENTGATEWAY_BASE_URL}\"" \
  -c 'model_providers.agentgateway.wire_api="responses"' \
  -c 'model_providers.agentgateway.env_key="CODEX_AGW_TOKEN"' \
  "Hello"
```

Persistent profile (`codex --profile agentgateway`):

```bash
mkdir -p ~/.codex
cat > ~/.codex/agentgateway.config.toml <<EOF
model_provider = "agentgateway"

[model_providers.agentgateway]
name = "OpenAI via agentgateway"
base_url = "${AGENTGATEWAY_BASE_URL}"
wire_api = "responses"
env_key = "CODEX_AGW_TOKEN"
EOF
```

Codex also probes `/v1/models`. Until
[agentgateway#1462](https://github.com/agentgateway/agentgateway/issues/1462)
adds a gateway-generated model list, Codex may warn that model metadata is not
found. That warning does **not** prevent `/v1/responses` traffic.

### ChatGPT desktop Codex

Same provider block. Back up and replace the user-level config, then restart
the app (or **Settings → Configuration → Open config.toml**):

```bash
cp ~/.codex/config.toml ~/.codex/config.toml.bak
# paste the [model_providers.agentgateway] block above
# export CODEX_AGW_TOKEN before launching the app
```

## Smoke test (curl + Entra ID token)

Anonymous must fail; a real Entra **ID token** for this app must reach OpenAI.

```bash
BASE="http://172.16.10.155:31928"
# or: BASE="http://127.0.0.1:18790"   # with the lab proxy running

# Expect 401/403
curl -sS -o /tmp/codex-anon -w '%{http_code}\n' -m 30 \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-5.5","input":"ping"}' \
  "${BASE}/v1/responses"

# Then:
# curl -sS ... -H "Authorization: Bearer ${CODEX_AGW_TOKEN}" ... "${BASE}/v1/responses"
```

For optional HTTPS smoke without trusting the cert:
`BASE="https://172.16.10.155:31540"` and `curl -skS …`.

## Lessons learned

1. **`base_url` must end in `/v1`** — Codex concatenates `/responses` onto that
   base (`POST …/v1/responses`). A URL without `/v1` 404s.
2. **Dedicated Gateway + PathPrefix `/`** — keep `/openai` on
   `agentgateway-proxy` for kagent; Codex expects `/v1/…` at the gateway root.
3. **Do not pin a model** on `openai-codex` — Codex sends the model per request
   (`provider.openai: {}`).
4. **Bearer = ID token** — gateway `aud` = `agw-codex` client ID. Access tokens
   fail Strict JWT. Codex `env_key` = `CODEX_AGW_TOKEN`.
5. **Use `jwtAuthentication` (AgentgatewayPolicy), not `entExtAuth`** — same
   pattern as Claude Desktop / `/secure-openai` / `/mcp-secure`.
6. **Reuse `entra-jwks` + `openai-secret`** — one JWKS backend and one Vault
   key; do not recreate the Entra app.
7. **HTTP is OK off loopback** — unlike Claude Desktop. LAN NodePort is the
   primary path; loopback proxy is optional.
8. **`/v1/models` probe warning is OK** — agentgateway issue #1462; Responses
   traffic still routes. Backend maps `/v1/models` → `Models` per Solo docs.
9. **Add the Gateway to `tracing.yaml`** — attachment is explicit; a new
   Gateway emits no spans until listed.

## Teardown

Removes only the Codex resources (keeps `entra-jwks`, `openai-secret`,
`solo-ui-tls`, and the kagent `/openai` route):

```bash
kubectl --context maniak-goose -n agentgateway-system delete \
  agentgatewaypolicy codex-jwt-auth \
  httproute codex \
  gateway codex-gateway \
  agentgatewaybackend openai-codex
```

Also drop the `codex-gateway` entry from `config/policies/tracing.yaml`
and delete the four YAML files under `config/` if reverting GitOps.
