fix(gateway): honor group/thread session isolation precedence in is_shared_multi_user_session - #62370
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a behavioral mismatch in the gateway’s session-sharing helper by making is_shared_multi_user_session() follow the same group/thread isolation precedence as build_session_key(), preventing false “isolated” classifications for sessions that are actually shared (notably for threaded group chats under specific config combinations).
Changes:
- Updates
gateway/session.pysois_shared_multi_user_session()computes isolation consistently withbuild_session_key()for threads vs. non-thread group/channel sessions. - Adjusts the helper docstring to reflect the corrected thread isolation rules.
- Adds a regression test in
tests/gateway/test_session.pycovering the previously divergent config combination (group shared + threads per-user).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
gateway/session.py |
Aligns is_shared_multi_user_session() logic/docstring with build_session_key() precedence for thread/group isolation. |
tests/gateway/test_session.py |
Adds a regression test to ensure the helper’s shared/isolated result matches the actual key shape for the divergent thread combo. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Mirrors the isolation rules in :func:`build_session_key`: | ||
| - DMs are never shared. | ||
| - Threads are shared unless ``thread_sessions_per_user`` is True. | ||
| - Non-thread group/channel sessions are shared unless | ||
| ``group_sessions_per_user`` is True (default: True = isolated). | ||
| - Threads follow the same group/thread isolation as |
There was a problem hiding this comment.
@copilot Thanks — I looked at this closely and am intentionally keeping the helper scoped to the isolation config decision rather than mirroring per-source participant presence, because all three consumers depend on that scoping and widening it would regress two of them:
-
Prompt contract (
build_session_context→ session.py):test_non_thread_group_shows_userdeliberately pins that a default-isolation group carrying auser_namebut nouser_idrenders**User:** "<name>"(single-user), and its siblingtest_shared_non_thread_group_prompt_hides_single_userflips to the multi-user note only whengroup_sessions_per_user=False. Keying the helper on missing participant-id would silently flip every no-user_idgroup prompt to multi-user, breaking that intended display (and the sender-prefix branch in run.py that follows it). -
Resume IDOR gate (
_resume_allowed, slash_commands.py): this is the security-critical one. The gate treats config-isolated sessions as not shared, then applies an explicit fail-closed participant check ("participant id is missing on one side: cannot prove the same owner — fail closed"). If the helper returned shared=True whenever the current source lacks a participant id, a caller with a missinguser_idin a config-isolated group would be classified shared and allowed to resume another identified member's per-user session, short-circuiting that fail-closed check. That widens the IDOR surface rather than closing it.
So the helper answers "is participant-sharing enabled by config?" — the question its three call sites actually consume — and the key-shape divergence you spotted for the missing-user_id case is handled downstream (the resume gate's own fail-closed pid check; the prompt's single-user display). Mirroring it into the helper would break the prompt contract and loosen the resume gate, so I'm leaving the helper as-is on this PR.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing this to the shared classification helper. The premise is confirmed on current main: gateway/session.py:846-848 ignores group_sessions_per_user, while build_session_key() applies it at gateway/session.py:952-957. The new logic in this PR matches that configuration precedence.
Problems
docs/session-lifecycle.md:269-275still documents the prior thread-only rule and should be updated with the same group/thread condition.tests/gateway/test_session.py:825-856describes a full key mirror, but the helper intentionally remains configuration-scoped when a participant ID is absent;build_session_key()only appends an ID conditionally atgateway/session.py:936-957. The PR discussion correctly preserves the resume gate's fail-closed behavior for that case.
Suggested changes
- Update the lifecycle documentation and reword the test contract to cover configuration-level isolation precedence, while retaining the reported regression case.
Automated hermes-sweeper review.
| @@ -821,6 +822,39 @@ def test_whatsapp_group_shared_sessions_untouched_by_canonicalisation(self): | |||
| == "agent:main:whatsapp:group:120363000000000000@g.us" | |||
| ) | |||
|
|
|||
| def test_shared_helper_matches_build_session_key_group_thread(self): | |||
| """is_shared_multi_user_session must mirror build_session_key exactly. | |||
There was a problem hiding this comment.
Please describe this as a group/thread configuration-precedence regression rather than an exact build_session_key() mirror. As discussed on this PR, the helper intentionally remains configuration-scoped when user_id_alt/user_id is absent, whereas the key conditionally appends that identifier.
There was a problem hiding this comment.
Addressed in 4a05a0ee (test framing) and bcb811ee (docs), and I have retitled the PR to drop the "exact mirror" framing.
The test docstring now states the narrower claim: the helper tracks build_session_key's group/thread isolation precedence, not its full key string, and deliberately reports the configuration-scoped decision when user_id_alt/user_id is absent instead of reconstructing the conditionally-appended identifier. docs/session-lifecycle.md describes it the same way.
The PR title is now "fix(gateway): honor group/thread session isolation precedence in is_shared_multi_user_session" so the most visible framing matches.
The change itself is unchanged and still needed — gateway/session.py:1004-1005 on current main is still if source.thread_id: return not thread_sessions_per_user, which drops group_sessions_per_user from the precedence entirely.
There was a problem hiding this comment.
Correction to my earlier reply on this thread: it said this was handled in 4a05a0ee (test framing) and bcb811ee (docs). The test half was true — the docstring on test_shared_helper_matches_build_session_key_group_thread does state the narrower precedence claim. The docs half was wrong. bcb811ee is the commit that introduced the "This tracks build_session_key() exactly" wording in docs/session-lifecycle.md; it did not remove it, and that sentence was still live on the head when I claimed otherwise.
Fixed in f2a613995 (docs only, one hunk). The paragraph after the decision-logic fence in docs/session-lifecycle.md now reads:
This tracks
build_session_key()'s group/thread isolation precedence — not its full key string (gateway/session.py): the group setting is the base, and a thread only stays per-user isolated when bothgroup_sessions_per_userandthread_sessions_per_userare enabled. The helper deliberately stays configuration-scoped: when nouser_id_alt/user_idis present,build_session_key()omits the per-user identifier it would otherwise append and the key is shared by construction, so the helper reports that configuration decision rather than reconstructing the literal key.
The divergence statement is checked against the code: build_session_key() computes participant_id = source.user_id_alt or source.user_id and appends it only under if isolate_user and participant_id:, so an absent identifier means no per-user suffix and a key that is shared by construction.
The code comment inside the fence was already precedence-framed by bcb811ee and is unchanged. gateway/session.py is untouched by this commit.
|
Both points addressed in f2a6364bf.
Docs + test-comment only, no behavior change. |
f2a6364 to
bcb811e
Compare
…ser_session is_shared_multi_user_session documents that it "mirrors the isolation rules in build_session_key", but its thread branch (return not thread_sessions_per_user) drops the group_sessions_per_user factor that build_session_key keeps. For a thread with group_sessions_per_user=False and thread_sessions_per_user=True, build_session_key emits a SHARED key (no participant_id) while the helper reports the session isolated. That one divergent combo makes the resume IDOR gate fail-closed and deny a legitimate co-member, and skips multi-user context/attribution handling for a genuinely shared thread. Compute isolate_user identically to build_session_key so all previously-correct combos are preserved and only the divergent one flips.
bcb811e to
a2402a7
Compare
The Multi-User Isolation prose still claimed the classification helper "tracks build_session_key() exactly", which overstates the contract: the helper answers the configuration-scoped isolation question, while the key conditionally appends user_id_alt/user_id and omits it entirely when no participant identifier is present (gateway/session.py, build_session_key: `if isolate_user and participant_id`). Describe the relationship as group/thread precedence tracking and state the configuration-scoped divergence explicitly. Docs only, no behavior change.
What does this PR do?
is_shared_multi_user_sessiondocuments that it "mirrors the isolation rules inbuild_session_key", but its thread branch drops a factor thatbuild_session_keykeeps:build_session_keydecides per-user isolation as:So for a thread, the key is per-user iff
group_sessions_per_user and thread_sessions_per_user. The helper's thread branch ignoresgroup_sessions_per_user, diverging in exactly one combo: thread present +group_sessions_per_user=False+thread_sessions_per_user=True. Therebuild_session_keyemits a shared key (no participant_id), but the helper returnsFalse(claims isolated).Impact for admins running "groups shared, threads per-user": a co-member of an actually-shared thread session is reported isolated, so the resume IDOR gate (
_origin_matches_caller) fail-closes and denies a legitimate member, and that thread's multi-user context/attribution handling is silently skipped.Fix computes
isolate_useridentically tobuild_session_key. This preserves all currently-correct combos and flips only the divergent one. The docstring bullet is reconciled to state threads follow the same group/thread isolation asbuild_session_key.Mirrors
build_session_key's precedence:isolate_user = group_sessions_per_user, then forced toFalsewhenthread_id and not thread_sessions_per_user.Related Issue
Fixes #
Type of Change
Changes Made
gateway/session.py: rewrite the thread branch ofis_shared_multi_user_sessionto computeisolate_userthe same waybuild_session_keydoes, and reconcile the docstring.tests/gateway/test_session.py: addtest_shared_helper_matches_build_session_key_group_thread, which ties the helper's result to the actual key (shared == (participant_id not in key)) for the divergent combo.How to Test
group_sessions_per_user=False, thread_sessions_per_user=True:build_session_key(...)produces a shared key (no participant) butis_shared_multi_user_session(...)returnsFalse.True, matching the key.uv run --with pytest --with pytest-asyncio python3 -m pytest tests/gateway/test_session.py -q→ 109 passed. The new test fails-before / passes-after; no other session test regresses.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A