codexbox: Four Environment Variables and a mkdir Standing Between You and OpenAI’s CLI

I already wrote the “why I ripped the plumbing out of my own repos and built a base image” rant in the aicodebox post, so I’m not doing it again here. Short version: I built claudebox for Claude Code, grew it an HTTP API, an OpenAI-compatible endpoint, an MCP server, a Telegram bot, and a cron scheduler, then wanted the exact same thing for a second agent and refused to copy-paste nine hundred lines of FastAPI and Telegram markdown rendering into a new repo a third time. So the plumbing got ripped out into aicodebox, an agent-agnostic base image, and every specific agent became a thin child image that just implements one adapter class.

codexbox is that adapter pointed at OpenAI’s Codex CLI. Same base, same REST surface, same Telegram bot, same cron scheduler — the only thing that’s actually new here is the code that translates a generic “run this prompt” request into whatever insane flag combination Codex wants that day, and the auth dance around two completely different ways of paying for it.

The Problem With OpenAI’s CLI Specifically

Codex CLI is fine. Codex CLI’s ergonomics as something you shell out to programmatically are a different story. A few highlights from actually reading the thing instead of trusting its docs:

  • It crashes on startup over a directory that doesn’t exist. Set CODEX_HOME to a path and don’t pre-create it, and codex just errors out instead of doing the one-line mkdir -p literally every other CLI on earth does for you. More on this below because it’s the best/worst detail in the whole repo.
  • The sandbox bypass flag reads like a legal disclaimer. --dangerously-bypass-approvals-and-sandbox. Not --yolo, not -y — a full sentence, presumably so nobody can claim they didn’t know what they were doing when their container went and rm -rf’d something.
  • Resume is a subcommand, not a flag. Every other agent CLI I’ve wired up takes --continue or --resume <id> as a flag on the normal run command. Codex makes you call exec resume <id> or exec resume --last as a distinct verb, which means the adapter has to build a completely different argv shape depending on whether you’re resuming or not.
  • There’s no “turn off every tool” switch. update_plan is unconditional and apply_patch sticks around as long as a local environment exists, so a real no-tools mode means dropping the shell/web-search tools via config and forcing the sandbox read-only as a belt-and-suspenders move, because config alone can’t fully neuter it.
  • Structured output only takes a file path. Codex has native JSON-schema enforcement via --output-schema, which is genuinely better than what the other adapters on this base get — no self-correction retries needed — but it only accepts a file on disk, not an inline schema, so the adapter has to write your schema to a temp file every single call.
  • Its own JSON stream lies to you about being JSON. Run codex exec --json and you get a JSONL ThreadEvent stream on stdout — except codex also interleaves plain-text ERROR ... log lines directly into that same stdout. You don’t get to assume every line parses. You parse, you catch the decode failure, you count it, you move on.

None of this makes Codex bad. It makes Codex a CLI built for a human typing in a terminal, not a program shelling out to it in a loop, which is exactly the gap the adapter exists to paper over.

Four Env Vars and One mkdir

The Dockerfile sets exactly four environment variables to wire the adapter into the base image, all in one block:

ENV AICODEBOX_ADAPTER=codexbox.adapter:CodexAdapter 
    AICODEBOX_AGENT_BINARY=codexbox-agent 
    CODEXBOX_IMAGE_VARIANT=minimal 
    CODEX_HOME=/home/aicode/.codex

AICODEBOX_ADAPTER points the base’s adapter-loading machinery at CodexAdapter so it knows how to build argv for codex specifically. AICODEBOX_AGENT_BINARY swaps in a launcher script instead of calling codex directly for interactive/passthrough use (why, below). CODEXBOX_IMAGE_VARIANT just flags which image you’re on (minimal here, full in the toolchain build). And CODEX_HOME is the one that actually matters, because of that startup crash I mentioned.

Codex reads auth.json, config.toml, and its session rollout files from $CODEX_HOME. If that env var is unset, codex falls back to a sane default. But if it is set — which it has to be here, because the whole point is bind-mounting it from the host so a login survives a container getting nuked and recreated — and the directory it points to doesn’t exist yet, codex refuses to start. Not a warning, not an auto-create, a hard error. So right after the ENV block, the Dockerfile does this:

RUN mkdir -p /home/aicode/.codex && chown -R aicode:aicode /home/aicode/.codex

