- Dockerfile: install pnpm (pinned via PNPM_VERSION arg) alongside dsh so 'dsh plugin --profile web add|update|remove' works in-container. Plain 'npm i -g' instead of corepack: corepack shims resolve versions at runtime and write to a root-owned cache, breaking the uid-1000/read-only contract. All pnpm writable dirs (store via PNPM_HOME, cache/state via XDG vars) point into the /home/node/.dsh volume - verified zero rootfs writes. - Dockerfile: ENTRYPOINT ['dsh'], CMD carries the patch flag AND --no-open (a headless server must not open a browser; on 0.1.2+ that can burn the one-time launch token). DSH_VERSION default pinned to 0.1.1-rc.2. - CI: never publishes or moves 'latest' (deleted from the registry). Builds only missing immutable version tags; a toolchain freshness check rebuilds the pinned version as <ver>-rN when a newer pnpm or node base image ships. Every build passes a smoke test before push: dsh/pnpm versions match and the server boots with the baked patch, answering on the container's non-loopback interface (200 pre-token, 303/401 on 0.1.2+). - README: operating contract - pinned version rationale, PostArgs-replace-CMD warning, 0.1.2 launch-token behavior, plugin management, lanmode incompatibility, template pinned to :0.1.1-rc.2. Co-Authored-By: Claude Fable 5 <[email protected]>
11 KiB
deepseek-harness — trusted mirror & container build
Mirror of the official deepseek-ai/deepseek-harness with a locally-built, auditable container image for homelab use (Unraid).
Branch layout
| Branch | Contents |
|---|---|
master |
Exact mirror of upstream master (plus all dsh-v* tags). Never edited locally. |
main |
This branch: Dockerfile, patch file, and the sync/build workflow. |
Image
gitea.milesward.dev/mward4/deepseek-harness:<version> e.g. 0.1.1-rc.2
gitea.milesward.dev/mward4/deepseek-harness:<version>-r<N> toolchain refresh of the same version
Built from node:22-bookworm-slim + the official @deepseek-ai/dsh npm
release, pinned to the exact version. Upstream publishes no container image;
this repo exists so the image provenance is:
upstream tag → upstream npm release → this Dockerfile → this registry.
There is no :latest tag, deliberately. This deployment once tracked
latest; a routine restart silently moved it from dsh 0.1.1-rc.2 to the
0.1.2 line, which introduced launch-token auth and replaced the ApiProxy
with the @Remote gateway (breaking the lanmode plugin). Every tag here is
immutable and every upgrade is an explicit template edit.
Pinned default: 0.1.1-rc.2 (ARG DSH_VERSION in the Dockerfile) — the
last version verified against this deployment: no launch-token auth, and
@goodandready/dsh-lanmode is compatible. Newer versions get their own tags
built by CI (see below) but nothing repoints until you edit the template.
No upstream code is modified. Upstream deliberately refuses --host 0.0.0.0
on the CLI (the web UI can execute code, so exposing it to a network is a
deliberate opt-in); the supported opt-in is a --patch launcher layer, which
this image ships at /opt/deepseek-harness/web.cordis.patch.yml
(bind 0.0.0.0:3080). The UI is reachable only through ports you explicitly
publish, and the /api trust fence still refuses any Host it doesn't know.
Paths inside the container:
| Path | Purpose |
|---|---|
/home/node/.dsh |
dsh state: settings, credentials, sessions, plugins (DSH_HOME) |
/workspace |
The agent's working directory (container WORKDIR) |
/opt/deepseek-harness/web.cordis.patch.yml |
Baked web-server bind patch |
Runs as user node (UID/GID 1000). Everything writable at runtime — dsh
state and pnpm's store/cache for plugin installs — lives inside the
/home/node/.dsh volume, never the rootfs.
Command contract
ENTRYPOINT ["dsh"]
CMD ["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]
-
--patchmust come immediately afterweb(it's a launcher flag; after the web command's own flags it fails with "unknown option"). -
--no-openis mandatory on a server: on 0.1.2+ an in-container browser launch can consume the one-time launch token before you do. -
Unraid "Post Arguments" REPLACE this CMD entirely. If you set Post Arguments you must repeat the whole command yourself:
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host <host> [--trusted-host <host2>] -
--trusted-hostis deployment-specific and is never baked into the image.
Plugin management
pnpm ships in the image (dsh's plugin manager requires it), so plugin operations work through dsh's own tooling against the state volume:
docker exec <container> dsh plugin --profile web add <pkg>
docker exec <container> dsh plugin --profile web update <pkg>
docker exec <container> dsh plugin --profile web remove <pkg>
Plugins live in the runtime volume (/home/node/.dsh/profiles/web), not in
the image — image rebuilds never touch them.
dsh 0.1.2+ launch-token auth (read before upgrading)
From 0.1.2, the Web UI requires a one-time launch token:
- The tokened URL is printed in the startup log:
docker logs <container>. - The token rotates on every restart of the container.
- Opening the tokened URL exchanges it for a ~30-day cookie bound to the exact origin you opened it on — do the exchange on the hostname you actually intend to use (e.g. your tunnel domain, not the LAN IP).
- Bare requests without token/cookie get "dsh web authentication required".
Known incompatibility: @goodandready/dsh-lanmode does not work with
dsh 0.1.2+ (the @Remote gateway replaced the ApiProxy it monkey-patches;
symptom: "web boot: 26 entries did not activate ... waiting for services:
remote.*"). Remove or update it before moving to 0.1.2:
docker exec <container> dsh plugin --profile web remove @goodandready/dsh-lanmode.
Unraid template
First run (required): the container runs as UID 1000. Before starting, from the Unraid terminal:
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
Otherwise dsh fails with EACCES on /home/node/.dsh.
<?xml version="1.0"?>
<Container version="2">
<Name>DeepSeek-Harness</Name>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2</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; non-root).
FIRST RUN (required): the container runs as UID 1000. Before starting, from the Unraid terminal:
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
Otherwise dsh fails with EACCES on /home/node/.dsh.
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: this registry publishes immutable version tags only - NO :latest. dsh is a developer preview with breaking changes between releases (0.1.2 adds mandatory launch-token auth: token in docker logs, rotates every restart). Upgrade by editing this tag deliberately. Tags like 0.1.1-rc.2-rN are the same dsh version rebuilt with updated pnpm/base-image security patches.
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. Must be owned by UID/GID 1000 (see Overview)." 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. Must be writable by UID 1000." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness/workspace</Config>
</Container>
Keeping current
.gitea/workflows/sync-upstream.yml
runs every 6 hours on the Gitea Actions runners:
- Force-syncs upstream
masteranddsh-v*tags into this repo. - Builds a new immutable version tag when a new
@deepseek-ai/dshappears on npm and the registry lacks it. Existing tags are never re-pushed, andlatestis never published or moved — a new upstream release cannot change what a running deployment executes. - Toolchain freshness check for the pinned default version: if a newer
pnpm release or an updated
node:22-bookworm-slimbase image (which is wherenpmitself comes from) has shipped since the newest existing build, CI builds a<version>-r<N>revision tag — same dsh, patched toolchain. Adopting it is still a deliberate template edit.
Every build must pass a smoke test before it is pushed: dsh --version and
pnpm --version match the intended versions, and the web server boots with
the baked patch and answers HTTP on the container's non-loopback interface
(200 on pre-token versions; 303/401 on 0.1.2+, which proves the server is up
behind its launch-token gate). A dsh release the Cordis patch no longer
applies to fails here, loudly, instead of at runtime.
Validating an image
.gitea/workflows/validate-suspect-image.yml
(manual dispatch) forensically compares any third-party deepseek-harness
image already present on the runner host's docker daemon against this
repository's trusted build — without ever executing the suspect image
(docker create + filesystem export only). It reports: image
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
tampering red flags (patched-out 0.0.0.0 refusal, startup hooks, unexpected
network callouts).
Ran 2026-09-02 against runzhliu/deepseek-harness:0.1.1-rc.2: dsh code
byte-identical to the official npm release; no tampering found.