Release v0.51.263 — Release IE (stage-r13) (#3625)
## 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 <luanxu-dev@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
parent
163df17410
commit
52261ebdaf
6 changed files with 248 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
64
tests/test_issue3595_activity_default_expanded.py
Normal file
64
tests/test_issue3595_activity_default_expanded.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue