Skip to content

fix(gateway): per-profile session isolation for shared-WhatsApp multiplex - #69042

Closed
lsshawn wants to merge 2 commits into
NousResearch:mainfrom
lsshawn:fix/multiplex-whatsapp-per-profile-isolation
Closed

fix(gateway): per-profile session isolation for shared-WhatsApp multiplex#69042
lsshawn wants to merge 2 commits into
NousResearch:mainfrom
lsshawn:fix/multiplex-whatsapp-per-profile-isolation

Conversation

@lsshawn

@lsshawn lsshawn commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Running gateway.multiplex_profiles with a single shared WhatsApp (Baileys) account routed to multiple profiles via gateway.profile_routes, inbound messages were served by the default profile's config/skills/memory/session-DB even when a profile_route matched — leaking default-profile data (memory, session_search history, filesystem) into routed replies. Three related gaps:

1. Secondary profiles tried to start their own WhatsApp adapter

_start_one_profile_adapters attempted a WhatsApp connection per profile. With one shared account, secondaries have no creds → retry-loop that stalls gateway startup. WhatsApp is a single-shared-connection platform like RELAY, so we now skip it for secondaries (mirrors the existing RELAY skip). The default profile owns the one connection; profile_routes routes groups.

2. Session-key namespace ignored profile_routes

SessionStore._resolve_profile_for_key fell back to the active profile (default) when source.profile was unset — even when a profile_route matched — so routed sessions landed in agent:main + the default state.db. Now consults gateway.profile_routes and stamps source.profile so the key namespace agrees with routing.

3. SessionStore._db + inbound session creation were default-bound

SessionStore held a single default-home SessionDB; every session's history/search/persistence resolved to default's state.db regardless of profile. Made _db a per-home-cached property (resolves get_hermes_home()/state.db under the active ContextVar scope), and wrapped inbound get_or_create_session in _profile_runtime_scope so the session is physically created in the routed profile's state.db.

Result

A WhatsApp group routed to profile X now runs fully as X — its own skills, memory, and state.db — and session_search no longer surfaces another profile's history.

Safety

All new behavior is gated on multiplex_profiles. Multiplex-off behavior is byte-identical.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/whatsapp WhatsApp Business adapter area/config Config system, migrations, profiles area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 22, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing a real multiplex storage seam. The current gateway still constructs one default-scoped SessionStore (gateway/run.py:3439-3448) and its initial SQLite handle is default-scoped (gateway/session.py:1205-1210).

Problems

  • The proposed _db property does not scope the rest of SessionStore: _entries and _loaded are single-store state (gateway/session.py:1172-1174, 1254-1257), and the routing mirror still reads/writes self.sessions_dir / "sessions.json" (gateway/session.py:1283-1287, 1464-1468). A profile-specific DB selected after another profile has loaded the store would therefore use shared in-memory routing state and the default routing directory.
  • Physical per-profile state.db storage needs an explicit contract decision first. Current docs specify profile-namespaced keys in a shared store (website/docs/user-guide/multi-profile-gateways.md:193-199), and the linked #66887 discussion identifies that choice as unresolved.
  • The PR has no tests for secondary-profile persistence, restart recovery, or shared-WhatsApp startup.

Suggested changes

  • Select the storage contract, then scope the full storage unit per profile if physical stores are intended; add an E2E temp-home regression covering creation and recovery.
  • gateway/platforms/base.py:6439-6485 already stamps routed source.profile, so avoid duplicating route resolution unless covering a verified bypass path.

Automated hermes-sweeper review.

Comment thread gateway/session.py Outdated
# Initialize SQLite session database
self._db = None
# Initialize SQLite session database.
# [LOCAL PATCH] Under multiplex, self._db must resolve to the CURRENT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making only _db profile-sensitive leaves this SessionStore's single _entries map, _loaded flag, and default sessions_dir shared across profiles. After one profile loads the store, a second profile will not load its own routing rows, while sessions.json remains under the default directory. Please scope the whole storage unit per profile after the shared-vs-physical persistence contract is selected.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 30, 2026
@lsshawn

lsshawn commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Good review — you talked me out of two thirds of this PR. Narrowed in 77f969c.

Storage contract. You're right that this needed deciding before the code, and on rereading, the docs already decide it: multi-profile-gateways.md specifies profile-namespaced keys in a shared store. Physical per-profile state.db contradicts that, so I've dropped it rather than front-run the choice. Your structural point stands independently — scoping the _db handle while _entries, _loaded, and the sessions.json mirror stayed single-store was half a change, and doing it properly means scoping the whole storage unit. If you do want physical per-profile stores, I'm happy to reopen that as its own PR against an explicit contract decision.

Duplicated route resolution. Also right, and dropped. build_source already resolves the route and stamps source.profile via _profile_name_for_source, and _resolve_profile_for_key reads that field first — I went looking for a real bypass path and couldn't find one.

Small correction on the pointer, in case it matters for future reviews: the citation gateway/platforms/base.py:6439-6485 doesn't resolve — that file is 5933 lines. The stamping is in build_source around L5700-5760. The conclusion was correct, just the line reference.

gateway/session.py is now byte-identical to base; the PR is one behavioral line.

What's left is the startup fix only: WhatsApp joins Relay as shared process-level ingress under multiplexing. The bridge is a single authenticated session tied to one phone number, so a secondary profile has no credentials of its own to bring — building an adapter for it only produces a connect/retry loop that stalls startup for every profile queued behind it.

