Files
deepseek-harness/Dockerfile
dsh agent c41d7fb403
Sync upstream and build images / sync-and-build (push) Successful in 9m9s
Repair workspace grouping that a container update erases
Updating the container recreates it, so anything outside the mounted
/workspace and state volume is gone. dsh joins a session to its project
when the session is created (upstream's history bootstrap runs once per
state volume and never again), and the workspace registry stores an
absolute canonical directory path — so a project directory under
/home/node, where the workspace picker opens by default, loses its chats
to "Ungrouped" permanently on the next update.

Ship dsh-workspace-doctor.mjs and run it from the entrypoint before dsh
starts: it reports every project directory that no longer resolves and
every chat recoverable from one, and with DSH_WORKSPACE_DOCTOR=repair it
recreates the missing directories and re-accounts those sessions in the
workspace registry. Session logs are never read for writing and never
modified, no project is invented, no chat is moved into a project it did
not run in, and the registry is copied to workspace.json.bak-<stamp>
before the atomic, 0600, fsynced write dsh's own JSON backend performs.
The doctor refuses to write a registry version it does not know, is
idempotent, and defaults to report-only in the image (warn) so nothing
changes unasked; the Unraid template defaults to repair.

CI: the doctor joins the recipe hash and the push path list, and the
image smoke test asserts it is installed and runs against a fresh volume
(exit 2, the tool itself failing, is the only outcome that fails a build).
2026-09-17 22:03:40 -04:00

115 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: 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.
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 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"]