File size: 17,534 Bytes
bed91b8
 
5e05f0e
bed91b8
 
 
 
 
 
 
5e05f0e
 
bed91b8
5e05f0e
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
bed91b8
5e05f0e
bed91b8
 
7d5636d
5e05f0e
7d5636d
5e05f0e
 
bed91b8
5e05f0e
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
7d5636d
5e05f0e
 
 
 
7d5636d
5e05f0e
7d5636d
5e05f0e
 
 
 
bed91b8
5e05f0e
 
 
 
 
7d5636d
5e05f0e
 
 
 
 
 
 
 
 
 
7d5636d
5e05f0e
 
bed91b8
5e05f0e
 
 
7d5636d
 
5e05f0e
 
 
 
 
 
 
7d5636d
 
 
5e05f0e
 
 
 
7d5636d
 
 
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d5636d
 
5e05f0e
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
7d5636d
5e05f0e
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
7d5636d
5e05f0e
 
 
bed91b8
5e05f0e
 
 
 
7d5636d
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
bed91b8
5e05f0e
bed91b8
5e05f0e
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bed91b8
5e05f0e
 
bed91b8
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d5636d
bed91b8
5e05f0e
 
 
 
bed91b8
5e05f0e
 
 
 
 
 
7d5636d
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d5636d
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
7d5636d
5e05f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d5636d
5e05f0e
 
 
 
7d5636d
5e05f0e
 
7d5636d
5e05f0e
7d5636d
5e05f0e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# 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"]