Codex on Cloudflare Sandboxes
July 24, 2026 · View on GitHub
Run Codex inside a Cloudflare sandbox container instead of on the orchestrator host, by driving the sandbox bridge Worker you deploy into your own account.
Module: bernstein.adapters.codex_cloudflare
Class: CodexCloudflareAdapter
!!! info "Implementation status"
Implemented against the HTTP bridge published by
@cloudflare/sandbox 0.12.4, which serves API contract version
1.0.0 at GET /v1/openapi.json.
**End-to-end verification against a live deployment is pending.** The
adapter is built and tested against the published bridge contract, with
recorded HTTP and SSE fixtures that mirror the documented payloads. It has
**not** been run against a real Cloudflare deployment. If you have a
Workers Paid account, the [live verification](#live-verification) command
below is the one-shot check — please report what you find.
What it does
| Step | Bridge route |
|---|---|
| Create sandbox | POST /v1/sandbox → {"id": ...} |
| Seed workspace | POST /v1/sandbox/:id/hydrate (tar) or PUT /v1/sandbox/:id/file/* |
| Open session (cwd + env) | POST /v1/sandbox/:id/session → {"id": ...} |
| Run the agent | POST /v1/sandbox/:id/exec → SSE, Session-Id header |
| Collect the workspace | POST /v1/sandbox/:id/persist → tar → diff |
| Stop and tear down | DELETE /v1/sandbox/:id |
| Pool bookkeeping | GET /v1/pool/stats |
stdout and stderr arrive as base64 SSE frames and are decoded and handed to
your on_output callback as they stream, not buffered to the end. The stream
terminates on exit ({"exit_code": N}) or error ({"error": ..., "code": ...}).
Prerequisites
- A Cloudflare Workers Paid plan ($5/month). Containers — which sandboxes are built on — cannot be deployed on the free plan.
- Docker running locally and Node.js ≥ 16.17, to build and push the container image at deploy time.
- An operator-deployed bridge Worker. Bernstein does not host it and cannot deploy it for you; it lives in your account and bills to it.
Operator deploy steps
# 1. Scaffold the bridge Worker from the upstream template.
npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker
cd sandbox-bridge
# 2. Pin the SDK version this adapter implements against.
npm install @cloudflare/sandbox@0.12.4
# 3. Set the shared secret. THIS STEP IS NOT OPTIONAL - see the warning below.
npx wrangler secret put SANDBOX_API_KEY
# 4. Deploy.
npx wrangler deploy
Then point Bernstein at it:
from bernstein.adapters.codex_cloudflare import CodexCloudflareAdapter, CodexSandboxConfig
adapter = CodexCloudflareAdapter(
CodexSandboxConfig(
bridge_url="https://sandbox-bridge.<your-subdomain>.workers.dev",
bridge_api_key="<the SANDBOX_API_KEY you just set>",
openai_api_key="<key the codex CLI needs inside the sandbox>",
),
)
Instance type: the default is too small for a coding agent
The upstream template ships "instance_type": "lite", which is 1/16 vCPU,
256 MiB RAM, 2 GB disk. That is enough to run echo; it is not enough to run
a coding agent that clones a repo, installs a toolchain, and runs a test suite.
Set the instance type in your Worker's wrangler.jsonc before deploying:
{
"containers": [
{
"class_name": "Sandbox",
"image": "./Dockerfile",
"instance_type": "standard-3", // 2 vCPU, 8 GiB RAM, 16 GB disk
"max_instances": 5 // raise if you run sandboxes concurrently
}
]
}
| Instance type | vCPU | Memory | Disk |
|---|---|---|---|
lite (template default) | 1/16 | 256 MiB | 2 GB |
basic | 1/4 | 1 GiB | 4 GB |
standard-1 | 1/2 | 4 GiB | 8 GB |
standard-2 | 1 | 6 GiB | 12 GB |
standard-3 | 2 | 8 GiB | 16 GB |
standard-4 | 4 | 12 GiB | 20 GB |
standard-3 or larger is the realistic floor for a coding agent. Sizing is
a deploy-time decision: the bridge exposes no per-request memory or CPU
control, which is why the adapter has no such setting (see
retired settings).
The container image must also contain the codex CLI. Add it to the
Dockerfile you deploy — the stock image does not include it.
The authentication warning
!!! danger "A bridge with no SANDBOX_API_KEY is open to the internet"
The bridge's auth checks are conditional on the secret being set. With
SANDBOX_API_KEY unset, both the /v1/sandbox/* middleware and the inline
check on POST /v1/sandbox pass every request through — anyone who finds
the URL can create containers, run commands, and read files, billed to your
account.
`CodexCloudflareAdapter.preflight()` therefore probes `POST /v1/sandbox`
with no `Authorization` header. If that call **succeeds**, preflight deletes
the sandbox it just created and raises
`CodexCloudflareBridgeAuthError` rather than using the deployment. Run
preflight before your first real run.
Preflight also reads GET /v1/openapi.json and:
- refuses a bridge whose API contract major differs from
1.0.0(CodexCloudflareBridgeVersionError— opt out withrequire_supported_api_version=False); - refuses a bridge whose published
pathsare missing any route the adapter drives (CodexCloudflareBridgeContractError— always strict).
The route check is the one that matters in practice: info.version is the
hand-maintained API contract version, not the npm package version, and it
can stay at 1.0.0 while routes move underneath it. The package has already
removed transports across releases.
Cancellation
Closing the /exec SSE stream does not stop the remote process. The
container keeps running the command and keeps billing. Cancellation is the
explicit DELETE /v1/sandbox/:id, which CodexCloudflareAdapter.cancel()
issues; execute() also issues it from a finally, so a failed or cancelled
run tears its sandbox down.
cancel() deliberately does not confirm with
GET /v1/sandbox/:id/running. That route sits behind the bridge's warm-pool
middleware, which acquires — and if necessary starts — a container for the
id before the handler runs true inside it. Probing it after a delete would
report running: true on a perfectly healthy bridge and start a fresh
billable container, undoing the cancellation. Teardown is confirmed against
GET /v1/pool/stats, which reads pool state without allocating, and the result
is returned on CancelOutcome.pool_after.
is_running() and get_status() remain available for asking about a sandbox
you intend to keep using — but they carry the same allocation caveat.
Sandbox evidence
A remote run lands the same signed evidence as a local one. execute() hashes
the exact tar returned by /persist into a content address and records it on
CodexSandboxResult.evidence:
result = await adapter.execute(prompt, workspace_id, workspace_tar=seed)
candidate = result.evidence.to_race_candidate("task-1", {"correctness": 1.0})
# -> bernstein.core.sandbox.selection_receipt.RaceCandidate
The candidate carries terminal_snapshot_digest (SHA-256 of the workspace tar,
re-hashable offline) and isolation = "requested:codex-cloudflare", so it signs
into a selection receipt alongside locally-produced candidates and a verifier
can see where the task ran. The requested: prefix matches the convention in
bernstein.core.sandbox.fork_race: the adapter asked a remote bridge to run the
work, it did not boot or probe the isolation boundary, so it does not claim to
attest the posture. What it does attest — the content-addressed digest — is
verifiable without the network.
Live verification
If you have a Workers Paid account and a deployed bridge, this is the one command that exercises the full path — preflight, seed, stream, diff, evidence, and teardown — against your real deployment:
BRIDGE_URL="https://sandbox-bridge.<your-subdomain>.workers.dev" \
BRIDGE_API_KEY="<your SANDBOX_API_KEY>" \
uv run python - <<'PY'
import asyncio, io, os, tarfile
from bernstein.adapters.codex_cloudflare import CodexCloudflareAdapter, CodexSandboxConfig
def seed() -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
data = b"before\n"
info = tarfile.TarInfo("./probe.txt"); info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
async def main() -> None:
adapter = CodexCloudflareAdapter(
CodexSandboxConfig(
bridge_url=os.environ["BRIDGE_URL"],
bridge_api_key=os.environ["BRIDGE_API_KEY"],
agent_command=("sh", "-c"), # stand in for `codex` so no model key is needed
),
)
print("preflight:", await adapter.preflight())
result = await adapter.execute(
"echo hello && echo after > /workspace/probe.txt",
"live-check",
model="ignored",
timeout_minutes=2,
workspace_tar=seed(),
on_output=lambda stream, chunk: print(f"[{stream}] {chunk.decode(errors='replace')}", end=""),
)
print("status:", result.status, "exit:", result.exit_code)
print("files_changed:", result.files_changed)
print("evidence:", result.evidence)
print("cancel:", await adapter.cancel(result.sandbox_id))
asyncio.run(main())
PY
Expected: preflight reports auth_enforced=True and api_version='1.0.0';
status: completed exit: 0; files_changed: ['probe.txt']; a 64-hex
terminal_snapshot_digest; and a cancel outcome whose pool_after.assigned
does not count the sandbox.
agent_command=("sh", "-c") keeps the check independent of whether your image
carries the codex CLI. With the real agent installed, drop that override and
the adapter runs codex exec --model <model> <prompt>.
!!! warning "This costs money"
The command starts a real container on your paid account. It tears it down
at the end, but a crashed run can leave one behind — check the Cloudflare
dashboard, or DELETE /v1/sandbox/:id, if you interrupt it.
Limitations
These are properties of the bridge, stated plainly rather than papered over.
No egress restriction. The bridge applies none, and exposes no domain allowlist. Code running in the sandbox can reach the internet. If you need network containment, the sandbox is not where you get it.
Symlink escape caveat. The bridge resolves request paths within
/workspace and rejects traversal, but a symlink created inside the
workspace can still name a path outside it, and the persist archive is built
by tar from that tree. Bernstein never extracts the returned tar — it only
reads member contents to compute the diff and the digest — so an escaping
symlink cannot become a write on your host. Extract a persisted workspace
yourself only with a tar reader you trust to refuse absolute and traversing
member paths.
32 MiB payload cap. Both PUT /v1/sandbox/:id/file/* and
POST /v1/sandbox/:id/hydrate reject payloads over 32 MiB. The adapter checks
locally first and raises CodexCloudflarePayloadTooLargeError with the size,
rather than letting you discover it as an opaque rejection. Trim the seed with
persist_excludes, or clone large inputs from inside the sandbox.
Sessions do not survive container sleep. The adapter opens one session per
run for cwd and environment. A container that sleeps mid-run loses it.
No remote log store. Output exists only on the /exec SSE stream while the
command runs. get_logs() returns the transcript this adapter buffered — it
does not fetch anything back from Cloudflare.
Unconfigured means refused. Without bridge_url and bridge_api_key,
every method raises CodexCloudflareNotConfiguredError. The adapter never
falls back to running the agent on the orchestrator host — if you want that,
use the codex adapter deliberately.
Configuration
CodexSandboxConfig fields:
| Field | Type | Default | Description |
|---|---|---|---|
bridge_url | str | "" | Base URL of your deployed bridge Worker |
bridge_api_key | str | "" | The Worker's SANDBOX_API_KEY, sent as Bearer |
openai_api_key | str | "" | Key the agent CLI needs inside the sandbox |
workdir | str | "/workspace" | Workspace root inside the sandbox |
agent_command | tuple[str, ...] | ("codex", "exec") | argv prefix; --model and the prompt are appended |
extra_env | tuple[tuple[str, str], ...] | () | Extra session environment pairs |
max_execution_minutes | int | 30 | Default run ceiling, sent as timeout_ms |
request_timeout_seconds | float | 60.0 | HTTP timeout for control-plane calls |
persist_excludes | tuple[str, ...] | (".git",) | Paths dropped from the persisted tar |
require_supported_api_version | bool | True | Refuse a different API contract major |
The agent's API key travels on the session, not in argv: the bridge's
ExecRequest schema is exactly {argv, timeout_ms, cwd} with no env field,
and keeping the key out of argv also keeps it out of the sandbox process table
and the shell command string the bridge assembles.
Settings that no longer exist
Building against the real bridge retired settings it cannot honour. They are
absent, not ignored, and CodexSandboxConfig.from_mapping() rejects each
by name with the reason — so an old config produces an explanation rather than
silent no-ops.
| Retired setting | Why the bridge cannot honour it |
|---|---|
memory_mb, cpu_cores | Container sizing is the deploy-time wrangler instance_type |
network_access | The bridge applies no egress restrictions and has no allowlist |
sandbox_image | The image is the deploy-time wrangler image |
r2_bucket | Bucket mounts need a deploy-time R2 binding; transfer uses hydrate/persist |
cloudflare_account_id, cloudflare_api_token | The bridge authenticates with its own secret |
tokens_used (result) | The bridge reports exit codes and output, never token counts |
CodexSandboxConfig.from_mapping({"memory_mb": 1024})
# CodexCloudflareConfigError: ... `memory_mb`: container memory is a deploy-time
# wrangler setting (`containers[].instance_type`); it cannot be sized per request.
Errors
| Error | Raised when |
|---|---|
CodexCloudflareNotConfiguredError | No bridge_url / bridge_api_key |
CodexCloudflareConfigError | Retired or unknown config key |
CodexCloudflareBridgeAuthError | Bridge serves /v1/sandbox unauthenticated |
CodexCloudflareBridgeVersionError | Served API contract major differs |
CodexCloudflareBridgeContractError | A required route is missing from the OpenAPI document |
CodexCloudflareBridgeApiError | Any bridge route returned non-2xx |
CodexCloudflarePayloadTooLargeError | Upload over the 32 MiB cap |
CodexCloudflareCancelError | The delete that stops the container was refused |