A single mkdir -p and a chown, baked into the image at build time, purely so that a CLI written by a company with a hundred billion dollars in a bank account doesn’t fall over the first time you point it at a fresh bind mount. This is genuinely my favorite detail in the entire repo — not because it’s clever, it’s the opposite of clever, it’s a workaround for a missing mkdir call. But it’s the kind of thing you only find by actually reading the Dockerfile instead of trusting a README, and it explains why CODEX_HOME gets pre-created instead of just declared and left for codex to figure out.

What CodexAdapter Actually Implements

The adapter contract from the base image gives you a handful of methods to fill in, and CodexAdapter implements all of them: validate (rejects unknown reasoning-effort values, warns and ignores a tools allowlist codex has no equivalent for), build_argv (the meat — translates a generic run request into codex’s actual flag soup), translate_auth (a no-op, because codex reads OPENAI_API_KEY / OPENAI_BASE_URL / auth.json natively, no aliasing needed), parse_output and parse_events (decode the JSONL stream into a normalized result, skipping the interleaved non-JSON log lines), parse_stream_event (turns individual lines into canonical stream deltas for the live-streaming endpoints), interactive_argv and passthrough_argv (raw codex binary invocation for TUI/passthrough), and auth_paths (tells the base image where the credential file lives so it can be checked for existence).

build_argv is where the interesting decisions live. A run with neither resume nor noContinue set defaults to exec resume --last — continuing the workspace’s most recent session by default, same idea as claudebox. systemPrompt maps to -c instructions=..., which replaces codex’s built-in system prompt; appendSystemPrompt maps to -c developer_instructions=..., which appends a developer-role message alongside it instead. Reasoning effort goes through -c model_reasoning_effort=<level> rather than a dedicated thinking flag. And every one of these is passed as a single argv element, not shell-interpolated, so multi-line prompt text and system prompts survive verbatim without getting mangled by a shell somewhere in the pipe.

What codexbox-agent.sh Restores That the Base Drops

aicodebox’s passthrough mode is deliberately dumb: for interactive and one-shot invocations it just runs exec $AICODEBOX_AGENT_BINARY "$@" and gets out of the way. That’s fine for a generic base image, but it means the container-side defaults — no approval prompts, sensible session continuation — don’t automatically apply to Codex’s own interactive TUI, only to the adapter-driven server modes. codexbox-agent.sh is the script plugged in via AICODEBOX_AGENT_BINARY to put those defaults back for the TUI and CLI passthrough paths specifically:

case "${1:-}" in
    login | logout | mcp | mcp-server | doctor | completion | update | resume | review | apply | sandbox | debug | features | help | -V | --version | -h | --help)
        exec "$CODEX_BIN" "$@"
        ;;
    exec | e)
        # inject --dangerously-bypass-approvals-and-sandbox unless already present
        exec "$CODEX_BIN" "$sub" "$BYPASS" "$@"
        ;;
esac
# bare interactive TUI — defaults to resuming the workspace's last session
exec "$CODEX_BIN" resume --last "$BYPASS" ${args[@]+"${args[@]}"}

Auth and maintenance subcommands (login, logout, mcp, doctor, update, and so on) run completely verbatim, no flags injected — that’s what lets codexbox login --device-auth drive the ChatGPT OAuth flow untouched. Anything else — a bare interactive session or an exec call — gets the bypass flag injected automatically so you’re not typing --dangerously-bypass-approvals-and-sandbox by hand every time, and the bare TUI additionally gets the same “continue last session for this directory” default the server-mode adapter uses. The server modes (API, Telegram, cron, MCP) never touch this script at all — they go through CodexAdapter.build_argv and spawn codex directly.

Dual Auth: API Key or a ChatGPT Subscription, One File

Codex supports two auth modes and codexbox has to keep both working without stomping on each other. An init script that runs once per boot (10-codex-auth-config.sh) reads any existing auth.json, checks its auth_mode field, and only seeds an API key via codex login --with-api-key when there’s no existing auth or the existing auth is already apikey mode. A ChatGPT-subscription OAuth login always wins and is never overwritten, even if OPENAI_API_KEY happens to be set in the environment. The reason the API key needs a login step at all instead of just being read from the env var directly: codex’s exec subcommand doesn’t accept a bare OPENAI_API_KEY for auth, it needs auth.json written to disk first.

Both auth modes converge on the exact same file, which is also exactly why CODEX_HOME has to survive a container getting blown away. CodexAdapter.auth_paths() returns a single path:

def auth_paths(self) -> list[str]:
    home = os.environ.get("HOME", "/home/aicode")
    cfg = os.environ.get("CODEX_HOME", f"{home}/.codex")
    return [f"{cfg}/auth.json"]

