From 52261ebdaf5ae417341ea8523d50aaad7959ce08 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 4 Jun 2026 23:56:44 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.263=20=E2=80=94=20Release=20IE?= =?UTF-8?q?=20(stage-r13)=20(#3625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Release v0.51.263 — Release IE (stage-r13) Batch 1 (fresh) — trimmed to the clean pair after the gate held two. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3621 | @luanxu-dev | `/codex-runtime` + `/codex_runtime` now run as a WebUI slash command (routed through the executor reusing the agent's `codex_runtime_switch`) instead of being sent to the model as a chat message. | ### Tests | Issue/PR | Author | Change | |----------|--------|--------| | #3595 | @rodboev | Regression coverage for the already-shipped `activity_feed_expanded_default` setting. | ### Held back from this batch (Codex regression gate) - **#3624** (passkey-challenge cap, security) — the cap **raises** `PasskeyRateLimitError` when full instead of **evicting oldest**, so an attacker (or 8 abandoned legit attempts per context) can lock out genuine registration/login until TTL — the protection becomes a lockout DoS. Held with the oldest-first-eviction fix. - **#3618** (prefer server-side STT) — forcing MediaRecorder by default breaks browser `SpeechRecognition` dictation on installs with **no** server STT configured (`_transcribeBlob` only toasts on failure, never falls back). Held with the graceful-fallback fix. ### Gate - Full pytest suite: **7714 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (#3624 DoS-lockout + #3618 STT-no-fallback) → both dropped/held → **SAFE TO SHIP** (verified no passkeys.py/boot.js remnants, codex-runtime dispatch reaches the allowlist). Co-authored-by: luanxu-dev Co-authored-by: rodboev --- CHANGELOG.md | 8 ++ api/commands.py | 54 ++++++++++- static/messages.js | 2 +- tests/test_cli_only_slash_commands.py | 31 +++++- tests/test_commands_endpoint.py | 97 ++++++++++++++++++- ...est_issue3595_activity_default_expanded.py | 64 ++++++++++++ 6 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 tests/test_issue3595_activity_default_expanded.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4933f9b6..3f526086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.263] — 2026-06-04 — Release IE (stage-r13 — codex-runtime slash command + activity-default test) + +### Fixed +- **`/codex-runtime` (and `/codex_runtime`) now run as a WebUI slash command** instead of being sent to the model as a normal chat message — routed through the slash-command executor reusing the agent's shared `codex_runtime_switch` parser/apply logic. (#3621, @luanxu-dev) + +### Tests +- Added regression coverage for the already-shipped `activity_feed_expanded_default` setting (default Activity-group expansion). (#3595, @rodboev) + ## [v0.51.262] — 2026-06-04 — Release ID (stage-r12 — zh localization + Docker reveal-path + recall-prefill role fix) ### Fixed diff --git a/api/commands.py b/api/commands.py index a983118e..52847ab0 100644 --- a/api/commands.py +++ b/api/commands.py @@ -24,13 +24,15 @@ _NEVER_EXPOSE: frozenset[str] = frozenset({ # Narrow agent-side execution allowlist for /api/commands/exec. _AGENT_COMMAND_ALIASES = { 'reload_mcp': 'reload-mcp', + 'codex_runtime': 'codex-runtime', } -_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp'}) +_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'codex-runtime'}) _RELOAD_MCP_LOCK = threading.Lock() +_CODEX_RUNTIME_LOCK = threading.Lock() -def _normalize_agent_command_name(command: str) -> str: - """Normalize slash text to a canonical command name.""" +def _parse_agent_command(command: str) -> tuple[str, str]: + """Return ``(canonical_name, arg_string)`` from slash-command text.""" raw = str(command or "").strip() if not raw: @@ -42,7 +44,14 @@ def _normalize_agent_command_name(command: str) -> str: if not cmd_base: raise ValueError("command is required") - return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base) + return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base), cmd_parts[1] if len(cmd_parts) > 1 else "" + + +def _normalize_agent_command_name(command: str) -> str: + """Normalize slash text to a canonical command name.""" + + canonical, _arg_string = _parse_agent_command(command) + return canonical def list_commands(_registry=None) -> list[dict[str, Any]]: @@ -105,16 +114,51 @@ def list_commands(_registry=None) -> list[dict[str, Any]]: def execute_agent_command(command: str) -> str: """Execute a narrow allowlist of agent-side runtime commands.""" - canonical = _normalize_agent_command_name(command) + canonical, arg_string = _parse_agent_command(command) if canonical not in _ALLOWED_AGENT_COMMANDS: raise KeyError(canonical) if canonical == 'reload-mcp': return _run_reload_mcp_command() + if canonical == 'codex-runtime': + return _run_codex_runtime_command(arg_string) raise KeyError(canonical) +def _run_codex_runtime_command(arg_string: str) -> str: + """Execute Hermes' shared Codex runtime switch for the active profile.""" + try: + from hermes_cli.codex_runtime_switch import apply, parse_args + except Exception as exc: + logger.warning("Codex runtime switch unavailable", exc_info=True) + raise RuntimeError("Codex runtime switch unavailable") from exc + + new_value, errors = parse_args(arg_string) + if errors: + return "\n".join(str(error) for error in errors) + + with _CODEX_RUNTIME_LOCK: + try: + from api import config as webui_config + + active_config = webui_config.get_config() + + def _persist_config(config_data: dict) -> None: + webui_config._save_yaml_config_file( + webui_config._get_config_path(), + config_data, + ) + webui_config.reload_config() + + status = apply(active_config, new_value, persist_callback=_persist_config) + except Exception as exc: + logger.warning("Failed to execute /codex-runtime", exc_info=True) + raise RuntimeError("Failed to update Codex runtime") from exc + + return str(getattr(status, "message", "") or "(no output)") + + def _run_reload_mcp_command() -> str: """Execute the MCP reconnect path and return a short user-facing summary.""" with _RELOAD_MCP_LOCK: diff --git a/static/messages.js b/static/messages.js index f08bf163..c476f665 100644 --- a/static/messages.js +++ b/static/messages.js @@ -300,7 +300,7 @@ const _sessionTitleProvisionalBySid = new Map(); // their canonical command is registered on the backend (for example // /reload-mcp). Keep this intentionally narrow and include underscore variants // observed by users so typing either form still routes through executeAgentCommand. -const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp']); +const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'codex-runtime', 'codex_runtime']); function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){ if(!S||!S.busy||compressionRunning) return false; diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 8fa04ecf..87d799b4 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -106,6 +106,13 @@ def _run_commands_js(script_body: str) -> dict: aliases: [], cli_only: false, gateway_only: false + }}, + {{ + name: 'codex-runtime', + description: 'Toggle Codex app-server runtime', + aliases: ['codex_runtime'], + cli_only: false, + gateway_only: false }} ] }}; @@ -188,12 +195,34 @@ def test_send_intercepts_reload_mcp_agent_command_before_agent_round_trip(): assert "executeAgentCommand(text,_agentCmd||{name:_agentCmdName})" in intercept -def test_reload_mcp_webui_intercept_aliases_are_defined_in_js_whitelist(): +def test_reload_mcp_and_codex_runtime_webui_intercept_aliases_are_defined_in_js_whitelist(): assert "'reload-mcp'" in MESSAGES_JS assert "'reload_mcp'" in MESSAGES_JS + assert "'codex-runtime'" in MESSAGES_JS + assert "'codex_runtime'" in MESSAGES_JS assert "if(_agentCmd&&_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" not in MESSAGES_JS +def test_codex_runtime_agent_command_metadata_resolves_alias(): + result = _run_commands_js( + """ + const byName = await getAgentCommandMetadata('codex-runtime'); + const byAlias = await getAgentCommandMetadata('codex_runtime'); + return { + by_name: byName && byName.name, + by_alias: byAlias && byAlias.name, + cli_only: byAlias && byAlias.cli_only === true + }; + """ + ) + + assert result == { + "by_name": "codex-runtime", + "by_alias": "codex-runtime", + "cli_only": False, + } + + def test_unknown_slash_commands_still_fall_through_to_agent(): """Only explicitly supported metadata-backed commands should be intercepted.""" intercept_idx = MESSAGES_JS.find("Slash command intercept") diff --git a/tests/test_commands_endpoint.py b/tests/test_commands_endpoint.py index 233bb21d..9637a6e5 100644 --- a/tests/test_commands_endpoint.py +++ b/tests/test_commands_endpoint.py @@ -4,7 +4,8 @@ import urllib.error import urllib.request import threading import time -from types import ModuleType +from types import ModuleType, SimpleNamespace +from typing import Any, cast import pytest @@ -25,6 +26,40 @@ def _install_fake_mcp_tool(monkeypatch, shutdown, discover, servers=None, lock=N return mcp_tool +def _install_fake_codex_runtime_switch(monkeypatch): + import sys + hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli") + hermes_cli_pkg.__path__ = [] + codex_runtime_switch = ModuleType("hermes_cli.codex_runtime_switch") + calls = [] + + def parse_args(arg_string): + calls.append(("parse_args", arg_string)) + if arg_string in ("on", "codex_app_server"): + return "codex_app_server", [] + if arg_string in ("", None): + return None, [] + return None, [f"bad arg: {arg_string}"] + + def apply(config, new_value, *, persist_callback=None): + calls.append(("apply", new_value, config.get("model", {}).get("openai_runtime"))) + if new_value is not None: + config.setdefault("model", {})["openai_runtime"] = new_value + if persist_callback: + persist_callback(config) + return SimpleNamespace( + success=True, + message=f"codex runtime -> {new_value or config.get('model', {}).get('openai_runtime', 'auto')}", + ) + + codex_runtime_switch_any = cast(Any, codex_runtime_switch) + codex_runtime_switch_any.parse_args = parse_args + codex_runtime_switch_any.apply = apply + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.codex_runtime_switch", codex_runtime_switch) + return calls + + def _get(path): """GET helper -- returns parsed JSON or raises HTTPError.""" with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r: @@ -118,6 +153,66 @@ def test_commands_exec_runs_reload_mcp_alias(): assert isinstance(body['output'], str) +def test_codex_runtime_command_uses_shared_switch_and_persists(monkeypatch, tmp_path): + """`/codex-runtime` executes through the same shared switch as CLI/gateway.""" + calls = _install_fake_codex_runtime_switch(monkeypatch) + saved = [] + + from api import config as webui_config + from api.commands import execute_agent_command + + config_data = {"model": {"openai_runtime": "auto"}} + monkeypatch.setattr(webui_config, "get_config", lambda: config_data) + monkeypatch.setattr(webui_config, "_get_config_path", lambda: tmp_path / "config.yaml") + monkeypatch.setattr( + webui_config, + "_save_yaml_config_file", + lambda path, data: saved.append((path, data.copy())), + ) + monkeypatch.setattr(webui_config, "reload_config", lambda: saved.append(("reload", None))) + + output = execute_agent_command('/codex-runtime on') + + assert output == "codex runtime -> codex_app_server" + assert config_data["model"]["openai_runtime"] == "codex_app_server" + assert calls == [ + ("parse_args", "on"), + ("apply", "codex_app_server", "auto"), + ] + assert saved[0][0] == tmp_path / "config.yaml" + assert saved[0][1] == {"model": {"openai_runtime": "codex_app_server"}} + assert saved[1] == ("reload", None) + + +def test_codex_runtime_command_accepts_underscore_alias(monkeypatch): + """Telegram/WebUI underscore spelling routes to the canonical command.""" + calls = _install_fake_codex_runtime_switch(monkeypatch) + + from api import config as webui_config + from api.commands import execute_agent_command + + monkeypatch.setattr(webui_config, "get_config", lambda: {"model": {"openai_runtime": "auto"}}) + monkeypatch.setattr(webui_config, "_save_yaml_config_file", lambda path, data: None) + monkeypatch.setattr(webui_config, "reload_config", lambda: None) + + output = execute_agent_command('/codex_runtime codex_app_server') + + assert output == "codex runtime -> codex_app_server" + assert calls[0] == ("parse_args", "codex_app_server") + + +def test_codex_runtime_invalid_argument_returns_switch_message(monkeypatch): + """Argument validation stays in the shared switch and returns user text.""" + calls = _install_fake_codex_runtime_switch(monkeypatch) + + from api.commands import execute_agent_command + + output = execute_agent_command('/codex-runtime nope') + + assert output == "bad arg: nope" + assert calls == [("parse_args", "nope")] + + def test_reload_mcp_error_is_generic(monkeypatch): """`/reload-mcp` errors must return a generic message, not raw internals.""" calls = [] diff --git a/tests/test_issue3595_activity_default_expanded.py b/tests/test_issue3595_activity_default_expanded.py new file mode 100644 index 00000000..c08712b0 --- /dev/null +++ b/tests/test_issue3595_activity_default_expanded.py @@ -0,0 +1,64 @@ +"""Pin the full behavioral contract for the Activity expanded-default setting.""" +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def test_setting_in_defaults(): + src = (ROOT / "api" / "config.py").read_text(encoding="utf-8") + assert '"activity_feed_expanded_default"' in src or "'activity_feed_expanded_default'" in src, \ + "activity_feed_expanded_default must exist in _SETTINGS_DEFAULTS" + # Verify default is False + assert re.search(r'["\']activity_feed_expanded_default["\']:\s*False', src), \ + "activity_feed_expanded_default default must be False (collapsed)" + + +def test_setting_in_bool_keys(): + src = (ROOT / "api" / "config.py").read_text(encoding="utf-8") + assert re.search(r'_SETTINGS_BOOL_KEYS\b.*?activity_feed_expanded_default', src, re.DOTALL), \ + "activity_feed_expanded_default must appear inside _SETTINGS_BOOL_KEYS (not just anywhere in config.py)" + + +def test_boot_initializes_window_flag(): + src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + assert "activityFeedExpandedDefault" in src, \ + "boot.js must initialize window._activityFeedExpandedDefault from settings" + + +def test_ensure_activity_group_checks_flag(): + src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + assert "_activityFeedExpandedDefault" in src, \ + "ensureActivityGroup must check window._activityFeedExpandedDefault" + + +def test_per_turn_override(): + src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + assert re.search(r"savedState\s*===\s*['\"]closed['\"]", src), \ + "ensureActivityGroup must check savedState==='closed' so per-turn persistence overrides the global default" + + +def test_collapse_class_applied(): + src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + assert "tool-call-group-collapsed" in src, \ + "The collapsed class 'tool-call-group-collapsed' must be present in ui.js for the activity group" + + +def test_settings_checkbox_exists(): + src = (ROOT / "static" / "index.html").read_text(encoding="utf-8") + assert "settings_label_activity_feed_expanded_default" in src, \ + "index.html must have a settings checkbox with data-i18n for activity_feed_expanded_default" + + +def test_panels_wiring(): + src = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + assert "activity_feed_expanded_default" in src, \ + "panels.js must read/write the activity_feed_expanded_default setting" + + +def test_i18n_keys(): + src = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + assert "settings_label_activity_feed_expanded_default" in src, \ + "i18n.js must have the label key for the setting" + assert "settings_desc_activity_feed_expanded_default" in src, \ + "i18n.js must have the description key for the setting"