fix(docker): address maintainer review on #377

Two real bugs the review caught, both verified live against a real
build on the Unraid host:

1. entrypoint.sh's chown fired on ANY ownership mismatch, not just a
   directory the daemon itself created root-owned. A host tree
   legitimately owned by some other account - an existing
   CODEMAN_CASES_PATH the README already allows pointing at a normal
   projects directory, or appdata under a different PUID/PGID
   convention than the one in use - got silently recursively re-owned
   with one log line to explain it. Now gated on the target actually
   being root-owned; anything else is a clean refusal naming the
   directory, its owner, and PUID/PGID. Start-Codeman.sh also now
   pre-creates CODEMAN_CASES_PATH the same way it already did
   CODEMAN_APPDATA_PATH, so Compose never has to materialise a missing
   bind source as root in the first place - the in-container chown
   becomes a safety net, not the primary mechanism.

2. The CLI-update chown (chown -R .../node_modules /usr/local/bin)
   handed the runtime account write access to entrypoint.sh itself
   (root-owned, executed as root on every container start with
   CHOWN/DAC_OVERRIDE/SETUID/SETGID) and the node binary - owning the
   DIRECTORY is enough to rename it aside and drop a replacement, which
   would let a compromised session arrange for its own script to run
   as root at the next restart. The four CLIs now install into a
   dedicated /opt/codeman-cli prefix (NPM_CONFIG_PREFIX); only that
   directory is chowned, /usr/local stays root-owned throughout.

Smaller fixes from the same review:

- Start-Codeman.sh's volume-refresh label filter wasn't project-scoped:
  a second Compose stack on the same host sharing the `codeman-dist`
  volume KEY could have had ITS volume deleted. Added a
  com.docker.compose.project filter, resolved from this stack's own
  `compose config --format json`.
- Override-file precedence was backwards (checked .yaml before .yml;
  Compose actually prefers .yml) - swapped, plus a warning when both
  exist.
- entrypoint.sh's setpriv now also passes --bounding-set -all, so
  CapBnd actually clears post-drop rather than just CapPrm/CapEff.
- A comment on git_head_commit() noting it returns nothing for a
  worktree checkout (.git as a file), consistent with the script's
  existing -d .git convention elsewhere.
- Doc drift: CLAUDE.md's Docker Compose section still described the
  old pre-created-and-chowned-by-hand model and didn't mention the
  root-then-drop entrypoint; the state-files list was missing
  docker-build-source.json; docs/docker-compose.md and
  docker/.env.example still had the pre-rename `Coding/codeman` path
  in one place each.

