Files
hermes-webui/tests/test_7027_state_dir_uid_probe.py
T
d13e922120 Release batch B: Docker single-container UID fix + GLM-5.3 catalog (experimental) (#7090)
* feat: add GLM-5.3 to Z.AI model list

Add glm-5.3 as the newest zai entry in _PROVIDER_MODELS and a matching
zai/glm-5.3 entry in _FALLBACK_MODELS, and bump the Z.AI onboarding
default_model from glm-5.1 to glm-5.3 (Z.ai's current flagship; legacy
GLM-5.2/5.1 requests are routed to GLM-5.3 per docs.z.ai).

Reasoning gating needs no change: _zai_glm_classification() treats
GLM >= 5.2 as effort-ladder capable, so glm-5.3 is already covered and
pinned by tests/test_zai_reasoning_effort_gating.py.

New regression coverage in tests/test_glm_5_3_catalog.py: catalog
presence, newest-first ordering, fallback entry, onboarding default,
full reasoning_effort ladder, and the get_available_models() payload.

* test: restore _cfg_fingerprint in catalog test fixture

Review follow-up (#7017): the isolation fixture snapshot restored cfg,
_cfg_mtime, and _cfg_path but left _cfg_fingerprint pointing at the
temporary config loaded by the payload test. api/config.py uses that
fingerprint to distinguish in-memory overrides from changed files
(config.py:371), so a stale value could make later same-process tests
skip reloading a changed config. Snapshot and restore it like the rest.

* fix: keep Z.AI onboarding default at glm-5.1 until direct API serves GLM-5.3

Review follow-up (#7017): GLM-5.3 is live on Z.ai's Coding Plan
endpoint only; the direct api.z.ai endpoint the zai provider uses
still lists the GLM-5.3 API as coming soon. Defaulting new direct-API
users onto glm-5.3 would fail their first message, so the catalog
addition stays (opt-in) and the default stays glm-5.1. Bump the
default in a follow-up once the direct endpoint serves GLM-5.3.

* fix(docker): probe the configured state dir before /workspace for UID/GID (#7027)

UID/GID auto-detection probed /workspace before the configured
HERMES_WEBUI_STATE_DIR. In a stock single-container image none of the
priority-1 candidates exist, but /workspace does — owned by the image's
build-time 1024:1024. Detection therefore returned the image's own owner,
which carries no information about the host, while the one directory whose
owner *is* the host UID by definition — the state-dir bind mount — was never
probed. With a host-owned state mount and no explicit WANTED_UID the
container remapped to 1024, failed its own state-dir writability check, and
restart-looped.

The log line made this expensive to debug: 1024 is also the fallback default,
so "Auto-detected workspace UID: 1024" read as if detection had found nothing.

- probe ${HERMES_WEBUI_STATE_DIR:-/app/data} first, for both UID and GID
- keep /workspace as a lower-priority signal (unchanged for setups that
  actually bind-mount it), and keep the hermes-home probes from #668
- stop treating an explicitly supplied 1024 as "unset": the sentinel and a
  valid UID were the same number, so an operator who deliberately ran as 1024
  got it overwritten by detection. The explicit/detected origin is persisted
  next to the value because `su` drops the environment when the script
  re-enters as the runtime user, so the second pass would otherwise see an
  explicit choice as a detected one.

Tests: tests/test_7027_state_dir_uid_probe.py runs the real resolution block
under bash with `stat` stubbed, covering the non-1024 state mount, the
explicit 1024 override (including across the privilege drop), and the
pre-existing /workspace + hermes-home fallback paths. A new state-dir-uid job
in the Docker smoke workflow boots a real container on a host-owned state
mount and gates on /health.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Release batch B: Docker UID fix + GLM-5.3 catalog (experimental)

Two independently gate-passed contributor PRs, rebased fresh onto master and
re-gated as a combined stage (Codex SAFE TO SHIP; full suite green except the 8
pre-existing approval-test failures that fail identically on clean origin/master
— CI green on the same commit; tracked separately for a fix).

- #7027 (@jorgejiro) probe configured state-dir before /workspace for Docker
  UID/GID; persist explicit marker so a supplied 1024 survives root->su re-entry
  (fixes the single-container restart loop) (#7034)
- #7017 (@rh-id) add GLM-5.3 to the Z.AI model list (onboarding default stays glm-5.1)

Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: Jorge <jorgejiro@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: n <a@n>
Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>
2026-08-16 21:52:14 +00:00

407 lines
17 KiB
Python

"""Regression tests for #7027 — UID/GID auto-detection probe order.
Background: ``docker_init.bash`` auto-detects the UID/GID to remap the
``hermeswebui`` user to, by stat-ing mounted directories. Before this fix the
probe list was:
priority 1: /home/hermeswebui/.hermes, $HERMES_HOME, /opt/data
priority 2: /workspace
fallback: 1024
In a stock single-container image none of the priority-1 candidates exist, but
``/workspace`` *does* — owned by the image's build-time ``1024:1024``. Detection
therefore returned the image's own owner, which carries no information about the
host, while the one directory that carries the host UID by definition — the
state-dir bind mount — was never probed. With a host-owned state mount and no
explicit ``WANTED_UID``, the container remapped to 1024, failed its own
writability check on the state dir, and restart-looped:
touch: cannot touch '/app/data/.testfile': Permission denied
!! ERROR: Failed to verify state directory at /app/data
The second half of the bug: ``1024`` was both the fallback sentinel and a
legitimate UID, so ``WANTED_UID=1024`` supplied explicitly by an operator was
overwritten by detection anyway.
The behavioural tests below extract the UID/GID resolution block from
``docker_init.bash`` and run it under real bash, with ``stat`` stubbed so
ownership can be simulated without root. Source-level assertions alone cannot
catch shell-quoting or ordering regressions inside the block (the pattern
mirrors ``tests/test_docker_env_readonly_vars.py``).
The startup-health half of this contract — a real container coming up on a
host-owned state mount — is covered by the ``state-dir-uid`` job in
``.github/workflows/docker-smoke.yml``.
"""
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INIT_SH = (REPO_ROOT / "docker_init.bash").read_text(encoding="utf-8")
# Logical probe name -> path basename used inside the sandbox. The extracted
# block's absolute paths are rewritten to these so the test can create/own them.
PROBE_PATHS = {
"state": "state",
"app_data": "app-data",
"hermes_home": "hermes-home",
"opt_data": "opt-data",
"workspace": "workspace",
}
_BLOCK_START = "it=$itdir/hermeswebui_user_uid\n"
_BLOCK_END = 'echo "-- WANTED_GID: \\"${WANTED_GID}\\""'
# ── source-level invariants ───────────────────────────────────────────────────
def _probe_loop_lines():
"""Both `for _probe_dir in ...` lines (UID block, then GID block)."""
return [ln for ln in INIT_SH.splitlines() if "for _probe_dir in" in ln]
def test_7027_both_probe_loops_include_the_state_dir():
"""UID *and* GID detection must probe the configured state directory."""
loops = _probe_loop_lines()
assert len(loops) == 2, f"expected one probe loop for UID and one for GID, found {len(loops)}"
for line in loops:
assert "HERMES_WEBUI_STATE_DIR" in line, (
"probe loop must include the configured state dir — it is the only "
f"path that is a bind mount by definition (#7027): {line.strip()!r}"
)
def test_7027_state_dir_is_probed_first_in_both_loops():
"""The state dir must be the first candidate, ahead of hermes-home/workspace."""
for line in _probe_loop_lines():
candidates = line.split("for _probe_dir in", 1)[1].rstrip("; do").strip()
first = candidates.split()[0]
assert "HERMES_WEBUI_STATE_DIR" in first, (
"the state dir must be probed before any image-owned path, otherwise "
f"a stock /workspace wins with the build-time 1024 (#7027): {first!r}"
)
def test_7027_workspace_probe_still_exists_as_lower_priority():
"""/workspace stays as a fallback signal — it is not removed (#569, #668)."""
assert 'if [ -d "/workspace" ]' in INIT_SH, (
"/workspace must remain a lower-priority probe for setups that bind-mount it"
)
state_pos = INIT_SH.find("HERMES_WEBUI_STATE_DIR:-/app/data")
workspace_pos = INIT_SH.find('if [ -d "/workspace" ]')
assert state_pos != -1 and workspace_pos != -1
assert state_pos < workspace_pos, "state-dir probe must precede the /workspace probe"
def test_7027_explicit_value_guard_present_on_every_detection_branch():
"""Each detection branch must be gated on the value not being explicit.
Without this, `WANTED_UID=1024` set deliberately by an operator is
indistinguishable from the unset fallback and gets overwritten.
"""
uid_guards = INIT_SH.count('[ "$_wanted_uid_source" != "explicit" ]')
gid_guards = INIT_SH.count('[ "$_wanted_gid_source" != "explicit" ]')
assert uid_guards == 2, f"both UID detection branches must be guarded, found {uid_guards}"
assert gid_guards == 2, f"both GID detection branches must be guarded, found {gid_guards}"
def test_7027_id_source_is_persisted_for_the_runtime_pass():
"""The explicit/detected origin must be persisted next to the value.
`su` drops the environment when the script re-enters as the runtime user,
so without persistence the second pass cannot tell an operator's explicit
1024 from the fallback.
"""
assert "hermeswebui_user_uid_source" in INIT_SH
assert "hermeswebui_user_gid_source" in INIT_SH
assert INIT_SH.count('write_privtmpfile $it_source') == 2, (
"both the UID and GID source markers must be written to $itdir"
)
def test_7027_fallback_default_preserved():
"""The 1024 fallback must survive (guard shared with #569)."""
assert "WANTED_UID=${WANTED_UID:-1024}" in INIT_SH
assert "WANTED_GID=${WANTED_GID:-1024}" in INIT_SH
# ── behavioural harness ───────────────────────────────────────────────────────
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
class TestIdResolutionBehaviour:
"""Run the real resolution block under bash against simulated mounts."""
@staticmethod
def _extract_block(init_sh: str) -> str:
start = init_sh.find(_BLOCK_START)
assert start != -1, "UID/GID resolution block not found in docker_init.bash"
end = init_sh.find(_BLOCK_END, start)
assert end != -1, "end of the UID/GID resolution block not found"
return init_sh[start:end + len(_BLOCK_END)]
def _run(self, tmp_path, *, owners, env=None, itdir=None, state_dir_env=True):
"""Execute the resolution block.
owners: {logical name: (uid, gid)} — each named directory is created and
its simulated ownership is returned by the stubbed `stat`.
Directories not listed are simply absent.
state_dir_env: when False, HERMES_WEBUI_STATE_DIR is left unset so the
block falls back to its built-in default path.
"""
sandbox = tmp_path / "sandbox"
sandbox.mkdir(exist_ok=True)
itdir = itdir or (tmp_path / "itdir")
Path(itdir).mkdir(exist_ok=True)
paths = {name: sandbox / base for name, base in PROBE_PATHS.items()}
for name in owners:
paths[name].mkdir(parents=True, exist_ok=True)
block = self._extract_block(INIT_SH)
# Rewrite the block's absolute probe paths into the sandbox. Longest
# first so /app/data is not clipped by a shorter prefix.
for absolute, name in (
("/home/hermeswebui/.hermes", "hermes_home"),
("/opt/data", "opt_data"),
("/app/data", "app_data"),
("/workspace", "workspace"),
):
block = block.replace(absolute, str(paths[name]))
# `stat -c FMT PATH` stub: $2 is the format, $3 the path. Unknown paths
# exit nonzero so the block's `|| echo ""` yields an empty detection.
cases = []
for name, (uid, gid) in owners.items():
cases.append(
f' {str(paths[name])!r}) '
f'if [ "$_fmt" = "%u" ]; then echo "{uid}"; else echo "{gid}"; fi; return 0 ;;'
)
stat_cases = "\n".join(cases) if cases else " __never__) return 1 ;;"
script = textwrap.dedent("""\
set -e
itdir={itdir}
write_privtmpfile() {{
tmpfile=$1
if [ -f "$tmpfile" ]; then rm -f "$tmpfile"; fi
printf '%s' "$2" > "$tmpfile"
chmod 600 "$tmpfile"
}}
error_exit() {{ echo "!! ERROR: $*"; exit 1; }}
stat() {{
_fmt=$2
_path=$3
case "$_path" in
{stat_cases}
esac
return 1
}}
{block}
echo "RESULT_UID=${{WANTED_UID}}"
echo "RESULT_GID=${{WANTED_GID}}"
echo "RESULT_UID_SOURCE=$(cat "$itdir/hermeswebui_user_uid_source" 2>/dev/null || echo missing)"
echo "RESULT_GID_SOURCE=$(cat "$itdir/hermeswebui_user_gid_source" 2>/dev/null || echo missing)"
""").format(
itdir=str(itdir),
stat_cases=stat_cases,
block=block,
)
run_env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}
if state_dir_env:
run_env["HERMES_WEBUI_STATE_DIR"] = str(paths["state"])
run_env.update(env or {})
result = subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
timeout=20,
env=run_env,
)
assert result.returncode == 0, (
f"resolution block failed: rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
parsed = dict(
line.split("=", 1)
for line in result.stdout.splitlines()
if line.startswith("RESULT_")
)
parsed["_stdout"] = result.stdout
parsed["_state_dir"] = str(paths["state"])
return parsed
# ── the reported bug ──────────────────────────────────────────────────────
def test_7027_state_mount_beats_stock_workspace(self, tmp_path):
"""The reproduction from the issue: host-owned state dir, stock /workspace.
Host directory owned by 1001:1001 bind-mounted as the state dir, no
explicit IDs, and an image-owned /workspace at 1024:1024. Detection must
return 1001 — the identity that makes the state dir writable.
"""
out = self._run(
tmp_path,
owners={"state": (1001, 1001), "workspace": (1024, 1024)},
)
assert out["RESULT_UID"] == "1001", (
"UID must be detected from the state bind mount, not from the "
f"image-owned /workspace (#7027). stdout:\n{out['_stdout']}"
)
assert out["RESULT_GID"] == "1001", (
f"GID must be detected from the state bind mount too. stdout:\n{out['_stdout']}"
)
assert f"(from {out['_state_dir']})" in out["_stdout"], (
"the log line must name the state dir as the detection source so the "
"operator can see which path decided the identity"
)
def test_7027_non_1024_state_mount_with_no_workspace_at_all(self, tmp_path):
"""Same, in the single-container shape where /workspace is not mounted."""
out = self._run(tmp_path, owners={"state": (501, 20)})
assert out["RESULT_UID"] == "501", "macOS-style host UID must be detected"
assert out["RESULT_GID"] == "20"
def test_7027_default_state_path_probed_when_env_unset(self, tmp_path):
"""With HERMES_WEBUI_STATE_DIR unset, the built-in default is still probed."""
out = self._run(
tmp_path,
owners={"app_data": (1001, 1001), "workspace": (1024, 1024)},
state_dir_env=False,
)
assert out["RESULT_UID"] == "1001"
assert out["RESULT_GID"] == "1001"
# ── explicit operator values ──────────────────────────────────────────────
def test_7027_explicit_1024_is_not_overwritten(self, tmp_path):
"""`WANTED_UID=1024` set deliberately must survive detection.
1024 is both the fallback default and a legitimate UID; before the fix
the two were indistinguishable and detection clobbered the explicit value.
"""
out = self._run(
tmp_path,
owners={"state": (1001, 1001), "workspace": (1001, 1001)},
env={"WANTED_UID": "1024", "WANTED_GID": "1024"},
)
assert out["RESULT_UID"] == "1024", (
"an explicitly supplied 1024 must be preserved, not treated as unset (#7027)"
)
assert out["RESULT_GID"] == "1024"
assert out["RESULT_UID_SOURCE"] == "explicit"
assert out["RESULT_GID_SOURCE"] == "explicit"
def test_7027_explicit_non_default_still_wins(self, tmp_path):
"""The pre-existing contract: any explicit value beats detection."""
out = self._run(
tmp_path,
owners={"state": (1001, 1001)},
env={"WANTED_UID": "1500", "WANTED_GID": "1500"},
)
assert out["RESULT_UID"] == "1500"
assert out["RESULT_GID"] == "1500"
def test_7027_explicit_choice_survives_the_privilege_drop(self, tmp_path):
"""Second pass (as the runtime user) must not re-detect over an explicit 1024.
docker_init.bash runs twice: once as root, then `exec su` re-enters it as
hermeswebui with the environment dropped. The second pass reads the value
back from $itdir — so the *origin* has to persist too, or the explicit
1024 gets auto-detected away and the UID no longer matches the running
user, which is a hard startup failure.
"""
itdir = tmp_path / "itdir-shared"
first = self._run(
tmp_path,
owners={"state": (1001, 1001)},
env={"WANTED_UID": "1024", "WANTED_GID": "1024"},
itdir=itdir,
)
assert first["RESULT_UID"] == "1024"
second = self._run( # no WANTED_* in env — `su` dropped it
tmp_path,
owners={"state": (1001, 1001)},
itdir=itdir,
)
assert second["RESULT_UID"] == "1024", (
"the runtime pass must honour the persisted explicit choice; "
"re-detecting here would make WANTED_UID disagree with the user the "
"script is already running as"
)
assert second["RESULT_GID"] == "1024"
def test_7027_persisted_default_is_still_re_detected(self, tmp_path):
"""A *detected*/fallback 1024 stays re-detectable on a later run.
This is what the `= 1024` sentinel was for: a container that fell back to
1024 with nothing mounted must pick up the right identity once a state
volume appears. Only explicit values are frozen.
"""
itdir = tmp_path / "itdir-shared"
first = self._run(tmp_path, owners={}, itdir=itdir)
assert first["RESULT_UID"] == "1024", "nothing mounted -> fallback"
assert first["RESULT_UID_SOURCE"] == "detected"
second = self._run(tmp_path, owners={"state": (1001, 1001)}, itdir=itdir)
assert second["RESULT_UID"] == "1001", (
"a persisted fallback 1024 must not freeze detection for later runs"
)
assert second["RESULT_GID"] == "1001"
# ── previously shipped behaviour that must not regress ────────────────────
def test_7027_workspace_fallback_preserved(self, tmp_path):
"""With no state mount, /workspace still decides (#569)."""
out = self._run(tmp_path, owners={"workspace": (501, 20)})
assert out["RESULT_UID"] == "501"
assert out["RESULT_GID"] == "20"
assert "from /workspace" in out["_stdout"] or "workspace UID" in out["_stdout"]
def test_7027_hermes_home_still_beats_workspace(self, tmp_path):
"""Two-container setups keep detecting from the shared hermes-home (#668)."""
out = self._run(
tmp_path,
owners={"hermes_home": (1001, 1001), "workspace": (1024, 1024)},
)
assert out["RESULT_UID"] == "1001"
assert out["RESULT_GID"] == "1001"
def test_7027_hermes_home_env_probe_preserved(self, tmp_path):
"""$HERMES_HOME remains a probe candidate (#668)."""
sandbox = tmp_path / "sandbox"
sandbox.mkdir(exist_ok=True)
hermes_home = sandbox / "hermes-home"
out = self._run(
tmp_path,
owners={"hermes_home": (1001, 1001)},
env={"HERMES_HOME": str(hermes_home)},
)
assert out["RESULT_UID"] == "1001"
def test_7027_root_owned_state_dir_is_skipped(self, tmp_path):
"""A root-owned state dir (fresh named volume) is not a host signal.
Docker creates a brand-new named volume owned by 0:0. Detection must skip
it — as it already does for every probe — and fall through.
"""
out = self._run(
tmp_path,
owners={"state": (0, 0), "workspace": (501, 20)},
)
assert out["RESULT_UID"] == "501", "root-owned probes must be ignored"
assert out["RESULT_GID"] == "20"
def test_7027_nothing_mounted_falls_back_to_1024(self, tmp_path):
"""No probe resolves -> the documented 1024 default."""
out = self._run(tmp_path, owners={})
assert out["RESULT_UID"] == "1024"
assert out["RESULT_GID"] == "1024"