Ward 50121a390f
Sync upstream and build images / sync-and-build (push) Successful in 2m18s
Name colliding plugin layers at boot; note the 0.1.5 file-upload collision
dsh 0.1.5 ships file upload under the loader id file-upload, which the
community dsh-file-upload plugin also registers. A profile that still carries
that plugin dies at boot with "duplicate loader entry id: file-upload" and the
stack trace never names the plugin. The entrypoint now compares the profile's
plugin layers (and the two hand-edited patch layers) against the installed
version's own bundle patches before every start and logs the offending plugin
with its remove command. Advisory only: the start is never blocked, and only
the profile manifest and patch YAML are read as data.

The 0.1.5+ release footer carries the caveat, and the publisher now re-renders
a release when its text changes rather than only when the digest does.
README: upgrade checklist covers this class of failure; the Highlights bullets
that still described the pre-mirror tag/release model are corrected.
2026-09-11 13:54:23 -04:00

DeepSeek Harness

DeepSeek Harness — trusted container build

Auditable, immutable container images of DeepSeek Harness (dsh), built from the official npm release and served from this Gitea.

Upstream ships no container image. Community images ship someone else's build.
This repo turns the official release into an image whose every byte you can trace — and keeps it that way on a schedule.

latest tags ci base license


dsh is DeepSeek's open-source agent harness: a browser UI that drives an AI agent with shell access over a workspace. It is distributed only as the npm package @deepseek-ai/dsh, so anyone who wants it in a container either trusts a third-party image or builds their own. This repository is the "build your own" option, done properly: the upstream source is mirrored here, the image installs the exact pinned official release on a slim Node base, and CI re-verifies and republishes on a cadence — without ever changing what a running deployment executes.

Status. :latest tracks the newest release-candidate build (currently 0.1.5-rc.2) and is the tag the Unraid template runs. Every <version>[-rN] tag is immutable, so pinning one is always an option. dsh is a developer preview: read Versions and upgrades before applying an update Unraid offers you — 0.1.2+ added launch-token auth and 0.1.5 migrates session logs irreversibly.

Contents

Why this image

Three things went wrong with the alternatives, and this repo is shaped by each:

  • A community image is an unverifiable build. The obvious candidate, runzhliu/deepseek-harness, turned out to be honest — this repo's forensic workflow found its dsh code byte-identical to the official release — but that took a filesystem-level diff to establish, and a 1.6 GB image bundling a Chromium desktop was never going to be re-audited on every update. Here the provenance is a chain you can follow: upstream tag → upstream npm release → this Dockerfile → this registry, with the Dockerfile in plain sight and no upstream code modified.
  • A moving tag once upgraded a live deployment by surprise. An update pulled :latest and jumped from 0.1.1-rc.2 to the 0.1.2 line, which added mandatory launch-token auth and swapped the API gateway — breaking remote access and a plugin in one go. The registry is therefore laid out exactly like upstream: one image tag per upstream version, plus :latest for the newest release candidate. A version tag never changes the dsh it contains — it is only ever rebuilt for our side (a patched base, a newer pnpm, a container fix). The rule for :latest is simple: do not enable Unraid auto-update on this container, and read the release before clicking "update". Anyone who wants a fixed deployment pins a version tag instead.
  • Upstream refuses to bind 0.0.0.0 from the CLI, on purpose (the web UI can execute code). The image doesn't patch that out; it uses upstream's own supported opt-in — a --patch launcher layer — so the safety intent survives and exposure is decided entirely by which ports you publish.

Highlights