Verified end to end against a real build on the Unraid host: a
root-owned bind source is corrected as before; a directory owned by
neither root nor PUID:PGID is refused rather than silently rewritten;
a correctly-owned directory is left alone entirely; the four CLIs
resolve via PATH from /opt/codeman-cli while /usr/local/bin,
/usr/local/lib/node_modules and entrypoint.sh itself stay root-owned;
CapBnd is fully cleared post-drop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru
This commit is contained in:
Devvyn
2026-09-13 17:41:31 +08:00
co-authored by Claude Sonnet 5
parent 8fe3f34fc5
commit ae32daf135
6 changed files with 113 additions and 24 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
# start script detects it from the compose file's own location, so it only needs
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
# at a directory that is not a git checkout and in-app updates are unavailable.
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
# CODEMAN_REPO_PATH=/mnt/user/appdata/codeman/app
# Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a
+54 -5
View File
@@ -15,11 +15,16 @@ fi
# Naming a Compose file explicitly disables Compose's automatic discovery of
# the override file, so it has to be added back by hand. Without this, local
# customisation in docker-compose.override.yml is silently ignored. The
# candidates are checked in Compose's own precedence order.
# candidates are checked in Compose's own precedence order - measured on
# Compose v5.5.0 with both present: it uses `.yml` and ignores `.yaml`.
override_yml="$script_dir/docker-compose.override.yml"
override_yaml="$script_dir/docker-compose.override.yaml"
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
"$override_yml" "$override_yaml" >&2
fi
compose_files=(-f "$compose_file")
for override_file in \
"$script_dir/docker-compose.override.yaml" \
"$script_dir/docker-compose.override.yml"; do
for override_file in "$override_yml" "$override_yaml"; do
if [[ -f "$override_file" ]]; then
compose_files+=(-f "$override_file")
printf 'Using Compose override file: %s\n' "$override_file"
@@ -31,6 +36,10 @@ appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
cases_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
@@ -50,6 +59,26 @@ if [[ ! -d "$appdata_path" ]]; then
mkdir -p -- "$appdata_path"
fi
if [[ -z "$cases_path" ]]; then
printf 'Error: CODEMAN_CASES_PATH is not set in %s\n' "$env_file" >&2
exit 1
fi
# Pre-creating this here, exactly like CODEMAN_APPDATA_PATH above, means Compose
# never has to materialise a missing bind source itself - which it does as
# root:root - so the in-container entrypoint's chown never has to run for this
# path at all. Unlike appdata, an EXISTING cases directory is left exactly as
# it is: the README explicitly allows pointing this at a normal projects
# directory the host account already owns, so no ownership check happens here.
if [[ ! -d "$cases_path" ]]; then
if [[ "$EUID" == '0' ]]; then
printf 'Error: Refusing to create CODEMAN_CASES_PATH as root: %s\n' "$cases_path" >&2
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$cases_path"
fi
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
:
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
@@ -109,6 +138,11 @@ fi
# Reads HEAD without requiring a `git` binary on the host — this script
# otherwise checks the checkout only by testing for `.git` as a directory, and
# resolving refs by hand keeps that the same "no host git needed" guarantee.
# ⚠️ A worktree checkout has `.git` as a FILE (`gitdir: <path>`), not a
# directory, so this returns nothing there and the volume-refresh check below
# silently no-ops — consistent with the `-d .git` test used everywhere else in
# this script, not a special case, but worth knowing if a worktree checkout
# stops picking up a stale-volume refresh it should have caught.
git_head_commit() {
local git_dir="$1/.git" head_ref ref_path
[[ -d "$git_dir" ]] || return 1
@@ -192,8 +226,23 @@ if [[ -n "$dockerfile_sha" ]]; then
# no-op, so there is no fresh-install case this needs to avoid.
printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_refresh[*]}"
"${compose_command[@]}" down
# `com.docker.compose.volume` is the volume KEY, not a project-qualified
# name - a second stack on the same host (a beta instance started with a
# different COMPOSE_PROJECT_NAME, say) that also declares a volume keyed
# `codeman-dist` shares that label, and `head -n1` would pick whichever
# the daemon happens to list first. Scope the lookup to THIS stack's own
# resolved project name so it can only ever match this stack's volume.
project_name=$(
"${compose_command[@]}" config --format json 2>/dev/null |
sed -n 's/^ "name": "\(.*\)",\{0,1\}$/\1/p' | head -n1
)
for key in "${volumes_to_refresh[@]}"; do
volume_name=$(docker volume ls -q --filter "label=com.docker.compose.volume=$key" | head -n1)
volume_name=$(
docker volume ls -q \
--filter "label=com.docker.compose.volume=$key" \
--filter "label=com.docker.compose.project=$project_name" |
head -n1
)
[[ -n "$volume_name" ]] && docker volume rm -- "$volume_name"
done
fi
+30 -6
View File
@@ -25,12 +25,30 @@ fi
for target in "${HOME:-}" "${CODEMAN_CASES_PATH:-}"; do
[ -n "$target" ] && [ -d "$target" ] || continue
[ "$(stat -c '%u:%g' "$target")" = "${PUID}:${PGID}" ] && continue
owner=$(stat -c '%u:%g' "$target")
[ "$owner" = "${PUID}:${PGID}" ] && continue
# Deliberately not fatal. 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.
# 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
@@ -45,4 +63,10 @@ done
supplementary=$(id -G | tr ' ' '\n' | grep -vx 0 | paste -sd, -)
[ -n "$supplementary" ] || supplementary="$PGID"
exec setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" "$@"
# --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 "$@"
+25 -9
View File
@@ -71,6 +71,24 @@ COPY --from=docker:29-cli \
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
#
# Installed into a DEDICATED prefix, /opt/codeman-cli, not the base image's
# default /usr/local. A session needs write access to wherever these CLIs live
# so it can self-update one in place (observed via Codex's own
# `npm install -g @openai/codex`, which renames the old package directory
# aside before installing the new one — a rename needs write access to the
# PARENT directory, not just the target, so the runtime account needs that
# access at the directory level). Chowning /usr/local/bin and
# /usr/local/lib/node_modules directly to get it would ALSO hand away
# entrypoint.sh (COPY'd to /usr/local/bin below, root-owned, executed as root
# on every container start with CHOWN/DAC_OVERRIDE/SETUID/SETGID) and the node
# binary: owning the DIRECTORY is enough to rename it aside and drop a
# replacement, even though the file itself stays root-owned, which would let a
# compromised session arrange for its own script to run as root at the next
# restart — undoing the "the server itself never runs privileged" guarantee
# the entrypoint exists to provide. /opt/codeman-cli holds nothing else to
# escalate through, so owning it is exactly the CLI-update access it needs and
# no more.
#
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
# a function of WHEN their image was built, not of any commit — so a Codeman
# release that depends on newer CLI behaviour (the trust-dialog handling is
@@ -82,6 +100,8 @@ COPY --from=docker:29-cli \
#
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
# this layer when only the pins change upstream.
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
ENV PATH=/opt/codeman-cli/bin:$PATH
RUN npm install --global \
@anthropic-ai/claude-code@2.1.258 \
@google/gemini-cli@0.58.0 \
@@ -94,14 +114,10 @@ RUN npm install --global \
# requested GID may not exist in the base image, and a host UID such as 1000 may
# already belong to the baked `node` account, so handle both cases explicitly.
#
# The trailing chown hands the globally-installed CLIs to that same account.
# They were `npm install --global`-ed above while still root, so
# /usr/local/lib/node_modules (and the /usr/local/bin symlinks pointing into it)
# start out root-owned; a session running as the unprivileged runtime user then
# hits EACCES the moment it tries to self-update one in place (observed via
# Codex's own `npm install -g @openai/codex`, which renames the old package dir
# aside before installing the new one — a rename needs write access to the
# PARENT directory, not just the target, so this must chown the whole tree).
# The trailing chown hands the CLI prefix (/opt/codeman-cli, populated above)
# to that same account, so a session can self-update one of the CLIs in place.
# /usr/local stays root-owned throughout — see the comment on the npm install
# above for why that boundary matters.
RUN set -eux; \
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
@@ -130,7 +146,7 @@ RUN set -eux; \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi; \
chown -R "${PUID}:${PGID}" /usr/local/lib/node_modules /usr/local/bin
chown -R "${PUID}:${PGID}" /opt/codeman-cli
WORKDIR /opt/codeman