# Single-container Möbius image.
#
# Builds the frontend, installs the backend + CLI tools, and serves
# everything from one FastAPI process.  Works on VPS, Railway, PikaPods.

# -- Stage 1: build the frontend --------------------------------------
FROM node:22-slim AS frontend

WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts
COPY frontend/ .
RUN npm run build

# -- Stage 2: backend + everything ------------------------------------
FROM python:3.12-slim

# Copy Node.js binary from the frontend stage instead of installing via
# apt.  The debian nodejs/npm packages pull in ~200MB of system node
# packages we don't need — only the claude CLI and npm globals need Node.
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
    && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx

# System deps and global npm packages in a single layer.
# agent-browser downloads its own Chromium during `install`; we move it
# to /opt/agent-browser so both root and the mobius user share a single
# Chromium copy via the symlinks below (~/.agent-browser is where
# agent-browser looks by default).
RUN apt-get update && apt-get install -y --no-install-recommends \
    cron curl ca-certificates git \
    libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
    libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
    libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64 \
    fonts-liberation fonts-noto-color-emoji \
    && npm install -g esbuild@0.25.12 \
    && npm install -g @anthropic-ai/claude-code@2.1.173 \
    && npm install -g @openai/codex@0.134.0 \
    && npm install -g agent-browser@0.27.0 \
    && agent-browser install \
    && mv /root/.agent-browser /opt/agent-browser \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

# tectonic is a server-side subprocess; CSP connect-src 'self' applies only to
# browser fetches from the mini-app iframe, not OS-level subprocesses — tectonic's
# package fetches (from Tectonic's bundle server) are unrestricted at the OS level.
# Placed after the apt-get layer so a tectonic version bump doesn't bust the apt cache.
ARG TECTONIC_VERSION=0.16.9
RUN curl -fsSL "https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${TECTONIC_VERSION}/tectonic-${TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
    | tar xz -C /usr/local/bin/ tectonic && chmod +x /usr/local/bin/tectonic && tectonic --version

# Share the agent-browser install between root and mobius via symlinks.
# The mobius user is created further down; we chown the shared dir to
# mobius:mobius after that, so mobius can write session sockets/locks
# as the owner without needing world-write on the Chromium binaries.
# (root still has access because root always does.)
RUN ln -s /opt/agent-browser /root/.agent-browser

# openai/codex-plugin-cc — Claude Code plugin that exposes Codex as a
# delegation/review subagent inside the agent's session. Cloned at
# image-build time so the source is reproducible and pinned to a
# release tag; the actual `claude plugin install` happens at first
# boot in entrypoint.sh (it has to write into the agent's runtime
# CLAUDE_CONFIG_DIR=/data/cli-auth/claude/, which is a volume and
# can't be baked into the image). Stays root-owned + world-readable
# (git clone's default 755/644) — install only reads from here.
RUN git clone --depth 1 --branch v1.0.4 \
      https://github.com/openai/codex-plugin-cc.git /opt/codex-plugin-cc

WORKDIR /app

COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# openai-codex Python SDK: installed in a separate step because its
# upstream pyproject pins openai-codex-cli-bin==0.131.0a4 (a version
# not published on PyPI). Install --no-deps to skip that broken pin,
# then pin the cli-bin to a version that actually exists.
# Pinned to commit SHA (not tag) for full reproducibility — tags are
# mutable on GitHub. SHA corresponds to refs/tags/rust-v0.134.0 as of
# 2026-05-27. 0.134 replaced the private `_client._sync._approval_handler`
# monkey-patch with a public `approval_handler` constructor argument
# on `openai_codex.client.AppServerClient` — codex_sdk_runner.py now
# uses that public API instead of monkey-patching.
RUN pip install --no-cache-dir --no-deps \
      'openai-codex @ git+https://github.com/openai/codex.git@a75c443fdb64db48c3cf4bdb247c7ee52c0144c9#subdirectory=sdk/python' \
    && pip install --no-cache-dir 'openai-codex-cli-bin==0.134.0'

