---
title: "CLI Proxy API Keys for Codex Desktop on macOS"
url: "https://vanducng.dev/2026/08/15/cli-proxy-api-keys-for-codex-desktop-on-macos/"
date: "2026-08-15"
categories: ["AI"]
tags: ["agentic-coding","codex","macos","launchd","cli-proxy"]
---

# 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:

```text
Missing environment variable: CLI_PROXY_API_KEY
```

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

![CLI and Desktop linked by launchctl setenv](https://vanducng.dev/2026/08/15/cli-proxy-api-keys-for-codex-desktop-on-macos/cover.png)

## 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:

```toml
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:

```bash
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:

```bash
# 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:

```bash
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](https://github.com/vanducng/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:

```bash
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:

```bash
"$HOME/.local/bin/set-cli-proxy-gui-env.sh" refresh
# or
"$HOME/.local/bin/set-cli-proxy-gui-env.sh" unset
```

4. **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](https://github.com/vanducng/dotfiles/pull/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

```text
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.
