HERMES-GIELIE/start.sh

169 lines
7.5 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
set -euo pipefail
fix(docker): salvage operational hardening from #1686 (env readonly + apt deps) Three independent operational hardening fixes salvaged from PR #1686 (@binhpt310) after the parent PR was deferred over a separate sibling-repo build-context concern unrelated to these fixes: 1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID, EGID, PPID) before `source`-ing. docker-compose.yml's macOS instructions document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount permission fixing — that .env was crashing start.sh with `UID: readonly variable` when `set -a; source ...; set +a` tried to assign to those names. Replaced with `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`. The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181 still passes — both `source` and `.env` are still on the modified line. 2. start.sh now defensively re-execs as the unprivileged hermeswebui user when invoked as root. Fires only when EUID==0 AND a hermeswebui user actually exists AND sudo is on PATH — so it's a no-op on host machines without the container user setup. The production image's entrypoint (docker_init.bash) already drops to hermeswebui before invoking start.sh, so this is a no-op on the canonical container path; it only matters for `sudo ./start.sh` or accidental root shells inside the container during interactive debugging. 3. Dockerfile installs xz-utils + git apt packages. xz-utils is required to decompress .tar.xz archives (e.g. Node.js distribution tarballs); git is needed for `git describe` (powers WEBUI_VERSION resolution at api/updates.py:_detect_webui_version) and any clone-based agent install path. Both are tiny apt packages on top of python:3.12-slim with no measurable image-size impact. What's NOT in this commit (deferred from #1686): - Pre-baking hermes-agent source into the image via `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context flip to `..`. Requires a sibling-repo layout that breaks the canonical `git clone hermes-webui && cd hermes-webui && docker compose build` flow. The right shape is a build arg gating the COPY behind --build-arg WITH_AGENT_SOURCE=1; left to a separate PR. - Pre-installing Node.js 22 LTS system-wide. Real motivation but worth evaluating the fix shape (full Node bake vs. opt-in vs. layer cache) separately from these three operational fixes. Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep on the start.sh filter pattern + 5 behavioral that actually run bash against synthetic .env files containing readonly vars + 2 Dockerfile package-presence tests). All 11 pass. Behavioral tests skip if bash is not on PATH. Full suite: 5028 → 5036 passing (+8 net new after pytest collection counted some behavioral tests under skip), 0 regressions, 147.84s. Closes the operational-hardening portion of #1686. Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
# If invoked as root (e.g. via `sudo ./start.sh` or accidental root shell
# inside the container), re-exec as the unprivileged hermeswebui user so the
# WebUI process never owns root-only file modes on bind-mounted state.
# Outside containers the EUID==0 case is rare; inside the production image
# the entrypoint drops to hermeswebui itself, so this is a defensive guard.
# Sourced from PR #1686 (@binhpt310) — Cluster 1 (operational hardening),
# extracted to a focused follow-up after the parent PR was deferred over a
# separate sibling-repo build-context concern unrelated to this fix.
#
# Four preconditions to fire (all must hold):
# - EUID == 0
# - hermeswebui user actually exists (id lookup)
# - sudo is on PATH (production image does not ship sudo, so this is the
# load-bearing no-op guard for the canonical container path)
# - sudo -u hermeswebui passes without prompting (NOPASSWD precheck)
# The NOPASSWD precheck via `sudo -n -u hermeswebui true` makes this a silent
# fall-through on host machines where the developer's hermeswebui user
# requires a password — better than exiting non-zero with `sudo: a password
# is required` and surprising the user who didn't ask for sudo behavior.
fix(docker): salvage operational hardening from #1686 (env readonly + apt deps) Three independent operational hardening fixes salvaged from PR #1686 (@binhpt310) after the parent PR was deferred over a separate sibling-repo build-context concern unrelated to these fixes: 1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID, EGID, PPID) before `source`-ing. docker-compose.yml's macOS instructions document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount permission fixing — that .env was crashing start.sh with `UID: readonly variable` when `set -a; source ...; set +a` tried to assign to those names. Replaced with `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`. The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181 still passes — both `source` and `.env` are still on the modified line. 2. start.sh now defensively re-execs as the unprivileged hermeswebui user when invoked as root. Fires only when EUID==0 AND a hermeswebui user actually exists AND sudo is on PATH — so it's a no-op on host machines without the container user setup. The production image's entrypoint (docker_init.bash) already drops to hermeswebui before invoking start.sh, so this is a no-op on the canonical container path; it only matters for `sudo ./start.sh` or accidental root shells inside the container during interactive debugging. 3. Dockerfile installs xz-utils + git apt packages. xz-utils is required to decompress .tar.xz archives (e.g. Node.js distribution tarballs); git is needed for `git describe` (powers WEBUI_VERSION resolution at api/updates.py:_detect_webui_version) and any clone-based agent install path. Both are tiny apt packages on top of python:3.12-slim with no measurable image-size impact. What's NOT in this commit (deferred from #1686): - Pre-baking hermes-agent source into the image via `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context flip to `..`. Requires a sibling-repo layout that breaks the canonical `git clone hermes-webui && cd hermes-webui && docker compose build` flow. The right shape is a build arg gating the COPY behind --build-arg WITH_AGENT_SOURCE=1; left to a separate PR. - Pre-installing Node.js 22 LTS system-wide. Real motivation but worth evaluating the fix shape (full Node bake vs. opt-in vs. layer cache) separately from these three operational fixes. Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep on the start.sh filter pattern + 5 behavioral that actually run bash against synthetic .env files containing readonly vars + 2 Dockerfile package-presence tests). All 11 pass. Behavioral tests skip if bash is not on PATH. Full suite: 5028 → 5036 passing (+8 net new after pytest collection counted some behavioral tests under skip), 0 regressions, 147.84s. Closes the operational-hardening portion of #1686. Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
if [[ ${EUID:-$(id -u)} -eq 0 ]] && id hermeswebui >/dev/null 2>&1 \
&& command -v sudo >/dev/null 2>&1 \
&& sudo -n -u hermeswebui true 2>/dev/null; then
fix(docker): salvage operational hardening from #1686 (env readonly + apt deps) Three independent operational hardening fixes salvaged from PR #1686 (@binhpt310) after the parent PR was deferred over a separate sibling-repo build-context concern unrelated to these fixes: 1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID, EGID, PPID) before `source`-ing. docker-compose.yml's macOS instructions document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount permission fixing — that .env was crashing start.sh with `UID: readonly variable` when `set -a; source ...; set +a` tried to assign to those names. Replaced with `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`. The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181 still passes — both `source` and `.env` are still on the modified line. 2. start.sh now defensively re-execs as the unprivileged hermeswebui user when invoked as root. Fires only when EUID==0 AND a hermeswebui user actually exists AND sudo is on PATH — so it's a no-op on host machines without the container user setup. The production image's entrypoint (docker_init.bash) already drops to hermeswebui before invoking start.sh, so this is a no-op on the canonical container path; it only matters for `sudo ./start.sh` or accidental root shells inside the container during interactive debugging. 3. Dockerfile installs xz-utils + git apt packages. xz-utils is required to decompress .tar.xz archives (e.g. Node.js distribution tarballs); git is needed for `git describe` (powers WEBUI_VERSION resolution at api/updates.py:_detect_webui_version) and any clone-based agent install path. Both are tiny apt packages on top of python:3.12-slim with no measurable image-size impact. What's NOT in this commit (deferred from #1686): - Pre-baking hermes-agent source into the image via `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context flip to `..`. Requires a sibling-repo layout that breaks the canonical `git clone hermes-webui && cd hermes-webui && docker compose build` flow. The right shape is a build arg gating the COPY behind --build-arg WITH_AGENT_SOURCE=1; left to a separate PR. - Pre-installing Node.js 22 LTS system-wide. Real motivation but worth evaluating the fix shape (full Node bake vs. opt-in vs. layer cache) separately from these three operational fixes. Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep on the start.sh filter pattern + 5 behavioral that actually run bash against synthetic .env files containing readonly vars + 2 Dockerfile package-presence tests). All 11 pass. Behavioral tests skip if bash is not on PATH. Full suite: 5028 → 5036 passing (+8 net new after pytest collection counted some behavioral tests under skip), 0 regressions, 147.84s. Closes the operational-hardening portion of #1686. Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
exec sudo -n -u hermeswebui "$0" "$@"
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${REPO_ROOT}/.env" ]]; then
fix(docker): salvage operational hardening from #1686 (env readonly + apt deps) Three independent operational hardening fixes salvaged from PR #1686 (@binhpt310) after the parent PR was deferred over a separate sibling-repo build-context concern unrelated to these fixes: 1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID, EGID, PPID) before `source`-ing. docker-compose.yml's macOS instructions document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount permission fixing — that .env was crashing start.sh with `UID: readonly variable` when `set -a; source ...; set +a` tried to assign to those names. Replaced with `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`. The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181 still passes — both `source` and `.env` are still on the modified line. 2. start.sh now defensively re-execs as the unprivileged hermeswebui user when invoked as root. Fires only when EUID==0 AND a hermeswebui user actually exists AND sudo is on PATH — so it's a no-op on host machines without the container user setup. The production image's entrypoint (docker_init.bash) already drops to hermeswebui before invoking start.sh, so this is a no-op on the canonical container path; it only matters for `sudo ./start.sh` or accidental root shells inside the container during interactive debugging. 3. Dockerfile installs xz-utils + git apt packages. xz-utils is required to decompress .tar.xz archives (e.g. Node.js distribution tarballs); git is needed for `git describe` (powers WEBUI_VERSION resolution at api/updates.py:_detect_webui_version) and any clone-based agent install path. Both are tiny apt packages on top of python:3.12-slim with no measurable image-size impact. What's NOT in this commit (deferred from #1686): - Pre-baking hermes-agent source into the image via `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context flip to `..`. Requires a sibling-repo layout that breaks the canonical `git clone hermes-webui && cd hermes-webui && docker compose build` flow. The right shape is a build arg gating the COPY behind --build-arg WITH_AGENT_SOURCE=1; left to a separate PR. - Pre-installing Node.js 22 LTS system-wide. Real motivation but worth evaluating the fix shape (full Node bake vs. opt-in vs. layer cache) separately from these three operational fixes. Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep on the start.sh filter pattern + 5 behavioral that actually run bash against synthetic .env files containing readonly vars + 2 Dockerfile package-presence tests). All 11 pass. Behavioral tests skip if bash is not on PATH. Full suite: 5028 → 5036 passing (+8 net new after pytest collection counted some behavioral tests under skip), 0 regressions, 147.84s. Closes the operational-hardening portion of #1686. Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
# Filter out shell-readonly vars (UID, GID, EUID, EGID, PPID) before
# `source`ing. docker-compose.yml's macOS instructions document
# `echo "UID=$(id -u)" >> .env` to set host UID/GID, which then crashes
# `start.sh` with "UID: readonly variable" when bash tries to assign to
# those names. Filtering them out lets the .env file carry those entries
# for docker-compose's variable substitution while keeping local invocation
# of start.sh working. The regression guard at
# tests/test_bootstrap_dotenv.py:181 still passes — the line below contains
# both `source` and `.env`.
# Sourced from PR #1686 (@binhpt310) — Cluster 1 (operational hardening),
# extracted to a focused follow-up after the parent PR was deferred.
2026-05-14 15:05:35 +00:00
_hermes_env_filtered="$(mktemp "${TMPDIR:-/tmp}/hermes-webui-env.XXXXXX")"
grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env" > "${_hermes_env_filtered}" || true
set -a
# shellcheck source=/dev/null
2026-05-14 15:05:35 +00:00
source "${_hermes_env_filtered}"
set +a
2026-05-14 15:05:35 +00:00
rm -f "${_hermes_env_filtered}"
unset _hermes_env_filtered
fi
PYTHON="${HERMES_WEBUI_PYTHON:-}"
if [[ -z "${PYTHON}" ]]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
PYTHON="$(command -v python)"
else
echo "[XX] Python 3 is required to run bootstrap.py" >&2
exit 1
fi
fi
# Pre-flight: detect an already-running server before launching bootstrap.py.
#
# bootstrap.py's detached (non-foreground) path spawns server.py, then probes
# /health and reports success once *anything* answers. If a server is already
# bound to this host:port, the freshly spawned child fails to bind and dies,
# but the EXISTING (often orphaned) server answers the /health probe — so
# bootstrap.py prints "ready" and exits 0 without having started anything. The
# user just keeps re-confirming the old instance on every run.
#
# To avoid that, probe /health here first. If a server is already up, tell the
# user plainly that nothing was (re)started and how to restart, then exit 0.
# ctl.sh already refuses to double-start via PID/state tracking; this brings
# the detached start.sh path to parity using a health probe (start.sh keeps no
# PID file of its own).
#
# Resolve host/port the same way bootstrap.py does: HERMES_WEBUI_HOST /
# HERMES_WEBUI_PORT (possibly just sourced from .env above), else the
# bootstrap.py defaults of 127.0.0.1 / 8787. A 0.0.0.0 / :: bind is probed via
# loopback, matching server.py's _abort_if_already_serving.
_hermes_host="${HERMES_WEBUI_HOST:-127.0.0.1}"
_hermes_port="${HERMES_WEBUI_PORT:-8787}"
# CLI args override the env/defaults exactly as bootstrap.py's argparse does
# (`port` is the first bare numeric positional; `--host VALUE` / `--host=VALUE`).
# Without this, `./start.sh <port>` or `--host X` would probe the wrong endpoint
# and could falsely report "already running" against a different instance.
_hermes_args=("$@")
_hermes_i=0
while [[ ${_hermes_i} -lt ${#_hermes_args[@]} ]]; do
_hermes_arg="${_hermes_args[${_hermes_i}]}"
case "${_hermes_arg}" in
--host)
_hermes_next=$(( _hermes_i + 1 ))
if [[ ${_hermes_next} -lt ${#_hermes_args[@]} ]]; then
_hermes_host="${_hermes_args[${_hermes_next}]}"
_hermes_i=${_hermes_next}
fi
;;
--host=*)
_hermes_host="${_hermes_arg#--host=}"
;;
--*)
: # other flags (e.g. --no-browser) carry no positional value here
;;
*)
# First bare numeric positional is the port (bootstrap.py: nargs="?").
if [[ "${_hermes_arg}" =~ ^[0-9]+$ ]]; then
_hermes_port="${_hermes_arg}"
fi
;;
esac
_hermes_i=$(( _hermes_i + 1 ))
done
case "${_hermes_host}" in
0.0.0.0|""|::|"[::]") _hermes_probe_host="127.0.0.1" ;;
*) _hermes_probe_host="${_hermes_host}" ;;
esac
# Best-effort, TLS-aware probe via the shared helper. If neither curl nor wget
# is present the helper returns non-zero and we fall through to the normal
# launch (unchanged behavior). Short 2s timeout so a normal cold start is not
# delayed. The helper mirrors the server scheme (https when TLS_CERT/KEY are
# set) and handles self-signed certs and the HTTP-fallback contract.
# shellcheck source=scripts/lib/health_probe.sh
. "${REPO_ROOT}/scripts/lib/health_probe.sh"
_hermes_probe_scheme="$(hermes_webui_probe_scheme)"
# Run the probe in the CURRENT shell (redirect, not $(...)) so the helper's
# _HERMES_WEBUI_PROBE_SCHEME global survives — a command-substitution subshell
# would discard it. server.py falls back to plain HTTP when the cert/key are
# unloadable, so a TLS-configured instance can be live on http:// while the
# configured scheme is https://; prefer the scheme that actually answered.
_hermes_probe_body_file="$(mktemp 2>/dev/null || echo "/tmp/hermes-webui-probe.$$")"
_hermes_already_up=""
if hermes_webui_probe_health "${_hermes_probe_host}" "${_hermes_port}" "/health" 2 > "${_hermes_probe_body_file}" 2>/dev/null; then
_hermes_already_up="$(cat "${_hermes_probe_body_file}" 2>/dev/null || true)"
fi
rm -f "${_hermes_probe_body_file}" 2>/dev/null || true
if [[ -n "${_HERMES_WEBUI_PROBE_SCHEME:-}" ]]; then
_hermes_probe_scheme="${_HERMES_WEBUI_PROBE_SCHEME}"
fi
if [[ -n "${_hermes_already_up}" ]]; then
cat >&2 <<EOF
[==] Hermes WebUI is already running at ${_hermes_probe_scheme}://${_hermes_probe_host}:${_hermes_port}
The server was NOT started again (start.sh does not double-start).
If you need to restart the server, do the following:
Preferred — use the daemon controller:
./ctl.sh restart
Otherwise, stop the running server and start it again manually:
1. Find the process listening on port ${_hermes_port}:
lsof -iTCP:${_hermes_port} -sTCP:LISTEN # macOS / Linux
2. Stop it (use -9 only if it ignores a normal stop):
kill <PID>
3. Start it again:
./start.sh
EOF
exit 0
fi
exec "${PYTHON}" "${REPO_ROOT}/bootstrap.py" --no-browser "$@"