From 4bdc22b7ce63e5ffaaaed3aacd38f68fd28d42ba Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 07:29:22 -0700 Subject: [PATCH] Release: gateway-default MoA send (#5869, @rodboev) (#6365) * fix(#5853): allow gateway-default MoA sends * fix(#5853): freeze gateway auth to one locked snapshot * docs(changelog): stamp #5869 gateway-default MoA send --------- Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/config.py | 19 + api/routes.py | 33 +- tests/test_agent_runtime_revision_guard.py | 8 +- tests/test_issue5057_moa_webui_route.py | 444 ++++++++++++++++++++- 5 files changed, 496 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7bd432..4e75b0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) + - **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) - **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400) diff --git a/api/config.py b/api/config.py index 586c2661..8c417ee9 100644 --- a/api/config.py +++ b/api/config.py @@ -484,6 +484,25 @@ def get_config() -> dict: return _cfg_cache +def get_config_snapshot() -> dict: + """Return a request-owned config snapshot captured under the cache lock.""" + with _cfg_lock: + config_path = _get_config_path() + try: + current_mtime = config_path.stat().st_mtime + except OSError: + current_mtime = 0.0 + path_changed = _cfg_path != config_path + mtime_stale = current_mtime != _cfg_mtime + if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()): + _refresh_config_cache(config_path) + try: + active_cfg = cfg if cfg is not _cfg_cache else _cfg_cache + except NameError: + active_cfg = _cfg_cache + return copy.deepcopy(active_cfg) + + def get_webui_session_save_mode(config_data: dict | None = None) -> str: """Return the validated first-turn session persistence mode. diff --git a/api/routes.py b/api/routes.py index fd57db10..d99873a1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2823,7 +2823,9 @@ from api.config import ( set_reasoning_display, set_reasoning_effort, create_stream_channel, + get_config, get_webui_session_save_mode, + get_config_snapshot, STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION, _get_config_path, @@ -21260,6 +21262,7 @@ def _start_run( route: str, diag=None, moa_config=None, + gateway_chat_enabled: bool | None = None, ): """Shared start-run helper for /api/chat/start and start_session_turn. @@ -21300,6 +21303,7 @@ def _start_run( diag=diag, source=request.source or source, moa_config=moa_config, + external_runtime_owned=gateway_chat_enabled, ) def _legacy_adapter_factory(): @@ -21340,7 +21344,7 @@ def _start_run( diag=diag, source=source, moa_config=moa_config, - external_runtime_owned=webui_gateway_chat_enabled(get_config()), + external_runtime_owned=gateway_chat_enabled, ) @@ -22066,7 +22070,8 @@ def _handle_chat_start(handler, body, diag=None): _pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider) explicit_model_pick = bool(body.get("explicit_model_pick")) moa_config = None - gateway_chat_enabled = webui_gateway_chat_enabled(get_config()) + config_snapshot = get_config_snapshot() + gateway_chat_enabled = webui_gateway_chat_enabled(config_snapshot) if body.get("moa_config"): if gateway_chat_enabled: return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409) @@ -22113,9 +22118,25 @@ def _handle_chat_start(handler, body, diag=None): explicit_model_pick=explicit_model_pick, profile_provider=catalog_profile_provider, ) - if model_provider == "moa" and moa_config is None: - if webui_gateway_chat_enabled(get_config()): + if model_provider == "moa" and gateway_chat_enabled: + from api.config import get_effective_default_model + + model_config = config_snapshot.get("model") if isinstance(config_snapshot, dict) else None + configured_default, configured_default_provider, configured_default_is_moa = ( + _moa_fast_path_model_state(get_effective_default_model(config_snapshot)) + ) + configured_provider = _clean_session_model_provider( + model_config.get("provider") if isinstance(model_config, dict) else None + ) + if configured_provider is None and configured_default_is_moa: + configured_provider = configured_default_provider + if ( + configured_provider != "moa" + or model != configured_default + or explicit_model_pick + ): return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409) + elif model_provider == "moa" and moa_config is None: from api.commands import resolve_moa_config try: @@ -22136,6 +22157,7 @@ def _handle_chat_start(handler, body, diag=None): "source": "webui", "route": "/api/chat/start", "diag": diag, + "gateway_chat_enabled": gateway_chat_enabled, } if not gateway_chat_enabled and moa_config is not None: start_run_kwargs["moa_config"] = moa_config @@ -25744,9 +25766,6 @@ def _handle_session_import(handler, body): return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}) -# ── MCP Server helpers ── -from api.config import get_config, _save_yaml_config_file, _get_config_path, reload_config - def _mask_secrets(obj): """Mask sensitive values in env vars and headers.""" if not isinstance(obj, dict): diff --git a/tests/test_agent_runtime_revision_guard.py b/tests/test_agent_runtime_revision_guard.py index 0cc1d5e7..506bf6a0 100644 --- a/tests/test_agent_runtime_revision_guard.py +++ b/tests/test_agent_runtime_revision_guard.py @@ -595,8 +595,11 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch): captured = {} monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False) monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: False) - monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) - monkeypatch.setattr(routes, "get_config", lambda: {}) + monkeypatch.setattr( + routes, + "get_config", + lambda: (_ for _ in ()).throw(AssertionError("_start_run reread shared config")), + ) monkeypatch.setattr( routes, "ensure_agent_runtime_current", @@ -621,6 +624,7 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch): normalized_model=False, source="webui", route="/api/chat/start", + gateway_chat_enabled=True, ) assert response["stream_id"] == "gateway-stream-1" diff --git a/tests/test_issue5057_moa_webui_route.py b/tests/test_issue5057_moa_webui_route.py index a3796b39..20e29eb1 100644 --- a/tests/test_issue5057_moa_webui_route.py +++ b/tests/test_issue5057_moa_webui_route.py @@ -12,6 +12,40 @@ import pytest from tests.conftest import TEST_BASE, requires_agent_modules +def test_config_snapshot_waits_for_reload_lock(monkeypatch, tmp_path): + """Snapshot capture cannot run concurrently with cache replacement.""" + import threading + + from api import config + + config_path = tmp_path / "config.yaml" + config_path.write_text("{}", encoding="utf-8") + shared_config = {"chat_backend": "gateway"} + monkeypatch.setattr(config, "_cfg_cache", shared_config) + monkeypatch.setattr(config, "cfg", shared_config) + monkeypatch.setattr(config, "_cfg_path", config_path) + monkeypatch.setattr(config, "_cfg_mtime", config_path.stat().st_mtime) + monkeypatch.setattr(config, "_cfg_fingerprint", config._fingerprint_config(shared_config)) + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) + + complete = threading.Event() + result = {} + + def read_snapshot(): + result["snapshot"] = config.get_config_snapshot() + complete.set() + + with config._cfg_lock: + reader = threading.Thread(target=read_snapshot) + reader.start() + assert not complete.wait(0.1) + reader.join(timeout=1) + + assert complete.is_set() + assert result["snapshot"] == shared_config + assert result["snapshot"] is not shared_config + + def _install_fake_moa_config(monkeypatch, *, default_preset="moa-default", usage_text="Usage: /moa "): hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli") # monkeypatch.setattr restores the REAL hermes_cli.__path__ on teardown; @@ -174,7 +208,7 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path): monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) monkeypatch.setattr(routes, "_start_run", start_run) - monkeypatch.setattr(routes, "get_config", lambda: {"chat_backend": "gateway"}) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway"}) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) monkeypatch.setattr(commands, "resolve_moa_config", resolve_moa_config) @@ -192,3 +226,411 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path): body = json.loads(handler.wfile.getvalue().decode("utf-8")) assert handler.status == 409 assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_moa_gateway_configured_default_reaches_start_run(monkeypatch, tmp_path): + """Gateway sessions may use only the configured MoA default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-moa-gateway-default", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert captured["model"] == "moa-configured" + assert captured["model_provider"] == "moa" + assert "moa_config" not in captured + + +def test_moa_gateway_string_model_config_reaches_start_run(monkeypatch, tmp_path): + """String-form gateway defaults may also authorize MoA sends.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-string-default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway", "model": "@moa:moa-configured"}) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-moa-gateway-string-default", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert captured["model"] == "moa-configured" + assert captured["model_provider"] == "moa" + assert captured["gateway_chat_enabled"] is True + assert "moa_config" not in captured + + +@pytest.mark.parametrize( + ("initial_config", "mutated_config", "expected_status"), + [ + ( + {"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}}, + {"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}}, + 409, + ), + ( + {"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}}, + {"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}}, + 200, + ), + ], +) +def test_moa_gateway_authorization_uses_request_owned_config( + monkeypatch, tmp_path, initial_config, mutated_config, expected_status +): + """Gateway MoA authorization stays on the request's config snapshot.""" + import io + + import api.routes as routes + + class _Handler: + def __init__(self): + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-config-race" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + config_snapshot = initial_config + original_resolve = routes._resolve_compatible_session_model_state + + def resolve_and_mutate(*args, **kwargs): + result = original_resolve(*args, **kwargs) + config_snapshot.clear() + config_snapshot.update(mutated_config) + return result + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", resolve_and_mutate) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: routes.copy.deepcopy(config_snapshot)) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-config-race", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert handler.status == expected_status + if expected_status == 200: + assert captured["gateway_chat_enabled"] is True + else: + assert not captured + + +def test_moa_gateway_explicit_configured_default_fails_closed(monkeypatch, tmp_path): + """A browser picker cannot select the gateway's MoA default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-picker" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + def start_run(*_args, **_kwargs): + raise AssertionError("explicit MoA picker selection must fail before starting a run") + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-picker", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + "explicit_model_pick": True, + }, + ) + + body = json.loads(handler.wfile.getvalue().decode("utf-8")) + assert handler.status == 409 + assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_moa_gateway_spoofed_provider_fails_closed(monkeypatch, tmp_path): + """A client cannot claim MoA for a model outside the configured default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-spoofed" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + def start_run(*_args, **_kwargs): + raise AssertionError("spoofed MoA provider must fail before starting a run") + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-spoofed", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-spoofed", + "model_provider": "moa", + }, + ) + + body = json.loads(handler.wfile.getvalue().decode("utf-8")) + assert handler.status == 409 + assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_gateway_non_moa_model_reaches_start_run(monkeypatch, tmp_path): + """Gateway sends keep their non-MoA provider when the default uses MoA.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-gateway-non-moa" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-gateway-non-moa", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "gpt-5.5", + "model_provider": "openai-codex", + }, + ) + + assert captured["model_provider"] == "openai-codex" + assert "moa_config" not in captured