# 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"]