mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Three changes to how the Compose container starts as root and drops to PUID:PGID, each reproduced on Docker 29.1.3 / Compose v5.5.0 with a minimal image of the same shape as server.Dockerfile. - cap_add gains KILL. `init: true` makes tini PID 1, and tini stays root while the entrypoint drops the server to PUID. Signalling a process of a different uid needs CAP_KILL, and `cap_drop: ALL` had removed it, so every `docker compose down`/`restart` ended in `[FATAL tini (1)] Unexpected error when forwarding signal: 'Operation not permitted'` and the server being SIGKILLed instead of running `server.stop()`. Measured: without KILL the trap never fires, with it the child logs `GOT SIGTERM`. - /opt/codeman-cli/bin is appended to PATH, never prepended, and entrypoint.sh pins its own PATH to the system directories before its first command. The prefix is chowned to the runtime account so sessions can update the agent CLIs in place, and the root entrypoint resolved stat/chown/setpriv by bare name through it: a `setpriv` planted there by the unprivileged uid ran as uid 0 at the next start. The image's full PATH is handed back to the server at the exec (`env PATH=...`), since Codeman resolves the CLIs through it. - The ownership gate becomes a writability probe. A directory owned by neither root nor PUID:PGID is no longer refused on ownership alone; it is tested with `setpriv --reuid PUID --regid PGID --groups <same groups> test -w`, the exact identity the server gets, so a group-writable tree, an ACL or a CIFS/NFS mount reporting some unrelated uid all pass, and the refusal names path, owner and PUID:PGID. Root-owned directories are still chowned first. Also: a pre-flight runs the drop before touching anything and, when it fails, prints the cap_add list the compose file needs, so an out-of-tree compose file (Unraid's Compose Manager) gets a one-line diagnosis instead of a restart loop; `--bounding-set -all` is gone, since it is a silent no-op without CAP_SETPCAP; a root:root Docker socket now produces a warning that Docker cases will not work rather than silently losing group 0 at the drop; and CODEMAN_ALLOWED_HOSTS is forwarded from .env with an empty default (documented as a commented entry in .env.example so the parity test and the updater's env gate both stay quiet). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
187 lines
8.4 KiB
Docker
187 lines
8.4 KiB
Docker
# syntax=docker/dockerfile:1
|
|
|
|
# Build the application from the checkout supplied as the Docker build context.
|
|
# No published Codeman application image is required.
|
|
FROM node:22-bookworm-slim AS build
|
|
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
WORKDIR /opt/codeman
|
|
|
|
COPY . .
|
|
|
|
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
|
|
# updater rebuilds from inside this container, and `npm run build` is tsc +
|
|
# esbuild — both devDependencies. Pruning them saves image size and takes the
|
|
# self-updater with it. See docs/docker-self-update.md.
|
|
RUN npm ci \
|
|
&& npm run build \
|
|
&& npm cache clean --force
|
|
|
|
# The Docker CLI talks to the host daemon through the socket mounted by
|
|
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
|
|
FROM node:22-bookworm-slim
|
|
|
|
ARG CODEMAN_RUNTIME_USER=codeman
|
|
ARG PUID=1000
|
|
ARG PGID=1000
|
|
|
|
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
|
|
# runs `npm install` inside the running container, and node-pty ships no Linux
|
|
# prebuild, so a release that bumps it compiles from source right here. Without
|
|
# a toolchain that install fails and the update rolls back — every time, on the
|
|
# releases that need it most. Same reason install.sh installs one on bare hosts.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends \
|
|
ca-certificates \
|
|
curl \
|
|
g++ \
|
|
git \
|
|
make \
|
|
openssh-client \
|
|
procps \
|
|
python3 \
|
|
ripgrep \
|
|
tmux \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# The Docker CLI, taken from the official image rather than Debian's `docker.io`.
|
|
# That package is the full ENGINE: with --no-install-recommends it still pulls 15
|
|
# packages including containerd, runc, dmsetup and iptables, none of which a
|
|
# client that only talks to a mounted socket can use. Measured on top of this
|
|
# base image: `docker.io` costs 266 MB and ships Docker 20.10.24 (2023), while
|
|
# these two files cost 108 MB and ship the current CLI (493 MB vs 335 MB total).
|
|
#
|
|
# The binaries are STATIC Go builds, so they run on this glibc image even though
|
|
# the image they come from is Alpine (verified: `docker --version`, `docker ps`
|
|
# and `docker build` all work here against a mounted host socket).
|
|
#
|
|
# buildx is copied on purpose. `scripts/build-agent-image.mjs` shells out to
|
|
# `docker build` — Codeman auto-builds the agent image on the first Docker case —
|
|
# and without the plugin that silently falls back to the CLASSIC builder, which
|
|
# Docker has deprecated and will eventually drop. `docker-compose` is NOT copied:
|
|
# Codeman never shells out to it.
|
|
COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker
|
|
COPY --from=docker:29-cli \
|
|
/usr/local/libexec/docker/cli-plugins/docker-buildx \
|
|
/usr/local/libexec/docker/cli-plugins/docker-buildx
|
|
|
|
# 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
|
|
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
|
|
# on an older image with no diff anywhere to explain why. In-app updates make
|
|
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
|
|
# a newer CLI" into a Dockerfile change, which the updater's environment gate
|
|
# already detects and refuses (docs/docker-self-update.md).
|
|
#
|
|
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
|
# this layer when only the pins change upstream.
|
|
# The prefix is APPENDED to PATH, never prepended: it is chowned to the runtime
|
|
# account below, and entrypoint.sh runs as root calling stat/chown/setpriv by
|
|
# bare name. A prefix ahead of /usr/bin would let a session drop a `setpriv`
|
|
# there and have it run as root at the next container start (measured with a
|
|
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
|
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
|
# system directories for the root part of the start.
|
|
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
|
ENV PATH=$PATH:/opt/codeman-cli/bin
|
|
RUN npm install --global \
|
|
@anthropic-ai/claude-code@2.1.258 \
|
|
@google/gemini-cli@0.58.0 \
|
|
@openai/codex@0.152.1 \
|
|
opencode-ai@1.18.26 \
|
|
&& npm cache clean --force
|
|
|
|
# Keep the web server and every local Codeman session unprivileged. PUID and
|
|
# PGID match the host-owned application-data directory mounted by Compose. The
|
|
# 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 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; \
|
|
if [ "${PUID}" -eq 0 ]; then \
|
|
echo "PUID must identify an unprivileged account, not root" >&2; \
|
|
exit 1; \
|
|
fi; \
|
|
if ! getent group "${PGID}" >/dev/null; then \
|
|
groupadd --gid "${PGID}" codeman-runtime; \
|
|
fi; \
|
|
existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \
|
|
if [ -n "${existing_user}" ]; then \
|
|
usermod \
|
|
--login "${CODEMAN_RUNTIME_USER}" \
|
|
--gid "${PGID}" \
|
|
--home "/home/${CODEMAN_RUNTIME_USER}" \
|
|
--move-home \
|
|
--shell /bin/bash \
|
|
"${existing_user}"; \
|
|
else \
|
|
useradd \
|
|
--uid "${PUID}" \
|
|
--gid "${PGID}" \
|
|
--create-home \
|
|
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
|
|
--shell /bin/bash \
|
|
"${CODEMAN_RUNTIME_USER}"; \
|
|
fi; \
|
|
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
|
|
|
WORKDIR /opt/codeman
|
|
|
|
COPY --from=build /opt/codeman /opt/codeman
|
|
|
|
# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
|
|
# than by asking an init system that is not here (src/web/self-update.ts).
|
|
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
|
|
# explicitly, since that value would otherwise omit the build toolchain.
|
|
ENV CODEMAN_IN_CONTAINER=1 \
|
|
CODEMAN_PORT=3000 \
|
|
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
|
NODE_ENV=production
|
|
|
|
# Runtime defaults for the entrypoint, matching the account created above.
|
|
ENV PGID=${PGID} PUID=${PUID}
|
|
|
|
EXPOSE 3000
|
|
|
|
# The container starts as root so the entrypoint can correct the ownership of
|
|
# the host bind mounts, which the daemon creates as root whenever they do not
|
|
# already exist. The entrypoint then drops to PUID:PGID with setpriv, so the
|
|
# server itself never runs privileged. Setting `user:` in Compose bypasses both
|
|
# steps, leaving the caller in full control.
|
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
|
|
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
|
|
CMD ["node", "dist/index.js", "web"]
|