mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
8fe3f34fc5
commit
ae32daf135
+1
-1
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user