Skip to content

Latest commit

 

History

History

README.md

Codex App Server

Runs OpenAI Codex inside a Cloudflare Sandbox. A Cloudflare Worker acts as a WebSocket middleman between the browser and the container, running every JSON-RPC message through a composable handler pipeline. An egress proxy intercepts all outbound HTTP and HTTPS from the container to inject the OpenAI API key, while blocking everything else.

Browser                     Worker (middleman)              Sandbox Container
 ─────────────           ─────────────────────          ──────────────────────
│             │ WebSocket │  handler pipeline  │ WebSocket │ codex app-server  │
│  Client UI  │◄─────────►│  (inspect/rewrite/ │◄────────►│ :4500             │
│             │           │   intercept)       │          │                   │
│             │           │  egress handlers   │          │ OPENAI_BASE_URL=  │
│             │           │  ┌───────────────┐ │          │ http://api.openai │
│             │           │  │api.openai.com │──► inject API key ──► OpenAI
│             │           │  │github.com     │──► upgrade to HTTPS ──► GitHub
│             │           │  │* (catch-all)  │──► 403 Forbidden
│             │           │  └───────────────┘ │
│             │           │                     │
│             │           │  enableInternet=false│
│             │           │  interceptHttps=true │

Quick start

cp .dev.vars.example .dev.vars   # add your OPENAI_API_KEY
npm install
npm run dev

Open http://localhost:8787. Enter a session name, optionally a repo URL, and click Connect.

The first run builds the Docker container (2-3 minutes). Subsequent runs reuse the cached image.

Note: HTTPS interception and enableInternet = false require the Cloudflare runtime environment. Local development via wrangler dev uses enableInternet = true with HTTP-only interception.

Deploy

wrangler secret put OPENAI_API_KEY
npm run deploy

Configuration

Environment variables (set in .dev.vars locally, wrangler secret in production):

Variable Required Description
OPENAI_API_KEY yes Injected into sandbox HTTP/HTTPS requests via the egress proxy. Never reaches the container.
AUTH_TOKEN no If set, clients must provide Authorization: Bearer <token> or ?token=<token>.
SANDBOX_SLEEP_AFTER no How long the container stays alive after the last request. Default: 1m.

How it works

Sandbox subclass

The Worker exports a Sandbox subclass with two security settings:

export class Sandbox extends BaseSandbox<Env> {
  enableInternet = false; // block direct internet at the network level
  interceptHttps = true; // intercept HTTPS via Cloudflare CA cert injection
}
  • enableInternet = false — disables direct outbound network access from the container. Only traffic handled by outboundByHost or outbound handlers can leave.
  • interceptHttps = true — injects a Cloudflare CA certificate into the container so HTTPS traffic flows through the same egress handlers as HTTP. Without this, HTTPS would bypass the proxy.

Session lifecycle

Each WebSocket connection targets /ws/<session-name>. The session name maps to a Sandbox Durable Object instance. On connect, the Worker:

  1. Destroys any existing sandbox for that session (clean slate)
  2. Generates a high-entropy Codex WebSocket capability token
  3. Writes the token to /tmp/codex-ws-token inside the sandbox
  4. Starts the Codex app-server process inside the container with --ws-auth capability-token --ws-token-file /tmp/codex-ws-token
  5. Bridges WebSocket frames between the browser and container through the handler pipeline, using Authorization: Bearer <token> only for the private Worker-to-container connection

The client then runs the connection flow:

  1. sandbox/setup — clone a git repo into /workspace (optional)
  2. initialize / initialized — Codex protocol handshake
  3. thread/start — create a single conversation thread
  4. turn/start — send prompts, receive streamed responses

Each session operates a single thread. On disconnect, the sandbox sleeps after SANDBOX_SLEEP_AFTER. Reconnecting with the same session name destroys and recreates it.

Codex WebSocket authentication

Codex app-server's WebSocket transport supports bearer-token authentication for non-loopback listeners. This example uses the documented capability-token mode with --ws-token-file, so the raw token never appears in the process command line.

The token is internal to the Worker-to-container connection. Browser clients authenticate to the Worker with AUTH_TOKEN when configured, but they never receive the Codex app-server capability token.

Handler pipeline

Every JSON-RPC message flowing through the WebSocket bridge passes through a composable handler pipeline. Each handler can pass through (return the message), rewrite (return a modified copy), or intercept (return null after responding via the context object).

type MessageHandler = (msg: JsonRpcMessage, ctx: HandlerContext) => JsonRpcMessage | null;

