Self-healing volume ownership: no more manual chown, no more EACCES
Sync upstream and build images / sync-and-build (push) Failing after 22s

A bind mount arrives with the host's ownership, so a freshly created appdata
directory killed dsh with EACCES on mkdir /home/node/.dsh/profiles/web. Only
root inside the container can fix that, so:

- docker-entrypoint.sh starts as root, aligns the node account with PUID/PGID
  (default 1000:1000, Unraid convention), takes ownership of the state volume
  when needed, then setpriv's down before exec'ing dsh. The network-reachable
  process is never root and no setuid binary is left behind. The workspace
  mount point is made writable but its CONTENTS are left alone unless
  DSH_CHOWN_WORKSPACE=1 — never silently rewrite ownership across user data.
- Dropped USER node (root is required to chown); dsh is wrapped so a root
   re-execs as PUID:PGID and cannot leave
  root-owned files in the volume. Wrapper resolves the real bin.js at build
  time and self-tests.
- Explicit --user is honored: ownership cannot be repaired, so the entrypoint
  verifies writability and exits with one actionable line instead of a Node
  stack trace.
- XDG_CONFIG_HOME pinned into the volume too; pnpm was reaching for
  /root/.config when HOME was unset.
- CI: labels now carry a recipe hash, and the freshness check cuts a -rN
  revision when the recipe changes — so an image fix reaches the pinned
  deployment without waiting for an upstream release.
- README/template: chown step removed, PUID/PGID/DSH_CHOWN_WORKSPACE
  documented, template pinned to 0.1.1-rc.2-r1.

Verified with a 23-case matrix: root-owned volume self-heals and serves 200,
already-correct volume skips the chown, 99:100 + PUID/PGID works, plugin
add/remove via root docker exec leaves no root-owned files, explicit --user
still works and fails clearly when it can't, both argument forms serve.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Miles WardandClaude Fable 5 committed 2026-09-11 12:00:30 -04:00
1 parent d6a97dd8ea
commit acd81b2f79
5 files changed
+243 -61

No files matched your search