COPY backend/app ./app/
COPY backend/scripts ./scripts/
COPY skill/ ./skill/
COPY core-apps/ ./core-apps/
COPY protected-files.txt ./protected-files.txt

# Recovery sources — immutable baked copies of the backend code and
# scripts. The entrypoint chowns the LIVE /app/app/ and /app/scripts/
# to mobius so the agent can edit them; if the agent breaks something,
# recovery_restore.sh restores from these baked copies. Stay root-owned
# and chmod a-w so even root running the recovery script can't
# accidentally modify them in-place. They are the floor of the
# recovery story: if the agent corrupts the live copy, this is what
# we copy back from.
COPY backend/app ./app-baked/
COPY backend/scripts ./scripts-baked/
RUN chmod -R a-w /app/app-baked /app/scripts-baked

# Frontend static files + app-frame served by FastAPI.
COPY --from=frontend /build/dist ./static/
COPY frontend/public/app-frame.html ./app-frame.html

# Self-hosted vendor libs for mini-app import maps. Pinned via npm
# install at image build time, served same-origin under /vendor/ with
# a long cache. Eliminates the cold-load esm.sh waterfall for three.js
# (cold-load saves 1-3s on any 3D app). Pinned to match the version
# we previously served via CDN.
# Copy the WHOLE build/ dir, not just three.module.js: since 0.163 the
# library is split (three.module.js does `export * from './three.core.js'`),
# so a single-file copy leaves three.core.js missing. Requests for it then
# fall through to the SPA HTML fallback (200 text/html), and strict module
# MIME checking rejects it as "failed to load dynamic module".
# The bare `/vendor/three/` is a maintained compat alias (relative
# symlink) — the seed documents that path, and PWAs that cached an older
# app-frame whose importmap pointed at the unversioned URL still request
# it. Without the alias those requests 404 → SPA HTML → spinner-forever.
RUN mkdir -p /tmp/vendor-install && cd /tmp/vendor-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent three@0.184.0 \
    && mkdir -p /app/static/vendor/three@0.184.0/addons \
    && cp -r node_modules/three/build/. /app/static/vendor/three@0.184.0/ \
    && cp -r node_modules/three/examples/jsm/. /app/static/vendor/three@0.184.0/addons/ \
    && ln -s three@0.184.0 /app/static/vendor/three \
    && cd / && rm -rf /tmp/vendor-install

# Self-hosted React for the mini-app import map — same rationale as
# three.js above, but load-bearing for OFFLINE rather than just cold-load
# speed. Mini-apps import react/react-dom via app-frame.html's (and
# standalone.py's) import map. Serving these from esm.sh meant offline-
# capable apps depended on a third-party CDN whose React entry is a
# multi-hop re-export chain (react@19.2.6 → /react@19.2.6/es2022/
# react.bundle.mjs → …; react-dom pulls scheduler + sub-chunks as separate
# URLs). The service worker cache-firsts esm.sh, but only opportunistically
# per URL, so a single uncached hop (or a version bump invalidating the
# prior cache) left an offline app blank on its top-level
# `import 'react-dom/client'`. Serving React same-origin under /vendor
# removes the third-party dependency entirely and makes offline
# deterministic.
#
# The build (backend/scripts/build-react-vendor.mjs) bundles all four
# import-map entries into ONE core.mjs (so React is included exactly once)
# and emits tiny facades that re-export it — every specifier resolves to a
# single shared React instance. Bundling each entry separately with
# `--external:react` does NOT work: react/react-dom are CommonJS, so
# esbuild emits a throwing `__require("react")` shim that breaks every
# mini-app in the browser. See the script header for the full rationale.
COPY backend/scripts/build-react-vendor.mjs /tmp/build-react-vendor.mjs
RUN mkdir -p /tmp/react-install && cd /tmp/react-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent react@19.2.6 react-dom@19.2.6 \
    && mkdir -p /app/static/vendor/react@19.2.6 \
    && node /tmp/build-react-vendor.mjs /tmp/react-install \
         /app/static/vendor/react@19.2.6 "$(command -v esbuild)" \
    && cd / && rm -rf /tmp/react-install /tmp/build-react-vendor.mjs

# pdf.js (Mozilla's engine — what Firefox's built-in PDF viewer uses),
# vendored same-origin so the LaTeX app renders a compiled PDF as a real
# scroll/zoom viewer rather than the "open externally" button mobile
# browsers show for an <iframe> blob PDF. It MUST be same-origin: a
# cross-origin worker (from esm.sh) is blocked by the same-origin policy
# regardless of CSP, and same-origin also makes the viewer work offline.
# pdfjs-dist ships prebuilt ESM — copy the lib + its matching worker; the
# app sets GlobalWorkerOptions.workerSrc to the /vendor worker URL.
RUN mkdir -p /tmp/pdfjs-install && cd /tmp/pdfjs-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent pdfjs-dist@4.10.38 \
    && mkdir -p /app/static/vendor/pdfjs@4.10.38 \
    && cp node_modules/pdfjs-dist/build/pdf.mjs /app/static/vendor/pdfjs@4.10.38/pdf.mjs \
    && cp node_modules/pdfjs-dist/build/pdf.worker.mjs /app/static/vendor/pdfjs@4.10.38/pdf.worker.mjs \
    && ln -s pdfjs@4.10.38 /app/static/vendor/pdfjs \
    && cd / && rm -rf /tmp/pdfjs-install

# Self-hosted CodeMirror 6 for the mini-app import map — same OFFLINE
# rationale as React above. The Notes / LaTeX / Editor / Web Studio apps
# import @codemirror/* + @lezer/highlight + the `codemirror` meta-package
# via the import map. Served from esm.sh, those were static top-level
# imports an offline (or flaky-network) app had to fetch from a third-party
# CDN before any app code ran — a single uncached hop took the WHOLE app
# down (this is the "LaTeX PDF won't load / struggling" report: CodeMirror's
# failed fetch rejected the app's dynamic import and the PDF viewer never
# mounted). The build (build-codemirror-vendor.mjs) bundles every import-map
# specifier into ONE core.mjs so the shared cores (@codemirror/state,
# @lezer/common) exist exactly once — CodeMirror requires a single instance —
# then emits facades that re-export it. See the script header for rationale.
COPY backend/scripts/build-codemirror-vendor.mjs /tmp/build-codemirror-vendor.mjs
RUN mkdir -p /tmp/cm-install && cd /tmp/cm-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent \
         codemirror@6.0.2 @codemirror/state@6.6.0 @codemirror/view@6.43.0 \
         @codemirror/commands@6.10.3 @codemirror/language@6.12.3 \
         @codemirror/lang-markdown@6.5.0 @lezer/highlight@1.2.3 \
    && mkdir -p /app/static/vendor/codemirror@6 \
    && node /tmp/build-codemirror-vendor.mjs /tmp/cm-install \
         /app/static/vendor/codemirror@6 "$(command -v esbuild)" \
    && cd / && rm -rf /tmp/cm-install /tmp/build-codemirror-vendor.mjs