Bind-mount ~/.codex from the host, and whichever way you authenticated — a stored API key or a ChatGPT OAuth token — survives every container recreate, because it’s not living in the container’s writable layer, it’s living on the mount that the base’s pre-created, chowned directory made safe to point CODEX_HOME at in the first place.

Minimal vs Full: Same Auth, Same Adapter, More Toolchain

The default psyb0t/codexbox:latest image is codex plus Node, Python, uv, Docker, git, jq, and curl — enough to run the agent and not much else. Dockerfile.full builds a second image on top of that minimal one, pinned by digest to a specific published tag, and layers on the same general-purpose dev toolchain claudebox’s full image ships: Go with golangci-lint, a Python toolchain via pyenv, a Node toolchain installed from a committed pnpm lockfile with lifecycle scripts disabled, GitHub CLI, Terraform, kubectl, Helm, and the usual pile of build tools, DB clients, and debuggers, split out into full-go/, full-node/, and full-python/ dependency directories so each toolchain’s inputs are version-pinned and checksum-verified rather than “whatever apt feels like installing today.” Same Codex adapter, same entrypoint, same auth behavior — the full build just adds tools around it, and every download in it is checksum-verified against a pinned SHA256 before it’s trusted.

The Host Side: wrapper.sh and install.sh

install.sh pulls the selected image (minimal by default, full via CODEXBOX_FULL=1), creates ~/.codex and an SSH key dir for git-over-SSH inside the container, downloads wrapper.sh, bakes the resolved image tag into it, and installs it on your PATH as codexbox. From there:

export OPENAI_API_KEY=sk-...          # or: codexbox login --device-auth
codexbox                              # interactive TUI, continues last session for this dir
codexbox --no-continue                # same, but forces a fresh session
codexbox exec "fix the failing test"  # one-shot exec, output to your terminal
echo "summarize README.md" | codexbox exec -
codexbox stop                         # kill this dir's running container(s)
codexbox clear-session                # drop saved codex sessions, keep auth + config

The wrapper’s own job is deliberately narrow: resolve the image, mount the workspace and the persisted ~/.codex and the docker socket, forward auth and any CODEXBOX_ENV_* / CODEXBOX_MOUNT_* variables, and manage a per-directory container by name. Every actual codex-flag decision — bypass injection, resume vs fresh session, subcommand passthrough — lives inside the image in codexbox-agent.sh, not in the host script. The wrapper hands your args over untouched and lets the container decide what to do with them.

Every Way Into The Box

Past the interactive shell and one-shot exec, the same server surfaces from aicodebox apply here, just running codex under the hood:

  • API mode — a FastAPI server with /run (sync or async agent runs), /files/* (list/read/write/delete inside the workspace, path-traversal checked), and an OpenAI-compatible /openai/v1/chat/completions endpoint. Requires CODEXBOX_AVAILABLE_MODELS to be set explicitly — codex has no hardcoded model list, it’s server-driven, so there’s no sane default to fall back to.
  • Telegram mode — text in, codex runs, Markdown gets rendered back to HTML. Per-chat overrides for model, reasoning effort, and system prompts, persisted across restarts.
  • Cron mode — croniter-driven scheduled jobs that fire codex with a fixed instruction on a schedule, each run logged to a per-job history dir.
  • MCP mode — the base image’s own file-ops-and-prompt-running MCP surface, mounted at /mcp when API mode is on or as a sidecar process otherwise. This is separate from codex’s own MCP client/server capabilities, which aren’t wired into any of this.

The one thing worth calling out explicitly: toolsAllowlist and noTools are accepted on /run for API compatibility with the other agents on this base, but codex has no named built-in tool allowlist. An allowlist gets logged and ignored. noTools is the one exception that’s actually honored — it drops the shell and web-search tools from the request and forces the sandbox read-only, because config alone can’t remove apply_patch or update_plan.

Was It Worth It

codexbox isn’t a rewrite of anything. It’s what “add another agent” is supposed to cost once the actual infrastructure work is done elsewhere: a Dockerfile, four environment variables, a mkdir -p for a directory OpenAI’s own CLI won’t create for itself, and one Python class that knows Codex’s specific flag vocabulary. Everything else — the API, the Telegram bot, the cron scheduler, the MCP surface, the whole host wrapper — is aicodebox’s, untouched.

If you want the full base-image rationale, read the aicodebox post. If you want the Claude Code sibling, that’s claudebox. If you just want a box that runs Codex without OpenAI’s CLI faceplanting on a missing directory, it’s on GitHub. It does one job and it stopped faceplanting, which is all I ever wanted from it.