Tests, as requested for the shared-WhatsApp startup path: a secondary profile skips WhatsApp while its other platforms still start (the skip doesn't abort the profile), and the skip stays gated on multiplex_profiles so single-profile installs are untouched. Verified the first fails without the fix.

I didn't add the persistence/restart-recovery E2E — with the storage changes gone there's no longer a storage behavior in this PR to regress. That belongs with the contract decision above.

79 tests pass across the multiplex suites; ruff clean. On a full tests/gateway/ run I see ~100 failures, but the pristine base fails 101 with the same command and this branch fails 97, so they're pre-existing in my environment (missing aiohttp/httpx extras plus some cross-test pollution), not from this change.

lsshawn and others added 2 commits August 3, 2026 00:41
…plex

Under gateway.multiplex_profiles with a single shared WhatsApp (Baileys)
account routed to multiple profiles via gateway.profile_routes, inbound
messages were served by the DEFAULT profile's config/skills/memory/session
DB even when a profile_route matched — leaking default-profile data into
routed replies. Three related gaps:

1. run.py _start_one_profile_adapters: secondary profiles tried to start
   their OWN WhatsApp adapter (no per-profile creds → retry-loop stalls
   startup). WhatsApp is a single-shared-connection platform like RELAY, so
   skip it for secondaries (mirrors the existing RELAY skip). The default
   profile owns the one connection; profile_routes routes groups.

2. session.py _resolve_profile_for_key: when source.profile was unset, the
   session-key namespace fell back to the active profile (default) even when
   a profile_route matched — so routed sessions landed in agent:main and the
   default state.db. Now consults gateway.profile_routes and stamps
   source.profile so the key namespace agrees with routing.

3. session.py SessionStore._db + run.py session creation: SessionStore held a
   single default-home SessionDB; every session's history/search/persistence
   resolved to default's state.db regardless of profile. Made _db a
   per-home-cached property (resolves get_hermes_home()/state.db under the
   active ContextVar scope), and wrapped inbound get_or_create_session in
   _profile_runtime_scope so the session is physically created in the routed
   profile's state.db.

Net: a WhatsApp group routed to profile X now runs fully as X — its own
skills, memory, and state.db — and session_search no longer surfaces another
profile's history. Multiplex-off behavior is byte-identical (all new code is
gated on multiplex_profiles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback on NousResearch#69042 by dropping the two storage changes
and keeping only the part that does not depend on an unresolved contract.

Reverted (session.py is now byte-identical to base):

* The per-home _db property. As reviewed, it scoped only the SQLite
  handle while _entries, _loaded, and the sessions.json routing mirror
  stayed single-store — and, more fundamentally, physical per-profile
  state.db contradicts the documented contract of profile-namespaced
  keys in a SHARED store (multi-profile-gateways.md, "Session keys are
  namespaced by profile"). That contract choice is yours to make, so I
  am not front-running it here; happy to reopen separately if you do
  want physical per-profile stores, in which case the whole storage
  unit needs scoping, not just the handle.

* The duplicate profile_routes resolution in _resolve_profile_for_key.
  You were right that this was redundant — build_source already resolves
  the route and stamps source.profile via _profile_name_for_source, and
  _resolve_profile_for_key reads that field first. I could not identify
  a real bypass path, so the duplication is gone.

What remains is the startup fix, unchanged in behavior: WhatsApp joins
Relay as shared process-level ingress under multiplexing. The bridge is
one authenticated session tied to a single phone number, so a secondary
profile has no credentials to connect with and building an adapter for
it only produces a connect/retry loop that stalls startup for every
profile queued behind it.

Adds the requested regression coverage: a secondary profile skips
WhatsApp while its other platforms still start (the skip does not abort
the profile), and the skip stays gated on multiplex_profiles so
single-profile installs are untouched. Verified the first test fails
without the fix.
@lsshawn
lsshawn force-pushed the fix/multiplex-whatsapp-per-profile-isolation branch from 77f969c to 579feb4 Compare August 2, 2026 16:43
@lsshawn

lsshawn commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — now conflict-free (579feb4).

Heads-up from the rebase: main has since removed the two Relay-skip tests in test_multiplex_adapter_registry.py (..._skips_relay / ..._keeps_relay) while keeping the Relay skip itself in run.py. So that branch is currently uncovered. The two WhatsApp tests here follow the same shape and sit in TestSecondaryProfileConfigHandling; happy to restore the Relay pair alongside them if you'd like that coverage back.

Net diff is one behavioral line in run.py plus the tests. Re-verified post-rebase that the skip test fails without the fix; 23 tests pass across the multiplex suites, ruff clean.

teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 2, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of #69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
@teknium1

teknium1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Thanks @lsshawn for this PR — Merged via #101252 (2e25b47) on current main.

#101252 won because it routes all the scoped-secret lookups through the shared _get_scoped_secret primitive rather than fixing the one site. You're credited via Co-authored-by on the merge and in the PR body of #101252.

Closing this PR as superseded by the merged work.

@teknium1 teknium1 closed this Sep 2, 2026
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…ofiles

_start_one_profile_adapters skipped only Platform.RELAY as shared
process-level ingress. WhatsApp is the same shape: the bridge is one
authenticated session tied to a single phone number, so a secondary
profile has no credential of its own to bring; constructing an adapter
for it only produced a connect/retry loop that stalled startup for every
profile queued behind it. Treat WhatsApp like Relay -- the active profile
owns the connection and route-stamped source.profile fans inbound turns
out to secondary profiles.

Salvage of NousResearch#69042 (narrowed by its author to this one behavioral line);
test re-expressed on the current secondary-startup fixtures.

Co-authored-by: sshawn <28279366+lsshawn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/whatsapp WhatsApp Business adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants