* 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>
407 lines
17 KiB
Python
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"
|