From fd657e94c69be0bf96235b9db34fe398d5763421 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 20 May 2026 02:04:19 +0530 Subject: [PATCH] fix(docker): chown workspace volume before dropping privileges so core can start (#2065) (#2235) Co-authored-by: Cyrus Gray --- .dockerignore | 3 ++ .github/workflows/deploy-smoke.yml | 81 ++++++++++++++++++++++++++++++ Dockerfile | 28 +++++++++-- gitbooks/features/cloud-deploy.md | 51 ++++++++++++++++++- scripts/docker-entrypoint-core.sh | 54 ++++++++++++++++++++ 5 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 scripts/docker-entrypoint-core.sh diff --git a/.dockerignore b/.dockerignore index d1ef50060..37e46e1bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,3 +37,6 @@ Thumbs.db # Tests (not needed in build context) tests/ scripts/ +# Re-include the Docker entrypoint for the core image (Dockerfile COPYs it). +# The negation must come after the broad exclusion above to take effect. +!scripts/docker-entrypoint-core.sh diff --git a/.github/workflows/deploy-smoke.yml b/.github/workflows/deploy-smoke.yml index 18ac08e13..a1cf557b2 100644 --- a/.github/workflows/deploy-smoke.yml +++ b/.github/workflows/deploy-smoke.yml @@ -121,3 +121,84 @@ jobs: - name: Tear down if: always() run: docker rm -f oh-smoke || true + + # Regression gate for issue #2065: named volume starts root-owned; the + # entrypoint must chown it before dropping to the openhuman user, otherwise + # the first disk write (init_rpc_token → write_token_file) raises EACCES + # and the process exits with code 1. + # + # This job deliberately omits OPENHUMAN_CORE_TOKEN (so the core WILL attempt + # to write core.token) and mounts a fresh anonymous volume at the workspace + # path (so Docker creates it root:root). The existing job above always sets + # the token and therefore short-circuits the write — it cannot catch this bug. + docker-volume-permissions: + name: Smoke-test core with fresh volume and no pre-set token + runs-on: ubuntu-22.04 + timeout-minutes: 45 + needs: docker-image + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 1 + submodules: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build openhuman-core image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: false + load: true + tags: openhuman-core:smoke + cache-from: type=gha,scope=deploy-smoke + cache-to: type=gha,scope=deploy-smoke,mode=max + + - name: Run container with fresh anonymous volume and no token + run: | + docker run -d \ + --name oh-vol-smoke \ + -p 7789:7788 \ + -v oh-vol-smoke-workspace:/home/openhuman/.openhuman \ + -e OPENHUMAN_APP_ENV=staging \ + -e BACKEND_URL=https://staging-api.tinyhumans.ai \ + openhuman-core:smoke + + - name: Wait for /health (volume-permissions path) + run: | + set -e + for i in $(seq 1 30); do + if curl -fsS http://localhost:7789/health > /tmp/vol-health.json; then + echo "Healthy on attempt $i" + cat /tmp/vol-health.json + exit 0 + fi + echo "attempt $i: not ready, sleeping..." + sleep 2 + done + echo "Container never became healthy. Logs:" + docker logs oh-vol-smoke || true + exit 1 + + - name: Assert no permission-denied errors in logs + run: | + set -e + if docker logs oh-vol-smoke 2>&1 | grep -q "Permission denied (os error 13)"; then + echo "FAIL: 'Permission denied (os error 13)' found in container logs" + docker logs oh-vol-smoke || true + exit 1 + fi + echo "PASS: no permission-denied errors in container logs" + + - name: Container logs (always) + if: always() + run: docker logs oh-vol-smoke || true + + - name: Tear down + if: always() + run: | + docker rm -f oh-vol-smoke || true + docker volume rm oh-vol-smoke-workspace || true diff --git a/Dockerfile b/Dockerfile index ab6ecbc65..526f02c8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,16 +68,34 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libx11-6 \ libevdev2 \ curl \ + gosu \ && rm -rf /var/lib/apt/lists/* -# Non-root user for security -RUN useradd --create-home --shell /bin/bash openhuman -USER openhuman -WORKDIR /home/openhuman +# Non-root user for security — fixed UID/GID so volume ownership is stable +# across image rebuilds. +RUN groupadd --gid 10001 openhuman \ + && useradd --uid 10001 --gid 10001 --create-home --shell /bin/bash openhuman + +# Pre-create and own the workspace directory inside the image so the +# entrypoint chown is a no-op on a fresh (root-owned) named volume and on +# first-time anonymous volume mounts. +ENV HOME=/home/openhuman +RUN mkdir -p /home/openhuman/.openhuman \ + && chown -R openhuman:openhuman /home/openhuman # Copy the built binary COPY --from=builder /build/target/release/openhuman-core /usr/local/bin/openhuman-core +# Copy the entrypoint script that chowns the workspace volume before dropping +# privileges. The script is a separate file so the E2E entrypoint +# (e2e/docker-entrypoint.sh) is not affected. +COPY scripts/docker-entrypoint-core.sh /usr/local/bin/docker-entrypoint-core.sh +RUN chmod +x /usr/local/bin/docker-entrypoint-core.sh + +# The entrypoint runs as root so it can chown the mounted volume, then execs +# gosu to drop to the openhuman user before starting the binary. +USER root + # Default workspace directory ENV OPENHUMAN_WORKSPACE=/home/openhuman/.openhuman # Bind to all interfaces so the container is reachable @@ -91,5 +109,5 @@ EXPOSE 7788 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -sf http://localhost:7788/health || exit 1 -ENTRYPOINT ["openhuman-core"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint-core.sh"] CMD ["serve"] diff --git a/gitbooks/features/cloud-deploy.md b/gitbooks/features/cloud-deploy.md index 7c2d455ae..1e8483c02 100644 --- a/gitbooks/features/cloud-deploy.md +++ b/gitbooks/features/cloud-deploy.md @@ -354,6 +354,35 @@ rejected by the app picker; use HTTPS for any publicly reachable core. --- +## Named-volume ownership and the Docker entrypoint + +Docker creates named volumes owned `root:root` by default. Because the core +runs as the non-root `openhuman` user (UID 10001), the first write after the +banner — `init_rpc_token → write_token_file` into `$OPENHUMAN_WORKSPACE` — +would raise `Permission denied (os error 13)` if nothing fixes the ownership +first. + +The image ships a dedicated entrypoint at +`/usr/local/bin/docker-entrypoint-core.sh` that: + +1. Starts as `root`. +2. Runs `mkdir -p` + `chown openhuman:openhuman` on both `$OPENHUMAN_WORKSPACE` + and `$HOME/.openhuman` (the directory `core.token` is written to when + `OPENHUMAN_CORE_TOKEN` is unset). +3. Calls `exec gosu openhuman openhuman-core "$@"` to drop privileges and + hand off to the binary. + +This is **idempotent**: on a freshly-created volume the chown heals the +root-owned directory; on a volume that was already healed the chown is a +no-op. No manual `docker volume rm` is required when upgrading from images +predating this fix. + +The entrypoint is named `docker-entrypoint-core.sh` and wired **only** into +the root `Dockerfile`. The E2E image (`e2e/docker-entrypoint.sh`) is +unaffected. + +--- + ## Smoke test The repo ships [`.github/workflows/deploy-smoke.yml`](../../.github/workflows/deploy-smoke.yml), @@ -361,14 +390,34 @@ which runs on every PR that touches the deploy artifacts. It builds the Docker image, boots it, and polls `/health`, so a regression in the cloud deploy path fails CI before it lands on `main`. +The workflow contains two jobs: + +- **`docker-image`** — sets `OPENHUMAN_CORE_TOKEN` and mounts no volume. + Protects the DigitalOcean App Platform path (`.do/app.yaml`) where the + token is always pre-set and no persistent volume is used. +- **`docker-volume-permissions`** — omits `OPENHUMAN_CORE_TOKEN` and mounts + a fresh anonymous volume at `/home/openhuman/.openhuman`. Reproduces the + exact failure mode of issue #2065 and asserts that `/health` returns 200 + and that `Permission denied (os error 13)` is absent from the logs. + To run the same check locally: ```bash docker build -t openhuman-core:smoke . + +# Token-set path (App Platform): docker run -d --name oh-smoke -p 7788:7788 \ -e OPENHUMAN_CORE_TOKEN=smoke-test-token \ openhuman-core:smoke -# Wait ~15s for the binary to come up, then: curl -fsS http://localhost:7788/health docker rm -f oh-smoke + +# Fresh-volume / no-token path (Docker Compose, VPS): +docker volume create oh-vol-test +docker run -d --name oh-vol-smoke -p 7789:7788 \ + -v oh-vol-test:/home/openhuman/.openhuman \ + openhuman-core:smoke +curl -fsS http://localhost:7789/health +docker rm -f oh-vol-smoke +docker volume rm oh-vol-test ``` diff --git a/scripts/docker-entrypoint-core.sh b/scripts/docker-entrypoint-core.sh new file mode 100644 index 000000000..1ba4b049e --- /dev/null +++ b/scripts/docker-entrypoint-core.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# docker-entrypoint-core.sh — runtime entrypoint for the openhuman-core container. +# +# Problem: Docker named volumes are created owned root:root, even when the image +# has a non-root USER. The first write openhuman-core makes after the banner +# (init_rpc_token → write_token_file → create_dir_all) hits EACCES and the +# process exits with code 1. +# +# Fix (gosu pattern): +# 1. Start as root so we can chown the mount point(s). +# 2. mkdir -p + chown the workspace directory *before* any application code +# runs, so the openhuman user owns it regardless of whether Docker created +# the volume as root. +# 3. exec gosu openhuman to drop privileges and hand off to the binary. +# +# This is idempotent: if the directory already exists with the right ownership +# (image-baked or a re-used volume that was healed on a previous run) the chown +# is a no-op. No manual "docker volume rm" is required when upgrading from a +# previously broken image. +# +# Requirements: gosu must be installed in the image (see Dockerfile). +# POSIX sh — no bashisms. +set -e + +OPENHUMAN_USER="openhuman" +OPENHUMAN_UID="$(id -u "${OPENHUMAN_USER}" 2>/dev/null || echo '')" + +# The workspace path the core will actually write to. +# Prefer the env var if set; otherwise fall back to the image default. +WORKSPACE_DIR="${OPENHUMAN_WORKSPACE:-/home/openhuman/.openhuman}" +# The home directory (where core.token is written when OPENHUMAN_CORE_TOKEN is +# unset — see src/core/auth.rs default_root_openhuman_dir()). +HOME_OPENHUMAN_DIR="/home/openhuman/.openhuman" + +echo "[docker-entrypoint] uid=$(id -u), gid=$(id -g), user=$(id -un 2>/dev/null || echo unknown)" +echo "[docker-entrypoint] chowning workspace dirs for ${OPENHUMAN_USER} (uid=${OPENHUMAN_UID})" +echo "[docker-entrypoint] WORKSPACE_DIR=${WORKSPACE_DIR}" +echo "[docker-entrypoint] HOME_OPENHUMAN_DIR=${HOME_OPENHUMAN_DIR}" + +# Ensure workspace dir exists and is owned by the openhuman user. +mkdir -p "${WORKSPACE_DIR}" +chown "${OPENHUMAN_USER}:${OPENHUMAN_USER}" "${WORKSPACE_DIR}" +echo "[docker-entrypoint] chown ${WORKSPACE_DIR} -> ${OPENHUMAN_USER}:${OPENHUMAN_USER} done" + +# If WORKSPACE_DIR and HOME_OPENHUMAN_DIR differ, heal the home dir too +# (core.token always lands in $HOME/.openhuman regardless of OPENHUMAN_WORKSPACE). +if [ "${WORKSPACE_DIR}" != "${HOME_OPENHUMAN_DIR}" ]; then + mkdir -p "${HOME_OPENHUMAN_DIR}" + chown "${OPENHUMAN_USER}:${OPENHUMAN_USER}" "${HOME_OPENHUMAN_DIR}" + echo "[docker-entrypoint] chown ${HOME_OPENHUMAN_DIR} -> ${OPENHUMAN_USER}:${OPENHUMAN_USER} done" +fi + +echo "[docker-entrypoint] dropping privileges -> exec gosu ${OPENHUMAN_USER} openhuman-core $*" +exec gosu "${OPENHUMAN_USER}" openhuman-core "$@"