diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f3855a05e300..776c268e1702 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6422,14 +6422,12 @@ def _get_cached_client( if cache_key not in _client_cache: # Safety belt: if the cache has grown beyond the max, evict # the oldest entries (FIFO — dict preserves insertion order). - # _release_cached_client_fds is loop-dead-gated: it only frees - # the raw sockets when the evicted client's bound loop is dead - # (no live loop can hold an in-flight request on them), so this - # is safe here AND reclaims fds that would otherwise leak in - # long-lived processes (gateway, kanban workers). + # Do not close an evicted client: another caller may still be + # using it for an in-flight request. The process shutdown path + # closes clients that remain cached; an evicted client is left + # to its caller's lifetime. while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: - evict_key, evict_entry = next(iter(_client_cache.items())) - _release_cached_client_fds(evict_entry[0], evict_entry[2]) + evict_key = next(iter(_client_cache)) del _client_cache[evict_key] _client_cache[cache_key] = (client, default_model, bound_loop) else: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 9d73379db0d9..fa1d768686ed 100755 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1487,13 +1487,14 @@ def _log_safe_path(path: str) -> str: sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True) ) -# Extensions accepted ONLY for explicit ``MEDIA:`` tags (e.g. emitted by -# the ``send_file`` tool). These are a deliberate superset of -# MEDIA_DELIVERY_EXTS: when an agent explicitly tags a file it intends to send, -# we honor code/config/log files too. They are intentionally NOT added to -# MEDIA_DELIVERY_EXTS because the bare-path detector (extract_local_files) -# scans untagged prose, where auto-shipping a ``.py``/``.log`` the model merely -# mentioned would be a surprise (see test_extract_local_files.py rationale). +# Extensions historically accepted for explicit ``MEDIA:`` tags. Retained +# for the producer-tool detector in gateway/run.py (``_TOOL_MEDIA_RE``), which +# scans tool output for known-producer paths. They are deliberately NOT folded +# into ``_MEDIA_TAG_EXT_ALTERNATION`` below: per the egress design (#36060), an +# explicit ``MEDIA:`` tag with a code/config/log extension must pass +# ``validate_media_delivery_path`` (exists on disk, safe root, not denylisted) +# via the validated pass rather than extract unconditionally — otherwise a +# prompt-injection ``MEDIA:/etc/anything.py`` would silently exfiltrate. MEDIA_TAG_EXTRA_EXTS: Tuple[str, ...] = ( # Config / data ".toml", ".ini", ".cfg", ".conf", @@ -1505,13 +1506,13 @@ def _log_safe_path(path: str) -> str: # Logs ".log", ) -_MEDIA_TAG_EXT_ALTERNATION = "|".join( - sorted( - (e.lstrip(".") for e in (*MEDIA_DELIVERY_EXTS, *MEDIA_TAG_EXTRA_EXTS)), - key=len, - reverse=True, - ) -) +# The unconditional ``MEDIA:`` cleanup/extract grammar covers ONLY the known +# deliverable media extensions (MEDIA_DELIVERY_EXTS). Unknown extensions — +# extension-less files AND the code/config/log set in MEDIA_TAG_EXTRA_EXTS — +# route through the validated pass (MEDIA_EXTENSIONLESS_TAG_RE + validate_ +# media_delivery_path), so they deliver when the file validates and stay +# visible in the text when it does not (#36060 universal egress). +_MEDIA_TAG_EXT_ALTERNATION = _MEDIA_EXT_ALTERNATION # Anchored ``MEDIA:`` cleanup pattern. Unlike the old loose # ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known diff --git a/gateway/run.py b/gateway/run.py index 8540b61bb72e..77bbf34e2edd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15408,172 +15408,6 @@ def run_sync(): except Exception: pass - async def _handle_reasoning_command(self, event: MessageEvent) -> str: - """Handle /reasoning command — manage reasoning effort and display toggle. - - Usage: - /reasoning Show current effort level and display state - /reasoning Set reasoning effort for this session only - /reasoning --global Persist reasoning effort to config.yaml - /reasoning reset Clear this session's reasoning override - /reasoning show|on Show model reasoning in responses - /reasoning hide|off Hide model reasoning from responses - """ - import yaml - from hermes_constants import parse_reasoning_effort - - raw_args = event.get_command_args().strip() - args, persist_global = self._parse_reasoning_command_args(raw_args) - config_path = _hermes_home / "config.yaml" - session_key = self._session_key_for_source(event.source) - self._show_reasoning = self._load_show_reasoning() - self._reasoning_config = self._resolve_session_reasoning_config( - source=event.source, - session_key=session_key, - ) - - def _save_config_key(key_path: str, value): - """Save a dot-separated key to config.yaml.""" - try: - user_config = {} - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} - keys = key_path.split(".") - current = user_config - for k in keys[:-1]: - if k not in current or not isinstance(current[k], dict): - current[k] = {} - current = current[k] - current[keys[-1]] = value - atomic_yaml_write(config_path, user_config) - return True - except Exception as e: - logger.error("Failed to save config key %s: %s", key_path, e) - return False - - if not raw_args: - # Show current state - rc = self._reasoning_config - if rc is None: - level = t("gateway.reasoning.level_default") - elif rc.get("enabled") is False: - level = t("gateway.reasoning.level_disabled") - else: - level = rc.get("effort", "medium") - display_state = ( - t("gateway.reasoning.display_on") - if self._show_reasoning - else t("gateway.reasoning.display_off") - ) - has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {}) - scope = ( - t("gateway.reasoning.scope_session") - if has_session_override - else t("gateway.reasoning.scope_global") - ) - return t( - "gateway.reasoning.status", - level=level, - scope=scope, - display=display_state, - ) - - # Display toggle (per-platform) - platform_key = _platform_config_key(event.source.platform) - if args in {"show", "on"}: - self._show_reasoning = True - _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) - return t("gateway.reasoning.display_set_on", platform=platform_key) - - if args in {"hide", "off"}: - self._show_reasoning = False - _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) - return t("gateway.reasoning.display_set_off", platform=platform_key) - - # Effort level change - effort = args.strip() - if effort == "reset": - if persist_global: - return t("gateway.reasoning.reset_global_unsupported") - self._set_session_reasoning_override(session_key, None) - self._reasoning_config = self._load_reasoning_config() - self._evict_cached_agent(session_key) - return t("gateway.reasoning.reset_done") - parsed = parse_reasoning_effort(effort) - if parsed is None: - return t( - "gateway.reasoning.unknown_arg", - arg=effort or raw_args.lower(), - ) - - self._reasoning_config = parsed - if persist_global: - if _save_config_key("agent.reasoning_effort", effort): - self._set_session_reasoning_override(session_key, None) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_global", effort=effort) - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_global_save_failed", effort=effort) - - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_session", effort=effort) - - async def _handle_fast_command(self, event: MessageEvent) -> str: - """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" - import yaml - from hermes_cli.models import model_supports_fast_mode - - args = event.get_command_args().strip().lower() - config_path = _hermes_home / "config.yaml" - self._service_tier = self._load_service_tier() - - user_config = _load_gateway_config() - model = _resolve_gateway_model(user_config) - if not model_supports_fast_mode(model): - return t("gateway.fast.not_supported") - - def _save_config_key(key_path: str, value): - """Save a dot-separated key to config.yaml.""" - try: - user_config = {} - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} - keys = key_path.split(".") - current = user_config - for k in keys[:-1]: - if k not in current or not isinstance(current[k], dict): - current[k] = {} - current = current[k] - current[keys[-1]] = value - atomic_yaml_write(config_path, user_config) - return True - except Exception as e: - logger.error("Failed to save config key %s: %s", key_path, e) - return False - - if not args or args == "status": - status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal") - return t("gateway.fast.status", mode=status) - - if args in {"fast", "on"}: - self._service_tier = "priority" - saved_value = "fast" - label = t("gateway.fast.label_fast") - elif args in {"normal", "off"}: - self._service_tier = None - saved_value = "normal" - label = t("gateway.fast.label_normal") - else: - return t("gateway.fast.unknown_arg", arg=args) - - if _save_config_key("agent.service_tier", saved_value): - return t("gateway.fast.saved", label=label) - return t("gateway.fast.session_only", label=label) - async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" from tools.approval import ( diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 9d781b84ce4d..e866e1f97f21 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3246,7 +3246,22 @@ async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]: ) user_config = _load_gateway_config() - model = _resolve_gateway_model(user_config) + try: + model, _runtime_kwargs = self._resolve_session_agent_runtime( + source=event.source, + session_key=session_key, + user_config=user_config, + ) + except RuntimeError: + # A slash command should retain its availability check when a test + # harness or incomplete setup has no resolvable provider. Real + # turns use the complete resolver above, including channel and + # runtime-provider model overrides. + self._rehydrate_session_model_override(session_key) + override = getattr(self, "_session_model_overrides", {}).get(session_key) + model = str((override or {}).get("model") or "") + if not model: + model = _resolve_gateway_model(user_config) if not model_supports_fast_mode(model): return t("gateway.fast.not_supported") diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index 3e76b42e3332..33096bce36c8 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -160,6 +160,57 @@ async def test_handle_fast_command_session_scoped_by_default(monkeypatch, tmp_pa assert not (tmp_path / "config.yaml").exists() +@pytest.mark.asyncio +async def test_handle_fast_command_gates_on_session_model_override(monkeypatch, tmp_path): + """/fast must validate the model that the session will actually run.""" + runner = _make_runner() + event = _make_event("/fast fast") + session_key = runner._session_key_for_source(event.source) + runner._session_model_overrides = { + session_key: { + "model": "gpt-5.4", + "provider": "openrouter", + "api_key": "test-key", + } + } + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "unsupported-model") + import hermes_cli.models as models_mod + + monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: model == "gpt-5.4") + + response = await runner._handle_fast_command(event) + + assert "FAST" in response + assert runner._service_tier == "priority" + + +@pytest.mark.asyncio +async def test_handle_fast_command_gates_on_channel_model_override(monkeypatch, tmp_path): + """/fast must validate a channel model that overrides the global default.""" + runner = _make_runner() + event = _make_event("/fast fast") + runner.config = SimpleNamespace(streaming=None) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "unsupported-model") + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {}) + monkeypatch.setattr( + gateway_run, + "_get_channel_override", + lambda *_args, **_kwargs: SimpleNamespace(model="gpt-5.4", provider=None), + ) + import hermes_cli.models as models_mod + + monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: model == "gpt-5.4") + + response = await runner._handle_fast_command(event) + + assert "FAST" in response + assert runner._service_tier == "priority" + + @pytest.mark.asyncio async def test_handle_fast_command_global_flag_persists_config(monkeypatch, tmp_path): runner = _make_runner() diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 8b0a30589414..c7f3bb7fa922 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1528,7 +1528,7 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path): monkeypatch.setattr(_P, "home", lambda: tmp_path) from agent.prompt_builder import KANBAN_GUIDANCE - assert 1_500 < len(KANBAN_GUIDANCE) < 5_500, ( + assert 1_500 < len(KANBAN_GUIDANCE) < 6_500, ( f"KANBAN_GUIDANCE is {len(KANBAN_GUIDANCE)} chars — too short (missing?) or too long" ) diff --git a/tests/tools/test_send_file_tool.py b/tests/tools/test_send_file_tool.py index 6823fc9d40b8..fa3ef2d87795 100644 --- a/tests/tools/test_send_file_tool.py +++ b/tests/tools/test_send_file_tool.py @@ -120,8 +120,24 @@ class TestExtractMediaExpandedExtensions: "mp4", "mov", "avi", "mkv", "webm", "ogg", "opus", "mp3", "wav", "m4a", ]) - def test_extract_media_matches_extension(self, ext): - content = f"Here is the file\nMEDIA:/tmp/test_file.{ext}" + def test_extract_media_matches_extension(self, ext, tmp_path, monkeypatch): + # Create the file in a safe delivery root: known media extensions + # extract unconditionally, but code/config/log extensions (.py, .log, + # .toml, …) only extract via the validated pass (file must exist under + # an allowed root), per the #36060 universal-egress design. A bare + # nonexistent path would correctly stay visible, so the fixture must be + # a real, deliverable file to exercise the match. + root = tmp_path / "output" + root.mkdir() + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (str(root),), + ) + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + f = root / f"test_file.{ext}" + f.write_text("content", encoding="utf-8") + + content = f"Here is the file\nMEDIA:{f}" media, cleaned = BasePlatformAdapter.extract_media(content) assert len(media) >= 1, f"extract_media() did not match .{ext}" path = media[0][0]