# Trusted container build of DeepSeek Harness (dsh).
#
# Upstream (https://github.com/deepseek-ai/deepseek-harness) publishes no
# official container image — only the @deepseek-ai/dsh npm package, released
# from the tagged source mirrored in this repository's `master` branch. This
# image installs that exact pinned release from registry.npmjs.org, so its
# provenance chain is: upstream tag -> upstream npm release -> this Dockerfile.
#
# No upstream code is patched. The bind address comes from the `--patch`
# launcher layer (/opt/deepseek-harness/web.cordis.patch.yml), which the
# entrypoint renders at startup: dsh 0.2.1-alpha.2+ rejects the unspecified
# address in the webserver schema and gets the container's own IPv4 instead,
# while releases whose schema still only accepts 127.0.0.1 or 0.0.0.0 keep
# 0.0.0.0. The UI is reachable only through ports you explicitly publish on the
# host.

FROM node:22-bookworm-slim

# DSH_VERSION default: the current release candidate, for local builds. CI
# passes every version it builds explicitly and derives "current" from the
# registry, so this default never steers the pipeline.
ARG DSH_VERSION=0.1.5-rc.2
# pnpm is required on PATH by dsh's plugin manager (`dsh plugin --profile web
# add|update|remove`). CI passes the current release; the scheduled freshness
# check rebuilds a -rN revision tag when a newer pnpm or base image ships.
ARG PNPM_VERSION=12.3.4

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates git \
    && rm -rf /var/lib/apt/lists/*

# pnpm is a fixed npm global rather than a corepack shim: corepack resolves
# versions at runtime and can attempt downloads/writes outside the state
# volume (its cache is root-owned), which breaks the non-root uid-1000 /
# read-only-rootfs contract. A pinned global install is deterministic,
# offline, and readable by uid 1000.
RUN npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \
    && npm cache clean --force

COPY web.cordis.patch.yml /opt/deepseek-harness/web.cordis.patch.yml
COPY docker-entrypoint.sh run-as-runtime-user.sh dsh-workspace-doctor.mjs /usr/local/bin/

# Replace npm's bin symlinks for dsh and the pnpm family with the
# privilege-dropping wrapper, resolving each real target at build time (never
# guessing paths across versions) and self-testing the result. Each symlink
# must be removed before writing, or the redirect would clobber the package's
# own entry point through it.
RUN set -eux; \
    for cmd in dsh pnpm pnpx pn pnx; do \
        [ -e "/usr/local/bin/${cmd}" ] || continue; \
        target="$(readlink -f "/usr/local/bin/${cmd}")"; \
        test -x "$target"; \
        rm "/usr/local/bin/${cmd}"; \
        sed "s|__TARGET__|${target}|" /usr/local/bin/run-as-runtime-user.sh > "/usr/local/bin/${cmd}"; \
        chmod 0755 "/usr/local/bin/${cmd}"; \
    done; \
    rm /usr/local/bin/run-as-runtime-user.sh; \
    chmod 0755 /usr/local/bin/docker-entrypoint.sh; \
    chmod 0755 /usr/local/bin/dsh-workspace-doctor.mjs; \
    mkdir -p /workspace /home/node/.dsh; \
    chown node:node /workspace /home/node/.dsh; \
    dsh --version; \
    pnpm --version

# Everything writable at runtime (dsh state, pnpm store/cache for plugin
# installs) lives inside the mounted state volume, never the rootfs.
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
# them at startup. Unraid's share default is 99:100.
# DSH_WORKSPACE_DOCTOR: the entrypoint's workspace-grouping step. `repair` is
# the default so an update heals the projects whose directories the update
# erased; `warn` reports without repairing, `off` is silent.
ENV DSH_HOME=/home/node/.dsh \
    PNPM_HOME=/home/node/.dsh/.pnpm \
    XDG_CACHE_HOME=/home/node/.dsh/.cache \
    XDG_STATE_HOME=/home/node/.dsh/.state \
    XDG_CONFIG_HOME=/home/node/.dsh/.config \
    DSH_WORKSPACE_DOCTOR=repair \
    PUID=1000 \
    PGID=1000

EXPOSE 3080

# No `USER` directive on purpose: the entrypoint needs root to repair
# bind-mount ownership, then drops to PUID:PGID via setpriv before exec'ing
# dsh. The dsh process — the one reachable from the network — is never root.
WORKDIR /workspace

ARG DSH_VERSION
ARG PNPM_VERSION
# BASE_DIGEST: resolved digest of the FROM image at build time; the scheduled
# freshness check compares it (and pnpm-version) against current releases.
# GIT_SHA / CI_RUN / BUILD_DATE: build provenance, read back by the release
# publisher so each release points at the commit that actually built it.
ARG BASE_DIGEST=""
ARG GIT_SHA=""
ARG CI_RUN=""
ARG BUILD_DATE=""
# RECIPE_HASH: digest of the build inputs (this Dockerfile, the entrypoint,
# the wrapper, the bind patch). The freshness check cuts a -rN revision of the
# current version when it changes, so an image-recipe fix actually reaches the
# deployment instead of waiting for the next upstream release.
ARG RECIPE_HASH=""
LABEL org.opencontainers.image.title="deepseek-harness" \
      org.opencontainers.image.description="DeepSeek Harness (dsh) web UI, built from the official npm release" \
      org.opencontainers.image.version="${DSH_VERSION}" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.source="https://gitea.milesward.dev/mward4/deepseek-harness" \
      org.opencontainers.image.url="https://github.com/deepseek-ai/deepseek-harness" \
      org.opencontainers.image.licenses="MIT" \
      dev.milesward.dsh-version="${DSH_VERSION}" \
      dev.milesward.pnpm-version="${PNPM_VERSION}" \
      dev.milesward.base-digest="${BASE_DIGEST}" \
      dev.milesward.ci-run="${CI_RUN}" \
      dev.milesward.recipe-hash="${RECIPE_HASH}"

ENTRYPOINT ["docker-entrypoint.sh"]
# --patch must come immediately after `web` (launcher flag), --no-open so a
# headless server never launches a browser (on 0.1.2+ an in-container browser
# could consume the one-time launch token). Unraid Post Arguments REPLACE this
# CMD entirely — operators must repeat the patch flag and --no-open.
CMD ["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]
