From c657cfd46ded9b37b56d8da5157332793e785f99 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Thu, 11 Jun 2026 23:22:51 -0700 Subject: [PATCH 1/3] feat(gateway): label shared session participants --- gateway/run.py | 33 +++++++----- gateway/session.py | 17 +++++- tests/gateway/test_session.py | 4 +- .../test_shared_group_sender_prefix.py | 53 ++++++++++++++++++- tests/tui_gateway/test_protocol.py | 35 ++++++++++++ 5 files changed, 124 insertions(+), 18 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index e7e641601bc3..499e7623dcaf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2190,6 +2190,7 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor build_session_key, is_shared_multi_user_session, neutralize_untrusted_inline_text, + shared_participant_label, ) from gateway.delivery import ( DeliveryRouter, @@ -12836,7 +12837,7 @@ async def _prepare_inbound_message_text( group_sessions_per_user=_group_sessions_per_user, thread_sessions_per_user=_thread_sessions_per_user, ) - if _is_shared_multi_user and source.user_name: + if _is_shared_multi_user: # source.user_name is the platform display name — attacker- # influenceable on any platform that lets participants set their # own name. Neutralize embedded newlines/control chars before @@ -12844,18 +12845,24 @@ async def _prepare_inbound_message_text( # a hostile name can masquerade as a fake markdown section # (mirrors the same field's treatment in # build_session_context_prompt via _format_untrusted_prompt_value). - _safe_user_name = neutralize_untrusted_inline_text(source.user_name) - # On Slack, expose the current author's verifiable user ID next to - # the display name (#17916): "mention me again" requests need a - # trusted `<@U...>` target for the CURRENT speaker — display names - # are ambiguous and historical mentions may point at someone else. - # The user_id comes from the Slack event envelope (not - # user-editable text), so it does not need neutralization. - if source.platform == Platform.SLACK and source.user_id: - _safe_user_name = ( - f"{_safe_user_name} | Slack user <@{source.user_id}>" - ) - message_text = f"[{_safe_user_name}] {message_text}" + # Senders with no display name at all still need a STABLE, + # non-identifying label so the model can tell participants apart + # (shared_participant_label falls back to a hashed user id). + _sender_label = shared_participant_label(source) + + if _sender_label: + _safe_user_name = neutralize_untrusted_inline_text(_sender_label) + # On Slack, expose the current author's verifiable user ID next to + # the display name (#17916): "mention me again" requests need a + # trusted `<@U...>` target for the CURRENT speaker — display names + # are ambiguous and historical mentions may point at someone else. + # The user_id comes from the Slack event envelope (not + # user-editable text), so it does not need neutralization. + if source.platform == Platform.SLACK and source.user_id: + _safe_user_name = ( + f"{_safe_user_name} | Slack user <@{source.user_id}>" + ) + message_text = f"[{_safe_user_name}] {message_text}" # Prepend channel context from history backfill (if any). This # happens after sender-prefix so the prefix only applies to the diff --git a/gateway/session.py b/gateway/session.py index 89f4188669e7..a7ff48d90dc4 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -292,7 +292,20 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": auto_thread_created=bool(data.get("auto_thread_created", False)), auto_thread_initial_name=data.get("auto_thread_initial_name"), ) - + + +def shared_participant_label(source: SessionSource) -> Optional[str]: + """Return the stable label used to disambiguate speakers in shared sessions.""" + display_name = str(source.user_name or "").strip() + if display_name: + return display_name + + for raw_id in (source.user_id_alt, source.user_id): + raw = str(raw_id or "").strip() + if raw: + return _hash_sender_id(raw) + + return None @dataclass @@ -549,7 +562,7 @@ def build_session_context_prompt( session_label = "Multi-user thread" if context.source.thread_id else "Multi-user session" lines.append( f"**Session type:** {session_label} — messages are prefixed " - "with [sender name]. Multiple users may participate." + "with [sender label]. Multiple users may participate." ) elif context.source.user_name: lines.append( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index ca86fc728414..6df65a439891 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -548,7 +548,7 @@ def test_multi_user_thread_prompt(self): prompt = build_session_context_prompt(ctx) assert "Multi-user thread" in prompt - assert "[sender name]" in prompt + assert "[sender label]" in prompt # Should NOT show a specific **User:** line (would bust cache) assert "**User:** Alice" not in prompt @@ -591,7 +591,7 @@ def test_shared_non_thread_group_prompt_hides_single_user(self): prompt = build_session_context_prompt(ctx) assert "Multi-user session" in prompt - assert "[sender name]" in prompt + assert "[sender label]" in prompt assert "**User:** Alice" not in prompt def test_dm_thread_shows_user_not_multi(self): diff --git a/tests/gateway/test_shared_group_sender_prefix.py b/tests/gateway/test_shared_group_sender_prefix.py index f2bd5e67169e..65729b1a76bd 100644 --- a/tests/gateway/test_shared_group_sender_prefix.py +++ b/tests/gateway/test_shared_group_sender_prefix.py @@ -3,7 +3,7 @@ from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.run import GatewayRunner -from gateway.session import SessionSource +from gateway.session import SessionSource, shared_participant_label def _make_runner(config: GatewayConfig) -> GatewayRunner: @@ -43,6 +43,57 @@ async def test_preprocess_prefixes_sender_for_shared_non_thread_group_session(): assert result == "[Alice] hello" +@pytest.mark.asyncio +async def test_preprocess_uses_stable_participant_label_without_display_name(): + runner = _make_runner( + GatewayConfig( + platforms={ + Platform.WEBHOOK: PlatformConfig(enabled=True, token="fake"), + }, + group_sessions_per_user=False, + ) + ) + source = SessionSource( + platform=Platform.WEBHOOK, + chat_id="room-ops", + chat_name="Ops Room", + chat_type="group", + user_id="opaque-user-1", + ) + event = MessageEvent(text="hello", source=source) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + label = shared_participant_label(source) + assert label + assert label.startswith("user_") + assert "opaque-user-1" not in label + assert result == f"[{label}] hello" + + +def test_shared_participant_labels_distinguish_multiple_unnamed_senders(): + first = SessionSource( + platform=Platform.WEBHOOK, + chat_id="room-ops", + chat_name="Ops Room", + chat_type="group", + user_id="human-a", + ) + second = SessionSource( + platform=Platform.WEBHOOK, + chat_id="room-ops", + chat_name="Ops Room", + chat_type="group", + user_id="human-b", + ) + + assert shared_participant_label(first) != shared_participant_label(second) + + @pytest.mark.asyncio async def test_preprocess_keeps_plain_text_for_default_group_sessions(): runner = _make_runner( diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2e64cc2605e2..8118a956be8e 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -81,6 +81,41 @@ def test_write_json(capture): assert json.loads(buf.getvalue()) == {"test": True} +def test_session_event_transport_can_fan_out_to_sidecar_listener(server): + from tui_gateway.transport import TeeTransport + + class _CaptureTransport: + def __init__(self): + self.frames = [] + + def write(self, obj): + self.frames.append(obj) + return True + + def close(self): + pass + + primary = _CaptureTransport() + sidecar = _CaptureTransport() + sid = "runtime-fanout" + server._sessions[sid] = {"transport": TeeTransport(primary, sidecar)} + + server._emit("message.delta", sid, {"text": "hello"}) + + assert primary.frames == [ + { + "jsonrpc": "2.0", + "method": "event", + "params": { + "type": "message.delta", + "session_id": sid, + "payload": {"text": "hello"}, + }, + } + ] + assert sidecar.frames == primary.frames + + def test_write_json_broken_pipe(server): class _Broken: def write(self, _): raise BrokenPipeError From 0795c589bf7caf271c0a51484e337f368d134ff0 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Thu, 11 Jun 2026 23:25:32 -0700 Subject: [PATCH 2/3] chore(release): map contributor email --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 0df1a1b70d57..f592e66363bf 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -600,6 +600,7 @@ "saeed919@pm.me": "falasi", "chrisdlc119@outlook.com": "chdlc", "omar@techdeveloper.site": "nycomar", + "omar@kostudios.io": "OmarB97", "qiyin.zuo@pcitc.com": "qiyin-code", "mr.aashiz@gmail.com": "aashizpoudel", "adityargadgil@gmail.com": "AdityaRajeshGadgil", From 7b2564f32f7848b0d4b7866aeba2ab6086556141 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Thu, 11 Jun 2026 23:29:09 -0700 Subject: [PATCH 3/3] test(gateway): sanitize shared participant labels --- gateway/session.py | 14 ++++++++- .../test_shared_group_sender_prefix.py | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/gateway/session.py b/gateway/session.py index a7ff48d90dc4..c048c69d30eb 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -84,6 +84,18 @@ def _hash_chat_id(value: str) -> str: return _hash_id(value) +def _sanitize_participant_label(value: str) -> str: + """Make a user-controlled display name safe inside ``[label]`` prefixes. + + Only whitespace collapsing and bracket neutralization happen here — length + clamping is the caller's job (``neutralize_untrusted_inline_text`` already + applies the shared prompt-metadata cap), so a long-but-legitimate name is + not truncated twice with two different limits. + """ + collapsed = " ".join(str(value or "").split()) + return collapsed.replace("[", "(").replace("]", ")").strip() + + from .config import ( Platform, GatewayConfig, @@ -296,7 +308,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": def shared_participant_label(source: SessionSource) -> Optional[str]: """Return the stable label used to disambiguate speakers in shared sessions.""" - display_name = str(source.user_name or "").strip() + display_name = _sanitize_participant_label(source.user_name or "") if display_name: return display_name diff --git a/tests/gateway/test_shared_group_sender_prefix.py b/tests/gateway/test_shared_group_sender_prefix.py index 65729b1a76bd..49de4159aaf1 100644 --- a/tests/gateway/test_shared_group_sender_prefix.py +++ b/tests/gateway/test_shared_group_sender_prefix.py @@ -75,6 +75,35 @@ async def test_preprocess_uses_stable_participant_label_without_display_name(): assert result == f"[{label}] hello" +@pytest.mark.asyncio +async def test_preprocess_sanitizes_display_name_for_shared_prefix(): + runner = _make_runner( + GatewayConfig( + platforms={ + Platform.WEBHOOK: PlatformConfig(enabled=True, token="fake"), + }, + group_sessions_per_user=False, + ) + ) + source = SessionSource( + platform=Platform.WEBHOOK, + chat_id="room-ops", + chat_name="Ops Room", + chat_type="group", + user_name=" Alice\n[ops]\tlead ", + ) + event = MessageEvent(text="hello", source=source) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert shared_participant_label(source) == "Alice (ops) lead" + assert result == "[Alice (ops) lead] hello" + + def test_shared_participant_labels_distinguish_multiple_unnamed_senders(): first = SessionSource( platform=Platform.WEBHOOK,