CLI Proxy API Keys for Codex Desktop on macOS

Codex CLI was talking to my OpenAI-compatible gateway just fine. Codex Desktop was not.

Same ~/.codex/config.toml. Same custom provider. Same machine. Desktop failed immediately with:

1
Missing environment variable: CLI_PROXY_API_KEY

The provider was not the problem. The process environment was.

Intent

I run coding agents through a single gateway - CLIProxyAPI - so model choice stays flexible without re-wiring auth in every client.

For Codex, that means a custom provider:

1
2
3
4
5
6
7
model_provider = "cli_proxy"

[model_providers.cli_proxy]
name = "CLIProxyAPI"
base_url = "https://cli-proxy.example.com/v1"
wire_api = "responses"
env_key = "CLI_PROXY_API_KEY"

The key never lives in the config file. Codex reads it from the process environment via env_key.

In the shell that is easy:

1
2
export CLI_PROXY_API_KEY="$(gopass show -o <secret-path>)"
codex

CLI inherits the export. Done.

Desktop is a different animal.

Why CLI worked and Desktop did not

Codex Desktop ships inside ChatGPT.app. When you open it from the Dock or Spotlight, macOS starts it through LaunchServices.

LaunchServices does not load ~/.zshrc.

So the same provider block that works in a terminal session fails in the GUI process, because CLI_PROXY_API_KEY is simply not present.

You can confirm the split:

1
2
3
4
5
# shell world
printenv CLI_PROXY_API_KEY | wc -c

# GUI session world
launchctl getenv CLI_PROXY_API_KEY | wc -c

If the first is non-zero and the second is zero, you have the bug.

The fix: bridge the GUI session with launchctl

macOS lets you inject environment variables into the GUI session:

1
launchctl setenv CLI_PROXY_API_KEY "$(gopass show -o <secret-path>)"

Then fully quit ChatGPT (Cmd+Q) and reopen it so the new process inherits the session env.

That is the whole idea. Everything else is packaging so you do not have to remember the command after every reboot.

Stow-managed LaunchAgent

I put it in my dotfiles as a macOS-only LaunchAgent:

Piece Role
~/.local/bin/set-cli-proxy-gui-env.sh Reads gopass, runs launchctl setenv
~/Library/LaunchAgents/local.cli-proxy-gui-env.plist Runs at login, retries every 5 minutes if gpg was locked

Install:

1
2
3
4
5
6
7
make stow-bin stow-launchd

launchctl bootout "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/local.cli-proxy-gui-env.plist" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/local.cli-proxy-gui-env.plist"
launchctl kickstart -k "gui/$(id -u)/local.cli-proxy-gui-env"

Design choices that mattered:

  1. Skip when already set - avoids pinentry spam on the 5-minute retry.
  2. Timeout around gopass - a hung pinentry under launchd must not stall forever.
  3. refresh / unset modes - key rotation without reboot:
1
2
3
"$HOME/.local/bin/set-cli-proxy-gui-env.sh" refresh
# or
"$HOME/.local/bin/set-cli-proxy-gui-env.sh" unset
  1. Portable $HOME paths - the plist launches via bash -c 'exec "$HOME/.local/bin/..."' so the package does not hardcode a username.

Shipped as dotfiles#107.

How we proved it

After loading the agent and relaunching ChatGPT:

  1. GUI env - launchctl getenv CLI_PROXY_API_KEY was non-empty.
  2. Process env - the ChatGPT process itself contained CLI_PROXY_API_KEY.
  3. Gateway - GET /v1/models with that key returned HTTP 200.
  4. Desktop chat - sent hello from computer-use env test on a CLIProxy-backed model. The footer showed CLIProxyAPI. The agent worked. No missing-env banner.

One amusing constraint: Codex Computer Use refuses to drive com.openai.codex for safety reasons, so the UI proof used window capture and Accessibility rather than Computer Use controlling Codex itself. The process-env + gateway + live chat evidence was enough.

Mental model

1
2
3
4
5
6
7
8
9
gopass secret
|
v
shell export --------------> Codex CLI (works)
|
+-- launchctl setenv ---> GUI session
|
v
ChatGPT.app / Codex Desktop (works after relaunch)

If the CLI works and Desktop fails on the same config file, check the process tree before the provider block.

Shells export. Docks do not.

Tradeoffs

  • launchctl setenv is session-scoped. Cold boot still depends on gopass unlock timing.
  • Rotating the secret leaves a stale GUI value until refresh or logout.
  • This is a deliberate bridge for one env var, not a general secret injector for every app.

Takeaway

Custom providers with env_key are only as reliable as the environment of the process that loads them.

For terminal agents, your shell is enough.

For macOS GUI agents, you need an explicit bridge into the LaunchServices world. A small LaunchAgent that reads gopass and runs launchctl setenv is that bridge - boring, portable, and enough to make Codex Desktop use the same CLI Proxy path as the CLI.

Bounded Autonomy and Guardrails for Claude Code

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×