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 │
cp .dev.vars.example .dev.vars # add your OPENAI_API_KEY
npm install
npm run devOpen 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 = falserequire the Cloudflare runtime environment. Local development viawrangler devusesenableInternet = truewith HTTP-only interception.
wrangler secret put OPENAI_API_KEY
npm run deployEnvironment 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. |
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 byoutboundByHostoroutboundhandlers 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.
Each WebSocket connection targets /ws/<session-name>. The session name maps to a Sandbox Durable Object instance. On connect, the Worker:
- Destroys any existing sandbox for that session (clean slate)
- Generates a high-entropy Codex WebSocket capability token
- Writes the token to
/tmp/codex-ws-tokeninside the sandbox - Starts the Codex app-server process inside the container with
--ws-auth capability-token --ws-token-file /tmp/codex-ws-token - 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:
sandbox/setup— clone a git repo into/workspace(optional)initialize/initialized— Codex protocol handshakethread/start— create a single conversation threadturn/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 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.
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 |
The Sandbox subclass combines three layers of network control to minimize data exfiltration risk:
enableInternet = false— blocks all direct outbound connections at the network level. Raw TCP to hosts not inoutboundByHostis refused.interceptHttps = true— HTTPS traffic is intercepted via a Cloudflare-injected CA certificate, so it flows through the same handlers as HTTP.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.
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/deltawith 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.
npm testRuns 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.
node test-egress.mjs # against localhost:8787
WS_URL=wss://your-app.workers.dev/ws/test node test-egress.mjs # against productionValidates egress constraints from inside the container:
api.openai.comreturns 200 with API key injected (container only has dummy key)github.comreturns 301 (allowed)example.comandhttpbin.orgreturn 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.
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)