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:
1 parent
2d033c323b
commit
5121d84b53
5 files changed
+384
-133
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
Reference in new issue
Block a user