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>
This commit is contained in:
authored and GitHub committed 2026-08-16 21:52:14 +00:00
1 parent 8686528b1b
commit d13e922120
7 files changed
+880 -16

No files matched your search

+170
View File
@@ -87,6 +87,176 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Startup-health gate for UID/GID auto-detection (#7027).
#
# The compose variants above all mount a hermes-home volume, so they never
# exercise the plain single-container shape from the issue: a state directory
# bind-mounted from a host directory owned by a non-1024 user, /workspace left
# as the image's own build-time 1024, and no explicit WANTED_UID/WANTED_GID.
# Before the fix the auto-detector read /workspace, remapped to 1024, failed
# its own state-dir writability check and restart-looped. Source-level
# invariants can't catch that — only actually booting the container can.
state-dir-uid:
name: State-dir UID detection (#7027)
runs-on: ubuntu-latest
needs: [compose-config, build-image]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Restore Docker image from cache
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
- name: Boot on a host-owned state mount with no explicit IDs
run: |
set -euo pipefail
CONTAINER="hermes-smoke-uid-${{ github.run_id }}-${{ github.run_attempt }}"
HOST_UID=1001
STATE_DIR="$(mktemp -d -t hermes-smoke-uidstate-XXXXXX)"
sudo chown -R "$HOST_UID:$HOST_UID" "$STATE_DIR"
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker logs "$CONTAINER" 2>&1 | tail -200 || true
docker rm -f "$CONTAINER" || true
sudo rm -rf "$STATE_DIR" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
# Deliberately NOT mounting /workspace: the point of this gate is that
# the stock image's own /workspace (owned 1024:1024) must not win over
# the host-owned state mount.
echo "::group::docker run"
docker run -d --name "$CONTAINER" \
-p 127.0.0.1:8788:8787 \
-e HERMES_WEBUI_STATE_DIR=/app/data \
-v "$STATE_DIR":/app/data \
ghcr.io/nesquena/hermes-webui:latest
echo "::endgroup::"
# ----- The product gate: the container must reach /health -----
echo "::group::Probe /health"
attempts=0
max_attempts=60
until curl --fail --silent --max-time 5 http://127.0.0.1:8788/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~5m)"
echo " Startup logs follow in the cleanup group below."
exit 1
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
echo "❌ Container exited before /health came up — this is the #7027 restart loop"
exit 1
fi
sleep 5
done
echo "✅ /health = 200 after $attempts attempts"
echo "::endgroup::"
echo "::group::Verify the detected identity"
LOGS="$(docker logs "$CONTAINER" 2>&1)"
# The identity must have been detected from the state dir, not /workspace.
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected UID: ${HOST_UID} \(from /app/data\)"; then
echo "❌ UID was not auto-detected from the state directory (#7027)"
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
exit 1
fi
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected GID: ${HOST_UID} \(from /app/data\)"; then
echo "❌ GID was not auto-detected from the state directory (#7027)"
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
exit 1
fi
# And the runtime user must really carry it.
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
ACTUAL_GID="$(docker exec "$CONTAINER" id -g hermeswebui)"
if [ "$ACTUAL_UID" != "$HOST_UID" ] || [ "$ACTUAL_GID" != "$HOST_UID" ]; then
echo "❌ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, expected ${HOST_UID}:${HOST_UID}"
exit 1
fi
echo "✅ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, detected from the state mount"
echo "::endgroup::"
echo "::group::Startup log scan"
BAD_PATTERNS='!! ERROR|!! Exiting script|Failed to verify state directory|Permission denied'
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
echo "❌ Startup logs contain known-bad pattern (see above)"
exit 1
fi
echo "✅ No known-bad patterns in startup logs"
echo "::endgroup::"
- name: Explicit WANTED_UID=1024 must survive auto-detection
run: |
set -euo pipefail
CONTAINER="hermes-smoke-uid1024-${{ github.run_id }}-${{ github.run_attempt }}"
STATE_DIR="$(mktemp -d -t hermes-smoke-uid1024-XXXXXX)"
HERMES_DIR="$(mktemp -d -t hermes-smoke-uid1024home-XXXXXX)"
# Both mounts are owned by a *different* UID than the one requested.
# The hermes-home mount is what makes this discriminating: it is a
# probe candidate the pre-fix script already read, so if the explicit
# 1024 is still treated as "unset" the container remaps to 1001 and
# the assertion below fails. World-writable state dir so the only
# thing under test is which identity wins, not mount permissions.
sudo chown -R 1001:1001 "$STATE_DIR" "$HERMES_DIR"
sudo chmod 0777 "$STATE_DIR"
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker logs "$CONTAINER" 2>&1 | tail -100 || true
docker rm -f "$CONTAINER" || true
sudo rm -rf "$STATE_DIR" "$HERMES_DIR" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
docker run -d --name "$CONTAINER" \
-p 127.0.0.1:8789:8787 \
-e HERMES_WEBUI_STATE_DIR=/app/data \
-e WANTED_UID=1024 \
-e WANTED_GID=1024 \
-v "$STATE_DIR":/app/data \
-v "$HERMES_DIR":/home/hermeswebui/.hermes \
ghcr.io/nesquena/hermes-webui:latest
attempts=0
max_attempts=60
until curl --fail --silent --max-time 5 http://127.0.0.1:8789/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 with explicit WANTED_UID=1024"
exit 1
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
echo "❌ Container exited before /health came up"
exit 1
fi
sleep 5
done
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
if [ "$ACTUAL_UID" != "1024" ]; then
echo "❌ explicit WANTED_UID=1024 was overwritten by auto-detection — got ${ACTUAL_UID} (#7027)"
exit 1
fi
echo "✅ explicit WANTED_UID=1024 preserved (sentinel no longer shadows the valid value)"
smoke: smoke:
name: Smoke ${{ matrix.variant }} name: Smoke ${{ matrix.variant }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4
View File
@@ -5,6 +5,10 @@
### Fixed ### Fixed
- **Docker single-container: an explicitly configured UID/GID is no longer lost, fixing a restart loop.** The entrypoint now probes the configured state-dir bind mount before the image-owned `/workspace` when auto-detecting the container UID/GID, and persists an `explicit` marker so a deliberately supplied `WANTED_UID`/`WANTED_GID` (e.g. `1024`) survives the root→user re-entry instead of being re-detected and overwritten. This fixes the documented single-container restart loop; the default stays `1024`, the two- and three-container topologies are unaffected, and a missing legacy marker falls back safely. Thanks @jorgejiro. (#7027)
- **The Z.AI model list now includes GLM-5.3.** GLM-5.3 was added to the Z.AI provider catalog so it's selectable in the model picker. The Z.AI onboarding default stays at GLM-5.1 until the direct API serves 5.3. Thanks @rh-id. (#7017)
- **A restart can no longer turn a suppressed `[SILENT]` control message into a phantom recovered turn on the `/goal` path.** The `/api/goal` endpoint now recognizes the exact reserved `[SILENT]` delivery-suppression sentinel and treats it as a successful no-op before any session lookup or goal-state mutation — matching the guard the chat-start endpoint already applies. Previously a wake relay that accidentally POSTed the sentinel to `/goal` could, after a restart, materialize it as a visible recovered user turn. Matching is exact and case-sensitive, so ordinary goals are unaffected. Thanks @webtecnica. (#7019) - **A restart can no longer turn a suppressed `[SILENT]` control message into a phantom recovered turn on the `/goal` path.** The `/api/goal` endpoint now recognizes the exact reserved `[SILENT]` delivery-suppression sentinel and treats it as a successful no-op before any session lookup or goal-state mutation — matching the guard the chat-start endpoint already applies. Previously a wake relay that accidentally POSTed the sentinel to `/goal` could, after a restart, materialize it as a visible recovered user turn. Matching is exact and case-sensitive, so ordinary goals are unaffected. Thanks @webtecnica. (#7019)
- **A busy subagent tree no longer promotes an active child to a misleading top-level sidebar row.** The session sidebar builds from a bounded oversampled candidate window; after the recency slice, subagent parents that were present in the candidate set could be dropped, leaving their still-running child leaves rendered as if they were their own top-level conversations. Those parents are now re-added after the slice (bounded, cycle-safe, subagent-scoped so WebUI ancestors stay excluded), so the delegated-session tree nests correctly. Thanks @carlotestor. (#7031) - **A busy subagent tree no longer promotes an active child to a misleading top-level sidebar row.** The session sidebar builds from a bounded oversampled candidate window; after the recency slice, subagent parents that were present in the candidate set could be dropped, leaving their still-running child leaves rendered as if they were their own top-level conversations. Those parents are now re-added after the slice (bounded, cycle-safe, subagent-scoped so WebUI ancestors stay excluded), so the delegated-session tree nests correctly. Thanks @carlotestor. (#7031)
+2
View File
@@ -1150,6 +1150,7 @@ _FALLBACK_MODELS = [
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7", "label": "MiniMax M2.7"}, {"provider": "MiniMax", "id": "minimax/MiniMax-M2.7", "label": "MiniMax M2.7"},
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"}, {"provider": "MiniMax", "id": "minimax/MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
# Z.AI / GLM # Z.AI / GLM
{"provider": "Z.AI", "id": "zai/glm-5.3", "label": "GLM-5.3"},
{"provider": "Z.AI", "id": "zai/glm-5.2", "label": "GLM-5.2"}, {"provider": "Z.AI", "id": "zai/glm-5.2", "label": "GLM-5.2"},
{"provider": "Z.AI", "id": "zai/glm-5.1", "label": "GLM-5.1"}, {"provider": "Z.AI", "id": "zai/glm-5.1", "label": "GLM-5.1"},
{"provider": "Z.AI", "id": "zai/glm-5", "label": "GLM-5"}, {"provider": "Z.AI", "id": "zai/glm-5", "label": "GLM-5"},
@@ -1735,6 +1736,7 @@ _PROVIDER_MODELS = {
{"id": "@nous:google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"}, {"id": "@nous:google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
], ],
"zai": [ "zai": [
{"id": "glm-5.3", "label": "GLM-5.3"},
{"id": "glm-5.2", "label": "GLM-5.2"}, {"id": "glm-5.2", "label": "GLM-5.2"},
{"id": "glm-5.1", "label": "GLM-5.1"}, {"id": "glm-5.1", "label": "GLM-5.1"},
{"id": "glm-5", "label": "GLM-5"}, {"id": "glm-5", "label": "GLM-5"},
+39 -16
View File
@@ -57,17 +57,31 @@ if [ ! -d "$itdir" ]; then error_exit "Failed to create $itdir"; fi
# logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run # 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 the root init phase to the hermeswebui runtime phase # 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 it=$itdir/hermeswebui_user_uid
if [ -z "${WANTED_UID+x}" ]; then # Remember where the value came from. Auto-detection must never overwrite an
if [ -f $it ]; then WANTED_UID=$(cat $it); fi # identity the operator chose explicitly — including the legitimate value 1024,
# which is also the built-in default and was therefore indistinguishable from
# "unset" (#7027). The source is persisted next to the value because `su` drops
# the environment when this script re-enters as the runtime user, so an explicit
# choice would otherwise look like a detected one on the second pass.
it_source=$itdir/hermeswebui_user_uid_source
_wanted_uid_source=""
if [ -n "${WANTED_UID+x}" ]; then
_wanted_uid_source=explicit
elif [ -f $it ]; then
WANTED_UID=$(cat $it)
if [ -f $it_source ]; then _wanted_uid_source=$(cat $it_source); fi
fi fi
# Auto-detect from mounted volumes if still unset (#569, #668). # Auto-detect from mounted volumes if still unset (#569, #668, #7027).
# On macOS, host UIDs start at 501. Using the wrong UID means the container # On macOS, host UIDs start at 501. Using the wrong UID means the container
# user cannot read the bind-mounted files, making the workspace appear empty. # user cannot read the bind-mounted files, making the workspace appear empty.
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home # In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
# volume may be owned by the agent container's UID — detect from there first. # volume may be owned by the agent container's UID — detect from there first.
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then if [ "$_wanted_uid_source" != "explicit" ] && { [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; }; then
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668) # Priority 1: the configured state dir first (#7027) — in a single-container
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do # deploy it is a bind mount by definition, so its owner is the host identity
# the container has to match — then the hermes-home shared volume, which
# covers two-container Zeabur/Compose setups (#668)
for _probe_dir in "${HERMES_WEBUI_STATE_DIR:-/app/data}" "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then if [ -d "$_probe_dir" ]; then
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "") _detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
@@ -78,8 +92,10 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
fi fi
done done
fi fi
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then if [ "$_wanted_uid_source" != "explicit" ] && { [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; }; then
# Priority 2: /workspace bind-mount — the standard single-container mount point # Priority 2: /workspace bind-mount — a useful signal when it is actually
# bind-mounted, but a stock image ships /workspace owned by the build-time
# 1024, which says nothing about the host, so it stays below the state dir (#7027)
if [ -d "/workspace" ]; then if [ -d "/workspace" ]; then
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "") _detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
@@ -90,16 +106,22 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
fi fi
WANTED_UID=${WANTED_UID:-1024} WANTED_UID=${WANTED_UID:-1024}
write_privtmpfile $it "$WANTED_UID" write_privtmpfile $it "$WANTED_UID"
write_privtmpfile $it_source "${_wanted_uid_source:-detected}"
echo "-- WANTED_UID: \"${WANTED_UID}\"" echo "-- WANTED_UID: \"${WANTED_UID}\""
it=$itdir/hermeswebui_user_gid it=$itdir/hermeswebui_user_gid
if [ -z "${WANTED_GID+x}" ]; then it_source=$itdir/hermeswebui_user_gid_source
if [ -f $it ]; then WANTED_GID=$(cat $it); fi _wanted_gid_source=""
if [ -n "${WANTED_GID+x}" ]; then
_wanted_gid_source=explicit
elif [ -f $it ]; then
WANTED_GID=$(cat $it)
if [ -f $it_source ]; then _wanted_gid_source=$(cat $it_source); fi
fi fi
# Auto-detect GID from mounted volumes to match (#569, #668) # Auto-detect GID from mounted volumes to match (#569, #668, #7027)
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then if [ "$_wanted_gid_source" != "explicit" ] && { [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; }; then
# Priority 1: hermes-home shared volume # Priority 1: configured state dir first, then the hermes-home shared volume
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do for _probe_dir in "${HERMES_WEBUI_STATE_DIR:-/app/data}" "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then if [ -d "$_probe_dir" ]; then
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "") _detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
@@ -110,8 +132,8 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
fi fi
done done
fi fi
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then if [ "$_wanted_gid_source" != "explicit" ] && { [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; }; then
# Priority 2: /workspace bind-mount # Priority 2: /workspace bind-mount — lower priority for the same reason as UID (#7027)
if [ -d "/workspace" ]; then if [ -d "/workspace" ]; then
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "") _detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
@@ -122,6 +144,7 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
fi fi
WANTED_GID=${WANTED_GID:-1024} WANTED_GID=${WANTED_GID:-1024}
write_privtmpfile $it "$WANTED_GID" write_privtmpfile $it "$WANTED_GID"
write_privtmpfile $it_source "${_wanted_gid_source:-detected}"
echo "-- WANTED_GID: \"${WANTED_GID}\"" echo "-- WANTED_GID: \"${WANTED_GID}\""
echo "== Most Environment variables set" echo "== Most Environment variables set"
+39
View File
@@ -468,6 +468,44 @@ services:
Then configure the URL as `http://host.docker.internal:<port>`. Also ensure the host service binds to an address reachable from containers (not only a loopback interface the Docker bridge cannot reach) and that your host firewall allows the connection. Then configure the URL as `http://host.docker.internal:<port>`. Also ensure the host service binds to an address reachable from containers (not only a loopback interface the Docker bridge cannot reach) and that your host firewall allows the connection.
### 9. "Failed to verify state directory" / restart loop on a bind-mounted state dir (#7027)
**Symptom**: Single-container deploy with the state directory bind-mounted from a
host directory, no `WANTED_UID` set. The container exits 1 and restart-loops:
```
-- Auto-detected workspace UID: 1024 (from /workspace)
touch: cannot touch '/app/data/.testfile': Permission denied
!! ERROR: Failed to verify state directory at /app/data
```
**Cause**: UID auto-detection used to read `/workspace` before the configured
state directory. In a stock image `/workspace` exists and is owned by the
image's own build-time `1024:1024`, so detection returned a value that carries
no information about the host — and because `1024` is also the fallback default,
the log read as if detection had found nothing.
**Fix**: Fixed in the init script — the configured `HERMES_WEBUI_STATE_DIR` is
now probed first. The full order is:
1. `$HERMES_WEBUI_STATE_DIR` (default `/app/data`) — a bind mount by definition
in a single-container deploy, so its owner is the host identity to match
2. `/home/hermeswebui/.hermes`, `$HERMES_HOME`, `/opt/data` — the hermes-home
shared volume in two-container setups (#668)
3. `/workspace` — used only when nothing above resolves
4. `1024` — fallback default
Root-owned candidates (UID 0, e.g. a freshly created named volume) are skipped
at every step. An explicitly supplied `WANTED_UID`/`WANTED_GID` always wins and
is never overwritten by detection — including the value `1024`, which earlier
versions treated as "unset".
If you are on an older image, the workaround is to set the IDs explicitly:
```bash
docker run -e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) ...
```
## Multi-container architecture ## Multi-container architecture
The two- and three-container setups use **named Docker volumes** (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required. The two- and three-container setups use **named Docker volumes** (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required.
@@ -588,6 +626,7 @@ volumes:
- #681 — tools running in WebUI container, not agent container (architectural) - #681 — tools running in WebUI container, not agent container (architectural)
- #668 — auto-detect UID/GID from mounted volume - #668 — auto-detect UID/GID from mounted volume
- #569 — UID/GID detection priority order - #569 — UID/GID detection priority order
- #7027 — state dir probed before `/workspace` in UID/GID detection (see [#9 above](#9-failed-to-verify-state-directory--restart-loop-on-a-bind-mounted-state-dir-7027))
If you hit a new failure mode not covered here, please [open an issue](https://github.com/nesquena/hermes-webui/issues/new) with: If you hit a new failure mode not covered here, please [open an issue](https://github.com/nesquena/hermes-webui/issues/new) with:
+406
View File
@@ -0,0 +1,406 @@
"""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"
+220
View File
@@ -0,0 +1,220 @@
"""Regression tests: GLM-5.3 catalog and onboarding integration.
GLM-5.3 is Z.ai's current flagship model (successor to GLM-5.2). These tests
pin catalog presence (opt-in selection), fallback entry, reasoning ladder,
and payload propagation — ensuring the model users see matches what's declared
in api/config.py. The onboarding default deliberately REMAINS glm-5.1 until
Z.ai's direct API serves GLM-5.3 (Coding-Plan-only today; see #7017 review).
"""
import unittest.mock as mock
import pytest
import api.config as cfg
import api.onboarding as onboarding
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the TTL model cache before AND after every test.
``get_available_models()`` caches its result keyed on config.yaml mtime.
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
the cache there, then let monkeypatch restore the original path. The
cache, keyed on the tmp_path's mtime, then poisons downstream tests
(e.g. test_model_resolver) which see stale data and never hit their
mocks. Clearing the cache around each test breaks that linkage.
Also snapshots/restores ``c.cfg``, ``c._cfg_mtime``, ``c._cfg_path``,
and ``c._cfg_fingerprint`` to prevent tests from leaking global config
state to neighboring test files.
"""
import api.config as c
import api.providers as p
import api.profiles as profiles
old_cfg = dict(c.cfg)
old_mtime = c._cfg_mtime
old_path = c._cfg_path
old_fingerprint = c._cfg_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
yield
c.cfg.clear()
c.cfg.update(old_cfg)
c._cfg_mtime = old_mtime
c._cfg_path = old_path
c._cfg_fingerprint = old_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
def test_glm_5_3_in_provider_models():
"""GLM-5.3 must appear in the zai provider catalog with the correct label."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
model_ids = [m["id"] for m in zai_models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai provider models; got {model_ids}"
)
# Verify the exact label
glm_5_3_entry = [m for m in zai_models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_positioned_before_glm_5_2():
"""GLM-5.3 must appear BEFORE GLM-5.2 (lists are newest-first)."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
glm_5_3_index = None
glm_5_2_index = None
for i, model in enumerate(zai_models):
if model["id"] == "glm-5.3":
glm_5_3_index = i
elif model["id"] == "glm-5.2":
glm_5_2_index = i
assert glm_5_3_index is not None, "glm-5.3 not found in zai models"
assert glm_5_2_index is not None, "glm-5.2 not found in zai models"
assert glm_5_3_index < glm_5_2_index, (
f"glm-5.3 (index {glm_5_3_index}) must appear before glm-5.2 (index {glm_5_2_index})"
)
def test_glm_5_3_in_fallback_models():
"""GLM-5.3 must appear in _FALLBACK_MODELS with correct provider and label."""
fallback_entries = [m for m in cfg._FALLBACK_MODELS if m["id"] == "zai/glm-5.3"]
assert len(fallback_entries) == 1, (
f"Expected exactly one zai/glm-5.3 entry in _FALLBACK_MODELS; "
f"found {len(fallback_entries)}"
)
entry = fallback_entries[0]
assert entry["provider"] == "Z.AI", (
f'Expected provider "Z.AI", got {entry["provider"]!r}'
)
assert entry["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {entry["label"]!r}'
)
def test_glm_5_3_positioned_first_in_zai_fallback_block():
"""GLM-5.3 must appear as the FIRST Z.AI entry in _FALLBACK_MODELS."""
zai_entries = [m for m in cfg._FALLBACK_MODELS if m["provider"] == "Z.AI"]
assert len(zai_entries) > 0, "No Z.AI entries found in _FALLBACK_MODELS"
first_zai_entry = zai_entries[0]
assert first_zai_entry["id"] == "zai/glm-5.3", (
f'Expected first Z.AI entry to be "zai/glm-5.3", got {first_zai_entry["id"]!r}'
)
def test_zai_onboarding_default_stays_glm_5_1_until_direct_api_serves_glm_5_3():
"""Z.AI onboarding default must remain glm-5.1 until the direct endpoint serves GLM-5.3.
The zai provider calls Z.ai's direct endpoint (api.z.ai/api/paas/v4), where the GLM-5.3
API is not live yet. Z.ai's GLM-5.3 guide marks it "coming soon"; GLM-5.3 is
Coding-Plan-only today. Defaulting direct-API users onto glm-5.3 would fail their first
message with model-not-found, so the default stays glm-5.1 — bump it only after the
direct endpoint serves GLM-5.3 (per the #7017 review gate).
"""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
default_model = zai_setup.get("default_model")
assert default_model == "glm-5.1", (
f'Expected default_model "glm-5.1" (not glm-5.3), got {default_model!r}'
)
def test_glm_5_3_in_zai_onboarding_models_list():
"""GLM-5.3 must appear in the zai onboarding setup's models list."""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
models = zai_setup.get("models", [])
model_ids = [m["id"] for m in models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai onboarding models list; got {model_ids}"
)
# Verify the exact label in the onboarding list
glm_5_3_entry = [m for m in models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3" in onboarding, got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_reasoning_efforts():
"""GLM-5.3 must support the full reasoning_effort ladder (GLM-5.2+ tier)."""
efforts = cfg.resolve_model_reasoning_efforts("glm-5.3", provider_id="zai")
assert set(efforts) == {"minimal", "low", "medium", "high", "xhigh", "max"}, (
f"glm-5.3 must support the full reasoning_effort ladder; got {efforts!r}"
)
def test_glm_5_3_in_models_payload_for_zai_provider(tmp_path, monkeypatch):
"""GLM-5.3 must appear in the /api/models payload when zai is active.
This tests the actual observable behavior — what the WebUI model dropdown
sees — not just module-level structures. It verifies that the catalog
entries flow through get_available_models() to the frontend payload.
The configured default is deliberately glm-5.2 (a different model), so
presence of glm-5.3 in the zai group's models list proves catalog
propagation from _PROVIDER_MODELS, not config echo.
"""
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: zai\n default: glm-5.2\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Mock list_available_providers to avoid real network calls
fake_prov = mock.MagicMock()
fake_prov.return_value = []
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
except Exception:
pass
result = c.get_available_models()
c.reload_config()
# Find the zai group
zai_group = None
for group in result.get("groups", []):
if group.get("provider_id") == "zai" or group.get("provider") == "zai":
zai_group = group
break
assert zai_group is not None, (
"No zai provider group found in get_available_models() output; "
f"got providers: {[g.get('provider_id') or g.get('provider') for g in result.get('groups', [])]}"
)
# Check that glm-5.3 appears in the models list
model_ids = [m["id"] for m in zai_group.get("models", [])]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai group models; got {model_ids}"
)