Files
deepseek-harness/docker-entrypoint.sh
T
dsh agent 3849709cc2
Sync upstream and build images / sync-and-build (push) Successful in 8m26s
Fix the doctor's invocation path; assert the boot log in CI
The entrypoint called /usr/local/bin/dsh-workspace-doctor while the
Dockerfile installs /usr/local/bin/dsh-workspace-doctor.mjs, so every
start logged "...: not found" and the workspace doctor never ran: no
report, no repair. The CI smoke test checked the .mjs path with a direct
`docker exec`, which cannot see a mistake in the entrypoint's own
invocation, so the build stayed green while the container was broken.

Resolve the doctor by name (`command -v dsh-workspace-doctor.mjs`) in
both the root and the non-root path, and log an explicit "not installed
in this image" line when it cannot be found, instead of the shell's bare
"not found". The smoke test now reads the container's boot log: it
requires the doctor's own "dsh-workspace-doctor:" output to appear, and
fails if the entrypoint logs its "workspace doctor:" failure line — that
covers path, exec bit and invocation together, which is the class of
mistake that shipped.

README: manual repair through a one-off container with --entrypoint (dsh
stopped, so nothing races the registry write), and the note that an
existing Unraid container does not pick up the template's new Workspace
Doctor variable on its own.
2026-09-17 22:21:58 -04:00

188 lines
9.5 KiB
Bash

#!/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)" ]
}
# 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.
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
# Workspace grouping doctor. dsh joins a session to its project when the
# session is created, against the project directory's canonical path — and a
# directory outside the mounted /workspace and state volume does not survive
# a container update. Those chats then collect under "Ungrouped" for good,
# because the one-time history bootstrap never runs again. Report it always;
# recreate the directories and re-account the orphaned sessions when asked
# (backup first, session logs never touched). See README:
# "Chats land in Ungrouped after an update".
#
# Resolve it by name: the installed file is dsh-workspace-doctor.mjs, and a
# path spelled out here is exactly how a "not found" reaches production. The
# CI smoke test asserts this step's output appears in the boot log, so a
# rename or a wrong spelling fails the build instead of the container start.
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
case "${DSH_WORKSPACE_DOCTOR:-warn}" in
0|off|no) ;;
repair|all|1|yes)
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --all \
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
# The doctor runs as root here; hand what it wrote back to the runtime user.
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT repaired. Update the image."
fi
;;
*)
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above - set DSH_WORKSPACE_DOCTOR=repair to fix it at startup"
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT checked. Update the image."
fi
;;
esac
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
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
fi
exec dsh "$@"