Skip to content

[Feature] Make direct-spawn Codex app-server work transparently with wildcard binds #1102

Closed

Description

@comfuture

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

  1. Set hostname to 0.0.0.0 in ~/.opencodex/config.json and run ocx start (proxy on 127.0.0.1:10100).
  2. 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" }
  3. Start an app-server from a host process that does not inherit OPENCODEX_API_AUTH_TOKEN and 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', ...])
  4. Send a turn from that client.
  5. Observed: the turn fails after 5 retries. The client surfaces a generic stream failure because the 401 happens at admission, before any SSE frame is emitted.
  6. Expected: the app-server authenticates against the local proxy transparently, exactly as codex launched 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, so 0.0.0.0 makes 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 of x-opencodex-api-key, Authorization: Bearer, or x-api-key, and requireApiAuth() answers 401 "opencodex API key required".
  • The expected value is read from the environment only: configuredApiAuthToken() returns process.env.OPENCODEX_API_AUTH_TOKEN?.trim().
  • shouldInjectApiAuthHeader() in src/codex/inject.ts uses the same loopback predicate, which is why the env_http_headers line is injected for a 0.0.0.0 bind.
  • buildUnixCodexShim() in src/codex/shim.ts exports the token before parsing the subcommand, and app-server is already listed in CODEX_INTERNAL_COMMANDS. So codex app-server through the shim is authenticated; the token is read from serviceApiTokenFilePath() once, at process start.
  • Consequence, observed on this host: the app-server started via the shim has OPENCODEX_API_AUTH_TOKEN set, while a host process that resolves @openai/codex/bin/codex.js directly has no such variable to pass down to the app-server it spawns.
  • writeServiceApiTokenFile() in src/service.ts rewrites 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-server regardless of whether the process was launched through the generated shim.
  • Handle token rotation so an app-server started before an ocx restart does not keep failing with a stale value.
  • Make the resulting failure diagnosable when the key genuinely cannot be supplied.

Non-goals

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() when OPENCODEX_API_AUTH_TOKEN is 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.1 even when the bind is 0.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 hostname set to 0.0.0.0, a turn driven by a codex app-server that was launched without OPENCODEX_API_AUTH_TOKEN in its environment completes successfully instead of returning 401.
  • After ocx restarts 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, and resolveApiAuth() 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

  1. Set hostname to 0.0.0.0, run ocx start.
  2. Launch codex app-server from a shell where OPENCODEX_API_AUTH_TOKEN is explicitly unset and the generated shim is not on PATH.
  3. Drive one turn through that app-server and confirm it streams to completion.
  4. Restart ocx, then drive another turn on the same app-server process and confirm the documented behavior (recovery or a specific error).
  5. Confirm curl against /v1/responses with no key still returns 401.

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 401 bursts, the keyless 401 response, and the authenticated response.completed were reproduced on the reporter's host; the suggested direction is inferred from reading the source and is not a proven design.

Activity

  1. github-actions commented on Aug 6, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    streamingSSE, WebSocket, terminal stream frames
    on Aug 6, 2026
  3. lidge-jun commented on Aug 6, 2026

    @lidge-jun
    Owner

    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-server is handled by the shim — it is in CODEX_INTERNAL_COMMANDS (src/codex/shim.ts:42) and the shim exports the persisted token before dispatch (src/codex/shim.ts:386), then execs the real binary (:416). So codex app-server through the shim authenticates fine. A host that resolves @openai/codex/bin/codex.js and spawns it directly bypasses that entire path, and there is no fallback: admission for /v1/responses accepts only x-opencodex-api-key and compares it against the live env token or a config.apiKeys entry (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 than text/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 in process.env.OPENCODEX_API_AUTH_TOKEN and 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 via ocx service start starts 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 via server.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_headers is environment-populated by design, static http_headers would serialize the secret into config on disk, and auth.command supplies 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.apiKeys and have the spawning host pass it as x-opencodex-api-key in 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.0 bind actually serving remote clients that must stay authenticated?

  4. added
    serviceService lifecycle (WinSW/launchd/scheduler)
    on Aug 6, 2026
  5. changed the title [-]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[/+] on Aug 7, 2026
  6. Wibias commented on Aug 7, 2026

    @Wibias
    Contributor

    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:

    1. A third-party host that resolves and launches the Codex entrypoint directly bypasses the generated shim and therefore does not inherit OPENCODEX_API_AUTH_TOKEN.
    2. A normal ocx restart 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 401 unless 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:

    • automatically configuring or enabling the dedicated loopback listener when a compatible setup requires it;
    • exposing the listener clearly in setup and configuration UI;
    • making Codex injection target the dedicated loopback endpoint when appropriate;
    • detecting an incompatible direct-spawn configuration and reporting an actionable diagnostic;
    • documenting the relationship between the public listener and the dedicated Codex loopback listener.

    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:

    public/wildcard listener -> authenticated
    dedicated 127.0.0.1 listener -> local Codex allowlisted surface
    

    Acceptance criteria

    • A direct-spawn codex app-server has a documented and supported path that does not require callers to manually inject OPENCODEX_API_AUTH_TOKEN.
    • The public 0.0.0.0 listener remains fully authenticated.
    • Remote clients cannot access the unauthenticated loopback listener.
    • Host/Origin protections on the loopback listener remain intact.
    • OpenCodex provides an actionable diagnostic when the direct-spawn configuration is incompatible.
    • Existing feat(server): optional unauthenticated loopback listener for direct-spawn Codex (#1102) #1220 admission and security boundaries remain intact.
    • The behavior is covered by an actual app-server activation/integration test where feasible.

    Resolved by #1220

    • Secure dedicated loopback-listener architecture.
    • Public-listener authentication remains unchanged.
    • No reliance on transport peer IP as proof of caller identity.
    • Local Codex-compatible route allowlisting.
    • Transactional startup/shutdown behavior for the two listeners.

    This issue now tracks activation, integration, and discoverability of that mechanism.

  7. lidge-jun commented on Aug 8, 2026

    @lidge-jun
    Owner

    Shipped in v2.11.0 as an opt-in listener.

    {
      "hostname": "0.0.0.0",
      "port": 10100,
      "unauthenticatedLoopbackListener": { "enabled": true, "port": 10200 }
    }

    ocx sync then writes base_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 to 127.0.0.1 means 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.mjs launches the resolved @openai/codex/bin/codex.js with OPENCODEX_API_AUTH_TOKEN stripped and an isolated CODEX_HOME, then asserts a routed model whose id is generated per run appears in model/list and 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.1 bind 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; writeServiceApiTokenFile mirrors 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingserviceService lifecycle (WinSW/launchd/scheduler)streamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions