Transparent brand logo, per-tag releases, README rewrite, MIT license

- assets/: replace the white-background avatar with a transparent 512px
  PNG (brand blue #4d6bfe, tight-cropped) plus a 128px header variant.
  Same path, so the Unraid template icon keeps working.
- CI: every image tag in the registry now gets a v<tag> git tag on main
  and a Gitea release carrying digest, dsh/pnpm/base versions, deploy
  notes and the 0.1.2+ launch-token caveat. Idempotent, so it backfills
  the three existing tags on the next run.
- README rewritten to match OpenPXE/Discord-Authenticator: centered brand
  header, badges, contents, why/highlights, quick start, Unraid template,
  image contract, versions & upgrade procedure (0.1.5 session-format V3
  has no downgrade), plugins, security model, pipeline, layout.
- LICENSE: MIT for the build tooling.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Miles WardandClaude Fable 5 committed 2026-09-10 15:34:47 -04:00
1 parent 2d033c323b
commit 5121d84b53
5 files changed
+384 -133

No files matched your search

+92
View File
@@ -164,3 +164,95 @@ jobs:
docker push "$REF"
done
# Every image tag in the registry gets a matching v<tag> git tag + Gitea
# release on main (the Dockerfile state that built it). Idempotent: only
# tags without a release are published, so this also backfills.
- name: Publish releases for image tags without one
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
set -eu
API="https://${REGISTRY}/api/v1"
MAIN_SHA=$(git rev-parse HEAD)
json_escape() { sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | awk '{printf "%s\\n", $0}'; }
TAGS=$(curl -s -H "Authorization: token ${REG_TOKEN}" \
"$API/packages/${OWNER}?type=container&q=deepseek-harness&limit=100" \
| tr ',' '\n' | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | grep -v '^sha256:' | sort -V)
for TAG in $TAGS; do
REL="v${TAG}"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token ${TOKEN}" "$API/repos/${REPO}/releases/tags/${REL}")
[ "$CODE" = 404 ] || { echo "$REL: release exists"; continue; }
echo "$REL: publishing"
DIGEST=$(curl -sI -u "${OWNER}:${REG_TOKEN}" \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
"https://${REGISTRY}/v2/${IMAGE}/manifests/${TAG}" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r')
MAN=$(curl -s -u "${OWNER}:${REG_TOKEN}" \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
"https://${REGISTRY}/v2/${IMAGE}/manifests/${TAG}")
CFG=$(printf '%s' "$MAN" | grep -o '"digest": *"sha256:[a-f0-9]*"' | head -1 | grep -o 'sha256:[a-f0-9]*' || true)
BLOB=$([ -n "$CFG" ] && curl -s -u "${OWNER}:${REG_TOKEN}" "https://${REGISTRY}/v2/${IMAGE}/blobs/$CFG" || echo '{}')
PNPMV=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.pnpm-version":"[^"]*"' | cut -d'"' -f4 || true)
BASEV=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.base-digest":"[^"]*"' | cut -d'"' -f4 || true)
VER=$(printf '%s' "$TAG" | sed 's/-r[0-9]*$//')
PRE=false; case "$VER" in *alpha*) PRE=true;; esac
case "$TAG" in
*-r[0-9]*) KIND="Toolchain refresh of dsh ${VER}: same upstream release, rebuilt with the current pnpm and a freshly patched \`node:22-bookworm-slim\` base."
TITLE="${REL} — dsh ${VER} toolchain refresh (pnpm ${PNPMV:-?})";;
*) KIND="Container build of **DeepSeek Harness (dsh) ${VER}** from the official npm release, built and smoke-tested by CI."
TITLE="${REL} — dsh ${VER} · pnpm ${PNPMV:-?} · node 22";;
esac
case "$VER" in
0.1.0*|0.1.1*) TOKEN_NOTE="";;
*) TOKEN_NOTE="> **Launch-token auth (dsh 0.1.2+):** the Web UI requires a one-time token printed in \`docker logs\`; it rotates on every restart and is exchanged for a ~30-day cookie bound to the origin you open it on. \`@goodandready/dsh-lanmode\` ≤0.6.8 is incompatible with this line.";;
esac
BODY=$(cat <<EOF
${KIND}
\`\`\`
docker pull gitea.milesward.dev/mward4/deepseek-harness:${TAG}
\`\`\`
### What's inside
| | |
|---|---|
| dsh | \`${VER}\` — [upstream release notes](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v${VER}) |
| pnpm | \`${PNPMV:-unknown}\` |
| base image | \`${BASEV:-unknown}\` |
| image digest | \`${DIGEST}\` |
| built from | \`main@${MAIN_SHA}\` (published by CI run #${RUN_NUMBER}) |
### Deploy on Unraid
- **Repository:** \`gitea.milesward.dev/mward4/deepseek-harness:${TAG}\`
- **Post Arguments** (replace the image command entirely):
\`web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host <host> [--trusted-host <host2>]\`
- First run: \`chown -R 1000:1000 /mnt/user/appdata/deepseek-harness\`
${TOKEN_NOTE}
Image tags are immutable — this release will never be rebuilt or moved. See the [README](https://gitea.milesward.dev/mward4/deepseek-harness#versions-and-upgrades) for the upgrade procedure.
EOF
)
BODY_JSON=$(printf '%s' "$BODY" | json_escape)
# Tag main at the commit that built the image (ignore "already exists").
curl -s -o /dev/null -w "tag ${REL}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
"$API/repos/${REPO}/tags" \
-d "{\"tag_name\":\"${REL}\",\"target\":\"${MAIN_SHA}\",\"message\":\"deepseek-harness image ${TAG}\"}"
curl -s -o /tmp/rel.json -w "release ${REL}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
"$API/repos/${REPO}/releases" \
-d "{\"tag_name\":\"${REL}\",\"target_commitish\":\"${MAIN_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY_JSON}\",\"draft\":false,\"prerelease\":${PRE}}"
grep -q '"id"' /tmp/rel.json || { echo "::error::release creation failed for ${REL}"; cat /tmp/rel.json; exit 1; }
done
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Miles Ward
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+271 -133
View File
@@ -1,121 +1,152 @@
<img src="assets/deepseek-logo.png" alt="DeepSeek logo" width="96" align="right">
<p align="center">
<img src="assets/deepseek-logo-128.png" alt="DeepSeek Harness" width="112" height="112" />
</p>
# deepseek-harness — trusted mirror & container build
<h1 align="center">DeepSeek Harness — trusted container build</h1>
Mirror of the official [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
with a locally-built, auditable container image for homelab use (Unraid).
<p align="center">
<strong>Auditable, immutable container images of <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness (<code>dsh</code>)</a>, built from the official npm release and served from this Gitea.</strong>
</p>
## Branch layout
<p align="center">
Upstream ships no container image. Community images ship someone else's build.<br/>
This repo turns the official release into an image whose every byte you can trace — and keeps it that way on a schedule.
</p>
| 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. |
<p align="center">
<img alt="pinned" src="https://img.shields.io/badge/deployed-0.1.1--rc.2-4d6bfe" />
<img alt="newest" src="https://img.shields.io/badge/newest%20build-0.1.5--rc.1-2874d7" />
<img alt="tags" src="https://img.shields.io/badge/tags-immutable%20%C2%B7%20no%20%3Alatest-59824f" />
<img alt="ci" src="https://img.shields.io/badge/CI-Gitea%20Actions%20%C2%B7%20every%206h-609926?logo=gitea&logoColor=white" />
<img alt="base" src="https://img.shields.io/badge/base-node%2022%20%C2%B7%20bookworm--slim-339933?logo=node.js&logoColor=white" />
<img alt="license" src="https://img.shields.io/badge/license-MIT-330f1f" />
</p>
## 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
`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.** The deployed version is pinned to **`0.1.1-rc.2`**. Newer upstream
> releases are built and published automatically as their own tags (currently up to
> `0.1.5-rc.1`) but nothing moves until the template is edited by hand — see
> [Versions and upgrades](#versions-and-upgrades) for why, and for what changed in
> `0.1.2+` before you jump.
## Contents
- [Why this image](#why-this-image)
- [Highlights](#highlights)
- [Quick start](#quick-start)
- [Unraid](#unraid)
- [Image contract](#image-contract)
- [Versions and upgrades](#versions-and-upgrades)
- [Plugins](#plugins)
- [Security model](#security-model)
- [How it stays current](#how-it-stays-current)
- [Validating a third-party image](#validating-a-third-party-image)
- [Repository layout](#repository-layout)
- [License](#license)
## 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.
- **`:latest` silently upgraded a live deployment.** A routine container restart pulled
a moving tag 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. So this registry publishes **immutable version tags only**. There is no
`:latest`, and CI is incapable of re-pushing an existing tag.
- **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.
- **Immutable tags.** `<version>` for each upstream release, `<version>-r<N>` for a
toolchain refresh of the same release. A tag's digest never changes.
- **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.
- **Toolchain freshness.** CI compares the newest build of the pinned version against the
current pnpm release and the current `node:22-bookworm-slim` digest (where `npm`
itself comes from); if either moved, it publishes a `-r<N>` revision with the patched
toolchain.
#### Runtime hygiene
- **Non-root, volume-confined.** Runs as `node` (UID/GID 1000). Everything writable — dsh
state, plugin installs, pnpm's store and cache — 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`, so
`dsh plugin --profile web add|update|remove` runs via `docker exec`.
#### Operations
- **Upstream mirror.** The `master` branch and every `dsh-v*` tag track GitHub exactly,
synced every six hours.
- **Releases.** Every image tag gets a matching `v<tag>` [release](../../releases) with
its digest, toolchain versions, and deployment notes — generated by CI.
- **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
```bash
mkdir -p dsh/state dsh/workspace && sudo chown -R 1000:1000 dsh
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:0.1.1-rc.2 \
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host $(hostname)
open http://$(hostname):3080
```
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.
Two things in that command are load-bearing. The volumes must be owned by UID 1000 or
`dsh` fails with `EACCES` on first start. And `--trusted-host` 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.
**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.
The image is `linux/amd64`, built on the Unraid host that runs it.
**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.
## Unraid
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"]
```
- `--patch` must come **immediately after `web`** (it's a launcher flag; after
the web command's own flags it fails with "unknown option").
- `--no-open` is 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-host` is 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:
```sh
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:
**First run (required):** the container runs as UID 1000. From the Unraid terminal:
```sh
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`.
Then 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 an explicit version tag. Never `:latest` — it doesn't exist here.
- **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
<?xml version="1.0"?>
@@ -144,7 +175,7 @@ Without a matching --trusted-host, non-loopback requests get HTTP 403 on /api -
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.
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; 0.1.5 migrates session logs to a format that cannot be downgraded). Upgrade by editing this tag deliberately, after backing up appdata. 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>
@@ -161,40 +192,147 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
</Container>
```
## Keeping current
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.
[`.gitea/workflows/sync-upstream.yml`](.gitea/workflows/sync-upstream.yml)
runs every 6 hours on the Gitea Actions runners:
## Image contract
1. Force-syncs upstream `master` and `dsh-v*` tags into this repo.
2. Builds a **new immutable version tag** when a new `@deepseek-ai/dsh`
appears on npm and the registry lacks it. Existing tags are never
re-pushed, and `latest` is never published or moved — a new upstream
release cannot change what a running deployment executes.
3. **Toolchain freshness check** for the pinned default version: if a newer
pnpm release or an updated `node:22-bookworm-slim` base image (which is
where `npm` itself 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.
| | |
|---|---|
| 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) |
| User | `node` — UID/GID **1000** |
| `ENTRYPOINT` | `["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. **Mount this.** |
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. |
| `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` |
| Labels | `dev.milesward.dsh-version`, `dev.milesward.pnpm-version`, `dev.milesward.base-digest` — what CI's freshness check reads |
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.
Two rules about the command line that are easy to get wrong:
## Validating an image
- **`--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.
[`.gitea/workflows/validate-suspect-image.yml`](.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).
## Versions and upgrades
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.
| Tag | dsh | Role |
|---|---|---|
| `0.1.1-rc.2` | 0.1.1-rc.2 | **Pinned / deployed.** No launch-token auth; `dsh-lanmode` compatible. |
| `0.1.2-rc.1` | 0.1.2-rc.1 | First launch-token release; Remote gateway replaces the ApiProxy. |
| `0.1.5-rc.1` | 0.1.5-rc.1 | Current upstream `latest`. Session format V3 — **no downgrade** once migrated. |
The full list, with digests and notes, is on the [Releases](../../releases) page — one
release per image tag, published by CI.
`dsh` is a developer preview and upstream says so: expect breaking changes between
releases. An upgrade here is always three deliberate steps:
1. **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.
2. **Reconcile plugins first.** `@goodandready/dsh-lanmode` ≤ 0.6.8 breaks the web client
on `0.1.2+` (`web boot: … waiting for services: remote.*`). Remove or update it *before*
changing the tag:
`docker exec DeepSeek-Harness dsh plugin --profile web remove @goodandready/dsh-lanmode`
3. **Edit the Repository tag** in the template and apply.
### 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:
```sh
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>
```
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.
- **The container is unprivileged.** Non-root, no capabilities, nothing writable outside
the mounted volume. Upstream's own [safety notice](https://github.com/deepseek-ai/deepseek-harness/blob/master/SAFETY.md)
still applies: dsh has not been security-audited, and its sandboxing does not guarantee
isolation.
## How it stays current
[`sync-upstream.yml`](.gitea/workflows/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.
2. **New releases** — if npm's `latest` for `@deepseek-ai/dsh` has no tag in the registry,
builds it, smoke-tests it, pushes it.
3. **Freshness** — reads the toolchain labels off the newest build of the pinned version
and compares them to the current pnpm release and `node:22-bookworm-slim` digest. If
either moved, publishes `<version>-r<run>` with the updated toolchain.
4. **Releases** — every image tag without a `v<tag>` release gets one, pointing at the
`main` commit that built it, with digest, toolchain, and deployment notes.
Nothing in this pipeline can change an existing tag or publish `latest`. Adopting any
new build — release or revision — is a template edit.
## Validating a third-party image
[`validate-suspect-image.yml`](.gitea/workflows/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 create`s (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` plus all `dsh-v*` tags. Never edited here. |
| `main` | This branch — the build. |
| `Dockerfile` | The image. `ARG DSH_VERSION` is the pinned default. |
| `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). |
| `assets/` | Logo (transparent PNG, also the Unraid template icon). |
Release tags on `main` are `v<image tag>`; upstream's mirrored tags are `dsh-v<version>`.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 47 KiB