diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 6cbd017d0c8a..490682f287f9 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3790,6 +3790,28 @@ async def _handle_footer_command(self, event: MessageEvent) -> str: return t("gateway.footer.saved", state=state, example=example) async def _handle_compress_command(self, event: MessageEvent) -> str: + """Profile-scoping wrapper around manual /compress. + + Multiplexed gateways resolve credentials through the fail-closed + per-profile secret scope (``agent.secret_scope``, Workstream A). The + agent turn installs it via ``_run_agent``'s wrapper, but slash-command + dispatch does not — so manual /compress reached the compressor's + provider resolution unscoped and died with ``UnscopedSecretError`` + (``get_secret('OPENROUTER_BASE_URL') called with no profile secret + scope active``). Install the source profile's scope around the whole + handler, mirroring ``_run_agent``. Single-profile gateways skip this + — zero behavior change. + """ + if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + return await self._handle_compress_command_inner(event) + + from gateway.run import _profile_runtime_scope + + profile_home = self._resolve_profile_home_for_source(event.source) + with _profile_runtime_scope(profile_home): + return await self._handle_compress_command_inner(event) + + async def _handle_compress_command_inner(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context. Accepts an optional focus topic: ``/compress `` guides the @@ -3975,9 +3997,13 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: if not compressor.has_content_to_compress(head): return t("gateway.compress.nothing_to_do") - loop = asyncio.get_running_loop() - compressed, _ = await loop.run_in_executor( - None, + # _run_in_executor_with_context (not a bare run_in_executor): + # the profile secret scope installed by the wrapper is a + # contextvar, and the default-executor hop would drop it — + # the compressor's aux-client provider resolution would then + # read credentials unscoped and fail closed under + # multiplexing. + compressed, _ = await self._run_in_executor_with_context( lambda: tmp_agent._compress_context( head, "", diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index b832c011a330..599ad2b16155 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -309,3 +309,114 @@ async def test_compress_command_passes_tool_messages_to_compressor(): assert any(m.get("tool_calls") for m in passed), "assistant tool_calls stub dropped" + + +@pytest.mark.asyncio +async def test_compress_command_multiplexed_runs_under_profile_secret_scope(tmp_path): + """Manual /compress must install the source profile's secret scope. + + Multiplexed gateways resolve credentials fail-closed (Workstream A): + ``get_secret`` raises ``UnscopedSecretError`` on any read outside a + ``set_secret_scope`` block. The agent turn is scoped by ``_run_agent``'s + wrapper, but slash-command dispatch is not — manual /compress reached the + compressor's provider resolution unscoped and died with + ``get_secret('OPENROUTER_BASE_URL') called with no profile secret scope + active``. The credential read happens inside the executor hop, so this + also pins that the handler uses the contextvar-preserving executor + (``_run_in_executor_with_context``), not a bare ``run_in_executor``. + """ + from agent import secret_scope as ss + + history = _make_history() + compressed = [ + history[0], + {"role": "assistant", "content": "compressed summary"}, + history[-1], + ] + runner = _make_runner(history) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}, + multiplex_profiles=True, + ) + profile_home = tmp_path / "profiles" / "milo" + profile_home.mkdir(parents=True) + (profile_home / ".env").write_text( + "OPENROUTER_BASE_URL=https://scoped.example/v1\n" + ) + runner._resolve_profile_home_for_source = MagicMock(return_value=profile_home) + + agent_instance = MagicMock() + agent_instance.shutdown_memory_provider = MagicMock() + agent_instance.close = MagicMock() + agent_instance._cached_system_prompt = "" + agent_instance.tools = None + agent_instance.context_compressor.has_content_to_compress.return_value = True + agent_instance.context_compressor._last_compress_aborted = False + agent_instance.context_compressor._last_summary_fallback_used = False + agent_instance.context_compressor._last_summary_dropped_count = 0 + agent_instance.context_compressor._last_summary_error = None + agent_instance.context_compressor._last_aux_model_failure_model = None + agent_instance.context_compressor._last_aux_model_failure_error = None + agent_instance.session_id = "sess-1" + agent_instance._compression_skipped_due_to_lock = False + + seen: dict[str, str | None] = {} + + def _compress(*_args, **_kwargs): + # Runs in the executor thread — exactly where the aux client + # resolves provider credentials. Fail-closed get_secret raises + # here unless the profile scope survived the thread hop. + seen["base_url"] = ss.get_secret("OPENROUTER_BASE_URL") + return (compressed, "") + + agent_instance._compress_context.side_effect = _compress + + ss.set_multiplex_active(True) + try: + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance), + patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100), + ): + result = await runner._handle_compress_command(_make_event()) + finally: + ss.set_multiplex_active(False) + runner._shutdown_executor() + + assert "failed" not in result.lower(), result + assert seen["base_url"] == "https://scoped.example/v1" + runner._resolve_profile_home_for_source.assert_called_once() + + +@pytest.mark.asyncio +async def test_compress_command_single_profile_skips_profile_resolution(): + """Multiplexing off → the scope wrapper is a transparent pass-through. + + Single-profile gateways must not pay the profile-resolution path (and + ``_resolve_profile_home_for_source`` assumes multiplex config exists) — + mirrors the gating contract of ``_run_agent``'s wrapper. + """ + history = _make_history() + runner = _make_runner(history) + runner._resolve_profile_home_for_source = MagicMock() + agent_instance = MagicMock() + agent_instance.shutdown_memory_provider = MagicMock() + agent_instance.close = MagicMock() + agent_instance._cached_system_prompt = "" + agent_instance.tools = None + agent_instance.context_compressor.has_content_to_compress.return_value = True + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (list(history), "") + agent_instance._compression_skipped_due_to_lock = False + + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance), + patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100), + ): + await runner._handle_compress_command(_make_event()) + + runner._resolve_profile_home_for_source.assert_not_called() + runner._shutdown_executor()