Discord allowed_channels config reading and wildcard behavior
NousResearch/hermes-agent · cluster 14920 · gateway
Data as of
Nothing open on GitHub. Fix merged in #15052. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #15052. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #15052. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and fixes around the Discord allowed_channels configuration: the config.yaml field was not being read (only the env var was), and wildcard values like "*" silently dropped all messages instead of allowing all channels.
Open issues0
Open PRs0
Fixes on record
-
#15052 fix: P1 batch — Discord wildcard, ACP+MCP, NO_PROXY bypass, resume-after-compression
files:
acp_adapter/server.py,acp_adapter/session.py,cli.py,gateway/platforms/discord.py,hermes_state.py,run_agent.py,scripts/release.py,tests/acp/test_server.py,tests/acp/test_session.py,tests/gateway/test_discord_allowed_channels.py,tests/hermes_state/test_resolve_resume_session_id.py,tests/run_agent/test_create_openai_client_proxy_env.pyShow the diff
-
acp_adapter/server.py@@ -60,7 +60,7 @@ make_tool_progress_cb, ) from acp_adapter.permissions import make_approval_callback -from acp_adapter.session import SessionManager, SessionState +from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets logger = logging.getLogger(__name__) @@ -287,7 +287,11 @@ async def _register_session_mcp_servers( try: from model_tools import get_tool_definitions - enabled_toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"] + enabled_toolsets = _expand_acp_enabled_toolsets( + getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"], + mcp_server_names=[server.name for server in mcp_servers], + ) + state.agent.enabled_toolsets = enabled_toolsets disabled_toolsets = getattr(state.agent, "disabled_toolsets", None) state.agent.tools = get_tool_definitions( enabled_toolsets=enabled_toolsets, @@ -754,7 +758,9 @@ def _cmd_model(self, args: str, state: SessionState) -> str: def _cmd_tools(self, args: str, state: SessionState) -> str: try: from model_tools import get_tool_definitions - toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"] + toolsets = _expand_acp_enabled_toolsets( + getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"] + ) tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True) if not tools: return "No tools available." -
acp_adapter/session.py@@ -106,6 +106,24 @@ def _register_task_cwd(task_id: str, cwd: str) -> None: logger.debug("Failed to register ACP task cwd override", exc_info=True) +def _expand_acp_enabled_toolsets( + toolsets: List[str] | None = None, + mcp_server_names: List[str] | None = None, +) -> List[str]: + """Return ACP toolsets plus explicit MCP server toolsets for this session.""" + expanded: List[str] = [] + for name in list(toolsets or ["hermes-acp"]): + if name and name not in expanded: + expanded.append(name) + + for server_name in list(mcp_server_names or []): + toolset_name = f"mcp-{server_name}" + if server_name and toolset_name not in expanded: + expanded.append(toolset_name) + + return expanded + + def _clear_task_cwd(task_id: str) -> None: """Remove task-specific cwd overrides for an ACP session.""" if not task_id: @@ -537,9 +555,18 @@ def _make_agent( elif isinstance(model_cfg, str) and model_cfg.strip(): default_model = model_cfg.strip() + configured_mcp_servers = [ + name + for name, cfg in (config.get("mcp_servers") or {}).items() + if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False + ] + kwargs = { "platform": "acp", - "enabled_toolsets": ["hermes-acp"], + "enabled_toolsets": _expand_acp_enabled_toolsets( + ["hermes-acp"], + mcp_server_names=configured_mcp_servers, + ), "quiet_mode": True, "session_id": session_id, "model": model or default_model, -
cli.py@@ -3254,6 +3254,23 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}") return False + # If the requested session is the (empty) head of a compression + # chain, walk to the descendant that actually holds the messages. + # See #15000 and SessionDB.resolve_resume_session_id. + try: + resolved_id = self._session_db.resolve_resume_session_id(self.session_id) + except Exception: + resolved_id = self.session_id + if resolved_id and resolved_id != self.session_id: + ChatConsole().print( + f"[{_DIM}]Session {_escape(self.session_id)} was compressed into " + f"{_escape(resolved_id)}; resuming the descendant with your " + f"transcript.[/]" + ) + self.session_id = resolved_id + resolved_meta = self._session_db.get_session(self.session_id) + if resolved_meta: + session_meta = resolved_meta restored = self._session_db.get_messages_as_conversation(self.session_id) if restored: restored = [m for m in restored if m.get("role") != "session_meta"] @@ -3472,6 +3489,22 @@ def _preload_resumed_session(self) -> bool: ) return False + # If the requested session is the (empty) head of a compression chain, + # walk to the descendant that actually holds the messages. See #15000. + try: + resolved_id = self._session_db.resolve_resume_session_id(self.session_id) + except Exception: + resolved_id = self.session_id + if resolved_id and resolved_id != self.session_id: + self._console_print( + f"[dim]Session {self.session_id} was compressed into " + f"{resolved_id}; resuming the descendant with your transcript.[/]" + ) + self.session_id = resolved_id + resolved_meta = self._session_db.get_session(self.session_id) + if resolved_meta: + session_meta = resolved_meta + restored = self._session_db.get_messages_as_conversation(self.session_id) if restored: restored = [m for m in restored if m.get("role") != "session_meta"] @@ -4686,6 +4719,22 @@ def _handle_resume_command(self, cmd_original: str) -> None: _cprint(" Use /history or `hermes sessions list` to see available sessions.") return + # If the target is the empty head of a compression chain, redirect to + # the descendant that actually holds the transcript. See #15000. + try: + resolved_id = self._session_db.resolve_resume_session_id(target_id) + except Exception: + resolved_id = target_id + if resolved_id and resolved_id != target_id: + _cprint( + f" Session {target_id} was compressed into {resolved_id}; " + f"resuming the descendant with your transcript." + ) + target_id = resolved_id + resolved_meta = self._session_db.get_session(target_id) + if resolved_meta: + session_meta = resolved_meta + if target_id == self.session_id: _cprint(" Already on that session.") return -
gateway/platforms/discord.py@@ -2719,7 +2719,12 @@ def _discord_require_mention(self) -> bool: return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") def _discord_free_response_channels(self) -> set: - """Return Discord channel IDs where no bot mention is required.""" + """Return Discord channel IDs where no bot mention is required. + + A single ``"*"`` entry (either from a list or a comma-separated + string) is preserved in the returned set so callers can short-circuit + on wildcard membership, consistent with ``allowed_channels``. + """ raw = self.config.extra.get("free_response_channels") if raw is None: raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") @@ -3212,14 +3217,14 @@ async def _handle_message(self, message: DiscordMessage) -> None: allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "") if allowed_channels_raw: allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()} - if not (channel_ids & allowed_channels): + if "*" not in allowed_channels and not (channel_ids & allowed_channels): logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_ids) return # Check ignored channels - never respond even when mentioned ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "") ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()} - if channel_ids & ignored_channels: + if "*" in ignored_channels or (channel_ids & ignored_channels): logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids) return @@ -3233,7 +3238,11 @@ async def _handle_message(self, message: DiscordMessage) -> None: voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} current_channel_id = str(message.channel.id) is_voice_linked_channel = current_channel_id in voice_linked_ids - is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel + is_free_channel = ( + "*" in free_channels + or bool(channel_ids & free_channels) + or is_voice_linked_channel + ) # Skip the mention check if the message is in a thread where # the bot has previously participated (auto-created or replied in). -
hermes_state.py@@ -1039,6 +1039,71 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]: result.append(msg) return result + def resolve_resume_session_id(self, session_id: str) -> str: + """Redirect a resume target to the descendant session that holds the messages. + + Context compression ends the current session and forks a new child session + (linked via ``parent_session_id``). The flush cursor is reset, so the + child is where new messages actually land — the parent ends up with + ``message_count = 0`` rows unless messages had already been flushed to + it before compression. See #15000. + + This helper walks ``parent_session_id`` forward from ``session_id`` and + returns the first descendant in the chain that has at least one message + row. If the original session already has messages, or no descendant + has any, the original ``session_id`` is returned unchanged. + + The chain is always walked via the child whose ``started_at`` is + latest; that matches the single-chain shape that compression creates. + A depth cap (32) guards against accidental loops in malformed data. + """ + if not session_id: + return session_id + + with self._lock: + # If this session already has messages, nothing to redirect. + try: + row = self._conn.execute( + "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1", + (session_id,), + ).fetchone() + except Exception: + return session_id + if row is not None: + return session_id + + # Walk descendants: at each step, pick the most-recently-started + # child session; stop once we find one with messages. + current = session_id + seen = {current} + for _ in range(32): + try: + child_row = self._conn.execute( + "SELECT id FROM sessions " + "WHERE parent_session_id = ? " + "ORDER BY started_at DESC, id DESC LIMIT 1", + (current,), + ).fetchone() + except Exception: + return session_id + if child_row is None: + return session_id + child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0] + if not child_id or child_id in seen: + return session_id + seen.add(child_id) + try: + msg_row = self._conn.execute( + "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1", + (child_id,), + ).fetchone() + except Exception: + return session_id + if msg_row is not None: + return child_id + current = child_id + return session_id + def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: """ Load messages in the OpenAI conversation format (role + content dicts). -
run_agent.py@@ -36,6 +36,7 @@ import time import threading from types import SimpleNamespace +import urllib.request import uuid from typing import List, Dict, Any, Optional from openai import OpenAI @@ -181,6 +182,25 @@ def _get_proxy_from_env() -> Optional[str]: return None +def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]: + """Return an env-configured proxy unless NO_PROXY excludes this base URL.""" + proxy = _get_proxy_from_env() + if not proxy or not base_url: + return proxy + + host = base_url_hostname(base_url) + if not host: + return proxy + + try: + if urllib.request.proxy_bypass_environment(host): + return None + except Exception: + pass + + return proxy + + def _install_safe_stdio() -> None: """Wrap stdout/stderr so best-effort console output cannot crash the agent.""" for stream_name in ("stdout", "stderr"): @@ -4466,7 +4486,7 @@ def _is_openai_client_closed(client: Any) -> bool: return False @staticmethod - def _build_keepalive_http_client() -> Any: + def _build_keepalive_http_client(base_url: str = "") -> Any: try: import httpx as _httpx import socket as _socket @@ -4480,8 +4500,9 @@ def _build_keepalive_http_client() -> Any: _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30)) # When a custom transport is provided, httpx won't auto-read proxy # from env vars (allow_env_proxies = trust_env and transport is None). - # Explicitly read proxy settings to ensure HTTP_PROXY/HTTPS_PROXY work. - _proxy = _get_proxy_from_env() + # Explicitly read proxy settings while still honoring NO_PROXY for + # loopback / local endpoints such as a locally hosted sub2api. + _proxy = _get_proxy_for_base_url(base_url) return _httpx.Client( transport=_httpx.HTTPTransport(socket_options=_sock_opts), proxy=_proxy, @@ -4539,7 +4560,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"} } if "http_client" not in safe_kwargs: - keepalive_http = self._build_keepalive_http_client() + keepalive_http = self._build_keepalive_http_client(base_url) if keepalive_http is not None: safe_kwargs["http_client"] = keepalive_http client = GeminiNativeClient(**safe_kwargs) @@ -4568,7 +4589,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant. if "http_client" not in client_kwargs: - keepalive_http = self._build_keepalive_http_client() + keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", "")) if keepalive_http is not None: client_kwargs["http_client"] = keepalive_http client = OpenAI(**client_kwargs) -
scripts/release.py@@ -438,6 +438,10 @@ "[redacted:email]": "topcheer", "[redacted:email]": "walli", "[redacted:email]": "Zhuofeng-Wang", + # April 2026 salvage-PR batch (#14920, #14986, #14966) + "[redacted:email]": "mrunmayee17", + "[redacted:email]": "camaragon", + "[redacted:email]": "shamork", # no-github-match — keep as display names "[redacted:email]": "Sisyphus", "[redacted:email]": "Marco Rutsch", -
tests/acp/test_server.py@@ -904,9 +904,15 @@ async def test_refreshes_agent_tool_surface(self, agent, mock_manager): ] with patch("tools.mcp_tool.register_mcp_servers", return_value=["mcp_srv_search"]), \ - patch("model_tools.get_tool_definitions", return_value=fake_tools): + patch("model_tools.get_tool_definitions", return_value=fake_tools) as mock_defs: await agent._register_session_mcp_servers(state, [server]) + mock_defs.assert_called_once_with( + enabled_toolsets=["hermes-acp", "mcp-srv"], + disabled_toolsets=None, + quiet_mode=True, + ) + assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"] assert state.agent.tools == fake_tools assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"} # _invalidate_system_prompt should have been called -
tests/acp/test_session.py@@ -138,6 +138,43 @@ def test_remove_session(self, manager): class TestPersistence: """Verify that sessions are persisted to SessionDB and can be restored.""" + def test_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch): + captured = {} + + def fake_resolve_runtime_provider(requested=None, **kwargs): + return { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + } + + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(model=kwargs.get("model"), enabled_toolsets=kwargs.get("enabled_toolsets")) + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: { + "model": {"provider": "openrouter", "default": "test-model"}, + "mcp_servers": { + "olympus": {"command": "python", "enabled": True}, + "exa": {"url": "https://exa.ai/mcp"}, + "disabled": {"command": "python", "enabled": False}, + }, + }) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + fake_resolve_runtime_provider, + ) + db = SessionDB(tmp_path / "state.db") + + with patch("run_agent.AIAgent", side_effect=fake_agent): + manager = SessionManager(db=db) + manager.create_session(cwd="/work") + + assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"] + def test_create_session_writes_to_db(self, manager): state = manager.create_session(cwd="/project") db = manager._get_db() -
tests/gateway/test_discord_allowed_channels.py@@ -0,0 +1,104 @@ +"""Regression guard for #14920: wildcard "*" in Discord channel config lists. + +Setting ``allowed_channels: "*"``, ``free_response_channels: "*"``, or +``ignored_channels: "*"`` in config (or their ``DISCORD_*_CHANNELS`` env var +equivalents) must behave as a wildcard — i.e. the bot responds in every +channel (or is silenced in every channel, for the ignored list). Previously +the literal string "*" was placed into a set and compared against numeric +channel IDs via set-intersection, which always produced an empty set and +caused every message to be silently dropped (for ``allowed_channels``) or +every ``free_response`` / ``ignored`` check to fail open. +""" + +import unittest + + +def _channel_is_allowed(channel_id: str, allowed_channels_raw: str) -> bool: + """Replicate the channel-allow-list check from discord.py on_message.""" + if not allowed_channels_raw: + return True + allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()} + if "*" in allowed_channels: + return True + return bool({channel_id} & allowed_channels) + + +def _channel_is_ignored(channel_id: str, ignored_channels_raw: str) -> bool: + """Replicate the ignored-channel check from discord.py on_message.""" + ignored_channels = { + ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip() + } + return "*" in ignored_channels or bool({channel_id} & ignored_channels) + + +def _channel_is_free_response(channel_id: str, free_channels_raw: str) -> bool: + """Replicate the free-response-channel check from discord.py on_message.""" + free_channels = { + ch.strip() for ch in free_channels_raw.split(",") if ch.strip() + } + return "*" in free_channels or bool({channel_id} & free_channels) + + +class TestDiscordAllowedChannelsWildcard(unittest.TestCase): + """Wildcard and channel-list behaviour for DISCORD_ALLOWED_CHANNELS.""" + + def test_wildcard_allows_any_channel(self): + """'*' should allow messages from any channel ID.""" + self.assertTrue(_channel_is_allowed("1234567890", "*")) + + def test_wildcard_in_list_allows_any_channel(self): + """'*' mixed with other entries still allows any channel.""" + self.assertTrue(_channel_is_allowed("9999999999", "111,*,222")) + + def test_exact_match_allowed(self): + """Channel ID present in the explicit list is allowed.""" + self.assertTrue(_channel_is_allowed("1234567890", "1234567890,9876543210")) + + def test_non_matching_channel_blocked(self): + """Channel ID absent from the explicit list is blocked.""" + self.assertFalse(_channel_is_allowed("5555555555", "1234567890,9876543210")) + + def test_empty_allowlist_allows_all(self): + """Empty DISCORD_ALLOWED_CHANNELS means no restriction.""" + self.assertTrue(_channel_is_allowed("1234567890", "")) + + def test_whitespace_only_entry_ignored(self): + """Entries that are only whitespace are stripped and ignored.""" + self.assertFalse(_channel_is_allowed("1234567890", " , ")) + + +class TestDiscordIgnoredChannelsWildcard(unittest.TestCase): + """Wildcard and channel-list behaviour for DISCORD_IGNORED_CHANNELS.""" + + def test_wildcard_silences_every_channel(self): + """'*' in ignored_channels silences the bot everywhere.""" + self.assertTrue(_channel_is_ignored("1234567890", "*")) + + def test_empty_ignored_list_silences_nothing(self): + self.assertFalse(_channel_is_ignored("1234567890", "")) + + def test_exact_match_is_ignored(self): + self.assertTrue(_channel_is_ignored("111", "111,222")) + + def test_non_match_not_ignored(self): + self.assertFalse(_channel_is_ignored("333", "111,222")) + + +class TestDiscordFreeResponseChannelsWildcard(unittest.TestCase): + """Wildcard and channel-list behaviour for DISCORD_FREE_RESPONSE_CHANNELS.""" + + def test_wildcard_makes_every_channel_free_response(self): + """'*' in free_response_channels exempts every channel from mention-required.""" + self.assertTrue(_channel_is_free_response("1234567890", "*")) + + def test_wildcard_in_list_applies_everywhere(self): + self.assertTrue(_channel_is_free_response("9999999999", "111,*,222")) + + def test_exact_match_is_free_response(self): + self.assertTrue(_channel_is_free_response("111", "111,222")) + + def test_non_match_not_free_response(self): + self.assertFalse(_channel_is_free_response("333", "111,222")) + + def test_empty_list_no_free_response(self): + self.assertFalse(_channel_is_free_response("111", "")) -
tests/hermes_state/test_resolve_resume_session_id.py@@ -0,0 +1,96 @@ +"""Regression guard for #15000: --resume <id> after compression loses messages. + +Context compression ends the current session and forks a new child session +(linked by ``parent_session_id``). The SQLite flush cursor is reset, so +only the latest descendant ends up with rows in the ``messages`` table — +the parent row has ``message_count = 0``. ``hermes --resume <parent_id>`` +used to load zero rows and show a blank chat. + +``SessionDB.resolve_resume_session_id()`` walks the parent → child chain +and redirects to the first descendant that actually has messages. These +tests pin that behaviour. +""" +import time + +import pytest + +from hermes_state import SessionDB + + [redacted:email] +def db(tmp_path): + return SessionDB(tmp_path / "state.db") + + +def _make_chain(db: SessionDB, ids_with_parent): + """Create sessions in order, forcing started_at so ordering is deterministic.""" + base = int(time.time()) - 10_000 + for i, (sid, parent) in enumerate(ids_with_parent): + db.create_session(sid, source="cli", parent_session_id=parent) + db._conn.execute( + "UPDATE sessions SET started_at = ? WHERE id = ?", + (base + i * 100, sid), + ) + db._conn.commit() + + +def test_redirects_from_empty_head_to_descendant_with_messages(db): + # Reproducer shape from #15000: 6 sessions, only the 5th holds messages. + _make_chain(db, [ + ("head", None), + ("mid1", "head"), + ("mid2", "mid1"), + ("mid3", "mid2"), + ("bulk", "mid3"), # has messages + ("tail", "bulk"), # empty tail after another compression + ]) + for i in range(5): + db.append_message("bulk", role="user", content=f"msg {i}") + + assert db.resolve_resume_session_id("head") == "bulk" + + +def test_returns_self_when_session_has_messages(db): + _make_chain(db, [("root", None), ("child", "root")]) + db.append_message("root", role="user", content="hi") + assert db.resolve_resume_session_id("root") == "root" + + +def test_returns_self_when_no_descendant_has_messages(db): + _make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")]) + assert db.resolve_resume_session_id("root") == "root" + + +def test_returns_self_for_isolated_session(db): + db.create_session("isolated", source="cli") + assert db.resolve_resume_session_id("isolated") == "isolated" + + +def test_returns_self_for_nonexistent_session(db): + assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist" + + +def test_empty_session_id_passthrough(db): + assert db.resolve_resume_session_id("") == "" + assert db.resolve_resume_session_id(None) is None + + +def test_walks_from_middle_of_chain(db): + # If the user happens to know an intermediate ID, we still find the msg-bearing descendant. + _make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")]) + db.append_message("d", role="user", content="x") + assert db.resolve_resume_session_id("b") == "d" + assert db.resolve_resume_session_id("c") == "d" + + +def test_prefers_most_recent_child_when_fork_exists(db): + # If a session was somehow forked (two children), pick the latest one. + # In practice, compression only produces single-chain shape, but the helper + # should degrade gracefully. + _make_chain(db, [ + ("parent", None), + ("older_fork", "parent"), + ("newer_fork", "parent"), + ]) + db.append_message("newer_fork", role="user", content="x") + assert db.resolve_resume_session_id("parent") == "newer_fork" -
tests/run_agent/test_create_openai_client_proxy_env.py@@ -22,7 +22,7 @@ import httpx -from run_agent import AIAgent, _get_proxy_from_env +from run_agent import AIAgent, _get_proxy_from_env, _get_proxy_for_base_url def _make_agent(): @@ -143,3 +143,78 @@ def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch): "pools were %r" % (pool_types,) ) http_client.close() + + +def test_get_proxy_for_base_url_returns_none_when_host_bypassed(monkeypatch): + """NO_PROXY must suppress the proxy for matching base_urls. + + Regression for #14966: users running a local inference endpoint + (Ollama, LM Studio, llama.cpp) with a global HTTPS_PROXY would see + the keepalive client route loopback traffic through the proxy, which + typically answers 502 for local hosts. NO_PROXY should opt those + hosts out via stdlib ``urllib.request.proxy_bypass_environment``. + """ + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", + "NO_PROXY", "no_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897") + monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,192.168.0.0/16") + + # Local endpoint — must bypass the proxy. + assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None + assert _get_proxy_for_base_url("http://localhost:1234/v1") is None + + # Non-local endpoint — proxy still applies. + assert _get_proxy_for_base_url("https://api.openai.com/v1") == "http://127.0.0.1:7897" + + +def test_get_proxy_for_base_url_returns_proxy_when_no_proxy_unset(monkeypatch): + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", + "NO_PROXY", "no_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HTTPS_PROXY", "http://corp:8080") + assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") == "http://corp:8080" + + +def test_get_proxy_for_base_url_returns_none_when_proxy_unset(monkeypatch): + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", + "NO_PROXY", "no_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1") + assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None + assert _get_proxy_for_base_url("https://api.openai.com/v1") is None + + +@patch("run_agent.OpenAI") +def test_create_openai_client_bypasses_proxy_for_no_proxy_host(mock_openai, monkeypatch): + """E2E: with HTTPS_PROXY + NO_PROXY=localhost, a local base_url gets a + keepalive client with NO HTTPProxy mount.""" + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", + "NO_PROXY", "no_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897") + monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1") + + agent = _make_agent() + kwargs = { + "api_key": "***", + "base_url": "http://127.0.0.1:11434/v1", + } + agent._create_openai_client(kwargs, reason="test", shared=False) + + forwarded = mock_openai.call_args.kwargs + http_client = _extract_http_client(forwarded) + assert isinstance(http_client, httpx.Client) + pool_types = [ + type(mount._pool).__name__ + for mount in http_client._mounts.values() + if mount is not None and hasattr(mount, "_pool") + ] + assert "HTTPProxy" not in pool_types, ( + "NO_PROXY host must not route through HTTPProxy; pools were %r" % (pool_types,) + ) + http_client.close()
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · superseded by another PR 1 · other 1
-
#14930 Fix/discord allowed channels wildcard · other →
8598746,8a1e247,#15052 -
#32289 fix(discord): read allowed_channels/ignored_channels from config.yaml · done elsewhere →
cc8e5ec,#16078 -
#32307 fix: honor discord.allowed_channels from config.yaml when env var unset · superseded by another PR →
#32289
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #15052
- Closed issues
- 2
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.