Provenance & integrity

  • Official release, pinned exactly. npm install -g @deepseek-ai/dsh@<version> from registry.npmjs.org, nothing else. The version is a build argument with a known-good default.
  • Mirrors upstream, tag for tag. The master branch and every dsh-v* tag are upstream's own — same names, same commits, same dates as github.com/deepseek-ai/deepseek-harness/tags. Each upstream version that npm published gets one image tag (0.1.5-rc.2) and one release on that tag, titled like upstream and carrying upstream's notes. :latest is a manifest-level alias to the newest release candidate — same bytes, same digest.
  • Smoke-tested before push. Every build must report the intended dsh and pnpm versions and boot the web server with the baked patch and answer HTTP on its non-loopback interface — so a release the patch no longer applies to fails in CI, loudly, not on your NAS.
  • Every image is built from the current recipe. Each image records the pnpm version, node:22-bookworm-slim digest (where npm itself comes from) and a hash of the build recipe it was made with. Every six hours CI compares those against today's, and rebuilds any version that's behind — so a security patch or a container fix reaches every tag, the release for it is updated in place, and no version is ever stuck on an old build.

Runtime hygiene

  • Volume ownership repairs itself. A bind mount arrives with the host's ownership, which is why a fresh appdata directory used to kill dsh with EACCES … mkdir '/home/node/.dsh/profiles/web'. The entrypoint starts as root purely to take ownership of the state volume, then drops to PUID:PGID (default 1000:1000, the Unraid PUID/PGID convention) with setpriv. No manual chown step.
  • Non-root where it counts, volume-confined. dsh — the process reachable from the network — never runs as root, and nothing setuid is left for it to climb back through. Everything writable (dsh state, plugin installs, pnpm's store, cache and config) lives inside the /home/node/.dsh volume; verified a full plugin install writes zero bytes to the rootfs.
  • Headless by contract. --no-open is baked into CMD; a server never launches a browser (on 0.1.2+ an in-container browser could burn the one-time launch token).
  • Plugin manager works in-container. pnpm is present on PATH, and both dsh and the pnpm family are wrapped so a root docker exec re-execs as the runtime user — plugin installs can't leave root-owned files that break the next start.
  • Plugin collisions are named, not guessed at. When a new dsh version ships built in what a third-party plugin used to add, dsh dies at boot with duplicate loader entry id: <id> and no hint which plugin. Before every start the entrypoint compares the profile's plugin layers against the installed version's own bundle patches and logs the offending plugin with its remove command.

Operations

  • Upstream mirror. The master branch and the dsh-v* tags track GitHub exactly, synced every six hours; the Tags page here reads the same as upstream's.
  • Releases. One release per built version, on upstream's tag and titled like upstream — upstream's English notes, then a short container footer (digest, pnpm, base image, build) plus the caveats that apply to that version — generated by CI from the image's own provenance labels and kept current when a version is rebuilt.
  • Deployment inspector. A manual job that reports what the container on the host is actually running — image digest, recent log, and how the profile in the state volume is composed (bundle layers, user patches, plugins) — read-only, credentials never read.
  • Forensic validation workflow. A manual job that diffs any third-party deepseek-harness image on the host against the trusted build without executing it.

Quick start

mkdir -p dsh/state dsh/workspace

docker run -d --name dsh -p 3080:3080 \
  -v "$PWD/dsh/state:/home/node/.dsh" \
  -v "$PWD/dsh/workspace:/workspace" \
  gitea.milesward.dev/mward4/deepseek-harness:latest \
  web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host $(hostname)

open http://$(hostname):3080

No chown needed — the entrypoint takes ownership of the state volume at startup and then drops privileges. The one load-bearing flag is --trusted-host: it must name every hostname or IP you will open the UI at (port-less entries match any port). Without it, anything but a loopback Host header gets HTTP 403 on /api — that's upstream's DNS-rebinding/CSRF fence doing its job, not a broken container.

The image is linux/amd64 and linux/arm64.

Unraid

No first-run preparation is required: point the template at a path and start it. The container takes ownership of its own state volume on every start, so a freshly created appdata directory works immediately. If your appdata is owned by something other than 1000:1000 and you'd rather keep it that way (Unraid's share default is 99:100), set PUID/PGID to match and dsh runs as those ids instead.

