Summary
In direct mode (codexAccountMode: "direct"), forwarding to the ChatGPT/OpenAI upstream fails with ForwardAdmissionCredentialError whenever the caller authenticates with the proxy's own admission token via Authorization: Bearer (the env_key contract). The guard validateForwardAdmissionCredential rejects the admission bearer unconditionally on current main, even though the proxy has a main Codex account token available to substitute.
This regressed in the 2.14.x WebSocket refactor: the 2.12-era guard if (getMainAccountToken()) return; (which allowed the forward when a substitution is possible) was removed together with the ctx.kind === "main" substitution block in headersForCodexAuthContext.
Reproduction
# config.toml:
# [model_providers.opencodex]
# base_url = "http://127.0.0.1:10100/v1"
# env_key = "OPENCODEX_API_AUTH_TOKEN"
# config.json:
# "providers": { "openai": { "codexAccountMode": "direct", "authMode": "forward" } }
# ~/.codex/auth.json contains a main ChatGPT account (getMainAccountToken() non-null)
curl -X POST http://127.0.0.1:10100/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENCODEX_API_AUTH_TOKEN" \
-d '{"model":"gpt-5.6-luna","input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],"stream":true,"store":false}'
Logs and screenshots
{"error":{"message":"OpenCodex admission credentials cannot be forwarded upstream","type":"invalid_request_error","code":"invalid_request_error"}}
Area
Proxy runtime
Version
2.14.2 (also on current main)
OS
Ubuntu 24.04 (VPS)
Config shape
{
"providers": {
"openai": {
"adapter": "openai-responses",
"baseUrl": "https://chatgpt.com/backend-api/codex",
"authMode": "forward",
"codexAccountMode": "direct"
}
}
}
Root cause
src/server/auth-cors.ts validateForwardAdmissionCredential (current main):
export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
}
In direct mode, resolveCodexAuthContext returns { kind: "main" }, and headersForCodexAuthContext no longer substitutes the admission bearer with the real ChatGPT token (getMainAccountToken() from ~/.codex/auth.json) — the ctx.kind === "main" block was removed in the WebSocket refactor. The 2.12 version of this guard was:
if (!bearer || !isProxyAdmissionSecret(bearer, config)) return;
if (getMainAccountToken()) return; // substitution happens in headersForCodexAuthContext
throw new ForwardAdmissionCredentialError();
Proposed fix
- Restore the guard in
validateForwardAdmissionCredential:
export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
- if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
+ if (bearer && isProxyAdmissionSecret(bearer, config)) {
+ // Safe to forward-substitute only when a main account token exists
+ // (headersForCodexAuthContext performs the substitution).
+ if (getMainAccountToken()) return;
+ throw new ForwardAdmissionCredentialError();
+ }
}
- Restore the
ctx.kind === "main" block in headersForCodexAuthContext (src/codex/auth-context.ts) that replaces the admission bearer with Bearer ${main.accessToken} + chatgpt-account-id when bearer === process.env.OPENCODEX_API_AUTH_TOKEN.
Alternatives considered
- Keep rejecting unconditionally and require clients to send the real ChatGPT token: breaks the
env_key injection contract and the whole point of codexAccountMode: "direct" with a proxy-managed main account. Not viable.
- Only document the limitation: the regression is a behavior change from 2.12; should be fixed, not documented.
Additional context
This mirrors how other gateways solve the same problem: the OmniRoute ChatGPT executor uses the stored session token plus a chatgpt-account-id header to route to the real account (open-sse/executors/chatgpt-web.ts:3055 — if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId;). Substituting the caller's admission credential with the pool/main account credential is the established pattern; the proxy just needs to keep doing it in the WS path.
Security note: without a main account token the guard still throws (fail-closed), so the change only relaxes the check when a substitution is actually possible.
Test matrix:
direct mode + admission bearer + main account present → 200 (token substituted upstream)
direct mode + admission bearer + no main account → 401 (fail-closed, unchanged)
pool mode → unchanged (pool accessToken override)
x-opencodex-api-key auth (non-bearer) → unchanged (no admission-bearer path)
Summary
In
directmode (codexAccountMode: "direct"), forwarding to the ChatGPT/OpenAI upstream fails withForwardAdmissionCredentialErrorwhenever the caller authenticates with the proxy's own admission token viaAuthorization: Bearer(theenv_keycontract). The guardvalidateForwardAdmissionCredentialrejects the admission bearer unconditionally on currentmain, even though the proxy has a main Codex account token available to substitute.This regressed in the 2.14.x WebSocket refactor: the 2.12-era guard
if (getMainAccountToken()) return;(which allowed the forward when a substitution is possible) was removed together with thectx.kind === "main"substitution block inheadersForCodexAuthContext.Reproduction
Logs and screenshots
{"error":{"message":"OpenCodex admission credentials cannot be forwarded upstream","type":"invalid_request_error","code":"invalid_request_error"}}Area
Proxy runtime
Version
2.14.2 (also on current
main)OS
Ubuntu 24.04 (VPS)
Config shape
{ "providers": { "openai": { "adapter": "openai-responses", "baseUrl": "https://chatgpt.com/backend-api/codex", "authMode": "forward", "codexAccountMode": "direct" } } }Root cause
src/server/auth-cors.tsvalidateForwardAdmissionCredential(currentmain):In
directmode,resolveCodexAuthContextreturns{ kind: "main" }, andheadersForCodexAuthContextno longer substitutes the admission bearer with the real ChatGPT token (getMainAccountToken()from~/.codex/auth.json) — thectx.kind === "main"block was removed in the WebSocket refactor. The 2.12 version of this guard was:Proposed fix
validateForwardAdmissionCredential:export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void { const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError(); + if (bearer && isProxyAdmissionSecret(bearer, config)) { + // Safe to forward-substitute only when a main account token exists + // (headersForCodexAuthContext performs the substitution). + if (getMainAccountToken()) return; + throw new ForwardAdmissionCredentialError(); + } }ctx.kind === "main"block inheadersForCodexAuthContext(src/codex/auth-context.ts) that replaces the admission bearer withBearer ${main.accessToken}+chatgpt-account-idwhenbearer === process.env.OPENCODEX_API_AUTH_TOKEN.Alternatives considered
env_keyinjection contract and the whole point ofcodexAccountMode: "direct"with a proxy-managed main account. Not viable.Additional context
This mirrors how other gateways solve the same problem: the OmniRoute ChatGPT executor uses the stored session token plus a
chatgpt-account-idheader to route to the real account (open-sse/executors/chatgpt-web.ts:3055—if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId;). Substituting the caller's admission credential with the pool/main account credential is the established pattern; the proxy just needs to keep doing it in the WS path.Security note: without a main account token the guard still throws (fail-closed), so the change only relaxes the check when a substitution is actually possible.
Test matrix:
directmode + admission bearer + main account present → 200 (token substituted upstream)directmode + admission bearer + no main account → 401 (fail-closed, unchanged)poolmode → unchanged (pool accessToken override)x-opencodex-api-keyauth (non-bearer) → unchanged (no admission-bearer path)