# syntax=docker/dockerfile:1 FROM node:24-slim AS build ARG CODEX_WEB_REF=0f71e2c901cc901931c8f43730fb41e899f71293 ENV NPM_CONFIG_PREFIX=/opt/npm-global \ NPM_CONFIG_UPDATE_NOTIFIER=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_AUDIT=false \ PATH=/opt/npm-global/bin:$PATH \ NODE_ENV=production RUN mkdir -p /opt/npm-global \ && apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl unzip patch python3 build-essential \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSL "https://github.com/0xcaff/codex-web/archive/${CODEX_WEB_REF}.tar.gz" \ | tar -xz -C /opt \ && mv /opt/codex-web-${CODEX_WEB_REF} /opt/codex-web COPY --chmod=0755 <<'PY' /opt/apply-hf-patches.py #!/usr/bin/env python3 from pathlib import Path root = Path("/opt/codex-web") # server: long-lived IPC websocket main = (root / "src/server/main.ts").read_text() old_ws = 'const websocketServer = new WebSocketServer({ noServer: true });' new_ws = 'const websocketServer = new WebSocketServer({ noServer: true, perMessageDeflate: false });' if old_ws not in main: raise SystemExit("patch fail: websocketServer constructor") main = main.replace(old_ws, new_ws, 1) old_conn = """ websocketServer.on("connection", (socket) => { sockets.add(socket); """ new_conn = """ websocketServer.on("connection", (socket) => { sockets.add(socket); try { const raw = (socket as unknown as { _socket?: { setKeepAlive?: (on: boolean, ms?: number) => void; setNoDelay?: (on: boolean) => void; setTimeout?: (ms: number) => void } })._socket; raw?.setKeepAlive?.(true, 15000); raw?.setNoDelay?.(true); raw?.setTimeout?.(0); } catch { /* ignore */ } const pingInterval = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { try { socket.ping(); } catch { /* proxy may strip ping frames */ } try { socket.send(JSON.stringify({ type: "keepalive", t: Date.now() })); } catch { /* ignore */ } } }, 12000); """ if old_conn not in main: raise SystemExit("patch fail: connection handler") main = main.replace(old_conn, new_conn, 1) old_close = """ socket.on("close", () => { sockets.delete(socket); """ new_close = """ socket.on("close", () => { clearInterval(pingInterval); sockets.delete(socket); """ if old_close not in main: raise SystemExit("patch fail: close handler") main = main.replace(old_close, new_close, 1) old_msg = """ socket.on("message", (rawData) => { let message: RendererToMainMessage; try { message = JSON.parse(String(rawData)) as RendererToMainMessage; } catch (error) { console.error("[ipc-bridge] invalid JSON payload", error); return; } """ new_msg = """ socket.on("message", (rawData) => { let message: RendererToMainMessage; try { message = JSON.parse(String(rawData)) as RendererToMainMessage; } catch (error) { console.error("[ipc-bridge] invalid JSON payload", error); return; } const maybeType = (message as { type?: string }).type; if (maybeType === "keepalive" || maybeType === "keepalive-ack") { if (maybeType === "keepalive" && socket.readyState === WebSocket.OPEN) { try { socket.send(JSON.stringify({ type: "keepalive-ack", t: (message as { t?: number }).t })); } catch { /* ignore */ } } return; } """ if old_msg not in main: raise SystemExit("patch fail: message handler") main = main.replace(old_msg, new_msg, 1) old_static = """ await app.register(fastifyStatic, { root: path.resolve(__dirname, "../../scratch/asar/webview"), prefix: "/", }); """ new_static = """ await app.register(fastifyStatic, { root: path.resolve(__dirname, "../../scratch/asar/webview"), prefix: "/", maxAge: 86400, setHeaders: (res, filePath) => { if (filePath.endsWith("index.html") || filePath.endsWith(".html")) { res.setHeader("Cache-Control", "no-cache"); } else if (/\\.[0-9a-f]{8,}\\.(js|css|woff2?|png|svg|webp)$/i.test(filePath)) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else { res.setHeader("Cache-Control", "public, max-age=86400"); } }, }); """ if old_static not in main: raise SystemExit("patch fail: static register") main = main.replace(old_static, new_static, 1) old_listen = """ await app.listen({ host: options.host, port: options.port }); console.log(`IPC bridge listening at ws://${options.host}:${options.port}`); """ new_listen = """ app.server.requestTimeout = 0; app.server.headersTimeout = 0; app.server.keepAliveTimeout = 120000; app.server.timeout = 0; await app.listen({ host: options.host, port: options.port }); console.log(`IPC bridge listening at ws://${options.host}:${options.port}`); """ if old_listen not in main: raise SystemExit("patch fail: listen") main = main.replace(old_listen, new_listen, 1) # gzip static assets only — never a streaming/IPC path if 'import fastifyCompress' not in main: main = main.replace( 'import fastifyStatic from "@fastify/static";', 'import fastifyCompress from "@fastify/compress";\nimport fastifyStatic from "@fastify/static";', 1, ) if "fastifyCompress" in main and "app.register(fastifyCompress" not in main: main = main.replace( " await app.register(fastifyMultipart, {", """ await app.register(fastifyCompress, { global: true, threshold: 1024, encodings: ["gzip", "deflate"], customTypes: /^text\\/|javascript|json|xml|svg|wasm/, }); await app.register(fastifyMultipart, {""", 1, ) (root / "src/server/main.ts").write_text(main) pkg = (root / "package.json").read_text() if '"@fastify/compress"' not in pkg: pkg = pkg.replace( '"@fastify/multipart": "^10.0.0",', '"@fastify/compress": "^9.2.0",\n "@fastify/multipart": "^10.0.0",', 1, ) (root / "package.json").write_text(pkg) # browser shim: application keepalive (proxies ignore WS ping frames) shim = (root / "src/browser/shim.ts").read_text() shim = shim.replace( "const RECONNECT_DELAY_MS = 1_000;", "const RECONNECT_DELAY_MS = 400;", 1, ) old_in = """function handleIncomingMessage(message: MainToRendererMessage): void { if (message.type === "ipc-main-event") { """ new_in = """function handleIncomingMessage(message: MainToRendererMessage): void { const maybeType = (message as { type?: string }).type; if (maybeType === "keepalive" || maybeType === "keepalive-ack") { return; } if (message.type === "ipc-main-event") { """ if old_in not in shim: raise SystemExit("patch fail: shim handleIncomingMessage") shim = shim.replace(old_in, new_in, 1) old_open = """ socket.addEventListener("open", () => { flushOutboundQueue(); }); """ new_open = """ socket.addEventListener("open", () => { flushOutboundQueue(); try { socket.send(JSON.stringify({ type: "keepalive", t: Date.now() })); } catch { /* ignore */ } }); """ if old_open not in shim: raise SystemExit("patch fail: shim open") shim = shim.replace(old_open, new_open, 1) needle = "ensureSocket();\n\nexport const contextBridge" if needle not in shim: raise SystemExit("patch fail: shim ensureSocket trailer") shim = shim.replace( needle, """ensureSocket(); setInterval(() => { if (socket && socket.readyState === WebSocket.OPEN) { try { socket.send(JSON.stringify({ type: "keepalive", t: Date.now() })); } catch { /* ignore */ } } else { ensureSocket(); } }, 12000); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { ensureSocket(); } }); window.addEventListener("online", () => ensureSocket()); window.addEventListener("pageshow", () => ensureSocket()); export const contextBridge""", 1, ) (root / "src/browser/shim.ts").write_text(shim) # electron getPath: cache/temp off the bucket, userdata stays persisted elec = (root / "src/server/electron/index.ts").read_text() old_gp = """ getPath(name: string): string { log("app.getPath", [name]); return process.cwd(); }, """ new_gp = """ getPath(name: string): string { log("app.getPath", [name]); const home = process.env.HOME || "/home/user"; const persist = process.env.CODEX_WEB_USERDATA || home + "/app-data"; const tmp = process.env.CODEX_WEB_CACHE || "/var/tmp/codex-electron"; switch (name) { case "home": return home; case "temp": case "cache": case "userCache": case "crashDumps": return tmp; case "logs": return tmp + "/logs"; case "desktop": case "documents": case "downloads": case "music": case "pictures": case "videos": return home; default: return persist; } }, """ if old_gp not in elec: raise SystemExit("patch fail: electron getPath") elec = elec.replace(old_gp, new_gp, 1) (root / "src/server/electron/index.ts").write_text(elec) # smaller, faster webview preload bundle vite = (root / "vite.browser.config.ts").read_text() vite = vite.replace("minify: false,", "minify: true,", 1) vite = vite.replace("sourcemap: true,", "sourcemap: false,", 1) (root / "vite.browser.config.ts").write_text(vite) print("hf patches applied") PY RUN python3 /opt/apply-hf-patches.py \ && cd /opt/codex-web \ && npm install --omit=dev \ && npm install -g @openai/codex \ && npm cache clean --force \ && chmod -R a+rX /opt/codex-web /opt/npm-global FROM node:24-slim ENV NPM_CONFIG_PREFIX=/opt/npm-global \ NPM_CONFIG_UPDATE_NOTIFIER=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_AUDIT=false \ PATH=/opt/npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ NODE_ENV=production \ NODE_OPTIONS=--dns-result-order=ipv4first RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl wget git tini sudo gosu \ python3 python3-pip python3-venv python3-dev \ gcc g++ make cmake pkg-config \ unzip zip \ libssl-dev libffi-dev \ procps bash \ ripgrep fd-find \ && rm -rf /var/lib/apt/lists/* \ && ln -sf "$(command -v fdfind || true)" /usr/local/bin/fd || true \ && groupmod -n user node \ && usermod -l user -d /home/user -m node \ && usermod -aG sudo user \ && mkdir -p /home/user/app /data /var/tmp/codex-ephemeral /opt/npm-global \ && echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \ && chmod 440 /etc/sudoers.d/user \ && chown -R user:user /home/user /data /var/tmp/codex-ephemeral COPY --from=build /opt/codex-web /opt/codex-web COPY --from=build /opt/npm-global /opt/npm-global RUN chown -R user:user /opt/npm-global \ && chmod -R a+rX /opt/codex-web /opt/npm-global COPY --chmod=0755 <<'EOF' /usr/local/bin/entrypoint #!/usr/bin/env bash set -euo pipefail DATA_DIR="/data" EPHEM="/var/tmp/codex-ephemeral" mkdir -p "$EPHEM" /var/tmp/codex-electron /tmp log() { echo "[entrypoint] $*"; } # Persist $HOME onto the bucket; never ls/du/rm -rf large FUSE trees on the boot path. if [ -w "$DATA_DIR" ]; then log "storage bucket detected at $DATA_DIR — persisting the entire home directory." PERSIST_HOME="$DATA_DIR/home" mkdir -p "$PERSIST_HOME" LISTING="$(timeout 8 ls -A "$PERSIST_HOME" 2>/dev/null || true)" if [ -z "$LISTING" ]; then log "bucket looks empty — seeding from the image's baked-in \$HOME" timeout 60 cp -a /home/user/. "$PERSIST_HOME"/ 2>/dev/null \ || log "WARNING: seed copy timed out or failed — continuing" fi export HOME="$PERSIST_HOME" else log "WARNING: no writable storage bucket at $DATA_DIR — EPHEMERAL storage, lost on restart." log "Attach a bucket at mount path '/data', Access: Read & Write, in Settings -> Storage." export HOME="${HOME:-/home/user}" fi # If started as root: chown the persist tree, bind-mount /usr/local, then re-exec as uid 1000 # before any Codex files are written so bucket objects are not stuck root-owned. if [ "$(id -u)" = "0" ] && [ "${1:-}" != "--as-user" ]; then mkdir -p "$HOME/.usr-local" "$HOME/.local" "$HOME/.codex" "$HOME/app-data" chown user:user "$HOME" "$HOME/.usr-local" "$HOME/.local" "$HOME/.codex" "$HOME/app-data" 2>/dev/null || true if [ -z "$(timeout 5 ls -A "$HOME/.usr-local" 2>/dev/null || true)" ]; then timeout 30 cp -a /usr/local/. "$HOME/.usr-local"/ 2>/dev/null || true chown -R user:user "$HOME/.usr-local" 2>/dev/null || true fi if mount --bind "$HOME/.usr-local" /usr/local 2>/dev/null; then log "bind-mounted \$HOME/.usr-local -> /usr/local (compilers you install here persist)" else log "could not bind-mount /usr/local (no CAP_SYS_ADMIN) — use \$HOME/.local instead" fi log "dropping privileges to user uid 1000" exec gosu user "$0" --as-user fi export CODEX_HOME="$HOME/.codex" mkdir -p "$CODEX_HOME" "$HOME/.local/bin" "$HOME/.local/lib" "$HOME/app-data" "$HOME/.usr-local" if ! timeout 15 touch "$HOME/.write-probe" 2>/dev/null; then log "FATAL: $HOME is not writable by uid $(id -u) (or the bucket didn't respond within 15s)." log "Check Settings -> Storage: Access mode must be Read & Write, then do a full Restart." exit 1 fi rm -f "$HOME/.write-probe" || true # Divert only Codex runtime bloat onto fast local disk; the rest of $HOME stays on the bucket. # Symlink, not env-var: Codex hardcodes ~/.cache/codex-runtimes and ignores XDG_CACHE_HOME. divert_to_ephemeral() { local src="$1" local dest="$2" mkdir -p "$(dirname "$src")" "$dest" if [ -L "$src" ]; then ln -sfn "$dest" "$src" return 0 fi if [ -e "$src" ]; then local stale="${src}.STALE.$$" if mv "$src" "$stale" 2>/dev/null; then log "moved existing $(basename "$src") off the live path — deleting in background" ( nice rm -rf "$stale" >/dev/null 2>&1 || true ) & else log "WARNING: could not rename $src — leaving it and still linking" fi fi ln -sfn "$dest" "$src" } mkdir -p "$HOME/.cache" "$HOME/.local/state/codex" divert_to_ephemeral "$HOME/.cache/codex-runtimes" "$EPHEM/codex-runtimes" divert_to_ephemeral "$HOME/.cache/codex-primary-runtime" "$EPHEM/codex-primary-runtime" divert_to_ephemeral "$HOME/.local/state/codex/logs" "$EPHEM/codex-logs-xdg" if compgen -G "$HOME/.cache/codex-runtimes.STALE*" >/dev/null \ || compgen -G "$HOME/.cache/codex-primary-runtime.STALE*" >/dev/null; then log "sweeping leftover STALE runtime dirs in the background" ( nice rm -rf "$HOME/.cache"/codex-runtimes.STALE* \ "$HOME/.cache"/codex-primary-runtime.STALE* >/dev/null 2>&1 || true ) & fi # Do NOT export XDG_CACHE_HOME / XDG_STATE_HOME to /tmp — that would throw # non-Codex caches (pip, huggingface, etc.) off the bucket too. export CODEX_WEB_USERDATA="$HOME/app-data" export CODEX_WEB_CACHE="/var/tmp/codex-electron" export PYTHONUSERBASE="$HOME/.local" export PIP_USER=1 export npm_config_prefix="$HOME/.local" export NPM_CONFIG_PREFIX="$HOME/.local" export CARGO_HOME="$HOME/.cargo" export RUSTUP_HOME="$HOME/.rustup" export GOPATH="$HOME/go" export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/go/bin:/opt/npm-global/bin:$PATH" mkdir -p "$HOME/.local/bin" "$CARGO_HOME/bin" "$GOPATH/bin" "$CODEX_WEB_USERDATA" # Codex CLI: image ships a working binary; only install if missing. if ! command -v codex >/dev/null 2>&1; then log "codex missing from PATH — installing latest into \$HOME/.local ..." npm install -g @openai/codex \ || { log "FATAL: could not install @openai/codex"; exit 1; } fi export CODEX_CLI_PATH="$(command -v codex)" if [ -z "${CODEX_CLI_PATH}" ]; then log "FATAL: 'codex' isn't on PATH." exit 1 fi log "codex: $($CODEX_CLI_PATH --version 2>/dev/null || echo unknown) at $CODEX_CLI_PATH" if [ -n "${OPENAI_API_KEY:-}" ] && [ ! -f "$CODEX_HOME/auth.json" ]; then log "logging in to Codex from \$OPENAI_API_KEY ..." printenv OPENAI_API_KEY | codex login --with-api-key \ || log "WARNING: codex login failed — check that OPENAI_API_KEY is valid." elif [ -z "${OPENAI_API_KEY:-}" ] && [ ! -f "$CODEX_HOME/auth.json" ]; then log "WARNING: no OPENAI_API_KEY and no existing login — Codex won't be usable until set." fi ( set +e sleep 8 LATEST="$(timeout 40 npm view @openai/codex version 2>/dev/null || true)" CURRENT="$(codex --version 2>/dev/null | awk '{print $NF}' || true)" if [ -n "$LATEST" ] && [ -n "$CURRENT" ] && [ "$LATEST" != "$CURRENT" ]; then log "codex: $CURRENT -> $LATEST (background, into \$HOME/.local)" npm install -g "@openai/codex@${LATEST}" \ && log "codex: now $(codex --version 2>/dev/null)" fi ) & ( set +e SEL="$HOME/.dpkg-selections" if [ -f "$SEL" ] && command -v sudo >/dev/null 2>&1; then log "replaying persisted apt selections in the background" timeout 90 sudo apt-get update -qq timeout 300 sudo dpkg --set-selections < "$SEL" timeout 600 sudo apt-get dselect-upgrade -y -qq fi while true; do if command -v dpkg >/dev/null 2>&1; then dpkg --get-selections > "$SEL.tmp" 2>/dev/null && mv "$SEL.tmp" "$SEL" fi sleep 180 done ) & cd "$CODEX_WEB_USERDATA" log "serving on 0.0.0.0:7860 — no auth in front of this (codex-web has none by design)" exec node /opt/codex-web/src/server/main.js --host 0.0.0.0 --port 7860 EOF ENV HOME=/home/user WORKDIR /home/user/app EXPOSE 7860 ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint"]