Add the container from the template below (Docker tab → Add Container → paste, or drop it in /boot/config/plugins/dockerMan/templates-user/). Two fields are the whole operating contract:

  • Repository is :latest (newest release-candidate build) or an explicit version tag if you want a fixed deployment. Either way, leave Unraid's auto-update off for this container: dsh versions can change behaviour, and the release notes tell you what.
  • Post Arguments replace the image command entirely. Always start with web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open, then add one --trusted-host per name you'll browse from.
<?xml version="1.0"?>
<Container version="2">
  <Name>DeepSeek-Harness</Name>
  <Repository>gitea.milesward.dev/mward4/deepseek-harness:latest</Repository>
  <Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
  <Network>bridge</Network>
  <Shell>sh</Shell>
  <Privileged>false</Privileged>
  <Project>https://github.com/deepseek-ai/deepseek-harness</Project>
  <Support>https://gitea.milesward.dev/mward4/deepseek-harness/issues</Support>
  <Overview>
DeepSeek Harness (dsh) - browser-based agent runtime from DeepSeek AI, packaged from the official npm release (built and served from this Gitea).

NO SETUP STEP NEEDED: the container takes ownership of its own appdata at startup, then drops to PUID:PGID (default 1000:1000) before dsh runs - so a freshly created appdata directory just works, with no chown from the Unraid terminal. If you keep appdata owned by Unraid's usual nobody:users (99:100), set PUID=99 and PGID=100 below and dsh runs as those ids instead.

SECURITY - READ BEFORE EXPOSING: the dsh Web UI has no authentication or TLS (before 0.1.2), and its web API can execute code on this container. Anything that can reach the port can run commands. Keep it on a trusted LAN, or put an authenticated reverse proxy (e.g. Cloudflare Access, Authelia) in front. Never port-forward it directly to the internet.

POST ARGUMENTS REPLACE THE IMAGE COMMAND ENTIRELY. Always include the patch flag and --no-open, then your trusted hosts:
  web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host YOUR-HOST [--trusted-host YOUR-HOST2]
Without a matching --trusted-host, non-loopback requests get HTTP 403 on /api - that is upstream's DNS-rebinding/CSRF fence working as intended.

