diff --git a/.gitea/workflows/sync-upstream.yml b/.gitea/workflows/sync-upstream.yml index e023087d12..7af5930200 100644 --- a/.gitea/workflows/sync-upstream.yml +++ b/.gitea/workflows/sync-upstream.yml @@ -164,3 +164,95 @@ jobs: docker push "$REF" done + + # Every image tag in the registry gets a matching v 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 < [--trusted-host ]\` +- 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..1d3dcc58b3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index fe614eb7da..f34b648d62 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,152 @@ -DeepSeek logo +

+ DeepSeek Harness +

-# deepseek-harness — trusted mirror & container build +

DeepSeek Harness — trusted container build

-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). +

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

-## Branch layout +

+ 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. +

-| 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. | +

+ pinned + newest + tags + ci + base + license +

-## Image +--- -``` -gitea.milesward.dev/mward4/deepseek-harness: e.g. 0.1.1-rc.2 -gitea.milesward.dev/mward4/deepseek-harness:-r 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@` from + `registry.npmjs.org`, nothing else. The version is a build argument with a known-good + default. +- **Immutable tags.** `` for each upstream release, `-r` 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` 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` [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 [--trusted-host ] - ``` - -- `--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 dsh plugin --profile web add -docker exec dsh plugin --profile web update -docker exec dsh plugin --profile web remove -``` - -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 `. -- 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 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 @@ -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. @@ -161,40 +192,147 @@ Your DeepSeek API key can be set below (stored in the container env) or entered ``` -## 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 `-r` 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@` and `pnpm@` 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 +docker exec DeepSeek-Harness dsh plugin --profile web update +docker exec DeepSeek-Harness dsh plugin --profile web remove +``` + +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. +- **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 `-r` with the updated toolchain. +4. **Releases** — every image tag without a `v` 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`; upstream's mirrored tags are `dsh-v`. + +## 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. diff --git a/assets/deepseek-logo-128.png b/assets/deepseek-logo-128.png new file mode 100644 index 0000000000..b1019af3f2 Binary files /dev/null and b/assets/deepseek-logo-128.png differ diff --git a/assets/deepseek-logo.png b/assets/deepseek-logo.png index 68b94bbdcc..59235dbf98 100644 Binary files a/assets/deepseek-logo.png and b/assets/deepseek-logo.png differ