+17 -8
View File
@@ -9,8 +9,10 @@ name: Sync upstream and build images
# dispatch) and the registry lacks.
# 3. Freshness check for the PINNED default version (ARG DSH_VERSION in the
# Dockerfile): if a newer pnpm release or a patched node base image has
# shipped since the newest existing build of that version, builds a
# <version>-r<run> revision tag with the updated toolchain.
# shipped, or the build recipe itself changed (Dockerfile, entrypoint,
# dsh wrapper, bind patch), builds a <version>-r<run> revision tag. This
# is how an image-level fix reaches the pinned deployment without waiting
# for the next upstream dsh release.
# 4. Publishes a v<tag> git tag + Gitea release (digest, toolchain, upstream
# highlights, deployment caveats) for every image tag without one.
#
@@ -31,6 +33,8 @@ on:
branches: [main]
paths:
- Dockerfile
- docker-entrypoint.sh
- dsh-wrapper.sh
- web.cordis.patch.yml
- .gitea/workflows/sync-upstream.yml
- .gitea/scripts/publish-releases.js
@@ -75,15 +79,16 @@ jobs:
PINNED=$(sed -n 's/^ARG DSH_VERSION=//p' Dockerfile | head -1)
docker pull -q "$BASE_IMAGE" > /dev/null
BASE_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$BASE_IMAGE")
echo "dsh latest=$DSH_LATEST next=$DSH_NEXT requested='$REQUESTED' pinned=$PINNED pnpm=$PNPM_LATEST base=$BASE_DIGEST"
RECIPE_HASH=$(cat Dockerfile docker-entrypoint.sh dsh-wrapper.sh web.cordis.patch.yml | sha256sum | cut -c1-12)
echo "dsh latest=$DSH_LATEST next=$DSH_NEXT requested='$REQUESTED' pinned=$PINNED pnpm=$PNPM_LATEST base=$BASE_DIGEST recipe=$RECIPE_HASH"
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
tag_exists() {
[ "$(curl -s -o /dev/null -w '%{http_code}' -u "${{ github.repository_owner }}:${TOKEN}" \
-H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1")" = 200 ]
}
# Labels of a single-arch manifest's config blob ("pnpm|base"); empty
# output (index manifests, missing labels) means "treat as stale".
# Labels of a single-arch manifest's config blob ("pnpm|base|recipe");
# empty output (index manifests, missing labels) means "treat as stale".
tag_toolchain() {
MAN=$(curl -s -u "${{ github.repository_owner }}:${TOKEN}" \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
@@ -93,7 +98,8 @@ jobs:
BLOB=$(curl -s -u "${{ github.repository_owner }}:${TOKEN}" "https://${REGISTRY}/v2/${IMAGE}/blobs/$CFG") || return 0
P=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.pnpm-version":"[^"]*"' | cut -d'"' -f4 || true)
B=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.base-digest":"[^"]*"' | cut -d'"' -f4 || true)
echo "${P}|${B}"
R=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.recipe-hash":"[^"]*"' | cut -d'"' -f4 || true)
echo "${P}|${B}|${R}"
}
BUILDS=""
@@ -118,8 +124,8 @@ jobs:
| grep -E "^${PINNED}(-r[0-9]+)?$" | sort -V | tail -1)
TOOL=$(tag_toolchain "$NEWEST")
echo "newest build of pinned: $NEWEST toolchain='$TOOL'"
if [ "$TOOL" != "${PNPM_LATEST}|${BASE_DIGEST}" ]; then
echo "toolchain stale (want ${PNPM_LATEST}|${BASE_DIGEST}) -> revision build"
if [ "$TOOL" != "${PNPM_LATEST}|${BASE_DIGEST}|${RECIPE_HASH}" ]; then
echo "stale (want ${PNPM_LATEST}|${BASE_DIGEST}|${RECIPE_HASH}) -> revision build"
BUILDS="$BUILDS ${PINNED}-r${{ github.run_number }}=$PINNED"
fi
fi
@@ -129,6 +135,7 @@ jobs:
echo "builds=${BUILDS}"
echo "pnpm=${PNPM_LATEST}"
echo "base_digest=${BASE_DIGEST}"
echo "recipe_hash=${RECIPE_HASH}"
} >> "$GITHUB_OUTPUT"
- name: Build, smoke-test, and push
@@ -137,6 +144,7 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
CI_RUN: ${{ github.run_number }}
run: |
set -eux
@@ -151,6 +159,7 @@ jobs:
--build-arg "DSH_VERSION=${VER}" \
--build-arg "PNPM_VERSION=${PNPM_VERSION}" \
--build-arg "BASE_DIGEST=${BASE_DIGEST}" \
--build-arg "RECIPE_HASH=${RECIPE_HASH}" \
--build-arg "GIT_SHA=${GIT_SHA}" \
--build-arg "CI_RUN=${CI_RUN}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
+32 -8
View File
@@ -34,22 +34,40 @@ RUN npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \
&& npm cache clean --force
COPY web.cordis.patch.yml /opt/deepseek-harness/web.cordis.patch.yml
COPY docker-entrypoint.sh dsh-wrapper.sh /usr/local/bin/
# /workspace: the agent's working directory (mount your project here);
# /home/node/.dsh: dsh state (settings, credentials, sessions, plugins).
RUN mkdir -p /workspace /home/node/.dsh \
&& chown node:node /workspace /home/node/.dsh
# Replace npm's `dsh` symlink with the privilege-dropping wrapper, resolving
# the real entry script at build time (never guessing its path across dsh
# versions) and self-testing it. The symlink must be removed first: writing
# through it would clobber the package's own bin.js.
RUN set -eux; \
DSH_BIN="$(readlink -f /usr/local/bin/dsh)"; \
test -f "$DSH_BIN"; \
rm /usr/local/bin/dsh; \
sed "s|__DSH_BIN__|${DSH_BIN}|" /usr/local/bin/dsh-wrapper.sh > /usr/local/bin/dsh; \
rm /usr/local/bin/dsh-wrapper.sh; \
chmod 0755 /usr/local/bin/dsh /usr/local/bin/docker-entrypoint.sh; \
mkdir -p /workspace /home/node/.dsh; \
chown node:node /workspace /home/node/.dsh; \
dsh --version
# Everything writable at runtime (dsh state, pnpm store/cache for plugin
# installs) lives inside the mounted state volume, never the rootfs.
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
# them at startup. Unraid's share default is 99:100.
ENV DSH_HOME=/home/node/.dsh \
PNPM_HOME=/home/node/.dsh/.pnpm \
XDG_CACHE_HOME=/home/node/.dsh/.cache \
XDG_STATE_HOME=/home/node/.dsh/.state
XDG_STATE_HOME=/home/node/.dsh/.state \
XDG_CONFIG_HOME=/home/node/.dsh/.config \
PUID=1000 \
PGID=1000
EXPOSE 3080
USER node
# No `USER` directive on purpose: the entrypoint needs root to repair
# bind-mount ownership, then drops to PUID:PGID via setpriv before exec'ing
# dsh. The dsh process — the one reachable from the network — is never root.
WORKDIR /workspace
ARG DSH_VERSION
@@ -62,6 +80,11 @@ ARG BASE_DIGEST=""
ARG GIT_SHA=""
ARG CI_RUN=""
ARG BUILD_DATE=""
# RECIPE_HASH: digest of the build inputs (this Dockerfile, the entrypoint,
# the dsh wrapper, the bind patch). The freshness check cuts a -rN revision of
# the pinned version when it changes, so an image-recipe fix actually reaches
# the deployment instead of waiting for the next upstream release.
ARG RECIPE_HASH=""
LABEL org.opencontainers.image.title="deepseek-harness" \
org.opencontainers.image.description="DeepSeek Harness (dsh) web UI, built from the official npm release" \
org.opencontainers.image.version="${DSH_VERSION}" \
@@ -73,9 +96,10 @@ LABEL org.opencontainers.image.title="deepseek-harness" \
dev.milesward.dsh-version="${DSH_VERSION}" \
dev.milesward.pnpm-version="${PNPM_VERSION}" \
dev.milesward.base-digest="${BASE_DIGEST}" \
dev.milesward.ci-run="${CI_RUN}"
dev.milesward.ci-run="${CI_RUN}" \
dev.milesward.recipe-hash="${RECIPE_HASH}"
ENTRYPOINT ["dsh"]
ENTRYPOINT ["docker-entrypoint.sh"]
# --patch must come immediately after `web` (launcher flag), --no-open so a
# headless server never launches a browser (on 0.1.2+ an in-container browser
# could consume the one-time launch token). Unraid Post Arguments REPLACE this
+81 -45
View File
@@ -86,19 +86,28 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
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.
- **Toolchain and recipe freshness.** CI compares the newest build of the pinned version
against the current pnpm release, the current `node:22-bookworm-slim` digest (where
`npm` itself comes from), and a hash of the build recipe; if any moved, it publishes a
`-r<N>` revision — so an image-level fix reaches the deployment without waiting for an
upstream release.
#### 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.
- **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`, so
`dsh plugin --profile web add|update|remove` runs via `docker exec`.
- **Plugin manager works in-container.** pnpm is present on `PATH`, and `dsh` is 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.
#### Operations
- **Upstream mirror.** The `master` branch tracks GitHub's `master` exactly, synced every
@@ -114,35 +123,34 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
## Quick start
```bash
mkdir -p dsh/state dsh/workspace && sudo chown -R 1000:1000 dsh
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:0.1.1-rc.2 \
gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2-r1 \
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host $(hostname)
open http://$(hostname):3080
```
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
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`, built on the Unraid host that runs it.
The image is `linux/amd64` and `linux/arm64`.
## Unraid
**First run (required):** the container runs as UID 1000. From the Unraid terminal:
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.
```sh
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
```
Then add the container from the template below (Docker tab → Add Container → paste, or
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:
@@ -155,7 +163,7 @@ operating contract:
<?xml version="1.0"?>
<Container version="2">
<Name>DeepSeek-Harness</Name>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2</Repository>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2-r1</Repository>
<Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
<Network>bridge</Network>
<Shell>sh</Shell>
@@ -163,12 +171,9 @@ operating contract:
<Project>https://github.com/deepseek-ai/deepseek-harness</Project>
<Support>https://gitea.milesward.dev/mward4/deepseek-harness/issues</Support>
<Overview>
DeepSeek Harness (dsh) - browser-based agent runtime from DeepSeek AI, packaged from the official npm release (built and served from this Gitea; non-root).
DeepSeek Harness (dsh) - browser-based agent runtime from DeepSeek AI, packaged from the official npm release (built and served from this Gitea).
FIRST RUN (required): the container runs as UID 1000. Before starting, from the Unraid terminal:
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
Otherwise dsh fails with EACCES on /home/node/.dsh.
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.
@@ -178,7 +183,11 @@ 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; 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.
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 a patched toolchain or an image fix - safe to move to within the same dsh version.
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.
Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
</Overview>
@@ -190,8 +199,11 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
<PostArgs>web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open</PostArgs>
<Config Name="WebUI Port" Target="3080" Default="3080" Mode="tcp" Description="dsh Web UI. Reachable by everything on your LAN - see the security note in the Overview." Type="Port" Display="always" Required="true" Mask="false">3080</Config>
<Config Name="DeepSeek API Key" Target="DEEPSEEK_API_KEY" Default="" Description="Optional. Official DeepSeek API key (sk-...). Can also be configured later in the dsh Settings UI." Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="DSH Home" Target="/home/node/.dsh" Default="/mnt/user/appdata/deepseek-harness" Mode="rw" Description="Persistent dsh state: settings, credentials, sessions, plugins. Must be owned by UID/GID 1000 (see Overview)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness</Config>
<Config Name="Workspace" Target="/workspace" Default="/mnt/user/appdata/deepseek-harness/workspace" Mode="rw" Description="The agent's working directory. Only mount data you are willing to let the agent read and modify. Must be writable by UID 1000." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness/workspace</Config>
<Config Name="DSH Home" Target="/home/node/.dsh" Default="/mnt/user/appdata/deepseek-harness" Mode="rw" Description="Persistent dsh state: settings, credentials, sessions, plugins. Ownership is adjusted automatically at startup - no chown needed. Back this path up." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness</Config>
<Config Name="Workspace" Target="/workspace" Default="/mnt/user/appdata/deepseek-harness/workspace" Mode="rw" Description="The agent's working directory. Only mount data you are willing to let the agent read and modify. The mount point is made writable automatically; existing files inside keep their ownership unless you set DSH_CHOWN_WORKSPACE=1." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness/workspace</Config>
<Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
</Container>
```
@@ -205,27 +217,32 @@ trusts.
|---|---|
| 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"]` |
| 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, 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. **Mount this.** |
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. |
| `/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` |
| Labels | `dev.milesward.dsh-version`, `dev.milesward.pnpm-version`, `dev.milesward.base-digest` — what CI's freshness check reads |
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, 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 |
Two rules about the command line that are easy to get wrong:
Three 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.
## Versions and upgrades
| Tag | dsh | Role |
|---|---|---|
| `0.1.1-rc.2` | 0.1.1-rc.2 | **Pinned / deployed.** No launch-token auth; `dsh-lanmode` compatible. |
| `0.1.1-rc.2-r1` | 0.1.1-rc.2 | **Pinned / deployed.** Adds the self-healing volume ownership fix. |
| `0.1.1-rc.2` | 0.1.1-rc.2 | Superseded by `-r1`: needs a manual `chown -R 1000:1000` on appdata. |
| `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 | Upstream npm `latest`. Session format V3 — **no downgrade** once migrated. |
| `0.1.5-rc.2` | 0.1.5-rc.2 | Upstream npm `next`. Feedback-dialog and file-card refinements on top of rc.1. |
@@ -233,8 +250,12 @@ Two rules about the command line that are easy to get wrong:
The full list, with digests and notes, is on the [Releases](../../releases) page — one
release per image tag, published by CI.
A `-r<N>` suffix means the same dsh version rebuilt with a patched toolchain or an image
fix, so moving between `X` and `X-rN` is safe and needs no preparation. Changing the dsh
version is the deliberate part.
`dsh` is a developer preview and upstream says so: expect breaking changes between
releases. An upgrade here is always three deliberate steps:
releases. A **version** 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.
@@ -267,6 +288,11 @@ docker exec DeepSeek-Harness dsh plugin --profile web update <package>
docker exec DeepSeek-Harness dsh plugin --profile web remove <package>
```
`docker exec` defaults to root, which would normally leave root-owned files in the state
volume and break dsh's next start. The `dsh` on `PATH` is a wrapper that re-execs as
`PUID:PGID` for exactly that reason, so no `-u` flag is needed and the volume stays
consistent.
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
@@ -287,8 +313,14 @@ container as the agent**. Everything below follows from that.
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)
- **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.
@@ -301,9 +333,11 @@ runners every six hours (`17 */6 * * *`) and on any change to the build inputs:
2. **New releases** — for each `@deepseek-ai/dsh` version npm publishes as `latest` or
`next` (release candidates; alphas only when asked for) that has no tag in the
registry: build, smoke-test, push. A manual run can name any exact version.
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.
3. **Freshness** — reads the labels off the newest build of the pinned version and
compares them to the current pnpm release, the current `node:22-bookworm-slim` digest,
and a hash of the build recipe (Dockerfile, entrypoint, `dsh` wrapper, bind patch). If
any moved, publishes `<version>-r<run>` — the path by which a security patch *or* an
image fix reaches the pinned deployment.
4. **Releases** — every image tag without a `v<tag>` release gets one, tagged at the
`main` commit recorded in the image's `org.opencontainers.image.revision` label, with
digest and toolchain, the upstream release's English notes, and the caveats that apply
@@ -334,6 +368,8 @@ the official release, upstream safety check intact, no tampering.
| `master` | Exact mirror of upstream `master`. Never edited here. |
| `main` | This branch — the build. |
| `Dockerfile` | The image. `ARG DSH_VERSION` is the pinned default. |
| `docker-entrypoint.sh` | Ownership repair + privilege drop, then `exec dsh "$@"`. |
| `dsh-wrapper.sh` | Installed as `/usr/local/bin/dsh`; 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. |
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
# Make the container's mounted volumes usable no matter how the host owns them.
#
# A bind mount arrives with the HOST's ownership, so an image that simply runs
# as uid 1000 dies with `EACCES … mkdir '/home/node/.dsh/profiles/web'` on any
# freshly created appdata directory. The fix has to happen inside the
# container, and only root can chown. So this entrypoint starts as root, takes
# ownership of the state volume, and then drops to PUID:PGID (default
# 1000:1000) with setpriv — dsh itself never runs as root, and there is no
# setuid binary left behind for it to climb back through.
#
# PUID/PGID follow the usual Unraid convention: set them if your appdata is
# owned by something else (Unraid's share default is 99:100) and the volume is
# adjusted to match at startup.
#
# If the container is started with an explicit user (`--user`), repairing
# ownership is impossible; this prints one actionable line instead of a Node
# stack trace.
set -eu
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
STATE="${DSH_HOME:-/home/node/.dsh}"
HOME_DIR=/home/node
WORKSPACE=/workspace
PATCH=/opt/deepseek-harness/web.cordis.patch.yml
log() { printf 'dsh-entrypoint: %s\n' "$*" >&2; }
# Unraid Post Arguments replace the image CMD wholesale, so a bare flag list
# ("--trusted-host tower.local") would otherwise reach dsh with no subcommand.
# Prepend the default web invocation in that case; a full command line
# ("web --patch … --no-open") is passed through untouched.
if [ "$#" -eq 0 ]; then
set -- web --patch "$PATCH" --no-open
else
case "$1" in
-*) set -- web --patch "$PATCH" --no-open "$@" ;;
esac
fi
# True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops
# at the first hit, so the common "already correct" case costs one stat.
needs_chown() {
[ -n "$(find "$1" \( ! -uid "$PUID" -o ! -gid "$PGID" \) -print -quit 2>/dev/null)" ]
}
if [ "$(id -u)" = 0 ]; then
# Keep the `node` account aligned with the requested ids so $HOME, group
# lookups and setpriv all resolve even for ids with no passwd entry.
CUR_UID="$(id -u node)"
CUR_GID="$(id -g node)"
[ "$PGID" = "$CUR_GID" ] || { log "moving group node to gid $PGID"; groupmod -o -g "$PGID" node; }
[ "$PUID" = "$CUR_UID" ] || { log "moving user node to uid $PUID"; usermod -o -u "$PUID" node; }
mkdir -p "$STATE" "$WORKSPACE"
chown "$PUID:$PGID" "$HOME_DIR" 2>/dev/null || true
# State volume is entirely dsh's own (settings, credentials, sessions,
# plugins, pnpm store) — safe to take wholesale.
if needs_chown "$STATE"; then
log "taking ownership of $STATE for ${PUID}:${PGID}"
chown -R "$PUID:$PGID" "$STATE"
fi
# The workspace holds the operator's data, so only the mount point itself
# is adjusted (enough for the agent to create files). Rewriting ownership
# across someone's project tree is opt-in, never a silent side effect.
if [ "$(stat -c '%u:%g' "$WORKSPACE")" != "${PUID}:${PGID}" ]; then
chown "$PUID:$PGID" "$WORKSPACE"
fi
if [ "${DSH_CHOWN_WORKSPACE:-0}" = 1 ]; then
if needs_chown "$WORKSPACE"; then
log "DSH_CHOWN_WORKSPACE=1: taking ownership of everything under $WORKSPACE"
chown -R "$PUID:$PGID" "$WORKSPACE"
fi
elif needs_chown "$WORKSPACE"; then
log "note: some files under $WORKSPACE are not owned by ${PUID}:${PGID}; the agent"
log " may be unable to modify them. Set DSH_CHOWN_WORKSPACE=1 to take ownership."
fi
export HOME="$HOME_DIR"
exec setpriv --reuid "$PUID" --regid "$PGID" --init-groups dsh "$@"
fi
# Non-root start (explicit --user): verify, don't guess.
if [ ! -w "$STATE" ]; then
log "ERROR: $STATE is not writable by uid $(id -u):$(id -g) — it is owned by $(stat -c '%u:%g' "$STATE" 2>/dev/null || echo '?')."
log "This container was started with an explicit --user, so it cannot repair that itself."
log "Either drop --user (the entrypoint then takes ownership at startup), or on the host run:"
log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>"
exit 1
fi
exec dsh "$@"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# `dsh` on PATH, wrapped so root invocations drop to the runtime user.
#
# The container starts as root (see docker-entrypoint.sh), which also makes
# `docker exec <container> dsh …` run as root by default — and the documented
# plugin workflow (`dsh plugin --profile web add …`) would then leave
# root-owned files in the state volume that dsh cannot read on its next start.
# Re-exec'ing as PUID:PGID keeps the volume consistent no matter how dsh is
# invoked. Set DSH_ALLOW_ROOT=1 to bypass (diagnostics only).
set -eu
BIN=__DSH_BIN__
if [ "$(id -u)" = 0 ] && [ "${DSH_ALLOW_ROOT:-0}" != 1 ]; then
exec setpriv --reuid "${PUID:-1000}" --regid "${PGID:-1000}" --init-groups \
env HOME=/home/node node "$BIN" "$@"
fi
exec node "$BIN" "$@"