The Settings/credentials pages are additionally restricted to loopback pages by upstream design; edit them via SSH tunnel (ssh -L 3080:127.0.0.1:3080 root@unraid-ip, then browse http://127.0.0.1:3080). The @goodandready/dsh-lanmode community plugin can lift that restriction on dsh 0.1.1 (it is NOT compatible with 0.1.2+, and it is third-party code that disables an upstream security fence - review it before trusting it with your credentials).

VERSIONS: :latest follows the newest release candidate; every upstream version also has its own image tag (e.g. 0.1.5-rc.2) that can be pinned instead - the Releases page on Gitea mirrors upstream's, one entry per version. dsh is a developer preview with breaking changes between releases: 0.1.2 added mandatory launch-token auth (token in docker logs, rotates every restart, exchanged for a 30-day cookie bound to the origin you open it on); 0.1.5 migrates session logs to a format that cannot be downgraded. LEAVE AUTO-UPDATE OFF for this container, back up appdata before applying an update, and read the release notes on the Gitea Releases page first.

PLUGINS: manage them with dsh's own tooling, e.g.
  docker exec DeepSeek-Harness dsh plugin --profile web add|update|remove PACKAGE
pnpm ships in the image and dsh re-execs as the runtime user, so this cannot leave root-owned files in appdata.

Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
  </Overview>
  <Category>AI: Tools:</Category>
  <WebUI>http://[IP]:[PORT:3080]</WebUI>
  <TemplateURL/>
  <Icon>https://gitea.milesward.dev/mward4/deepseek-harness/raw/branch/main/assets/deepseek-logo.png</Icon>
  <ExtraParams/>
  <PostArgs>web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open</PostArgs>
  <Config Name="WebUI Port" Target="3080" Default="3080" Mode="tcp" Description="dsh Web UI. Reachable by everything on your LAN - see the security note in the Overview." Type="Port" Display="always" Required="true" Mask="false">3080</Config>
  <Config Name="DeepSeek API Key" Target="DEEPSEEK_API_KEY" Default="" Description="Optional. Official DeepSeek API key (sk-...). Can also be configured later in the dsh Settings UI." Type="Variable" Display="always" Required="false" Mask="true"></Config>
  <Config Name="DSH Home" Target="/home/node/.dsh" Default="/mnt/user/appdata/deepseek-harness" Mode="rw" Description="Persistent dsh state: settings, credentials, sessions, plugins. Ownership is adjusted automatically at startup - no chown needed. Back this path up." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness</Config>
  <Config Name="Workspace" Target="/workspace" Default="/mnt/user/appdata/deepseek-harness/workspace" Mode="rw" Description="The agent's working directory. Only mount data you are willing to let the agent read and modify. The mount point is made writable automatically; existing files inside keep their ownership unless you set DSH_CHOWN_WORKSPACE=1." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness/workspace</Config>
  <Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
  <Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
  <Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
</Container>

Behind a Cloudflare Tunnel, add the public hostname as a --trusted-host and have the tunnel rewrite Host to that name (and strip Origin) so the fence sees a name it trusts.

Image contract

Base node:22-bookworm-slim + git + ca-certificates
Installed @deepseek-ai/dsh@<version> and pnpm@<version> as npm globals (exact versions recorded as image labels)
Runs as PUID:PGID, default 1000:1000. The entrypoint starts as root to repair volume ownership, then setprivs down — dsh itself is never root.
ENTRYPOINT ["docker-entrypoint.sh"] — fixes ownership, warns about plugin layers that collide with the installed version, drops privileges, execs dsh "$@"
CMD ["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]
Port 3080
/home/node/.dsh DSH_HOME — settings, credentials, sessions, plugins, pnpm store/cache/config. Mount this. Taken over at startup.
/workspace The agent's working directory (WORKDIR). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless DSH_CHOWN_WORKSPACE=1.
/opt/deepseek-harness/web.cordis.patch.yml The baked bind patch: webserver.host: 0.0.0.0, port: 3080
Env PUID, PGID, DSH_CHOWN_WORKSPACE, plus DSH_HOME / PNPM_HOME / XDG_* pinned into the state volume
Labels dev.milesward.dsh-version, .pnpm-version, .base-digest, .recipe-hash — what CI's freshness check reads

Three rules that are easy to get wrong:

  • --patch must come immediately after web. It's a launcher flag; placed after the web command's own flags it fails with unknown option '--patch'.
  • Nothing deployment-specific is baked in. No --trusted-host, no keys, no hostnames. Those are yours to supply.
  • Don't pass --user. Doing so blocks the ownership repair; the entrypoint then verifies the volume is writable and, if it isn't, exits with one line naming the fix instead of a Node stack trace. Use PUID/PGID to change ids instead.

Versions and upgrades

The registry has one image tag per upstream version, named after it, and :latest:

Tag Meaning
:latest The newest release candidate (currently 0.1.5-rc.2). Moves when upstream ships a new RC. Alphas never qualify.
<version> — e.g. 0.1.5-rc.2, 0.1.5-alpha.2 That upstream version, from 0.1.1-rc.2 onward, for every version upstream also published to npm. Pin one for a fixed deployment. (Three upstream tags have no image: 0.1.2-alpha.1 and 0.1.3-alpha.1 were never published to npm; 0.1.3-alpha.2 needs a C toolchain to install.)

The Releases page mirrors upstream's: one entry per version, on the same dsh-v<version> tag, titled the same way, with upstream's own notes followed by a short container footer (digest, pnpm, base image, build). Version tags are rebuilt in place when our recipe changes — the dsh inside never does — and the release footer keeps the previous digests.

Changing the dsh version is the deliberate part, and with :latest in the template that happens whenever you accept an update Unraid offers. Before you do:

  1. Read the release for the version you're about to land on — every one lists what changed upstream and what it means for this deployment.

  2. Back up the state volume (/mnt/user/appdata/deepseek-harness). From 0.1.5 upstream migrates session logs to a new format that cannot be read by older versions.

  3. Reconcile plugins first. Upstream keeps absorbing what community plugins did, and a plugin whose loader row id a new version ships built in stops dsh at boot with duplicate loader entry id: <id> — the stack trace never names the plugin, so the entrypoint does: it checks the profile against the installed version before every start and logs the plugin and the remove command. Known cases:

    • 0.1.5+ ships file upload → remove dsh-file-upload.
    • 0.1.2+ → @goodandready/dsh-lanmode ≤ 0.6.8 breaks the web client (web boot: … waiting for services: remote.*); update it to 0.7 or remove it.

    docker exec <container> dsh plugin --profile web remove <package> — or, once the container no longer stays up, the same command against the volume:

    docker run --rm -v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
      gitea.milesward.dev/mward4/deepseek-harness:latest dsh plugin --profile web remove <package>
    
  4. Apply the update (or edit the Repository tag, if you pin).

What changes in 0.1.2+: launch-token auth

From 0.1.2 the Web UI is gated by a one-time launch token:

  • The tokened URL is printed at startup — docker logs DeepSeek-Harness.
  • The token rotates on every container restart.
  • Opening the URL exchanges it for a ~30-day cookie bound to the exact origin you used. Do the exchange on the hostname you actually intend to browse from (your tunnel domain, not the LAN IP), or you'll be doing it twice.
  • Requests without token or cookie get dsh web authentication required.

Plugins

Plugins live in the state volume (/home/node/.dsh/profiles/web), never in the image, so image rebuilds and version bumps don't touch them. Manage them with dsh's own tooling, which works because pnpm ships in the image:

docker exec DeepSeek-Harness dsh plugin --profile web add    <package>
docker exec DeepSeek-Harness dsh plugin --profile web update <package>
docker exec DeepSeek-Harness dsh plugin --profile web remove <package>

docker exec defaults to root, which would normally leave root-owned files in the state volume — dsh's profile, and pnpm's PNPM_HOME/config — that the unprivileged dsh process can't read on its next start. Both dsh and the pnpm commands on PATH are wrappers that re-exec as PUID:PGID for exactly that reason, so no -u flag is needed and the volume stays consistent however you invoke them.

On the third-party plugin question: @goodandready/dsh-lanmode exists because upstream restricts the Settings/credentials pages to loopback origins, and it removes that restriction. A quick source audit found nothing hostile, but it is unaffiliated code whose purpose is disabling a security fence in front of your API keys. The fence-preserving alternative is an SSH tunnel: ssh -L 3080:127.0.0.1:3080 root@<unraid> then http://127.0.0.1:3080.

Security model

Be clear about what this thing is: a web page that runs shell commands on the container as the agent. Everything below follows from that.

  • Reachability is the perimeter. Before 0.1.2 there is no authentication; from 0.1.2 there is a launch token, but no TLS. Keep the port on a trusted LAN or behind an authenticated proxy (Cloudflare Access, Authelia). Never port-forward it to the internet.
  • The trust fence is a feature. /api refuses any Host that isn't loopback or listed in --trusted-host — that is upstream's DNS-rebinding and CSRF protection. Configure it; don't work around it.
  • Mount deliberately. /workspace is what the agent can read and modify. The state volume holds credentials — back it up and keep it out of anything world-readable.
  • dsh runs unprivileged. No added capabilities, not privileged, nothing writable outside the mounted volume. The entrypoint does hold root for the moment it takes to chown the state volume, then setprivs down to PUID:PGID before execing dsh — the network-reachable process is never root, and nothing setuid is left behind for it to climb back through. If you would rather the container never hold root at all, pass --user 1000:1000 and keep appdata chowned yourself; the entrypoint detects that and verifies instead of repairing.
  • Upstream's own safety notice still applies: dsh has not been security-audited, and its sandboxing does not guarantee isolation.

How it stays current

sync-upstream.yml runs on the Gitea Actions runners every six hours (17 */6 * * *) and on any change to the build inputs:

  1. Mirror — force-syncs upstream master and every dsh-v* tag into this repo, so the Tags page is upstream's.
  2. Build — for every upstream version that npm published (from 0.1.1-rc.2 up): build the image if the registry lacks it, or rebuild it in place if the recipe (Dockerfile, entrypoint, wrapper, bind patch), pnpm, or the node base image moved since it was built. Each build must pass a smoke test — correct dsh/pnpm versions, dsh running as uid 1000, and the web server answering on its non-loopback interface from a fresh root-owned volume — before it is pushed. A failing older version is reported and left on its previous image; a failing current version fails the run.
  3. :latest — pointed at the newest release candidate, as a manifest copy through the registry API (identical digest, nothing rebuilt).
  4. Releases — one per built version, on the mirrored upstream tag, titled like upstream, with upstream's English notes and a container footer. Updated in place when the image is rebuilt. Generated by publish-releases.js, which runs on the image's own Node base so the runner needs nothing installed.

A manual run can force-rebuild specific versions (or all). Whether a new build reaches your deployment is decided at the Unraid update button — which is why auto-update stays off.

Validating a third-party image

validate-suspect-image.yml is a manual job for the question this repo started with: is that image someone handed me actually the official code? Given an image already present on the runner host's daemon, it docker creates (never runs) both it and the trusted build, exports both filesystems, and reports: config/entrypoint/labels, whether any containers use it, a byte-level diff of dsh's own code against the official release, dependency-tree drift, and red flags — patched-out 0.0.0.0 refusal, startup hooks, unexpected network callouts.

Run 2026-09-02 against runzhliu/deepseek-harness:0.1.1-rc.2: dsh byte-identical to the official release, upstream safety check intact, no tampering.

Repository layout

Branch / path Contents
master Exact mirror of upstream master. Never edited here.
dsh-v* tags Upstream's tags, mirrored verbatim; releases attach to them.
main This branch — the build.
Dockerfile The image. ARG DSH_VERSION is the default for local builds; CI passes every version explicitly.
docker-entrypoint.sh Ownership repair + privilege drop, then exec dsh "$@".
run-as-runtime-user.sh Installed over dsh, pnpm, pnpx, pn, pnx; re-execs root invocations as the runtime user.
web.cordis.patch.yml The 0.0.0.0 bind patch, applied via --patch.
.gitea/workflows/ sync-upstream.yml (mirror · build · freshness · releases), validate-suspect-image.yml (forensics).
.gitea/scripts/ publish-releases.js — the release generator.
assets/ Logo (transparent PNG, also the Unraid template icon).

Tags and releases here look like upstream's on purpose: the tag is upstream's source tag, the release describes our image of it. Which main commit built the image is in each release's footer and in the image's org.opencontainers.image.revision label.

License

The build tooling in this repository is MIT. DeepSeek Harness itself is MIT-licensed by DeepSeek AI; the DeepSeek name and logo belong to them and are used here to identify what the image contains.

S
Description
Trusted, immutable container builds of DeepSeek Harness (dsh) from the official npm release. Upstream mirror + scheduled CI.
https://github.com/deepseek-ai/deepseek-harness Readme MIT
568 MiB
0 Stars 1 Watchers 0 Forks
v0.2.1-rc.4
Latest
2026-10-28 03:37:33 -04:00
Languages
JavaScript 60.4%
Shell 27.4%
Dockerfile 12.2%