diff --git a/CHANGELOG.md b/CHANGELOG.md index b12f4a7f..86dc0b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,37 @@ # Hermes Web UI -- Changelog -## [v0.51.28] — 2026-05-08 — 2-PR contributor batch (Release E2: MCP server Option A rewrite + WebUI /goal command) +## [v0.51.29] — 2026-05-08 — 6-PR contributor batch (Release F: Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup) + +### Added (1 PR) + +- **PR #1919** by @franksong2702 — Persist login rate limit attempts (closes #1910). Stores failed-login buckets in `STATE_DIR/.login_attempts.json` instead of in-process memory, so password-auth deployments keep the same failed-attempt window across restarts. Atomic temp+rename writes, `0600` permissions, prunes expired entries on load. If the file is missing, malformed, or unwritable, the auth path falls back to current in-memory behavior with debug-level logging — no infinite-loop risk. + +### Fixed (5 PRs) + +- **PR #1920** by @franksong2702 — Remove dead `kanban_card_start` i18n key. PR #1886 removed the Kanban card-level Start action (direct `running` transitions are now owned by the dispatcher), but the `kanban_card_start` locale key was left present in every locale block. Removed across all 9 locales and strengthened the Kanban static regression test so the dead key cannot be reintroduced. + +- **PR #1921** by @Michaelyklam — Production Docker image hardening (closes #1908). Removes passwordless sudo path, drops the `hermeswebuitoo` sudo-capable staging user, and reworks `docker_init.bash` so privileged setup runs in an explicit root init block before re-execing as the `hermeswebui` user without sudo. Init scratch state now uses owner-only permissions (`umask 0077`, `0700` directory, `0600` files). Added `docs/docker.md` with production-image security model notes. A shell gained through the WebUI runtime no longer has a passwordless sudo path to root inside the production container. + +- **PR #1926** by @ai-ag2026 — Prevent chat scroll resets after final render. The final-render path could write/rebuild DOM, queue native scroll events, and then lose the explicit bottom pin before delayed layout growth settled. Separately, clicking the already-open session still ran the `loadSession()` teardown/setup path. Fix: keep explicit bottom scroll pins stable across `renderMessages({preserveScroll: true})` and late Markdown/layout growth, and make clicking the currently-active sidebar session a no-op before `loadSession()` mutates state. + +- **PR #1927** by @ai-ag2026 — Preserve viewport when loading older messages. Pre-fix, prepending older history could snap the viewport to the bottom or surface only a larger hidden-count marker. Fix: expand transcript render window before rendering newly fetched older messages, then anchor at the current viewport instead of snapping. Adds focused regression coverage for older-history viewport anchoring. + +- **PR #1930** by @ai-ag2026 — Collapse stale compression sidebar segments. The sidebar collapse key treated any row whose `parent_session_id` pointed at another visible row as a non-collapsible child/fork row — correct for subagent/fork sessions, but wrong for automatic compression continuations that already carry `_lineage_root_id`/`lineage_root_id` and should collapse by lineage even when stale optimistic parent segments are still locally visible. Fix: prefer explicit lineage metadata before the visible-parent guard. + +### Tests + +4947 → **4960 collected, 4960 passing, 0 regressions** (+13 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) clean on `static/i18n.js`, `static/sessions.js`, `static/ui.js`. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Opus advisor pass: SHIP-READY (only flag was a #1919 CHANGELOG conflict already auto-resolved during stage rebase). + +### Pre-release verification + +- Full pytest under `HERMES_HOME` isolation: **4960 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 145.24s. +- Browser API harness against stage-324 on port 8789: all 11 endpoints + 20 QA tests PASS (110.90s for QA phase). +- `node -c` on all 3 modified `static/*.js` files: clean. +- Stage diff: 18 files, +588/-150. +- Opus advisor pass on stage-324 brief: VERDICT=SHIP-WITH-FIXES (single fix: #1919 CHANGELOG rebase — already auto-resolved during stage merge). Coexistence verified for #1926/#1927/#1930 sharing `static/sessions.js` (different functions, scroll-pin and viewport-anchor cannot fight; lineage metadata degrades gracefully on legacy sessions). +- v0.51.28 carry-overs verified preserved (no in-batch changes to `api/routes.py:_strip_workspace_prefix`, `api/streaming.py:evaluate_goal_after_turn`, `api/profiles.py:_profiles_match`, `tests/test_mcp_server.py` module-restoration logic). +- Pre-stamp re-fetch of all 6 PR heads: no contributor force-push during Opus window. + ### Added (2 PRs) diff --git a/Dockerfile b/Dockerfile index 26d98022..693d61e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ RUN apt-get update -y --fix-missing --no-install-recommends \ apt-utils \ locales \ ca-certificates \ - sudo \ curl \ rsync \ openssh-client \ @@ -41,24 +40,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /apptoo -# Every sudo group user does not need a password -RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers - -# Create a new group for the hermeswebui and hermeswebuitoo users -RUN groupadd -g 1024 hermeswebui \ - && groupadd -g 1025 hermeswebuitoo - -# The hermeswebui (resp. hermeswebuitoo) user will have UID 1024 (resp. 1025), -# be part of the hermeswebui (resp. hermeswebuitoo) and users groups and be sudo capable (passwordless) -RUN useradd -u 1024 -d /home/hermeswebui -g hermeswebui -s /bin/bash -m hermeswebui \ - && usermod -G users hermeswebui \ - && adduser hermeswebui sudo -RUN useradd -u 1025 -d /home/hermeswebuitoo -g hermeswebuitoo -s /bin/bash -m hermeswebuitoo \ - && usermod -G users hermeswebuitoo \ - && adduser hermeswebuitoo sudo -RUN chown -R hermeswebuitoo:hermeswebuitoo /apptoo - -USER root +# Create the unprivileged runtime user. The entrypoint starts as root only for +# UID/GID alignment and filesystem preparation, then execs the server as this user. +RUN groupadd -g 1024 hermeswebui \ + && useradd -u 1024 -d /home/hermeswebui -g hermeswebui -G users -s /bin/bash -m hermeswebui \ + && mkdir -p /app /uv_cache \ + && chown -R hermeswebui:hermeswebui /home/hermeswebui /app /uv_cache COPY --chmod=555 docker_init.bash /hermeswebui_init.bash @@ -75,9 +62,7 @@ USER root # 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 --chown=hermeswebuitoo:hermeswebuitoo . /apptoo +COPY --chown=root:root . /apptoo # Bake the git version tag into the image so the settings badge works even # when .git is not present (it is excluded by .dockerignore). @@ -95,5 +80,8 @@ EXPOSE 8787 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8787/health || exit 1 +# docker_init.bash performs root-only bind-mount setup, then drops to hermeswebui +# before starting the WebUI server. The production image does not ship sudo. +USER root CMD ["/hermeswebui_init.bash"] diff --git a/ROADMAP.md b/ROADMAP.md index 30080d0f..f39cdfad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.28 (May 8, 2026) — 4947 tests collected — 2-PR Release E2 batch (MCP server Option A rewrite + WebUI /goal command) +> Last updated: v0.51.29 (May 8, 2026) — 4960 tests collected — 6-PR Release F batch (Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index d254ea4d..e9e7a980 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.28, May 8, 2026* -*Total automated tests collected: 4947* +*Last updated: v0.51.29, May 8, 2026* +*Total automated tests collected: 4960* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/auth.py b/api/auth.py index 0927e5f7..5e9c6c4e 100644 --- a/api/auth.py +++ b/api/auth.py @@ -77,24 +77,79 @@ def _save_sessions(sessions: dict[str, float]) -> None: _sessions = _load_sessions() # ── Login rate limiter ────────────────────────────────────────────────────── -_login_attempts = {} # ip -> [timestamp, ...] +_LOGIN_ATTEMPTS_FILE = STATE_DIR / '.login_attempts.json' _LOGIN_MAX_ATTEMPTS = 5 _LOGIN_WINDOW = 60 # seconds + +def _load_login_attempts() -> dict[str, list[float]]: + """Load persisted login attempts from STATE_DIR, pruning expired entries.""" + try: + if _LOGIN_ATTEMPTS_FILE.exists(): + data = json.loads(_LOGIN_ATTEMPTS_FILE.read_text(encoding='utf-8')) + if not isinstance(data, dict): + raise ValueError('malformed login-attempts file — expected dict') + now = time.time() + attempts: dict[str, list[float]] = {} + for ip, raw_times in data.items(): + if not isinstance(ip, str) or not isinstance(raw_times, list): + continue + fresh = [ + float(t) + for t in raw_times + if isinstance(t, (int, float)) and now - float(t) < _LOGIN_WINDOW + ] + if fresh: + attempts[ip] = fresh + return attempts + except Exception as e: + logger.debug("Failed to load login attempts file, starting fresh: %s", e) + return {} + + +def _save_login_attempts(attempts: dict[str, list[float]]) -> None: + """Atomically persist login attempts to STATE_DIR/.login_attempts.json (0600).""" + try: + _LOGIN_ATTEMPTS_FILE.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=_LOGIN_ATTEMPTS_FILE.parent, suffix='.login_attempts.tmp') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + json.dump(attempts, f) + os.chmod(tmp, 0o600) + os.replace(tmp, _LOGIN_ATTEMPTS_FILE) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + except Exception as e: + logger.debug("Failed to persist login attempts: %s", e) + + +_login_attempts = _load_login_attempts() # ip -> [timestamp, ...] + + def _check_login_rate(ip: str) -> bool: """Return True if the IP is allowed to attempt login.""" now = time.time() attempts = _login_attempts.get(ip, []) # Prune old attempts attempts = [t for t in attempts if now - t < _LOGIN_WINDOW] - _login_attempts[ip] = attempts + if attempts: + _login_attempts[ip] = attempts + else: + _login_attempts.pop(ip, None) + _save_login_attempts(_login_attempts) return len(attempts) < _LOGIN_MAX_ATTEMPTS + def _record_login_attempt(ip: str) -> None: now = time.time() attempts = _login_attempts.get(ip, []) attempts.append(now) _login_attempts[ip] = attempts + _save_login_attempts(_login_attempts) def _signing_key(): diff --git a/docker_init.bash b/docker_init.bash index dc77b246..fde98d28 100644 --- a/docker_init.bash +++ b/docker_init.bash @@ -36,25 +36,25 @@ script_fullname=$0 echo " - script_fullname: ${script_fullname}" ignore_value="VALUE_TO_IGNORE" -# everyone can read our files by default -umask 0022 +# Keep init scratch files private to the container user that owns them. +umask 0077 -# Write a world-writeable file (preferably inside /tmp -- ie within the container) -write_worldtmpfile() { +write_privtmpfile() { tmpfile=$1 - if [ -z "${tmpfile}" ]; then error_exit "write_worldfile: missing argument"; fi - if [ -f $tmpfile ]; then rm -f $tmpfile; fi - echo -n $2 > ${tmpfile} - chmod 777 ${tmpfile} + if [ -z "${tmpfile}" ]; then error_exit "write_privtmpfile: missing argument"; fi + if [ -f "$tmpfile" ]; then rm -f "$tmpfile"; fi + printf '%s' "$2" > "$tmpfile" + chmod 600 "$tmpfile" } itdir=/tmp/hermeswebui_init -if [ ! -d $itdir ]; then mkdir $itdir; chmod 777 $itdir; fi -if [ ! -d $itdir ]; then error_exit "Failed to create $itdir"; fi +if [ ! -d "$itdir" ]; then mkdir -p "$itdir"; fi +chmod 700 "$itdir" || error_exit "Failed to secure $itdir" +if [ ! -d "$itdir" ]; then error_exit "Failed to create $itdir"; fi # Set user and group id # logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run -# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from hermeswebuitoo to hermeswebuitoo transition (those values are the only ones loaded before the environment variables dump file are loaded) +# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from the root init phase to the hermeswebui runtime phase it=$itdir/hermeswebui_user_uid if [ -z "${WANTED_UID+x}" ]; then if [ -f $it ]; then WANTED_UID=$(cat $it); fi @@ -88,7 +88,7 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then fi fi WANTED_UID=${WANTED_UID:-1024} -write_worldtmpfile $it "$WANTED_UID" +write_privtmpfile $it "$WANTED_UID" echo "-- WANTED_UID: \"${WANTED_UID}\"" it=$itdir/hermeswebui_user_gid @@ -120,7 +120,7 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then fi fi WANTED_GID=${WANTED_GID:-1024} -write_worldtmpfile $it "$WANTED_GID" +write_privtmpfile $it "$WANTED_GID" echo "-- WANTED_GID: \"${WANTED_GID}\"" echo "== Most Environment variables set" @@ -180,22 +180,20 @@ load_env() { fi } -# hermeswebuitoo is a specfiic user not existing by default on ubuntu, we can check its whomai -if [ "A${whoami}" == "Ahermeswebuitoo" ]; then - echo "-- Running as hermeswebuitoo, will switch hermeswebui to the desired UID/GID" - # The script is started as hermeswebuitoo -- UID/GID 1025/1025 +# The production image does not ship sudo. The entrypoint starts as root only +# long enough to align the hermeswebui UID/GID with mounted volumes, prepare +# root-owned paths, and then drop privileges for the server process. +if [ "A${whoami}" == "Aroot" ]; then + echo "-- Running as root for one-time container init; will switch to hermeswebui" # We are altering the UID/GID of the hermeswebui user to the desired ones and restarting as that user - # using usermod for the already create hermeswebui user, knowing it is not already in use + # using usermod for the already created hermeswebui user, knowing it is not already in use # per usermod manual: "You must make certain that the named user is not executing any processes when this command is being executed" # Guard for read-only root filesystem (podman with read_only=true, issue #1470). - # The script runs as hermeswebuitoo (non-root), but groupmod/usermod use sudo. - # So we must check writability via sudo — a non-root user cannot write /etc/group - # even on a normal writable rootfs, which caused a false positive (issue #1658). _readonly_root=false - if ! sudo sh -c 'test -w /etc/group && test -w /etc/passwd' 2>/dev/null; then + if ! sh -c 'test -w /etc/group && test -w /etc/passwd' 2>/dev/null; then _readonly_root=true - echo " !! Detected read-only root filesystem — /etc/group or /etc/passwd is not writable (even via sudo)" + echo " !! Detected read-only root filesystem — /etc/group or /etc/passwd is not writable" fi if [ "A${_readonly_root}" == "Atrue" ]; then _current_hermeswebui_gid=$(id -g hermeswebui 2>/dev/null || echo "") @@ -206,20 +204,41 @@ if [ "A${whoami}" == "Ahermeswebuitoo" ]; then error_exit "Cannot modify /etc/group or /etc/passwd (read-only root fs). Set UID=${_current_hermeswebui_uid} and GID=${_current_hermeswebui_gid} to match, or run without read_only=true. See issue #1470." fi else - sudo groupmod -o -g ${WANTED_GID} hermeswebui || error_exit "Failed to set GID of hermeswebui user" - sudo usermod -o -u ${WANTED_UID} hermeswebui || error_exit "Failed to set UID of hermeswebui user" + groupmod -o -g "${WANTED_GID}" hermeswebui || error_exit "Failed to set GID of hermeswebui user" + usermod -o -u "${WANTED_UID}" hermeswebui || error_exit "Failed to set UID of hermeswebui user" fi - sudo chown -R ${WANTED_UID}:${WANTED_GID} /home/hermeswebui || error_exit "Failed to set owner of /home/hermeswebui" - save_env /tmp/hermeswebuitoo_env.txt + + chown -R "${WANTED_UID}:${WANTED_GID}" /home/hermeswebui || error_exit "Failed to set owner of /home/hermeswebui" + + echo ""; echo "-- Preparing /app for the hermeswebui runtime user" + mkdir -p /app || error_exit "Failed to create /app directory" + chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user" + rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership" + + if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi + 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 + if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi + chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" 2>/dev/null || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)" + + export UV_CACHE_DIR=${UV_CACHE_DIR:-/uv_cache} + mkdir -p "${UV_CACHE_DIR}" || error_exit "Failed to create ${UV_CACHE_DIR} directory" + chown hermeswebui:hermeswebui "${UV_CACHE_DIR}" || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user" + + chown -R "${WANTED_UID}:${WANTED_GID}" "$itdir" || error_exit "Failed to set owner of $itdir" + save_env /tmp/hermeswebui_root_env.txt + chown "${WANTED_UID}:${WANTED_GID}" /tmp/hermeswebui_root_env.txt || error_exit "Failed to set owner of /tmp/hermeswebui_root_env.txt" + chmod 600 /tmp/hermeswebui_root_env.txt || error_exit "Failed to secure /tmp/hermeswebui_root_env.txt" + # restart the script as hermeswebui set with the correct UID/GID this time echo "-- Restarting as hermeswebui user with UID ${WANTED_UID} GID ${WANTED_GID}" - sudo su hermeswebui $script_fullname || error_exit "subscript failed" - ok_exit "Clean exit" + exec su -s /bin/bash -c "exec \"${script_fullname}\"" hermeswebui || error_exit "subscript failed" fi -# If we are here, the script is started as another user than hermeswebuitoo -# because the whoami value for the hermeswebui user can be any existing user, we can not check against it -# instead we check if the UID/GID are the expected ones +# If we are here, the script is started as an unprivileged runtime user. +# Because the whoami value for the hermeswebui user can be any existing user, we cannot check against it; +# instead we check if the UID/GID are the expected ones. if [ "$WANTED_GID" != "$new_gid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi @@ -228,18 +247,16 @@ if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running # We are therefore running as hermeswebui echo ""; echo "== Running as hermeswebui" -# Load environment variables one by one if they do not exist from /tmp/hermeswebuitoo_env.txt -it=/tmp/hermeswebuitoo_env.txt -if [ -f $it ]; then - echo "-- Loading not already set environment variables from $it" - load_env $it true +# Load environment variables one by one if they do not exist from the root init phase +tmp_root_env=/tmp/hermeswebui_root_env.txt +if [ -f $tmp_root_env ]; then + echo "-- Loading not already set environment variables from $tmp_root_env" + load_env $tmp_root_env true fi ## -echo ""; echo "-- Making sure /app is owned by the hermeswebui user to avoid permission issues when running the server " -sudo mkdir -p /app || error_exit "Failed to create /app directory" -sudo chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user" -sudo rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership" +echo ""; echo "-- Verifying /app is writable by the hermeswebui runtime user" +if [ ! -d /app ]; then error_exit "/app directory does not exist"; fi it=/app/.testfile; touch $it || error_exit "Failed to verify /app directory" rm -f $it || error_exit "Failed to delete test file in /app" @@ -258,19 +275,18 @@ 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" -# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357). -# Skip mkdir if the directory already exists (e.g. a read-only mount — #670). +# The root init phase creates/chowns missing bind-mount directories before +# dropping privileges. After that, the runtime user only verifies access. if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then - sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" + mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" fi if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi -# Only chown and write-test if the workspace is writable. Read-only bind-mounts -# (:ro) are valid — the workspace is used for browsing, not writing by the server. +# Only write-test if the workspace is writable. Read-only bind-mounts (:ro) +# are valid — the workspace is used for browsing, not writing by the server. if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then - sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)" it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)" else - echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)" + echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping write check (read-only workspace is supported)" fi echo ""; echo "===================" @@ -285,9 +301,9 @@ else fi export UV_PROJECT_ENVIRONMENT=venv -export UV_CACHE_DIR=/uv_cache -sudo mkdir -p ${UV_CACHE_DIR} || error_exit "Failed to create /uv_cache directory" -sudo chown hermeswebui:hermeswebui ${UV_CACHE_DIR} || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user" +export UV_CACHE_DIR=${UV_CACHE_DIR:-/uv_cache} +mkdir -p "${UV_CACHE_DIR}" || error_exit "Failed to create ${UV_CACHE_DIR} directory" +test -w "${UV_CACHE_DIR}" || error_exit "${UV_CACHE_DIR} is not writable by hermeswebui" cd /app if [ -f /app/venv/bin/python3 ]; then diff --git a/docs/docker.md b/docs/docker.md index 04c1bc3e..ada305b3 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -13,6 +13,24 @@ This is the comprehensive Docker reference. For a 5-minute quickstart, see the [ If something stops working, **start with the single-container setup** — it's the simplest path and fixes most permission/UID/path-mismatch issues by construction. +## Production image security model + +The production Docker image is hardened for the normal single-tenant container threat model: +Hermes WebUI assumes one operator controls the container, mounted Hermes home, and workspace. +The image does **not** install `sudo`, does not add runtime users to a sudo group, and does not +grant `NOPASSWD` escalation. If an agent/tool process gains a shell as `hermeswebui`, it should +not be able to become root with a passwordless sudo command. + +The entrypoint still starts as `root` for a narrow init phase because Docker bind mounts often need +UID/GID alignment and ownership preparation before the app can read `~/.hermes`, `/workspace`, +`/app`, and `/uv_cache`. After that setup, `docker_init.bash` re-execs itself as the unprivileged +`hermeswebui` user and starts the server there. Init scratch files under `/tmp/hermeswebui_init` +are owner-only (`0700` directory, `0600` files), not world-writable. + +For multi-tenant or hostile-container environments, rebuild with your own runtime user, mount policy, +and supervisor assumptions. Development images that need package-manager convenience should add +those tools in a dev-only Dockerfile instead of reintroducing passwordless sudo to production. + ## 5-minute quickstart (single container) ```bash diff --git a/static/i18n.js b/static/i18n.js index 41c97275..d32cfa84 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -536,7 +536,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -1558,7 +1557,6 @@ const LOCALES = { kanban_board_color: '色 (任意)', kanban_board_name_required: '名前は必須です', kanban_board_slug_required: 'スラッグは必須です', - kanban_card_start: '開始', kanban_card_complete: '完了', kanban_card_archive: 'アーカイブ', kanban_unassigned: '未割り当て', @@ -2419,7 +2417,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -3375,7 +3372,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -4319,7 +4315,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -5284,7 +5279,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -7283,7 +7277,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', @@ -8203,7 +8196,6 @@ const LOCALES = { kanban_board_color: 'Color (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', - kanban_card_start: 'start', kanban_card_complete: 'complete', kanban_card_archive: 'archive', kanban_unassigned: 'unassigned', diff --git a/static/sessions.js b/static/sessions.js index e0b29990..8a88217a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -330,6 +330,11 @@ async function newSession(flash){ } async function loadSession(sid){ + const currentSid = S.session ? S.session.session_id : null; + // Clicking the already-open session in the sidebar is a no-op. Reloading it + // tears down active pane state and can reset the long-session scroll window + // to the top even though the user did not navigate anywhere. + if(currentSid===sid) return; // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). _loadingSessionId = sid; @@ -339,7 +344,6 @@ async function loadSession(sid){ if(typeof hideClarifyCard==='function') hideClarifyCard(); // Show loading indicator immediately for responsiveness. // Cleared by renderMessages() once full session data arrives. - const currentSid = S.session ? S.session.session_id : null; // Persist the current composer draft before switching away so it can be // restored when the user switches back (#1060). if (currentSid && currentSid !== sid) { @@ -1020,18 +1024,32 @@ async function _loadOlderMessages() { const container = $('messages'); const prevScrollH = container ? container.scrollHeight : 0; S.messages = [...olderMsgs, ...S.messages]; + // renderMessages() windows long transcripts from the end. If we do not + // expand that window before rendering, the newly prepended page stays + // hidden and the "hidden" counter rises while the viewport appears stuck. + // Count roughly by the same visible-message rules used by renderMessages(). + const addedRenderable = olderMsgs.filter(m=>{ + if(!m||!m.role||m.role==='tool') return false; + if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)) return false; + if(typeof _isPreservedCompressionTaskListMessage==='function'&&_isPreservedCompressionTaskListMessage(m)) return false; + const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; + const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); + return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||(typeof _messageHasReasoningPayload==='function'&&_messageHasReasoningPayload(m))))); + }).length; + _messageRenderWindowSize=_currentMessageRenderWindowSize()+Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT); _messagesTruncated = !!data.session._messages_truncated; _oldestIdx = data.session._messages_offset || 0; - renderMessages(); - // Restore scroll position so the user stays at the same message. - // renderMessages() calls scrollToBottom() at the end, so we must - // counter-scroll to where the user was before loading older messages. + renderMessages({ preserveScroll: true }); if (container) { + // Prepending older messages must not teleport the reader. Preserve the + // currently visible viewport by adding the inserted height to scrollTop. + const oldTop = container.scrollTop; const newScrollH = container.scrollHeight; - container.scrollTop = newScrollH - prevScrollH; + const addedHeight = Math.max(0, newScrollH - prevScrollH); + _programmaticScroll = true; + container.scrollTop = oldTop + addedHeight; + requestAnimationFrame(()=>{ _programmaticScroll = false; }); } - // renderMessages() called scrollToBottom() which set _scrollPinned=true. - // We just restored the user's scroll position, so mark as not pinned. _scrollPinned = false; } catch(e) { console.warn('_loadOlderMessages failed:', e); @@ -1819,12 +1837,17 @@ function _isChildSession(s){ function _sessionLineageKey(s, sessionIdsInList){ if(!s||!s.session_id) return null; if(_isChildSession(s)) return null; + const lineageKey=s._lineage_root_id||s.lineage_root_id||null; + if(lineageKey) return lineageKey; // If parent_session_id points to another session in the current list, - // this is a subagent child — don't collapse it into lineage (#494). + // this is a subagent/fork child without compression metadata — don't + // collapse it into lineage (#494). Compression continuations carry an + // explicit lineage root, even when stale optimistic rows leave parent + // segments in the browser cache during active compression. if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){ return null; } - return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null; + return s.parent_session_id || null; } function _sessionLineageContainsSession(s, sid){ diff --git a/static/ui.js b/static/ui.js index 6ba7346b..3e95e1b0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1510,8 +1510,10 @@ let _lastScrollTop=null; let _lastNonMessageScrollIntentMs=0; let _lastMessageUpwardIntentMs=0; let _messageUserUnpinned=false; +let _bottomSettleToken=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; const MESSAGE_UPWARD_INTENT_MS=450; +function _cancelBottomSettle(){ _bottomSettleToken++; } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); const target=e&&e.target; @@ -1527,6 +1529,9 @@ function _recordNonMessageScrollIntent(e){ // not masquerade as user intent and strand live streaming away from bottom. _lastMessageUpwardIntentMs=performance.now(); _messageUserUnpinned=true; + // User is intentionally moving in the transcript. Cancel any delayed + // scrollToBottom settling that was scheduled by session-load/layout growth. + _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; } @@ -1559,8 +1564,14 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS // scrollToBottomBtn visibility is updated below after pin state settles. const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent(); _lastScrollTop=top; - if(movedUp){ _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731 - else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; if(_scrollPinned) _messageUserUnpinned=false; } // #1360 + if(movedUp){ _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731 + else { + if(nearBottom){ + _nearBottomCount=_nearBottomCount+1; + if(_nearBottomCount>=2) _scrollPinned=true; + } else { _nearBottomCount=0; _scrollPinned=false; } + if(_scrollPinned) _messageUserUnpinned=false; + } // #1360 const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; // Load older messages when scrolled near the top @@ -1852,6 +1863,8 @@ function _setMessageScrollToBottom(){ _programmaticScroll=true; el.scrollTop=el.scrollHeight; _lastScrollTop=el.scrollTop; + _nearBottomCount=2; + _scrollPinned=true; requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); } function _isMessagePaneNearBottom(threshold=250){ @@ -1862,16 +1875,42 @@ function _isMessagePaneNearBottom(threshold=250){ function _shouldFollowMessagesOnDomReplace(){ return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200)); } +function _settleMessageScrollToBottom(force){ + // Markdown post-processing (Prism, tables, Mermaid/KaTeX/PDF placeholders) + // can grow the transcript after the first scroll write. Re-apply the bottom + // position across a few frames while pinned so late layout does not leave the + // viewport a few lines above the real end. User scroll increments + // _bottomSettleToken and cancels the delayed passes. + const token=++_bottomSettleToken; + const passes=[0,16,80,180]; + passes.forEach(delay=>setTimeout(()=>{ + if(token!==_bottomSettleToken) return; + if(!force && (!_scrollPinned||_recentNonMessageScrollIntent())) return; + _setMessageScrollToBottom(); + },delay)); + requestAnimationFrame(()=>{ + if(token!==_bottomSettleToken) return; + if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom(); + requestAnimationFrame(()=>{ + if(token!==_bottomSettleToken) return; + if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom(); + }); + }); +} function scrollIfPinned(){ if(!_scrollPinned) return; if(_recentNonMessageScrollIntent()) return; - const el=$('messages'); - if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;_lastScrollTop=el.scrollTop;requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });} + _settleMessageScrollToBottom(false); } function scrollToBottom(){ _scrollPinned=true; _messageUserUnpinned=false; + // Write the first bottom position synchronously. A final renderMessages() + // rebuild can queue a native scroll event from the temporary scrollTop=0 + // layout state; if we only schedule delayed settles, that event can cancel + // them before the viewport ever reaches the bottom. _setMessageScrollToBottom(); + _settleMessageScrollToBottom(true); const btn=$('scrollToBottomBtn'); if(btn) btn.style.display='none'; } diff --git a/tests/test_issue1731_upward_scroll_unpins.py b/tests/test_issue1731_upward_scroll_unpins.py index e387e5c2..c8bfc862 100644 --- a/tests/test_issue1731_upward_scroll_unpins.py +++ b/tests/test_issue1731_upward_scroll_unpins.py @@ -142,13 +142,17 @@ def test_downward_path_preserves_macos_momentum_hysteresis(): end_idx = block.index("const btn=", else_idx) downward_branch = block[else_idx:end_idx] - assert "_nearBottomCount=nearBottom?_nearBottomCount+1:0" in downward_branch, ( + assert "if(nearBottom)" in downward_branch, ( + "Downward path must branch on near-bottom state so the macOS momentum " + "re-pin guard still applies (#1360)." + ) + assert "_nearBottomCount=_nearBottomCount+1" in downward_branch, ( "Downward path must keep incrementing the near-bottom counter so " "the macOS momentum re-pin guard still applies (#1360)." ) - assert "_scrollPinned=_nearBottomCount>=2" in downward_branch, ( + assert "if(_nearBottomCount>=2) _scrollPinned=true" in downward_branch, ( "Downward path must keep the >=2 hysteresis re-pin requirement " - "(#1360)." + "without downgrading an explicit bottom pin on the first near-bottom event (#1360)." ) diff --git a/tests/test_issue1908_docker_hardening.py b/tests/test_issue1908_docker_hardening.py new file mode 100644 index 00000000..32f47980 --- /dev/null +++ b/tests/test_issue1908_docker_hardening.py @@ -0,0 +1,60 @@ +"""Regression coverage for issue #1908 Docker production hardening.""" +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") +DOCKER_DOCS = (REPO / "docs" / "docker.md").read_text(encoding="utf-8") + + +def _dockerfile_install_packages() -> str: + match = re.search( + r"apt-get install -y --no-install-recommends \\\n(?P.*?)&& apt-get upgrade -y", + DOCKERFILE, + re.DOTALL, + ) + assert match, "Could not find the production apt package install block" + return match.group("body") + + +def test_production_dockerfile_does_not_grant_passwordless_sudo(): + """The production image must not install sudo or grant NOPASSWD root escalation.""" + packages = _dockerfile_install_packages() + assert "sudo" not in packages, "production Dockerfile must not install sudo" + assert "NOPASSWD" not in DOCKERFILE, "production Dockerfile must not grant passwordless sudo" + assert "adduser hermeswebui sudo" not in DOCKERFILE + assert "adduser hermeswebuitoo sudo" not in DOCKERFILE + assert "hermeswebuitoo" not in DOCKERFILE, "production image should not keep a sudo-capable staging user" + + +def test_init_script_does_not_depend_on_sudo_at_runtime(): + """Runtime setup may start as root, but must drop privileges without sudo.""" + assert re.search(r"^if \[ \"A\$\{whoami\}\" == \"Aroot\" \]; then", INIT_SCRIPT, re.MULTILINE), ( + "docker_init.bash should perform privileged setup only in an explicit root init block" + ) + assert "sudo " not in INIT_SCRIPT, "docker_init.bash must not invoke sudo in production" + assert re.search(r"\bsu\b.*\bhermeswebui\b", INIT_SCRIPT), ( + "docker_init.bash must drop from root to hermeswebui before launching the server" + ) + + +def test_init_script_uses_private_scratch_permissions(): + """Init scratch paths under /tmp must be owner-only, not world-writable.""" + assert "chmod 777" not in INIT_SCRIPT + assert "umask 0077" in INIT_SCRIPT + assert re.search(r"chmod\s+700\s+\"?\$itdir\"?", INIT_SCRIPT), ( + "/tmp/hermeswebui_init should be mode 700" + ) + assert re.search(r"chmod\s+600\s+\"?\$\{?tmpfile\}?\"?", INIT_SCRIPT), ( + "scratch files storing UID/GID/env data should be mode 600" + ) + + +def test_docker_docs_explain_production_privilege_model(): + """Docs must describe the production threat model rather than hiding the tradeoff.""" + hardening_section = DOCKER_DOCS[DOCKER_DOCS.find("## Production image security model") :] + assert "## Production image security model" in DOCKER_DOCS + assert "passwordless sudo" in hardening_section + assert "root" in hardening_section and "hermeswebui" in hardening_section + assert "single-tenant" in hardening_section diff --git a/tests/test_issue1910_login_attempt_persistence.py b/tests/test_issue1910_login_attempt_persistence.py new file mode 100644 index 00000000..eba3bb42 --- /dev/null +++ b/tests/test_issue1910_login_attempt_persistence.py @@ -0,0 +1,52 @@ +import json +import stat +import time + +from api import auth + + +def test_login_attempts_persist_failed_attempts(tmp_path, monkeypatch): + attempts_file = tmp_path / ".login_attempts.json" + monkeypatch.setattr(auth, "_LOGIN_ATTEMPTS_FILE", attempts_file) + monkeypatch.setattr(auth, "_login_attempts", {}) + + auth._record_login_attempt("203.0.113.10") + + data = json.loads(attempts_file.read_text(encoding="utf-8")) + assert "203.0.113.10" in data + assert len(data["203.0.113.10"]) == 1 + assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600 + + +def test_login_attempts_load_prunes_expired_entries(tmp_path, monkeypatch): + attempts_file = tmp_path / ".login_attempts.json" + now = time.time() + attempts_file.write_text( + json.dumps( + { + "203.0.113.10": [now], + "203.0.113.11": [now - auth._LOGIN_WINDOW - 5], + "bad": "not-a-list", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(auth, "_LOGIN_ATTEMPTS_FILE", attempts_file) + + loaded = auth._load_login_attempts() + + assert list(loaded) == ["203.0.113.10"] + assert len(loaded["203.0.113.10"]) == 1 + + +def test_login_rate_limit_survives_reload(tmp_path, monkeypatch): + attempts_file = tmp_path / ".login_attempts.json" + monkeypatch.setattr(auth, "_LOGIN_ATTEMPTS_FILE", attempts_file) + monkeypatch.setattr(auth, "_login_attempts", {}) + + for _ in range(auth._LOGIN_MAX_ATTEMPTS): + auth._record_login_attempt("203.0.113.12") + + monkeypatch.setattr(auth, "_login_attempts", auth._load_login_attempts()) + + assert not auth._check_login_rate("203.0.113.12") diff --git a/tests/test_issue357.py b/tests/test_issue357.py index 49ce59c0..f9c9efeb 100644 --- a/tests/test_issue357.py +++ b/tests/test_issue357.py @@ -7,9 +7,9 @@ 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. +2. workspace directory setup must happen before the server drops privileges; + bind-mount dirs created by Docker as root are unwritable by hermeswebui. + Fix: root init mkdir/chown, then runtime verifies access without sudo. """ import pathlib import re @@ -133,57 +133,60 @@ class TestInitScriptUvSkip: class TestWorkspacePermissions: - def test_workspace_uses_sudo_mkdir(self): - """docker_init.bash must use 'sudo mkdir' for the workspace directory. + def test_workspace_uses_root_init_mkdir(self): + """docker_init.bash must create missing workspaces during root init. Docker auto-creates bind-mount directories as root if they don't exist, - leaving them unwritable by hermeswebui. sudo mkdir + chown fixes this. + leaving them unwritable by hermeswebui. The production image no longer + ships sudo, so root init handles mkdir before dropping privileges. """ - # Find the workspace section - ws_section = INIT_SCRIPT[ - INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"): - INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800 + root_section = INIT_SCRIPT[ + INIT_SCRIPT.find('if [ "A${whoami}" == "Aroot" ]; then'): + INIT_SCRIPT.find('exec su') ] - 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)" + assert 'mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in root_section, ( + "docker_init.bash must mkdir the workspace during root init " + "to handle Docker-created bind-mount dirs (#357)" ) - def test_workspace_uses_sudo_chown(self): - """docker_init.bash must chown the workspace to hermeswebui when writable. + def test_workspace_uses_root_init_chown(self): + """docker_init.bash must chown the workspace before dropping privileges. - The chown is now conditional on the workspace being writable, to allow - read-only (:ro) workspace mounts without crashing (#670). The sudo chown - must still be present in the script (just guarded by [ -w ]). + The server runtime does not have sudo; the privileged init phase may + chown writable bind mounts, while read-only mounts continue with a warning. """ - assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, ( - "docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace " - "when it is writable, so the app user can write to it (#357)" + root_section = INIT_SCRIPT[ + INIT_SCRIPT.find('if [ "A${whoami}" == "Aroot" ]; then'): + INIT_SCRIPT.find('exec su') + ] + assert 'chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in root_section, ( + "docker_init.bash must chown the workspace during root init " + "so the app user can write to it when possible (#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" + """Root init mkdir must come before root init chown in docker_init.bash.""" + mkdir_pos = INIT_SCRIPT.find('mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"') + chown_pos = INIT_SCRIPT.find('chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"') + assert mkdir_pos != -1, "root init mkdir for workspace not found" + assert chown_pos != -1, "root init chown for workspace not found" + assert mkdir_pos < chown_pos, "root init mkdir must come before root init 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" + """Root init mkdir must call error_exit on failure.""" + assert 'mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, ( + "workspace mkdir must call error_exit on failure" ) - def test_workspace_chown_is_conditional_on_writable(self): - """chown and write-test must be skipped for read-only workspace mounts (#670). + def test_workspace_write_test_is_conditional_on_writable(self): + """Write-test must be skipped for read-only workspace mounts (#670). - The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before - attempting chown or a write test, so :ro bind-mounts don't crash startup. + The runtime phase must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before + attempting a write test, so :ro bind-mounts don't crash startup. """ assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, ( - "docker_init.bash must guard chown with [ -w ] to support read-only " - "workspace mounts (:ro) without crashing (#670)" + "docker_init.bash must guard the workspace write-test with [ -w ] " + "to support read-only workspace mounts (:ro) without crashing (#670)" ) # Read-only path must log a clear message rather than calling error_exit assert "read-only workspace is supported" in INIT_SCRIPT, ( diff --git a/tests/test_issue569_579.py b/tests/test_issue569_579.py index 74d8b856..3f01fe7e 100644 --- a/tests/test_issue569_579.py +++ b/tests/test_issue569_579.py @@ -36,11 +36,11 @@ def test_569_autodetect_before_usermod(): detect_pos = INIT_SH.find("stat -c '%u'") if detect_pos == -1: detect_pos = INIT_SH.find("stat -c") - usermod_pos = INIT_SH.find("sudo usermod") + usermod_pos = INIT_SH.find("usermod -o -u") assert detect_pos != -1, "stat UID detection not found" - assert usermod_pos != -1, "sudo usermod not found" + assert usermod_pos != -1, "usermod not found" assert detect_pos < usermod_pos, ( - "UID auto-detect must occur before 'sudo usermod' so the correct UID " + "UID auto-detect must occur before 'usermod' so the correct UID " "is used when remapping the hermeswebui user" ) diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 89e019ba..359aca22 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -220,6 +220,7 @@ def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes( def test_kanban_lifecycle_controls_do_not_offer_manual_running_start(): assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS assert "kanban_card_start" not in PANELS + assert "kanban_card_start" not in I18N assert '' not in INDEX assert "Cannot set status to 'running' directly" not in PANELS assert "kanban_work_queue_hint" in PANELS diff --git a/tests/test_older_history_viewport_preservation.py b/tests/test_older_history_viewport_preservation.py new file mode 100644 index 00000000..997566d0 --- /dev/null +++ b/tests/test_older_history_viewport_preservation.py @@ -0,0 +1,55 @@ +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, signature: str) -> str: + start = src.index(signature) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"function body not found: {signature}") + + +def test_loading_older_messages_expands_render_window_before_rendering(): + body = _function_body(SESSIONS_JS, "async function _loadOlderMessages") + + prepend_idx = body.index("S.messages = [...olderMsgs, ...S.messages]") + expand_idx = body.index("_messageRenderWindowSize=_currentMessageRenderWindowSize()") + render_idx = body.index("renderMessages({ preserveScroll: true });") + + assert prepend_idx < expand_idx < render_idx, ( + "scroll-to-top paging must expand the DOM render window before renderMessages(); " + "otherwise fetched older messages stay hidden and only the hidden counter changes" + ) + assert "Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT)" in body + + +def test_loading_older_messages_preserves_viewport_without_bottom_snap(): + body = _function_body(SESSIONS_JS, "async function _loadOlderMessages") + + assert "renderMessages({ preserveScroll: true });" in body + assert "const oldTop = container.scrollTop" in body + assert "const addedHeight = Math.max(0, newScrollH - prevScrollH)" in body + assert "container.scrollTop = oldTop + addedHeight" in body + assert "container.scrollTop = newScrollH - prevScrollH" not in body + + restore_idx = body.index("container.scrollTop = oldTop + addedHeight") + unpin_idx = body.rindex("_scrollPinned = false") + assert restore_idx < unpin_idx + + +def test_loading_older_messages_marks_scroll_programmatic_while_anchoring(): + body = _function_body(SESSIONS_JS, "async function _loadOlderMessages") + + set_idx = body.index("_programmaticScroll = true;") + restore_idx = body.index("container.scrollTop = oldTop + addedHeight") + clear_idx = body.index("requestAnimationFrame(()=>{ _programmaticScroll = false; })") + assert set_idx < restore_idx < clear_idx diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index f31b2085..69cb6168 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -589,7 +589,7 @@ class TestScrollPositionPreservation: ) def test_resets_scroll_pinned_after_restore(self): - """_scrollPinned must be set to false after restoring scroll position.""" + """_scrollPinned must be false after older-history scroll anchoring.""" SESSIONS_JS = pathlib.Path(__file__).parent.parent / "static" / "sessions.js" src = SESSIONS_JS.read_text(encoding="utf-8") @@ -598,13 +598,12 @@ class TestScrollPositionPreservation: fn_body = src[fn_start:fn_end] assert "_scrollPinned = false" in fn_body, ( - "renderMessages() calls scrollToBottom() which sets _scrollPinned=true. " - "After restoring the user's scroll position we must set _scrollPinned=false " - "to prevent the next render from snapping back to the bottom." + "Older-history paging must leave the transcript unpinned so the next " + "render does not snap back to the newest output." ) - # _scrollPinned must appear after the scrollTop restore - restore_idx = fn_body.find("container.scrollTop = newScrollH - prevScrollH") - pinned_idx = fn_body.find("_scrollPinned = false") - assert restore_idx >= 0 and pinned_idx >= 0 and restore_idx < pinned_idx, ( - "_scrollPinned = false must appear AFTER the scrollTop restore." + target_idx = fn_body.find("container.scrollTop = oldTop + addedHeight") + scroll_idx = fn_body.find("requestAnimationFrame(()=>{ _programmaticScroll = false; })") + pinned_idx = fn_body.rfind("_scrollPinned = false") + assert target_idx >= 0 and scroll_idx >= 0 and pinned_idx >= 0 and target_idx < scroll_idx < pinned_idx, ( + "_scrollPinned = false must appear AFTER the older-history viewport-preserve scroll." ) diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index d8d162f2..89e4af54 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -126,6 +126,49 @@ console.log(JSON.stringify({{sid: collapsed[0].session_id, containsRoot: _sessio assert '"containsRoot":true' in result +def test_stale_optimistic_compression_tips_collapse_even_when_parents_are_visible(): + """Active compression can leave old streaming tips in browser memory. + + The server/index already expose only the latest tip, but client-side + optimistic rows from previous tips may still include parent_session_id links. + Those rows carry explicit lineage metadata and must collapse as one sidebar + conversation instead of rendering 7/8/9/10 segment duplicates. + """ + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +const sessions = [ + {{session_id:'seg7', title:'Graphify', parent_session_id:'seg6', message_count:1141, updated_at:70, last_message_at:70, _lineage_root_id:'root', _compression_segment_count:7}}, + {{session_id:'seg8', title:'Graphify', parent_session_id:'seg7', message_count:1254, updated_at:80, last_message_at:80, _lineage_root_id:'root', _compression_segment_count:8, pending_user_message:'old'}}, + {{session_id:'seg9', title:'Graphify', parent_session_id:'seg8', message_count:1404, updated_at:90, last_message_at:90, _lineage_root_id:'root', _compression_segment_count:9, active_stream_id:'old-stream'}}, + {{session_id:'seg10', title:'Graphify', parent_session_id:'seg9', message_count:1490, updated_at:100, last_message_at:100, _lineage_root_id:'root', _compression_segment_count:10, active_stream_id:'current-stream'}}, +]; +const collapsed = _collapseSessionLineageForSidebar(sessions); +console.log(JSON.stringify(collapsed)); +""" + collapsed = json.loads(_run_node(source)) + assert [row["session_id"] for row in collapsed] == ["seg10"] + assert collapsed[0]["_lineage_collapsed_count"] == 4 + assert collapsed[0]["_compression_segment_count"] == 10 + assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg10", "seg9", "seg8", "seg7"] + def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): diff --git a/tests/test_streaming_sidebar_scroll.py b/tests/test_streaming_sidebar_scroll.py index 60aa9339..f4dcecec 100644 --- a/tests/test_streaming_sidebar_scroll.py +++ b/tests/test_streaming_sidebar_scroll.py @@ -38,8 +38,12 @@ def test_scroll_if_pinned_skips_during_recent_non_message_scroll(): fn = _extract_fn(UI_JS, "scrollIfPinned") assert "_recentNonMessageScrollIntent()" in fn guard_index = fn.find("_recentNonMessageScrollIntent()") - write_index = fn.find("scrollTop=el.scrollHeight") - assert guard_index >= 0 and write_index >= 0 and guard_index < write_index + settle_index = fn.find("_settleMessageScrollToBottom(false)") + assert guard_index >= 0 and settle_index >= 0 and guard_index < settle_index + + settle = _extract_fn(UI_JS, "_settleMessageScrollToBottom") + assert "_setMessageScrollToBottom();" in settle + assert "_recentNonMessageScrollIntent()" in settle def test_session_list_has_its_own_scroll_boundary(): diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py new file mode 100644 index 00000000..b05fa4e8 --- /dev/null +++ b/tests/test_tars_scroll_reset_regressions.py @@ -0,0 +1,86 @@ +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, signature: str) -> str: + start = src.index(signature) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"function body not found: {signature}") + + +def _scroll_listener_block() -> str: + start = UI_JS.index("el.addEventListener('scroll'") + return UI_JS[start : UI_JS.index("})();", start)] + + +def test_clicking_current_session_is_noop_before_load_session_side_effects(): + load_session = _function_body(SESSIONS_JS, "async function loadSession") + + current_idx = load_session.index("const currentSid = S.session ? S.session.session_id : null") + noop_idx = load_session.index("if(currentSid===sid) return") + loading_idx = load_session.index("_loadingSessionId = sid") + stop_idx = load_session.index("stopApprovalPolling") + + assert current_idx < noop_idx < loading_idx < stop_idx, ( + "clicking the already-open sidebar row must be a no-op before loadSession() " + "mutates loading/runtime state or scroll-affecting UI" + ) + + +def test_scroll_to_bottom_settles_across_late_markdown_layout_growth(): + settle = _function_body(UI_JS, "function _settleMessageScrollToBottom") + scroll = _function_body(UI_JS, "function scrollToBottom") + pinned = _function_body(UI_JS, "function scrollIfPinned") + + assert "requestAnimationFrame" in settle + assert "setTimeout" in settle + assert "const passes=[0,16,80,180]" in settle + assert "_settleMessageScrollToBottom(true)" in scroll + assert "_settleMessageScrollToBottom(false)" in pinned + assert "!_scrollPinned" in settle + assert "const token=++_bottomSettleToken" in settle + assert "token!==_bottomSettleToken" in settle + + +def test_scroll_to_bottom_writes_scroll_position_immediately_before_delayed_settle(): + scroll = _function_body(UI_JS, "function scrollToBottom") + + immediate_idx = scroll.index("_setMessageScrollToBottom();") + settle_idx = scroll.index("_settleMessageScrollToBottom(true)") + + assert immediate_idx < settle_idx, ( + "scrollToBottom() must write scrollTop synchronously before scheduling delayed settles; " + "otherwise a DOM-rebuild scroll event can cancel the delayed passes and strand the viewport at the top" + ) + + +def test_message_scroll_listener_does_not_downgrade_explicit_bottom_pin_on_first_near_bottom_event(): + listener_block = _scroll_listener_block() + set_bottom = _function_body(UI_JS, "function _setMessageScrollToBottom") + + assert "_nearBottomCount=2" in set_bottom + assert "_scrollPinned=_nearBottomCount>=2" not in listener_block + assert "if(_nearBottomCount>=2) _scrollPinned=true" in listener_block + assert "else { _nearBottomCount=0; _scrollPinned=false; }" in listener_block + + +def test_user_scroll_cancels_delayed_bottom_settling(): + listener_block = _scroll_listener_block() + record = _function_body(UI_JS, "function _recordNonMessageScrollIntent") + + assert "function _cancelBottomSettle" in UI_JS + assert "_cancelBottomSettle();" in listener_block + assert "e.deltaY<0" in record + assert "_cancelBottomSettle();" in record + assert "_scrollPinned=false" in record