# KaTeX — self-hosted for both the shell (window.katex via <script> in
# index.html) and mini-apps (ES module import via the app-frame.html
# importmap). Eliminates the last two third-party CDN dependencies
# (cdn.jsdelivr.net for the shell, esm.sh for mini-apps).
#
# JS: katex.min.js (UMD global, loaded as window.katex by the shell) +
#     katex.mjs (ESM, imported by mini-apps via importmap).
# CSS: katex.min.css with @font-face rules that reference ./fonts/*.
# Fonts: woff2 only (all modern browsers support woff2; skipping ttf/woff
#        shrinks the layer by ~1.5 MB).
# A bare /vendor/katex/ symlink acts as a stable unversioned alias so
# any cached standalone PWA app-frame that referenced the old
# esm.sh-backed katex still resolves after the upgrade.
RUN mkdir -p /tmp/katex-install && cd /tmp/katex-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent katex@0.17.0 \
    && mkdir -p /app/static/vendor/katex@0.17.0/fonts \
    && cp node_modules/katex/dist/katex.min.js /app/static/vendor/katex@0.17.0/ \
    && cp node_modules/katex/dist/katex.mjs    /app/static/vendor/katex@0.17.0/ \
    && cp node_modules/katex/dist/katex.min.css /app/static/vendor/katex@0.17.0/ \
    && cp node_modules/katex/dist/fonts/*.woff2 /app/static/vendor/katex@0.17.0/fonts/ \
    && ln -s katex@0.17.0 /app/static/vendor/katex \
    && cd / && rm -rf /tmp/katex-install

# recharts — self-hosted for the mini-app import map (P1-C). Mini-apps that
# render charts import recharts; serving from esm.sh meant an offline-capable
# chart app depended on a third-party CDN fetch. We self-host same-origin so
# offline is deterministic. recharts externalises react/react-dom and maps them
# to the already-vendored /vendor/react entries in the importmap.
# The build (build-recharts-vendor.mjs) bundles only the exported components
# listed in the old esm.sh ?exports= filter so the bundle is not inflated.
COPY backend/scripts/build-recharts-vendor.mjs /tmp/build-recharts-vendor.mjs
RUN mkdir -p /tmp/recharts-install && cd /tmp/recharts-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent recharts@2.15.4 react@19.2.6 react-dom@19.2.6 \
    && mkdir -p /app/static/vendor/recharts@2.15.4 \
    && node /tmp/build-recharts-vendor.mjs /tmp/recharts-install \
         /app/static/vendor/recharts@2.15.4 "$(command -v esbuild)" \
    && cd / && rm -rf /tmp/recharts-install /tmp/build-recharts-vendor.mjs

# date-fns — self-hosted for the mini-app import map (P1-C). date-fns is a
# pure-JS date utility library with no peer deps; a simple bundle suffices.
COPY backend/scripts/build-date-fns-vendor.mjs /tmp/build-date-fns-vendor.mjs
RUN mkdir -p /tmp/datefns-install && cd /tmp/datefns-install \
    && npm init -y >/dev/null \
    && npm install --no-audit --no-fund --silent date-fns@4.3.0 \
    && mkdir -p /app/static/vendor/date-fns@4.3.0 \
    && node /tmp/build-date-fns-vendor.mjs /tmp/datefns-install \
         /app/static/vendor/date-fns@4.3.0 "$(command -v esbuild)" \
    && cd / && rm -rf /tmp/datefns-install /tmp/build-date-fns-vendor.mjs

# Full frontend source so the agent can edit and rebuild the shell.
# /app/shell-src/ is the read-only reference (originals for recovery).
# On first boot, entrypoint copies to /data/shell/ if it doesn't exist.
COPY frontend/ ./shell-src/
RUN cd ./shell-src && npm ci --ignore-scripts 2>/dev/null && rm -rf .vite

# Create a non-root user so the agent can use --dangerously-skip-permissions.
RUN useradd -m -s /bin/bash mobius \
    && mkdir -p /data/db /data/apps /data/compiled /data/shared \
    && chown -R mobius:mobius /data \
    && ln -s /opt/agent-browser /home/mobius/.agent-browser \
    && chown -R mobius:mobius /opt/agent-browser

COPY backend/scripts/entrypoint.sh ./scripts/entrypoint.sh
RUN chmod +x ./scripts/entrypoint.sh

# Build identity — passed at `docker compose build` time (deploy-prod.sh
# exports BUILD_SHA=$(git rev-parse HEAD)). Declared LATE, after the heavy
# apt/pip/npm layers, so a per-build SHA change invalidates only these trivial
# trailing layers — the expensive ones stay cached. Surfaced at GET
# /api/version so a deploy can verify the served backend matches the commit.
ARG BUILD_SHA=unknown
ENV BUILD_SHA=${BUILD_SHA}

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
  CMD curl -f http://localhost:8000/api/health || exit 1

CMD ["./scripts/entrypoint.sh"]
