Sync upstream and build images / sync-and-build (push) Successful in 8m26s
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.
188 lines
9.5 KiB
Bash
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 "$@"
|