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
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. The
server runs unprivileged as CODEMAN_RUNTIME_USER, so it cannot create its own
state directory, and the container restarts forever on:
Failed to start web server: EACCES: permission denied, mkdir '/home/<user>/.codeman'
Start-Codeman.sh already worked around this by preparing the directory on the
host, so the failure only appears when Compose is run directly, which the README
documents as a supported path.
Add docker/entrypoint.sh, which starts as root, corrects the ownership of both
bind mounts, then drops to PUID:PGID with setpriv. The Dockerfile's USER
instruction is replaced by that entrypoint and CMD is unchanged.
docker-compose.yaml adds back only the four capabilities the chown and the
privilege drop require, so cap_drop: ALL continues to remove everything else.
Two guards keep existing deployments working:
- A container started with an explicit `user:` is left alone. The entrypoint
execs straight through, with no elevation and no chown.
- A chown that fails is a warning, not an error. Bind mounts backed by NFS,
CIFS or a rootless daemon can refuse chown while remaining perfectly
writable, and those deployments must keep starting.
PUID and PGID are also exported as runtime environment defaults so the image
behaves correctly when run without Compose, rather than depending on build args
alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>