Repository navigation
[Feature] Make direct-spawn Codex app-server work transparently with wildcard binds #1102
Description
Client or integration
Codex SDK
Area
Authentication and account pool
Summary
When hostname is set to 0.0.0.0, the data plane requires x-opencodex-api-key, but only callers that go through the generated codex shim receive OPENCODEX_API_AUTH_TOKEN. A codex app-server launched by a third-party host that resolves the Codex entrypoint directly (require.resolve('@openai/codex/bin/codex.js')) never gets the token, so every model call is rejected with 401 before an SSE stream opens.
Request: codex app-server should receive the key as transparently as a local codex invocation does, so that a non-loopback bind does not silently break app-server-driven clients. Today the shim already covers app-server when it is the process being launched; the gap is that the token is only available when the shim is on the execution path at all, and it is captured once at process start.
Reproduction
- Set
hostnameto0.0.0.0in~/.opencodex/config.jsonand runocx start(proxy on127.0.0.1:10100). - Configure Codex with the injected provider block:
model = "kiro/claude-opus-5" model_provider = "opencodex" [model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" wire_api = "responses" env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }
- Start an app-server from a host process that does not inherit
OPENCODEX_API_AUTH_TOKENand does not go through the generated shim. Any harness that resolves the entrypoint directly reproduces this:spawn(process.execPath, [require.resolve('@openai/codex/bin/codex.js'), 'app-server', ...])
- Send a turn from that client.
- Observed: the turn fails after 5 retries. The client surfaces a generic stream failure because the
401happens at admission, before any SSE frame is emitted. - Expected: the app-server authenticates against the local proxy transparently, exactly as
codexlaunched through the shim does.
A second, related trigger without any third-party host: restart ocx so the token file is rewritten. Every already-running codex app-server keeps the previous value and starts returning 401 until it is restarted.
Version
@bitkyc08/opencodex v2.10.1
Operating system
Ubuntu 22.04.5 LTS (aarch64), Node.js v22.12.0, Codex CLI v0.146.1
Provider and model
kiro / claude-opus-5 (also reproduced with gpt-5.6-sol; the failure is provider-independent)
Logs or error output
From Codex's structured log store, target codex_core::responses_retry. Four separate turns each exhausted all 5 retries:
13:14:55~13:15:01 unexpected status 401 Unauthorized: opencodex API key required,
url: http://127.0.0.1:10100/v1/responses model=gpt-5.6-sol retries=1..5
13:17:44~13:17:50 same 401 model=kiro/claude-opus-5
13:20:42~13:20:48 same 401 model=kiro/claude-opus-5
13:25:24~13:25:30 same 401 model=kiro/claude-opus-5
Reproduced directly against the proxy with no key. No SSE frames are produced:
POST http://127.0.0.1:10100/v1/responses {"model":"kiro/claude-opus-5","stream":true}
HTTP 401
{"error":{"message":"opencodex API key required","type":"authentication_error","code":"invalid_api_key"}}
The same request with x-opencodex-api-key set streams normally and ends with response.completed.
Screenshots and supporting files
N/A
Redacted configuration
{
"port": 10100,
"hostname": "0.0.0.0",
"defaultProvider": "openai",
"providers": {
"kiro": { "adapter": "kiro", "authMode": "oauth", "defaultModel": "kiro-auto" }
}
}Current implementation facts
isApiAuthRequired()keys admission entirely off the bind hostname, so0.0.0.0makes the key mandatory:// src/server/auth-cors.ts export function isApiAuthRequired(config: OcxConfig): boolean { return !isLoopbackHostname(config.hostname); }
resolveApiAuth()returns{ kind: "loopback" }and skips the token entirely on a loopback bind; otherwise it requires one ofx-opencodex-api-key,Authorization: Bearer, orx-api-key, andrequireApiAuth()answers401 "opencodex API key required".- The expected value is read from the environment only:
configuredApiAuthToken()returnsprocess.env.OPENCODEX_API_AUTH_TOKEN?.trim(). shouldInjectApiAuthHeader()insrc/codex/inject.tsuses the same loopback predicate, which is why theenv_http_headersline is injected for a0.0.0.0bind.buildUnixCodexShim()insrc/codex/shim.tsexports the token before parsing the subcommand, andapp-serveris already listed inCODEX_INTERNAL_COMMANDS. Socodex app-serverthrough the shim is authenticated; the token is read fromserviceApiTokenFilePath()once, at process start.- Consequence, observed on this host: the app-server started via the shim has
OPENCODEX_API_AUTH_TOKENset, while a host process that resolves@openai/codex/bin/codex.jsdirectly has no such variable to pass down to the app-server it spawns. writeServiceApiTokenFile()insrc/service.tsrewrites the token file from the current environment on service start, so the file contents can change while older app-server processes keep a stale value in memory.
Problem statement
A non-loopback bind is a supported configuration, and the provider block is injected for it automatically. But authentication only works when the caller's environment happens to carry the token. Any Codex-app-server-based integration that launches the Codex entrypoint directly gets a 401 at admission, which reaches the user as an opaque stream failure rather than an auth error, because the rejection precedes the first SSE frame. Token rotation on ocx restart produces the same symptom for already-running app-servers.
Goal
Make the API key reach codex app-server transparently whenever opencodex has injected the x-opencodex-api-key header requirement, so that switching hostname to 0.0.0.0 does not break app-server clients or require callers to plumb OPENCODEX_API_AUTH_TOKEN themselves.
Scope
- Ensure the token is available to
codex app-serverregardless of whether the process was launched through the generated shim. - Handle token rotation so an app-server started before an
ocxrestart does not keep failing with a stale value. - Make the resulting failure diagnosable when the key genuinely cannot be supplied.
Non-goals
- Changing the
isApiAuthRequired()loopback predicate or weakening auth for a0.0.0.0bind. - Redesigning the admission header precedence in
resolveApiAuth(). - Fixing how third-party clients render a failed turn; that is their concern.
- The
Host-header admission problem in Port-remapped tunnels are rejected by the loopback Host check: dashboard loads, every API call 403s #570, which is a different gate.
Suggested direction
Inferred, not verified as the maintainers' preferred design:
- Have the app-server auth path resolve the token at request time rather than only from the start-time environment, falling back to
serviceApiTokenFilePath()whenOPENCODEX_API_AUTH_TOKENis absent or no longer matches. This addresses the direct-spawn case and rotation together, and mirrors what the shim already does for the CLI. - Alternatively, or in addition, accept a loopback-only admission for requests that arrive on
127.0.0.1even when the bind is0.0.0.0, keyed on the local socket address rather than the configured hostname. That keeps the network surface gated while making local app-server traffic work without a key. This is a security-relevant change and deserves an explicit decision. - Likely files:
src/server/auth-cors.ts(configuredApiAuthToken,resolveApiAuth),src/codex/shim.ts,src/lib/service-secrets.ts. - Consider making
requireApiAuth()'s message distinguish "no key presented" from "key did not match", so a stale token is not misread as a missing one.
Acceptance criteria
- With
hostnameset to0.0.0.0, a turn driven by acodex app-serverthat was launched withoutOPENCODEX_API_AUTH_TOKENin its environment completes successfully instead of returning401. - After
ocxrestarts and rewrites the token file, an app-server process that was already running either recovers or reports a specific, actionable error. - Regression: a loopback bind (
127.0.0.1) still admits requests with no key, andresolveApiAuth()still reports{ kind: "loopback" }. - Regression: with a non-loopback bind, a request presenting a wrong key is still rejected with
401, and the network-facing surface remains gated. - A test covers the direct-spawn path, that is, an app-server whose environment lacks
OPENCODEX_API_AUTH_TOKEN.
Suggested smoke test
- Set
hostnameto0.0.0.0, runocx start. - Launch
codex app-serverfrom a shell whereOPENCODEX_API_AUTH_TOKENis explicitly unset and the generated shim is not onPATH. - Drive one turn through that app-server and confirm it streams to completion.
- Restart
ocx, then drive another turn on the same app-server process and confirm the documented behavior (recovery or a specific error). - Confirm
curlagainst/v1/responseswith no key still returns401.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Note: This issue was filed with AI assistance. The
401bursts, the keyless401response, and the authenticatedresponse.completedwere reproduced on the reporter's host; the suggested direction is inferred from reading the source and is not a proven design.
Activity
github-actions commentedon Aug 6, 2026
Automated translation bookkeeping — detected language: English.
lidge-jun commentedon Aug 6, 2026
Investigated on current
dev. The gap is real and your description of the mechanism is accurate. One of your two triggers turns out to have a different cause than stated, and that distinction matters for what we can fix.Confirmed: the direct-spawn gap.
app-serveris handled by the shim — it is inCODEX_INTERNAL_COMMANDS(src/codex/shim.ts:42) and the shim exports the persisted token before dispatch (src/codex/shim.ts:386), thenexecs the real binary (:416). Socodex app-serverthrough the shim authenticates fine. A host that resolves@openai/codex/bin/codex.jsand spawns it directly bypasses that entire path, and there is no fallback: admission for/v1/responsesaccepts onlyx-opencodex-api-keyand compares it against the live env token or aconfig.apiKeysentry (src/server/auth-cors.ts:256,369). The token file is never read at admission — it is only loaded into the proxy's own process before bind (src/lib/service-secrets.ts:15,src/cli/index.ts:173). Your diagnosis is exactly right.Also confirmed: the 401 is pre-SSE. It resolves and returns before
handleResponses()is invoked (src/server/index.ts:874,902), as ordinary JSON rather thantext/event-stream. That is why your client surfaced an opaque stream failure instead of an auth error — worth fixing on the client-diagnostics side regardless.Corrected: opencodex does not rotate that token.
writeServiceApiTokenFile()writes the value already present inprocess.env.OPENCODEX_API_AUTH_TOKENand returns without writing when it is absent (src/service.ts:347). Its only callers are service install/repair paths (:1707,:1799,:1937,:2116); a plain restart viaocx service startstarts the supervisor without rewriting the file (:2660). So a restart alone will not invalidate a running app-server.What can produce what you saw: the token is user-supplied, so if the value in your environment changed and a service install or repair then rewrote the mirror, already-running app-servers keep the old inherited value and 401 — a real failure, but operator-driven rather than automatic. If you are confident nothing changed on your side and a bare restart still invalidated live app-servers, that is a separate bug and I want to know; please say which command you ran to restart.
On fixing it. The tempting option is to exempt loopback peers even when bound to
0.0.0.0. It is technically feasible — Bun exposes the socket peer viaserver.requestIP(req)and our handler already has that server object (src/server/index.ts:529) — but it redefines the boundary from "authenticate all callers on a remote bind" to "trust any local transport peer," which includes any local process and any local reverse-proxy or tunnel terminator that lands the connection on loopback. That is a real weakening, so it should be an explicit opt-in policy rather than an implicit consequence of binding non-loopback, and it needs to cover the WebSocket handshake and fail closed when the peer address is unavailable.The file-based-credential alternative does not work: Codex's
env_http_headersis environment-populated by design, statichttp_headerswould serialize the secret into config on disk, andauth.commandsupplies a bearer credential rather than our dedicated header — which admission deliberately rejects to avoid colliding with Codex Direct (src/server/auth-cors.ts:365).So this is needs-design, not a quick patch, and I would rather say that than ship a boundary change quietly. Meanwhile there is a working answer for your harness today: add an entry to
config.apiKeysand have the spawning host pass it asx-opencodex-api-keyin the app-server's environment. That is a supported credential path and does not require the shim.Keeping this open. Two questions that would shape the design: (1) is your spawning host something you control, or a third-party harness that cannot be asked to pass a credential? and (2) would an opt-in setting such as "trust loopback peers on a non-loopback bind" be acceptable in your deployment, or is your
0.0.0.0bind actually serving remote clients that must stay authenticated?docs(devlog): measure the Windows suite instead of restating the plan
[-]Pass the API key to codex app-server transparently when hostname is 0.0.0.0[/-][+][Feature] Make direct-spawn Codex app-server work transparently with wildcard binds[/+]Wibias commentedon Aug 7, 2026
Current state
#1220 added a secure mechanism for Codex app-server integrations that bypass the generated OpenCodex shim.
OpenCodex can expose a second listener bound strictly to loopback:
{ "unauthenticatedLoopbackListener": { "enabled": true, "port": 10101 } }This keeps the public or wildcard listener authenticated while giving local Codex app-server processes a transport path that does not depend on inheriting
OPENCODEX_API_AUTH_TOKEN.The dedicated listener is bound to
127.0.0.1, so remote clients cannot connect to it directly. It also retains the normal loopback Host/Origin protections.Corrected diagnosis
The original report contained two related theories:
OPENCODEX_API_AUTH_TOKEN.ocxrestart rotates the token and leaves already-running app-server processes with a stale value.The first problem is valid.
The second was not confirmed by source investigation: ordinary service restart does not normally generate a new token in the way originally assumed.
This issue should therefore no longer track token rotation as a primary defect.
Remaining problem
The secure loopback-listener solution added by #1220 is currently opt-in.
Therefore the original direct-spawn reproduction can still fail with
401unless the user knows that the dedicated loopback listener exists, enables it, and points the app-server integration at the correct local endpoint.The remaining problem is no longer how to weaken authentication or inject a secret into an already-running foreign process.
It is how OpenCodex should make the safe local-listener path discoverable and sufficiently transparent for supported Codex app-server integrations.
Goal
Provide a supported user experience for direct-spawn Codex app-server clients when OpenCodex is also bound to a non-loopback address.
Possible solutions include:
The public listener must remain authenticated.
Security boundary
Do not solve this by trusting the apparent socket peer address on the public listener.
Container networking, WSL, forwarding layers, tunnels, and similar transports can cause a remote-originated request to appear locally forwarded.
The security property established by #1220 should remain:
Acceptance criteria
codex app-serverhas a documented and supported path that does not require callers to manually injectOPENCODEX_API_AUTH_TOKEN.0.0.0.0listener remains fully authenticated.Resolved by #1220
This issue now tracks activation, integration, and discoverability of that mechanism.
docs(devlog): plan the lidge-jun#1102 loopback listener after five au…
feat(server): optional unauthenticated loopback listener for direct-s…
test(server): exercise the loopback listener over real sockets, and r…
test(server): probe each loopback route with its real method, and rea…
refactor(server): make composite listener shutdown testable, and pin …
test(server): prove the direct-spawn path with a real Codex app-server
lidge-jun commentedon Aug 8, 2026
Shipped in v2.11.0 as an opt-in listener.
{ "hostname": "0.0.0.0", "port": 10100, "unauthenticatedLoopbackListener": { "enabled": true, "port": 10200 } }ocx syncthen writesbase_url = "http://127.0.0.1:10200/v1"into the managed Codex provider block and omits the auth header, so an app-server spawned straight from the resolved entrypoint works without any credential plumbing.Why a second listener rather than exempting local callers. The obvious fix was to admit requests whose socket peer looks like loopback. That is unsound:
requestIP()proves only the last transport hop, and Docker Desktop port forwarding, host-network containers, WSL mirrored networking and tunnel terminators all re-open remote connections locally. It would also have widened admission on eight endpoints unrelated to this report. Binding a second socket to127.0.0.1means the kernel refuses remote connections outright — there is no address to judge.Verified against a real app-server, not just in-process.
scripts/verify-loopback-direct-spawn.mjslaunches the resolved@openai/codex/bin/codex.jswithOPENCODEX_API_AUTH_TOKENstripped and an isolatedCODEX_HOME, then asserts a routed model whose id is generated per run appears inmodel/listand that a turn reaches the listener. Codex falls back to a bundled catalog when a refresh fails, so a name no bundled catalog can contain is the only honest oracle — an earlier version of that script watched Codex return its five bundled ids while never touching the listener at all.Two things to know before enabling it. Every local process can use it without a credential, spending account quota and paid provider credentials. And a
127.0.0.1bind stops remote TCP but not a browser — a page you visit can make your browser connect locally — so the listener applies the same Host and Origin checks as an ordinary loopback bind.Off by default, and the correction in your report stands: opencodex never rotates
OPENCODEX_API_AUTH_TOKEN;writeServiceApiTokenFilemirrors a value you supply and only runs on service install or repair.Closing as delivered. Reopen if the option does not fit your deployment — the peer-trust alternative was rejected on security grounds, so if you need something else it is worth discussing.
test(server,cli): pin the companion listener, the drift set, and the …
docs(devlog): plan the lidge-jun#1102 loopback listener after five au…
feat(server): optional unauthenticated loopback listener for direct-s…
test(server): exercise the loopback listener over real sockets, and r…
test(server): probe each loopback route with its real method, and rea…
refactor(server): make composite listener shutdown testable, and pin …
test(server): prove the direct-spawn path with a real Codex app-server
test(server,cli): pin the companion listener, the drift set, and the …