Vscode / Dockerfile
Hemifield's picture
Update Dockerfile
5e05f0e verified
Raw History Blame Contribute Delete
17.5 kB
# 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"]