Files
deepseek-harness/docker-entrypoint.sh
dsh-relay 0af110766d
Sync upstream and build images / sync-and-build (push) Successful in 38m42s
ci: pick the webserver bind address from the installed schema
The 0.2.1-alpha.2 fix rendered `host` onto the container's own address
unconditionally. Every earlier release still validates it against the union of
the two literals 127.0.0.1 and 0.0.0.0, so those images now die in the boot
smoke test with

  @deepseek-ai/dsh-host-webserver: ValidationError: invalid config:
    - $.host expected "127.0.0.1" | "0.0.0.0" but got "172.17.0.22" (at host)

and their refresh rebuilds -- which the recipe-hash change re-queued anyway --
can no longer succeed. Probe the installed webserver schema instead: where it
rejects the unspecified address render the container's own IPv4, otherwise keep
0.0.0.0 so older versions build exactly as they did before. The probe is
overridable through DSH_INSTALL_ROOT so both branches are testable.
2026-10-13 21:09:56 -04:00

254 lines
13 KiB
Bash
Executable File

#!/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
# 0.2.1-alpha.2 replaced the webserver's old `host: "127.0.0.1" | "0.0.0.0"`
# union with a config transform that rejects the unspecified address outright —
# "host … is an unspecified (wildcard) address, which is not supported … bind
# one concrete IPv4 or IPv6 address of a local interface instead" — and a
# schema-level check cannot be opted out of from the launcher. The shipped patch
# is therefore a template: render it onto this container's own address and hand
# dsh the rendered copy, so a port published on the host still reaches the UI
# while the socket binds one concrete interface instead of every interface.
# Releases whose schema still accepts the wildcard keep 0.0.0.0, so old images
# rebuild exactly as before. DSH_WEB_HOST overrides the address.
if [ -r "$PATCH" ]; then
BIND_HOST="${DSH_WEB_HOST:-}"
if [ -z "$BIND_HOST" ]; then
# Detect the installed webserver schema rather than guessing from a
# version string.
dsh_root="${DSH_INSTALL_ROOT:-/usr/local/lib/node_modules/@deepseek-ai/dsh}"
ws="$dsh_root/node_modules/@deepseek-ai/dsh-host-webserver/lib/index.js"
if [ -f "$ws" ] && grep -qs 'unspecified (wildcard) address' "$ws"; then
wildcard_rejected=1
elif [ ! -f "$ws" ] && grep -rqs 'unspecified (wildcard) address' "$dsh_root" 2>/dev/null; then
wildcard_rejected=1
else
wildcard_rejected=0
fi
if [ "$wildcard_rejected" = 1 ]; then
# Same discovery dsh's own boot smoke test uses: first non-internal IPv4.
BIND_HOST="$(node -e 'const os=require("os");const n=Object.values(os.networkInterfaces()).flat().find(i=>i&&i.family==="IPv4"&&!i.internal);process.stdout.write(n?n.address:"")' 2>/dev/null || true)"
[ -n "$BIND_HOST" ] || BIND_HOST=127.0.0.1
else
BIND_HOST=0.0.0.0
fi
fi
render_patch() {
sed -e "s|__DSH_BIND_HOST__|${BIND_HOST}|g" \
-e "s|^\([[:space:]]*host:[[:space:]]*\)0\.0\.0\.0[[:space:]]*$|\1${BIND_HOST}|" "$PATCH"
}
mkdir -p "$STATE" 2>/dev/null || true
RUNTIME_PATCH="$STATE/web.cordis.patch.yml"
if ! render_patch > "$RUNTIME_PATCH" 2>/dev/null; then
RUNTIME_PATCH=/tmp/web.cordis.patch.yml
render_patch > "$RUNTIME_PATCH"
fi
chmod 0644 "$RUNTIME_PATCH" 2>/dev/null || true
log "webserver binds ${BIND_HOST}; runtime patch: ${RUNTIME_PATCH}"
# Point every --patch argument that names the shipped template at the
# rendered copy. Rebuilt with a rotating `set --`, so quoting survives.
expect=0
left=$#
while [ "$left" -gt 0 ]; do
arg="$1"
shift
if [ "$expect" = 1 ]; then
if [ "$arg" = "$PATCH" ]; then arg="$RUNTIME_PATCH"; fi
expect=0
elif [ "$arg" = "--patch" ]; then
expect=1
fi
set -- "$@" "$arg"
left=$((left - 1))
done
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. The default is to
# repair: recreate the directories and re-account the orphaned sessions
# (backup first, session logs never touched), so a container update heals
# itself with no configuration. DSH_WORKSPACE_DOCTOR=warn only reports,
# =off is silent. 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:-repair}" in
0|off|no) ;;
warn|check|report)
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above; DSH_WORKSPACE_DOCTOR defaults to repair, so this start was told not to fix it"
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT checked. Update the image."
fi
;;
*)
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
;;
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 "$@"