diff --git a/CHANGELOG.md b/CHANGELOG.md index 702d22b..52c8b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ --- +## [v0.50.17] Docker: pre-install uv at build time + fix workspace permissions (fixes #357) + +- **Docker containers no longer need internet access at startup** (`Dockerfile`): `uv` is now installed at image build time via `RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh` (run as root, so `uv` lands in `/usr/local/bin` — accessible to all users). The init script skips the download if `uv` is already on PATH (`command -v uv`), and falls back to downloading with a proper `error_exit` if it isn't. This fixes startup failures in air-gapped, firewalled, or isolated Docker networks where `github.com` is unreachable at runtime. + - **Fix applied during review**: the original PR installed `uv` as the `hermeswebuitoo` user (to `~hermeswebuitoo/.local/bin`), which is not on the `hermeswebui` runtime user's `PATH`. Changed to install as `root` with `UV_INSTALL_DIR=/usr/local/bin` so `uv` is in the system PATH for all users. +- **Workspace directory now writable by the hermeswebui user** (`docker_init.bash`): The init script now uses `sudo mkdir -p` and `sudo chown hermeswebui:hermeswebui` for `HERMES_WEBUI_DEFAULT_WORKSPACE`. Docker auto-creates bind-mount directories as `root` if they don't exist on the host, making them unwritable by the app user. The `sudo chown` corrects ownership after creation. + - 15 new structural tests in `tests/test_issue357.py`; 915 tests total (up from 900) + ## [v0.50.16] Fix CSRF check failing behind reverse proxy on non-standard ports (PR #360) - **CSRF no longer rejects POST requests from reverse-proxied deployments on non-standard ports** (`api/routes.py`, fixes #355): When serving behind Nginx Proxy Manager or similar on a port like `:8000`, browsers send `Origin: https://app.example.com:8000` while the proxy forwards `Host: app.example.com` (port stripped). The old string comparison failed this as cross-origin. Two changes fix it: diff --git a/Dockerfile b/Dockerfile index 8efbd20..0b7feda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,13 @@ RUN touch /.within_container RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \ && apt-get clean +USER root + +# Pre-install uv system-wide so the container doesn't need internet access at runtime. +# Installing as root places uv in /usr/local/bin, available to all users. +# The init script will skip the download when uv is already on PATH. +RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh + USER hermeswebuitoo COPY . /apptoo diff --git a/docker_init.bash b/docker_init.bash index 86dbbdf..0141274 100644 --- a/docker_init.bash +++ b/docker_init.bash @@ -187,7 +187,10 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR" echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch" if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE" -if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then mkdir -p $HERMES_WEBUI_DEFAULT_WORKSPACE || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi +# Use sudo for mkdir/chown — Docker may auto-create bind-mount directories as root, +# leaving them unwritable by the hermeswebui user (#357). +sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" +sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to set owner of $HERMES_WEBUI_DEFAULT_WORKSPACE" if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE" @@ -195,8 +198,13 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WOR echo ""; echo "===================" echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui" -curl -LsSf https://astral.sh/uv/install.sh | sh export PATH="/home/hermeswebui/.local/bin/:$PATH" +if command -v uv &>/dev/null; then + echo "-- uv already installed ($(uv --version)), skipping download" +else + echo "-- uv not found, downloading..." + curl -LsSf https://astral.sh/uv/install.sh | sh || error_exit "Failed to install uv — check network connectivity" +fi export UV_PROJECT_ENVIRONMENT=venv export UV_CACHE_DIR=/uv_cache diff --git a/tests/test_issue357.py b/tests/test_issue357.py new file mode 100644 index 0000000..db8ce42 --- /dev/null +++ b/tests/test_issue357.py @@ -0,0 +1,189 @@ +""" +Tests for GitHub issue #357: Docker container fails to start without internet access. + +Structural tests — verify Dockerfile and docker_init.bash contain the expected +patterns for pre-installed uv and workspace permission fixes. + +Two problems fixed: +1. uv was downloaded at container startup; fails in air-gapped / firewalled environments. + Fix: pre-install uv in the Docker image at build time (system-wide in /usr/local/bin). +2. workspace directory created with plain mkdir (as root); bind-mount dirs created by + Docker as root are unwritable by the hermeswebui user. + Fix: sudo mkdir + sudo chown for workspace directory. +""" +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent +DOCKERFILE = (REPO / "Dockerfile").read_text(encoding="utf-8") +INIT_SCRIPT = (REPO / "docker_init.bash").read_text(encoding="utf-8") + + +# ── Dockerfile: uv pre-installed at build time ─────────────────────────────── + +class TestDockerfileUvPreinstall: + + def test_dockerfile_installs_uv_at_build_time(self): + """Dockerfile must install uv via RUN curl at build time (not only at runtime).""" + assert "RUN curl" in DOCKERFILE and "uv/install.sh" in DOCKERFILE, ( + "Dockerfile must install uv at build time via RUN curl .../uv/install.sh" + ) + + def test_dockerfile_uv_installed_system_wide(self): + """uv must be installed to a system-wide directory (/usr/local/bin) accessible + to all users, not to a user-specific ~/.local/bin that another user can't see.""" + # The install command must target /usr/local/bin or use root to install globally + uv_install_line = next( + (line for line in DOCKERFILE.splitlines() if "uv/install.sh" in line), + None, + ) + assert uv_install_line is not None, "Could not find uv install line in Dockerfile" + # Must either use UV_INSTALL_DIR pointing to /usr/local/bin, or run as root + # (so the default install location is accessible to hermeswebui user) + has_system_dir = "/usr/local/bin" in uv_install_line or "UV_INSTALL_DIR=/usr/local/bin" in DOCKERFILE + assert has_system_dir, ( + "uv must be installed to /usr/local/bin (system-wide) so hermeswebui user " + "can find it. Installing as hermeswebuitoo puts it in /home/hermeswebuitoo/.local/bin " + "which is NOT on hermeswebui's PATH." + ) + + def test_dockerfile_uv_installed_before_copy(self): + """uv installation must happen before COPY . /apptoo so it's in the image.""" + uv_pos = DOCKERFILE.find("uv/install.sh") + copy_pos = DOCKERFILE.find("COPY . /apptoo") + assert uv_pos != -1, "uv install not found in Dockerfile" + assert copy_pos != -1, "COPY . /apptoo not found in Dockerfile" + assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo" + + def test_dockerfile_uv_installed_as_root_or_before_user_switch(self): + """uv must be installed as root (USER root) to reach /usr/local/bin. + If installed as hermeswebuitoo, it lands in ~hermeswebuitoo/.local/bin, + which the hermeswebui user at runtime can't see. + """ + lines = DOCKERFILE.splitlines() + uv_line_idx = next(i for i, l in enumerate(lines) if "uv/install.sh" in l) + # Find the last USER directive before the uv install line + user_before = None + for i in range(uv_line_idx - 1, -1, -1): + if lines[i].strip().startswith("USER "): + user_before = lines[i].strip().split()[1] + break + assert user_before == "root", ( + f"uv install must run as USER root (found USER {user_before!r}). " + "Installing as hermeswebuitoo puts uv in /home/hermeswebuitoo/.local/bin " + "which is not accessible to the hermeswebui runtime user." + ) + + +# ── docker_init.bash: skip uv download when already present ───────────────── + +class TestInitScriptUvSkip: + + def test_init_script_checks_uv_before_download(self): + """docker_init.bash must check 'command -v uv' before attempting download.""" + assert "command -v uv" in INIT_SCRIPT, ( + "docker_init.bash must check 'command -v uv' to skip download " + "when uv is already pre-installed in the image (#357)" + ) + + def test_init_script_skips_download_if_present(self): + """Init script must use conditional logic (if/else) around the uv download.""" + # Pattern: if command -v uv ... else ... fi + assert re.search(r'if\s+command\s+-v\s+uv', INIT_SCRIPT), ( + "docker_init.bash must use 'if command -v uv' guard around the download" + ) + + def test_init_script_curl_download_in_else_branch(self): + """The curl download must be in the else branch (only runs if uv not found).""" + # Find the conditional block + m = re.search( + r'if\s+command\s+-v\s+uv.*?fi', + INIT_SCRIPT, re.DOTALL + ) + assert m, "Could not find uv conditional block in docker_init.bash" + block = m.group(0) + # curl must appear after 'else' not in the 'then' branch + else_pos = block.find("else") + curl_pos = block.find("curl") + assert else_pos != -1, "No 'else' branch in uv conditional" + assert curl_pos != -1, "No 'curl' in uv conditional block" + assert curl_pos > else_pos, ( + "curl download must be in the 'else' branch, not the 'if/then' branch" + ) + + def test_init_script_error_exit_on_download_failure(self): + """Curl download must call error_exit on failure (not silently continue).""" + assert "error_exit" in INIT_SCRIPT and "Failed to install uv" in INIT_SCRIPT, ( + "docker_init.bash must call error_exit if uv download fails, " + "so the container exits with a clear message instead of failing silently" + ) + + def test_init_script_path_includes_hermeswebui_local_bin(self): + """PATH must include /home/hermeswebui/.local/bin for fallback runtime install.""" + assert "/home/hermeswebui/.local/bin" in INIT_SCRIPT, ( + "docker_init.bash must include /home/hermeswebui/.local/bin in PATH " + "for the case where uv is installed at runtime via curl" + ) + + +# ── docker_init.bash: workspace directory permissions ──────────────────────── + +class TestWorkspacePermissions: + + def test_workspace_uses_sudo_mkdir(self): + """docker_init.bash must use 'sudo mkdir' for the workspace directory. + + Docker auto-creates bind-mount directories as root if they don't exist, + leaving them unwritable by hermeswebui. sudo mkdir + chown fixes this. + """ + # Find the workspace section + ws_section = INIT_SCRIPT[ + INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"): + INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800 + ] + assert "sudo mkdir" in ws_section, ( + "docker_init.bash must use 'sudo mkdir -p' for the workspace directory " + "to handle the case where Docker created the bind-mount dir as root (#357)" + ) + + def test_workspace_uses_sudo_chown(self): + """docker_init.bash must chown the workspace to hermeswebui after mkdir.""" + ws_section = INIT_SCRIPT[ + INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"): + INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800 + ] + assert "sudo chown" in ws_section and "hermeswebui" in ws_section, ( + "docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace " + "directory after creating it, so the app user can write to it (#357)" + ) + + def test_workspace_mkdir_before_chown(self): + """sudo mkdir must come before sudo chown in docker_init.bash.""" + mkdir_pos = INIT_SCRIPT.find("sudo mkdir -p \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"") + chown_pos = INIT_SCRIPT.find("sudo chown hermeswebui:hermeswebui \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"") + assert mkdir_pos != -1, "sudo mkdir for workspace not found" + assert chown_pos != -1, "sudo chown for workspace not found" + assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown" + + def test_workspace_error_exit_on_mkdir_failure(self): + """sudo mkdir must call error_exit on failure.""" + assert 'sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, ( + "sudo mkdir for workspace must call error_exit on failure" + ) + + def test_workspace_error_exit_on_chown_failure(self): + """sudo chown must call error_exit on failure.""" + assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, ( + "sudo chown for workspace must call error_exit on failure" + ) + + def test_init_script_syntax_valid(self): + """docker_init.bash must pass bash -n syntax check.""" + import subprocess + result = subprocess.run( + ["bash", "-n", str(REPO / "docker_init.bash")], + capture_output=True, text=True + ) + assert result.returncode == 0, ( + f"docker_init.bash failed bash -n syntax check:\n{result.stderr}" + )