Name colliding plugin layers at boot; note the 0.1.5 file-upload collision
Sync upstream and build images / sync-and-build (push) Successful in 2m18s

dsh 0.1.5 ships file upload under the loader id file-upload, which the
community dsh-file-upload plugin also registers. A profile that still carries
that plugin dies at boot with "duplicate loader entry id: file-upload" and the
stack trace never names the plugin. The entrypoint now compares the profile's
plugin layers (and the two hand-edited patch layers) against the installed
version's own bundle patches before every start and logs the offending plugin
with its remove command. Advisory only: the start is never blocked, and only
the profile manifest and patch YAML are read as data.

The 0.1.5+ release footer carries the caveat, and the publisher now re-renders
a release when its text changes rather than only when the digest does.
README: upgrade checklist covers this class of failure; the Highlights bullets
that still described the pre-mirror tag/release model are corrected.
This commit is contained in:
Ward committed 2026-09-11 13:54:23 -04:00
1 parent dfaa1a82fc
commit 50121a390f
3 files changed
+90 -14

No files matched your search

+10 -2
View File
@@ -116,6 +116,7 @@ function containerNotes(ver) {
}
if (atLeast(ver, '0.1.5')) {
out.push('Session logs are migrated to format V3 on first start and cannot be read by older versions — back up appdata before moving up to this line.');
out.push('File upload is built in from this line (loader id `file-upload`). A profile that still carries the community `dsh-file-upload` plugin does not boot (`duplicate loader entry id: file-upload`) — remove it before updating: `dsh plugin --profile web remove dsh-file-upload`.');
}
return out;
}
@@ -227,13 +228,20 @@ async function publish(ver) {
const existing = await gitea('GET', `/repos/${REPO}/releases/tags/${tag}`);
if (existing.status === 200) {
const prev = existingDigest(existing.json.body);
if (prev === facts.digest && existing.json.name === title) { console.log(`${tag}: up to date`); return; }
const history = [prev, ...existingHistory(existing.json.body)].filter((d) => d && d !== facts.digest).slice(0, HISTORY_MAX);
const body = renderBody(ver, facts, up, history);
// Same image, same title, same rendered text -> nothing to do. Comparing
// the text (not just the digest) lets a changed container note or edited
// upstream notes reach existing releases on the next run.
const norm = (s) => (s || '').replace(/\r\n/g, '\n').trim();
const sameImage = prev === facts.digest;
if (sameImage && existing.json.name === title && norm(existing.json.body) === norm(body)) { console.log(`${tag}: up to date`); return; }
if (DRY_RUN) { console.log(`\n===== DRY RUN update ${tag} =====\n${title}\n\n${body}\n`); return; }
const r = await gitea('PATCH', `/repos/${REPO}/releases/${existing.json.id}`, { name: title, body, prerelease, draft: false });
if (r.status !== 200) throw new Error(`${tag}: update HTTP ${r.status} ${r.text.slice(0, 200)}`);
console.log(`${tag}: updated (image rebuilt: ${prev.slice(0, 19)} -> ${facts.digest.slice(0, 19)})`);
console.log(sameImage
? `${tag}: updated (notes)`
: `${tag}: updated (image rebuilt: ${prev.slice(0, 19)} -> ${facts.digest.slice(0, 19)})`);
return;
}
if (existing.status !== 404) throw new Error(`${tag}: release lookup HTTP ${existing.status}`);
+30 -12
View File
@@ -115,15 +115,22 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
- **Plugin manager works in-container.** pnpm is present on `PATH`, and both `dsh` and
the `pnpm` family are wrapped so a root `docker exec` re-execs as the runtime user —
plugin installs can't leave root-owned files that break the next start.
- **Plugin collisions are named, not guessed at.** When a new dsh version ships built in
what a third-party plugin used to add, dsh dies at boot with
`duplicate loader entry id: <id>` and no hint which plugin. Before every start the
entrypoint compares the profile's plugin layers against the installed version's own
bundle patches and logs the offending plugin with its remove command.
#### Operations
- **Upstream mirror.** The `master` branch tracks GitHub's `master` exactly, synced every
six hours. (Upstream's own `dsh-v*` tags are deliberately not mirrored — this repo's
tag list is exclusively its image releases; each release links to the upstream tag.)
- **Releases.** Every image tag gets a matching `v<tag>` [release](../../releases) —
digest, toolchain versions, the upstream release's English highlights, and the
deployment caveats that apply to that version — generated by CI from the image's own
provenance labels.
- **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly,
synced every six hours; the Tags page here reads the same as upstream's.
- **Releases.** One [release](../../releases) per built version, on upstream's tag and
titled like upstream — upstream's English notes, then a short container footer (digest,
pnpm, base image, build) plus the caveats that apply to that version — generated by CI
from the image's own provenance labels and kept current when a version is rebuilt.
- **Deployment inspector.** A manual job that reports what the container on the host is
actually running — image digest, recent log, and how the profile in the state volume is
composed (bundle layers, user patches, plugins) — read-only, credentials never read.
- **Forensic validation workflow.** A manual job that diffs any third-party
`deepseek-harness` image on the host against the trusted build without executing it.
@@ -227,7 +234,7 @@ 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) |
| 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 "$@"` |
| `ENTRYPOINT` | `["docker-entrypoint.sh"]` — fixes ownership, warns about plugin layers that collide with the installed version, drops privileges, `exec`s `dsh "$@"` |
| `CMD` | `["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]` |
| Port | `3080` |
| `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. |
@@ -268,10 +275,21 @@ that happens whenever you accept an update Unraid offers. Before you do:
changed upstream and what it means for this deployment.
2. **Back up the state volume** (`/mnt/user/appdata/deepseek-harness`). From `0.1.5`
upstream migrates session logs to a new format that cannot be read by older versions.
3. **Reconcile plugins first.** `@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*
updating:
`docker exec <container> dsh plugin --profile web remove @goodandready/dsh-lanmode`
3. **Reconcile plugins first.** Upstream keeps absorbing what community plugins did, and a
plugin whose loader row id a new version ships built in stops dsh at boot with
`duplicate loader entry id: <id>` — the stack trace never names the plugin, so the
entrypoint does: it checks the profile against the installed version before every start
and logs the plugin and the remove command. Known cases:
- `0.1.5+` ships file upload → remove `dsh-file-upload`.
- `0.1.2+` → `@goodandready/dsh-lanmode` ≤ 0.6.8 breaks the web client
(`web boot: … waiting for services: remote.*`); update it to 0.7 or remove it.
`docker exec <container> dsh plugin --profile web remove <package>` — or, once the
container no longer stays up, the same command against the volume:
```sh
docker run --rm -v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
gitea.milesward.dev/mward4/deepseek-harness:latest dsh plugin --profile web remove <package>
```
4. **Apply the update** (or edit the Repository tag, if you pin).
### What changes in `0.1.2+`: launch-token auth
+50
View File
@@ -45,6 +45,56 @@ needs_chown() {
[ -n "$(find "$1" \( ! -uid "$PUID" -o ! -gid "$PGID" \) -print -quit 2>/dev/null)" ]
}
# A third-party plugin whose bundle layer inserts a loader row id that the
# shipped bundles also define kills dsh at boot with `duplicate loader entry
# id: <id>` — and nothing in that stack trace names the plugin. It happens
# whenever upstream absorbs what a community plugin did (0.1.5 took
# `file-upload` from the dsh-file-upload plugin this way). Name the plugin and
# the remedy up front. The id list is read from the very patch files dsh
# composes, so it tracks whatever version is installed. Advisory only: the
# start is never blocked, and only the profile's own manifest and patch YAML
# are read (as plain JSON/text — nothing from the volume is executed).
DSH_PKG=/usr/local/lib/node_modules/@deepseek-ai/dsh
warn_plugin_collisions() {
profile="$1"
manifest="$STATE/profiles/$profile/package.json"
[ -f "$manifest" ] || return 0
shipped="$(cat "$DSH_PKG/node_modules/@deepseek-ai/dsh-base/cordis.patch.yml" \
"$DSH_PKG/node_modules/@deepseek-ai/dsh-web-app/cordis.patch.yml" 2>/dev/null \
| sed -n 's/^[[:space:]]*-[[:space:]]*id:[[:space:]]*//p' | sort -u)"
[ -n "$shipped" ] || return 0
version="$(node -e 'console.log(require(process.argv[1] + "/package.json").version)' "$DSH_PKG" 2>/dev/null || echo '?')"
node -e 'for (const b of require(process.argv[1]).dsh?.profile?.bundles ?? []) if (!b.startsWith("@deepseek-ai/")) console.log(b)' "$manifest" 2>/dev/null \
| while IFS= read -r pkg; do
dir="$STATE/profiles/$profile/node_modules/$pkg"
patch="$(node -e 'console.log(require(process.argv[1] + "/package.json").dsh?.bundle?.patch ?? "")' "$dir" 2>/dev/null || true)"
[ -n "$patch" ] && [ -f "$dir/$patch" ] || continue
# Only rows nested under an `insert:` list register new ids; a top-level
# `- id:` row is a legitimate override of a shipped row, not a collision.
inserted="$(sed -n 's/^[[:space:]]\{1,\}-[[:space:]]*id:[[:space:]]*//p' "$dir/$patch" | sort -u)"
dup="$(printf '%s\n%s\n' "$shipped" "$inserted" | sort | uniq -d | tr '\n' ' ')"
[ -z "$dup" ] || {
log "WARNING: plugin $pkg registers loader id(s) ${dup}which dsh $version ships built in."
log " dsh will refuse to start (\"duplicate loader entry id\"). Remove the plugin:"
log " dsh plugin --profile $profile remove $pkg"
log " (run it through 'docker run --rm -v <appdata>:$STATE <image> …' if the container will not stay up)"
}
done
# The two hand-edited layers can insert rows too.
for f in "$STATE/profiles/$profile/cordis.patch.yml" "$STATE/cordis.patch.yml"; do
[ -f "$f" ] || continue
inserted="$(sed -n 's/^[[:space:]]\{1,\}-[[:space:]]*id:[[:space:]]*//p' "$f" | sort -u)"
dup="$(printf '%s\n%s\n' "$shipped" "$inserted" | sort | uniq -d | tr '\n' ' ')"
[ -z "$dup" ] || {
log "WARNING: $f inserts loader id(s) ${dup}which dsh $version ships built in."
log " dsh will refuse to start (\"duplicate loader entry id\"). Remove that insert from the file."
}
done
}
if [ "$1" = web ]; then
warn_plugin_collisions web
fi
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.