Sync upstream and build images / sync-and-build (push) Successful in 9m9s
Updating the container recreates it, so anything outside the mounted /workspace and state volume is gone. dsh joins a session to its project when the session is created (upstream's history bootstrap runs once per state volume and never again), and the workspace registry stores an absolute canonical directory path — so a project directory under /home/node, where the workspace picker opens by default, loses its chats to "Ungrouped" permanently on the next update. Ship dsh-workspace-doctor.mjs and run it from the entrypoint before dsh starts: it reports every project directory that no longer resolves and every chat recoverable from one, and with DSH_WORKSPACE_DOCTOR=repair it recreates the missing directories and re-accounts those sessions in the workspace registry. Session logs are never read for writing and never modified, no project is invented, no chat is moved into a project it did not run in, and the registry is copied to workspace.json.bak-<stamp> before the atomic, 0600, fsynced write dsh's own JSON backend performs. The doctor refuses to write a registry version it does not know, is idempotent, and defaults to report-only in the image (warn) so nothing changes unasked; the Unraid template defaults to repair. CI: the doctor joins the recipe hash and the push path list, and the image smoke test asserts it is installed and runs against a fresh volume (exit 2, the tool itself failing, is the only outcome that fails a build).
169 lines
8.6 KiB
Bash
169 lines
8.6 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".
|
|
case "${DSH_WORKSPACE_DOCTOR:-warn}" in
|
|
0|off|no) ;;
|
|
repair|all|1|yes)
|
|
/usr/local/bin/dsh-workspace-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
|
|
;;
|
|
*)
|
|
/usr/local/bin/dsh-workspace-doctor --check \
|
|
|| log "workspace doctor: see the report above - set DSH_WORKSPACE_DOCTOR=repair to fix it at startup"
|
|
;;
|
|
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
|
|
/usr/local/bin/dsh-workspace-doctor --check \
|
|
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
|
|
exec dsh "$@"
|