const pipeline = compose(
  log(),                    // observe all traffic
  enforceModel('gpt-5.4'), // force model on thread/turn start
  enforcePolicy({...}),    // override approval + sandbox policies
  sandboxSetup(sandbox),   // intercept sandbox/setup
  sandboxExec(sandbox),    // intercept sandbox/exec
  autoApprove()            // auto-approve tool execution requests
);

Built-in handlers (defined in src/rpc.ts):

Handler Direction Action
log() both Log every message to the Workers console
enforceModel(m) client→server Force model on thread/start and turn/start
enforcePolicy(o) client→server Override approval/sandbox policy on turn/start, thread/start, command/exec
autoApprove() server→client Auto-approve commandExecution and fileChange requests

Custom handlers (defined in src/index.ts):

Handler Direction Action
sandboxSetup(s) client→server Intercept sandbox/setup — wipe /workspace and gitCheckout a repo
sandboxExec(s) client→server Intercept sandbox/exec — run a shell command, return stdout/stderr

Egress control

The Sandbox subclass combines three layers of network control to minimize data exfiltration risk:

  1. enableInternet = false — blocks all direct outbound connections at the network level. Raw TCP to hosts not in outboundByHost is refused.
  2. interceptHttps = true — HTTPS traffic is intercepted via a Cloudflare-injected CA certificate, so it flows through the same handlers as HTTP.
  3. outboundByHost + outbound — application-level allowlist with a deny-by-default catch-all.
Host Protocol Action
api.openai.com HTTP + HTTPS Allowed — Worker injects OPENAI_API_KEY and upgrades to HTTPS
github.com HTTP + HTTPS Allowed — upgrades to HTTPS (needed for sandbox/setup git clone)
Everything else HTTP + HTTPS Blocked with 403 Forbidden
Non-HTTP traffic Raw TCP Blocked by enableInternet = false for non-allowed hosts

The container never sees the real API key. It uses OPENAI_BASE_URL=http://api.openai.com/v1 so requests flow through the egress proxy, and receives a dummy key (proxy-injected). The Worker swaps in the real key and upgrades to HTTPS before forwarding to OpenAI.

Note: DNS resolution is unrestricted, but without network access to blocked hosts, DNS alone does not enable data exfiltration.

Browser client

public/index.html is a single-file vanilla HTML/CSS/JS client with a dark terminal-meets-chat aesthetic:

  • Session gate — enter a session name and optional repo URL (persisted in localStorage)
  • Streaming chat — agent messages stream in via item/agentMessage/delta with a blinking cursor
  • Tool call grid — command executions and file changes render in a two-column grid with collapsible output, exit codes, duration, and color-coded diffs
  • JSON-RPC log — toggleable side panel showing raw protocol traffic for debugging

The WebSocket endpoint is injected into the HTML via HTMLRewriter setting a data-ws-endpoint attribute on the <html> element.

Testing

Integration test

npm test

Runs run-integration-tests.sh, which starts wrangler dev, waits for readiness, then runs test.mjs. The test connects via WebSocket and exercises the full flow: sandbox/setup repo clone, initialize handshake, thread/start, and turn/start with streaming delta collection.

Egress validation

node test-egress.mjs                    # against localhost:8787
WS_URL=wss://your-app.workers.dev/ws/test node test-egress.mjs  # against production

Validates egress constraints from inside the container:

  • api.openai.com returns 200 with API key injected (container only has dummy key)
  • github.com returns 301 (allowed)
  • example.com and httpbin.org return 403 (blocked)
  • Response body contains "Forbidden by egress policy"

When deployed with interceptHttps = true, HTTPS requests to blocked hosts also return 403. With enableInternet = false, raw TCP connections to non-allowed hosts time out.

Code structure

codex-app-server/
├── Dockerfile               cloudflare/sandbox:0.12.5 + @openai/codex CLI
├── wrangler.jsonc            Worker + Sandbox Durable Object + container config
├── .dev.vars.example         Environment variable template
├── src/
│   ├── index.ts              Worker: Sandbox subclass, egress proxy, WebSocket bridge
│   └── rpc.ts                JSON-RPC types + composable handler pipeline
├── public/
│   └── index.html            Browser client (session gate, streaming chat, tool grid)
├── test.mjs                  Integration test (full Codex flow over WebSocket)
├── test-egress.mjs           Egress constraint validation test
└── run-integration-tests.sh  Test runner (starts wrangler dev, runs test, tears down)