Files
deepseek-harness/Dockerfile
T
Miles WardandClaude Fable 5 400797ecac
Sync upstream and build images / sync-and-build (push) Successful in 1m45s
Wrap the pnpm family too, not just dsh
Verifying the pushed -r1 image found one hole: only dsh was wrapped, so a
root 'docker exec <c> pnpm ...' created PNPM_HOME/XDG_CONFIG_HOME owned by
root inside the state volume — which the unprivileged dsh could not then use
for plugin installs (self-healing on the next restart, since the entrypoint
re-chowns, but broken until then).

The wrapper is now generic (execs the resolved target directly; dsh's bin.js
is +x with a node shebang and pnpm's bin is an ELF, so no node prefix is
needed) and is installed over dsh, pnpm, pnpx, pn and pnx. Renamed
dsh-wrapper.sh -> run-as-runtime-user.sh to match what it does.

Verified: root 'docker exec pnpm --version' + 'pnpm store path' + 'dsh plugin
add' leave zero root-owned files in the volume; 23-case matrix still green.
Ships as 0.1.1-rc.2-r2.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-09-11 12:08:54 -04:00

114 lines
5.5 KiB
Docker

# 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 all-interfaces bind goes through upstream's
# supported `--patch` launcher layer (/opt/deepseek-harness/web.cordis.patch.yml);
# the UI is only reachable through ports you explicitly publish on the host.
FROM node:22-bookworm-slim
# DSH_VERSION default is the pinned, known-good deployment version: 0.1.2+
# introduced launch-token auth and the @Remote gateway (breaks lanmode).
# Moving off it is a deliberate template edit, never a side effect of CI.
ARG DSH_VERSION=0.1.1-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 /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; \
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.
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 \
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 dsh wrapper, the bind patch). The freshness check cuts a -rN revision of
# the pinned 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"]