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.
---
`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 `[-rN]` tag is
> immutable, so pinning one is always an option. `dsh` is a developer preview: read
> [Versions and upgrades](#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](#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.
- **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@` 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](https://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: ` 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.
- **Workspace grouping is checked before every start.** A project directory outside the
mounted `/workspace` and state volume does not survive a container update, and dsh joins
a session to its project only at creation — so those chats collect under "Ungrouped" and
stay there. Before every start the entrypoint recreates the missing project directories
and re-accounts the orphaned chats, so an update heals itself with no configuration.
Session logs are never touched; the workspace registry is backed up first.
`DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent. See
[Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
#### 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](../../releases) 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
```bash
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
DeepSeek-Harness
gitea.milesward.dev/mward4/deepseek-harness:latest
https://gitea.milesward.dev/mward4/deepseek-harness
bridge
sh
false
https://github.com/deepseek-ai/deepseek-harness
https://gitea.milesward.dev/mward4/deepseek-harness/issues
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.
WORKSPACE GROUPING: create projects under the Workspace path, not under /home/node. /home/node is the container's own filesystem and the next update erases it - a project directory there loses its files, and its chats drop to "Ungrouped" permanently because dsh only joins a chat to a project when the chat is created. The entrypoint checks this before every start and, with the Workspace Doctor variable below set to repair (the default here), recreates the erased directories and re-accounts the orphaned chats so they return to their project. A backup of the workspace registry is written before it touches anything; session logs are never modified.
Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
AI: Tools:
http://[IP]:[PORT:3080]
https://gitea.milesward.dev/mward4/deepseek-harness/raw/branch/main/assets/deepseek-logo.png
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open
3080
/mnt/user/appdata/deepseek-harness
/mnt/user/appdata/deepseek-harness/workspace
1000
1000
0
repair
```
An **existing** container keeps the fields it was created with: Unraid does not add a
variable a template gained later. That is fine here — the image default is already
`repair`, so the Workspace Doctor row above only matters if you want to differ from it
(`warn` reports without repairing, `off` is silent), set by hand under Docker → Edit →
*Add another Path, Port, Variable, Label or Device* → Variable.
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@` and `pnpm@` 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 `setpriv`s down — dsh itself is never root. |
| `ENTRYPOINT` | `["docker-entrypoint.sh"]` — fixes ownership, warns about plugin layers that collide with the installed version, runs the workspace doctor, drops privileges, `exec`s `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`, `DSH_WORKSPACE_DOCTOR` (`repair` default — reports and repairs lost grouping at every start; `warn` reports only, `off` is silent), 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 |
Four 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.
- **Keep projects under `/workspace`.** The workspace directory picker opens in
`/home/node`, which the next container update erases: any project created there loses
its directory and its chats drop to "Ungrouped" for good. See
[Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
## 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. |
| `` — 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](../../releases) page mirrors upstream's: one entry per version, on the same
`dsh-v` 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. One deliberate difference: the release for the version `:latest` serves
is the repository's **Latest** release (Gitea lists only a non-pre-release on the home page
and at `/releases/latest`); every other version carries upstream's pre-release flag.
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: ` — 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 dsh plugin --profile web remove ` — or, once the
container no longer stays up, the same command against the volume:
```sh
docker run --rm -v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
gitea.milesward.dev/mward4/deepseek-harness:latest dsh plugin --profile web remove
```
4. **Apply the update** (or edit the Repository tag, if you pin).
### Chats land in Ungrouped after an update
dsh lists a chat under the project directory it was created in, and it joins that project
**when the session is created** — there is no later re-scan. (Upstream's history bootstrap
runs once per state volume, on the first project, and never again.) Two consequences:
- A **project directory outside `/workspace` and `/home/node/.dsh`** — anything created
under `/home/node`, where the workspace directory picker opens by default — lives in the
container's own filesystem. Updating the container recreates it, that directory is gone,
and its chats no longer match a project.
- Adding the folder back creates a **fresh, empty** project; dsh never moves an existing
chat into it. The old chats stay under "Ungrouped".
Before every start the entrypoint reports this — each project with its storage, each
missing directory, every chat it could recover — and repairs it, with no configuration to
set. `DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent:
| | |
|---|---|
| Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists |
| Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in |
| Before writing | copies the workspace registry to `workspace.json.bak-`; the write itself is atomic and in dsh's own format |
| By hand | report with `docker exec dsh-workspace-doctor.mjs --check`; repair with the one-off container below |
To repair without waiting for a container start, stop the container first so nothing races
the registry write, then run the doctor against the state volume directly (`--entrypoint`
bypasses dsh, and the image's `PUID`/`PGID` are applied to what it writes):
```sh
docker run --rm --entrypoint /usr/local/bin/dsh-workspace-doctor.mjs \
-v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
gitea.milesward.dev/mward4/deepseek-harness:latest --all
```
The recreated directory is empty: this restores the *grouping*, not the files — those lived
in the container and nothing here can bring them back. Keep project data under `/workspace`
and the situation cannot recur. A repair run against a *running* dsh can be overwritten by
it; let the entrypoint do it at startup instead.
### 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
docker exec DeepSeek-Harness dsh plugin --profile web update
docker exec DeepSeek-Harness dsh plugin --profile web remove
```
`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@` 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 `setpriv`s down to `PUID:PGID` before `exec`ing 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](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, 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. Pushes go through Cloudflare, which caps
a request at 100 MB and dsh's dependency layer is past that since `0.1.6-alpha.2`, so
images are uploaded with [regctl](https://github.com/regclient/regclient) in 50 MB
chunks (pinned by version and checksum). A version that fails to build or push leaves
its previous image untouched and **fails the run** — a missed upstream release shows up
as a red badge, never as a quiet warning.
3. **`:latest`** — pointed at the newest release candidate, as a manifest copy through the
registry API (identical digest, nothing rebuilt). Manifests left untagged by rebuilds
are pruned so the Packages page lists only the tags.
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`](.gitea/scripts/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`](.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`. 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, workspace-doctor run, then `exec dsh "$@"`. |
| `dsh-workspace-doctor.mjs` | Reports (and optionally repairs) workspace grouping lost to a container update: missing project directories, chats orphaned from their project. |
| `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.