Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <focus>`` guides the
Expand Down Expand Up @@ -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,
"",
Expand Down
111 changes: 111 additions & 0 deletions tests/gateway/test_compress_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading