fix: set a writable HOME for the app user so PaddleOCR works in non-root deployments (#430)

The container dropped privileges to the non-root snapotter user via gosu
(external) and s6-setuidgid (embedded), both of which preserve the
environment without setting HOME. The app therefore kept root's HOME=/root,
which is not writable by snapotter, and PaddleOCR died with
PermissionError: '/root/.paddlex/temp' -- breaking the ocr tool at default
quality in every non-root deployment. Prior GPU QA ran the app as root, which
masked it.

Fix: export HOME=/data/.home (persistent, writable, hidden) at every
privilege-drop point:
- entrypoint.sh external gosu path and non-root tini path (the latter uses
  $DD/.home so a DATA_DIR override stays consistent).
- the s6 snapotter/run service (scoped there, not globally before /init, so
  postgres/redis do not inherit a snapotter-owned HOME).
The root preflight creates /data/.home and the existing chown sweep owns it as
the PUID/PGID-remapped snapotter; the dir is added to both ensure_writable
probes so an unwritable HOME fails fast with the storage-permission guidance
instead of crashing late. The Dockerfile passwd home moves from /app
(read-only) to /data/.home as the getpwuid fallback when HOME is unset.

Because bridge.ts forwards HOME to the Python sidecar, this also repairs the
expanduser("~") caches in inpaint/outpaint/restore/noise_removal/remove_bg,
not just PaddleOCR.

Also fixes a test-harness inconsistency: tool-default-settings passport-photo
countryCode "us" -> "US" (the route exact-matches uppercase PASSPORT_SPECS
codes; the UI already sends "US", so users were never affected).

Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7
This commit is contained in:
SnapOtter
2026-07-04 09:40:04 +00:00
committed by GitHub
parent 5dcc06a99e
commit e0dbf2a5c3
4 changed files with 23 additions and 6 deletions
+4 -1
View File
@@ -557,7 +557,10 @@ ENV PYTHONWARNINGS=default \
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
# Create non-root user for runtime
RUN groupadd -r snapotter && useradd -r -g snapotter -d /app -s /sbin/nologin snapotter
# Home is /data/.home (created + chowned by the entrypoint at runtime). Runtime
# also exports HOME; this passwd entry is the writable fallback for any code that
# resolves ~ via getpwuid when HOME is unset (the old /app home was read-only).
RUN groupadd -r snapotter && useradd -r -g snapotter -d /data/.home -s /sbin/nologin snapotter
# /app and /opt/venv are read-only at runtime -> owned by snapotter.
# /data and /tmp/workspace are written at runtime: make them group-0 (root group)
# owned and group-writable with the setgid bit so the app can still write when the
+13 -4
View File
@@ -81,8 +81,10 @@ DD="${DATA_DIR:-/data}"
# user and fail fast with actionable guidance -- otherwise the venv bootstrap
# and the app below would die later with a cryptic EACCES.
if [ "$(id -u)" != "0" ]; then
mkdir -p "$DD/files" "$DD/logs" "$DD/ai/models" "$DD/ai/pip-cache" "$DD/ai/venv" "$WS" 2>/dev/null || true
ensure_writable "$WS" "$DD" || exit 1
mkdir -p "$DD/files" "$DD/logs" "$DD/ai/models" "$DD/ai/pip-cache" "$DD/ai/venv" "$WS" "$DD/.home" 2>/dev/null || true
# Group-writable so a second in-group-0 UID (OpenShift / fsGroup:0) can reuse HOME.
chmod g+rwX "$DD/.home" 2>/dev/null || true
ensure_writable "$WS" "$DD" "$DD/.home" || exit 1
fi
# Clean up any interrupted bootstrap from a previous start
@@ -216,7 +218,7 @@ if [ "$(id -u)" = "0" ]; then
fi
# Ensure all writable subdirectories exist before chown
mkdir -p /data/files /data/logs /data/ai/models /data/ai/pip-cache /data/ai/venv /tmp/workspace
mkdir -p /data/files /data/logs /data/ai/models /data/ai/pip-cache /data/ai/venv /tmp/workspace /data/.home
[ -n "${EMBEDDED_MODE:-}" ] && mkdir -p /data/redis
# Chown writable directories (/data is the persistent volume, /tmp/workspace is
@@ -238,7 +240,7 @@ if [ "$(id -u)" = "0" ]; then
# Root can write anywhere, so verify as the unprivileged snapotter user that
# actually runs the app. This catches root-squashed or foreign-owned mounts
# where the chown above silently failed, and fails fast with guidance.
if ! gosu snapotter sh -c '. /usr/local/bin/entrypoint-lib.sh; ensure_writable "$@"' _ "$WS" "$DD"; then
if ! gosu snapotter sh -c '. /usr/local/bin/entrypoint-lib.sh; ensure_writable "$@"' _ "$WS" "$DD" /data/.home; then
exit 1
fi
@@ -250,10 +252,17 @@ if [ "$(id -u)" = "0" ]; then
fi
# External mode: tini becomes PID 1 (reaps zombies, forwards signals) and runs
# the app as snapotter, the same end state as the prior tini ENTRYPOINT.
# gosu preserves the environment (unlike su), so HOME=/root would survive the
# drop; set a writable HOME or libraries that write under ~ (PaddleOCR's
# ~/.paddlex, plus the sidecar's expanduser caches) fail with EACCES.
export HOME=/data/.home
exec tini -- gosu snapotter "$@"
fi
# Already running as snapotter (e.g. Kubernetes runAsUser). External mode only:
# embedded mode requires root and exited earlier. tini becomes PID 1.
# HOME points at the data volume (created and writability-checked in the
# non-root preflight above) so ~-based caches land somewhere writable.
print_banner
export HOME="$DD/.home"
exec tini -- "$@"
+5
View File
@@ -1,3 +1,8 @@
#!/command/with-contenv sh
cd /app/apps/api
# s6-setuidgid preserves the environment and does not set HOME, so the app would
# inherit root's HOME=/root. Scope a writable HOME to this service only (not a
# global export before /init) so postgres/redis do not inherit a snapotter-owned
# home. Created and chowned by the entrypoint root preflight before s6 starts.
export HOME=/data/.home
exec s6-setuidgid snapotter ./node_modules/.bin/tsx --import ./src/tracing.ts --import ./src/instrument.ts src/index.ts
+1 -1
View File
@@ -14,7 +14,7 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
convert: { format: "png" },
"watermark-text": { text: "Test" },
"text-overlay": { text: "Test" },
"passport-photo": { countryCode: "us" },
"passport-photo": { countryCode: "US" },
"trim-video": { startS: 0, endS: 5 },
"trim-audio": { startS: 0, endS: 5 },
"split-pdf": { mode: "range", range: "1" },