Files
Codeman/docker/entrypoint.sh
T
DevvynandClaude Sonnet 5 c179daf869 fix(docker): re-assert /opt/codeman-cli ownership every start, not just at build
/opt/codeman-cli is chowned to PUID:PGID once, at image build time, from
the PUID/PGID build args. That bake only happens when the image is
actually rebuilt (`docker compose up --build`, which Start-Codeman.sh
always does) — a deployment that runs the compose file directly instead
(Unraid's Compose Manager, a native systemd unit, any plain
`docker compose up`/`restart`) can change PUID/PGID in .env and restart
without ever rebuilding. The container then runs as the NEW uid via
entrypoint's setpriv (Linux needs no /etc/passwd entry to setuid to an
arbitrary number) while the CLI directory is still owned by the OLD one
baked into the image layer — silently breaking the self-update-a-CLI-
in-place fix that directory exists for.

Unlike HOME/CODEMAN_CASES_PATH, this one is pure image content Codeman
itself populated, never host data that might legitimately belong to
someone else, so there is no ownership to be careful about — it is
always correct for it to be owned by whoever the container is about to
run as. Re-assert it unconditionally on every start.

Verified live: built an image with PUID=99/PGID=100, ran it with
PUID=1234/PGID=4321 (no rebuild, simulating a changed .env restarted
directly), confirmed /opt/codeman-cli ends up 1234:4321-owned and is
genuinely writable by the running process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru
2026-09-13 17:41:31 +08:00

97 lines
5.1 KiB
Bash
Executable File

#!/bin/sh
# Corrects ownership - host bind mounts, and the image-baked CLI prefix -
# then drops to PUID:PGID.
#
# Compose binds CODEMAN_APPDATA_PATH and CODEMAN_CASES_PATH from the host. When
# either path does not exist yet - a first run, a cleared application-data
# directory, a restored backup - the Docker daemon creates it owned by root,
# and an unprivileged server cannot then create its own state directory. The
# result is a container that restarts forever on:
#
# Failed to start web server: EACCES: permission denied, mkdir '/home/<user>/.codeman'
#
# Running this as root and dropping afterwards removes that failure mode without
# leaving the server privileged. The same root start also lets it re-assert
# /opt/codeman-cli's ownership on every start, not just at image build time -
# see the comment at that chown below for why that matters for anyone who
# runs the compose file directly rather than through Start-Codeman.sh.
set -eu
# Honour an explicit `user:` in Compose: when the container was not started as
# root there is nothing to correct and no privilege to drop.
if [ "$(id -u)" -ne 0 ]; then
exec "$@"
fi
: "${PUID:=1000}"
: "${PGID:=1000}"
for target in "${HOME:-}" "${CODEMAN_CASES_PATH:-}"; do
[ -n "$target" ] && [ -d "$target" ] || continue
owner=$(stat -c '%u:%g' "$target")
[ "$owner" = "${PUID}:${PGID}" ] && continue
# Only ever correct a directory the DAEMON created (root-owned, because
# neither PUID nor PGID existed yet when it materialised the missing bind
# source). Anything else - a host tree that legitimately belongs to some
# OTHER account, such as an existing CODEMAN_CASES_PATH the README already
# allows pointing at a normal project directory - is not this container's
# to reassign; recursively chowning it on every mismatch silently rewrote
# a credentials tree or a projects directory to PUID:PGID with one log
# line to explain it. Refuse instead, the same way Start-Codeman.sh already
# refuses to touch a root-owned appdata directory it did not expect.
if [ "${owner%%:*}" != '0' ]; then
printf 'entrypoint: %s is owned by %s, which is neither root nor PUID:PGID (%s:%s).\n' \
"$target" "$owner" "$PUID" "$PGID" >&2
printf 'entrypoint: refusing to change ownership of a directory this container did not create.\n' >&2
printf 'entrypoint: either chown it on the host, or set PUID/PGID to match its current owner.\n' >&2
exit 1
fi
# Deliberately not fatal for a root-owned directory. A bind mount backed by
# NFS, CIFS or a rootless daemon can refuse chown while still being
# perfectly writable, and those deployments must keep working. A warning is
# more useful than a container that will not start.
if chown -R "${PUID}:${PGID}" "$target" 2>/dev/null; then
printf 'entrypoint: corrected ownership of %s to %s:%s\n' "$target" "$PUID" "$PGID"
else
printf 'entrypoint: warning: cannot change ownership of %s to %s:%s\n' \
"$target" "$PUID" "$PGID" >&2
printf 'entrypoint: warning: continuing; set the ownership on the host if startup fails\n' >&2
fi
done
# /opt/codeman-cli (the four agent CLIs) is chowned to PUID:PGID once, at
# image BUILD time, from the PUID/PGID build args - server.Dockerfile's own
# comment on that RUN step explains why it lives in its own prefix rather than
# /usr/local. Unlike HOME/CODEMAN_CASES_PATH above, that bake happens only
# when the image is actually rebuilt (`docker compose up --build`, which
# Start-Codeman.sh always does) - a deployment that instead runs the compose
# file directly (Unraid's Compose Manager, a native Debian systemd unit, any
# `docker compose up`/`restart` with no --build) can change PUID/PGID in .env
# and restart without ever rebuilding, at which point the container runs as
# the NEW uid while the CLI directory is still owned by the OLD one baked into
# the image layer - silently breaking the very "self-update a CLI in place"
# fix this directory exists for. Re-assert it here, every start, unconditionally:
# unlike the host bind mounts above, this is pure image content Codeman itself
# populated, never host data that might legitimately belong to someone else,
# so there is no ownership to be careful about - it is always correct for it
# to be owned by whoever this container is about to run as.
if [ -d /opt/codeman-cli ] && [ "$(stat -c '%u:%g' /opt/codeman-cli)" != "${PUID}:${PGID}" ]; then
chown -R "${PUID}:${PGID}" /opt/codeman-cli
fi
# Preserve the supplementary groups Compose granted through group_add - that is
# how the Docker socket stays reachable - while discarding root's own group.
supplementary=$(id -G | tr ' ' '\n' | grep -vx 0 | paste -sd, -)
[ -n "$supplementary" ] || supplementary="$PGID"
# --bounding-set -all: with the reuid/regid drop above, CapPrm/CapEff are
# already empty, but the bounding set otherwise still lists everything
# cap_add granted (visible as a nonzero CapBnd even post-drop). no-new-privileges
# already makes that moot - nothing can regain a capability outside the
# bounding set - but clearing it too is free and matches what "drops to
# PUID:PGID" actually promises.
exec setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" --bounding-set -all "$@"