fix(docker): chown workspace volume before dropping privileges so core can start (#2065) (#2235)

Co-authored-by: Cyrus Gray <cyrus@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-05-20 02:04:19 +05:30
committed by GitHub
co-authored by Cyrus Gray
parent 89bc70bcb4
commit fd657e94c6
5 changed files with 211 additions and 6 deletions
+3
View File
@@ -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
+81
View File
@@ -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
+23 -5
View File
@@ -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"]
+50 -1
View File
@@ -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
```
+54
View File
@@ -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 "$@"