Skip to content

fix(gateway): fence multiplex profile attribution in session lookup and inheritance - #88387

Closed
69k4xmdfm2-blip wants to merge 1 commit into
NousResearch:mainfrom
69k4xmdfm2-blip:fix/multiplex-profile-inherit-fence
Closed

69k4xmdfm2-blip wants to merge 1 commit into
NousResearch:mainfrom
69k4xmdfm2-blip:fix/multiplex-profile-inherit-fence

Conversation

@69k4xmdfm2-blip

Copy link
Copy Markdown

What

Two profile fences in hermes_state.py so a multiplexed gateway can never attribute a session to
the wrong profile.

Fixes the durable-mislabel half of #88381; hardens the lookup half of #74285.

Why

A Telegram private chat reports the user's own id as chat.id, so for every bot in a
multiplexed gateway the peer tuple (source, user_id, chat_id, chat_type, thread_id) is
byte-identical. Two places trusted that tuple:

1. find_latest_gateway_session_for_peer — the conservative fallback (#74285). With the
tuple alone it happily returns a sibling profile's row, so a DM to bot A can be answered by
profile B, with B's persona, credentials and filesystem scope.

2. create_session — parent inheritance (#88381, this PR's new finding). Once (1) has
crossed profiles, the reset records the adopted row as parent_session_id, and the unconditional

profile_name = COALESCE(sessions.profile_name,
               (SELECT p.profile_name FROM sessions p WHERE p.id = sessions.parent_session_id))

stamps the child with the parent's profile. Because "default" persists as NULL
(run_agent.py::_ensure_db_session, conversation_compression.py), a default child is always
NULL at insert and therefore always eligible — inheritance can only flow default → named
profile, never back. Every subsequent daily/idle reset copies the wrong name forward again, so
one crossing mislabels the lineage permanently while session_key stays correct. The two
disagree, and profile_name is the one that session lists, per-profile aggregation and the
desktop sidebar read.

Real rows from my install (default + medicina, two bot tokens, one gateway):

id                        session_key                        profile_name
20260816_122957_0d860ebc  agent:medicina:telegram:dm:<uid>    medicina   <- genuine
20260816_224836_5f838b2f  agent:main:telegram:dm:<uid>        medicina   <- WRONG (default session)
20260817_082108_0a750a44  agent:main:telegram:dm:<uid>        medicina   <- WRONG (inherited again)

The four open PRs for #74285 (#75043, #74153, #85205, #74593) all fence the lookup; none touches
the inheritance write, so already-crossed lineages stay mislabelled after any of them lands. This
PR is deliberately scoped to be compatible with whichever one you take: the lookup fence here is
a namespace comparison in the same predicate position, so it rebases cleanly or drops out
entirely if you prefer their shape.

How

session_key already carries the truth (agent:main:… for default, agent:<profile>:…
otherwise), so both fences derive from it — no new column, no migration.

  • Lookup: the fallback requires the candidate to share the requesting key's agent:<ns>:
    prefix. A keyless candidate (identity write lost the key — the case the fallback exists for) is
    compared on profile_name instead, '' == default. No match now returns None, which mints a
    fresh session: strictly safer than borrowing a sibling's.
  • Inheritance: profile_name moves out of the shared parent-backfill UPDATE into its own
    statement, gated on both rows sharing that namespace. cwd / git_repo_root / git_branch
    keep inheriting unconditionally (harmless across profiles; gating them would regress bug(state): compression-split child sessions lose cwd/git_repo_root, causing project sidebar entry to disappear every compression #64709).
    Keyless lineage — CLI, subagents, delegation — still inherits unconditionally, since there the
    parent's profile is the only signal available. Namespace extraction uses substr/instr so no
    UDF is needed.

Both fences fail closed and are no-ops on a single-profile gateway (agent:main: on both sides).

Testing

New: tests/gateway/test_multiplex_peer_fallback_profile_fence.py — 10 cases.

tests/gateway/test_multiplex_peer_fallback_profile_fence.py .......... 10 passed
+ tests/gateway/test_session_continuity_82616.py                      21 passed (combined)

RED verified against a pristine hermes_state.py (same tests, module shadowed via PYTHONPATH):
3 fail without the patch —
test_fallback_ignores_sibling_profile_row,
test_fallback_ignores_sibling_keyless_row_by_profile_name,
test_child_does_not_inherit_sibling_profile_across_namespaces
(AssertionError: default child inherited sibling profile: 'medicina').

Coverage includes the negative cases that keep the fences honest: own keyless row still recovers
(both from default and from a named profile), exact-key matching unaffected with both rows
present, a newer sibling row does not outrank this profile's older correct row, same-namespace
rotation still inherits, keyless subagent lineage still inherits, and an explicit child
profile_name is never overwritten.

Neighbouring suites (test_session_continuity_82616, test_branch_routing_columns,
test_hermes_state.py, test_session.py, test_multiplex_profile_authz,
test_profile_resolution, test_profile_routing, test_multiplex_phase0) show no new failures:
the handful that fail here reproduce identically against a pristine hermes_state.py on this
machine, so they are pre-existing and unrelated to this change.

Platform: Windows 11, Python 3.11 (venv) — verified end-to-end on a live 2-profile ×
2-Telegram-bot multiplexed gateway, which is where the mislabelled rows above came from.

Existing data

Deployments that already crossed can be repaired offline, since session_key is authoritative:

UPDATE sessions SET profile_name = NULL
 WHERE session_key LIKE 'agent:main:%' AND profile_name IS NOT NULL;

plus clearing the cross-namespace parent_session_id links that caused them. I did not add that
to hermes sessions repair-routing in this PR to keep the diff reviewable — happy to follow up
if you want it there.

…nd inheritance

A Telegram private chat reports the user's own id as chat.id, so in a
multiplexed gateway the peer tuple (source, user_id, chat_id, chat_type,
thread_id) is byte-identical for every bot. Two sites trusted it:

find_latest_gateway_session_for_peer's conservative fallback matched on
that tuple alone and returned a sibling profile's row, executing the wrong
persona, credentials and filesystem scope (NousResearch#74285).

create_session then made that crossing permanent: the reset records the
adopted row as parent_session_id, and the unconditional COALESCE stamped
profile_name from the parent. Since "default" persists as NULL, a default
child is always eligible, so inheritance only ever flows default -> named
profile and every later reset copies the wrong name forward again, while
session_key stays correct (NousResearch#88381).

Both fences derive from session_key, which already carries the namespace
(agent:main: for default, agent:<profile>: otherwise) - no new column and
no migration. The fallback now requires a shared namespace, comparing a
keyless candidate on profile_name instead ('' == default); no match
returns None and mints a fresh session rather than borrowing a sibling's.
profile_name inheritance moves into its own statement gated on that same
namespace, leaving cwd/git_repo_root/git_branch unconditional (NousResearch#64709) and
keyless CLI/subagent lineage untouched, where the parent's profile is the
only signal available.

Both fences fail closed and are no-ops on a single-profile gateway.
@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/telegram Telegram bot adapter area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 17, 2026
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

Correct and well-tested fix for a serious multiplex-contamination bug: Telegram DMs make the peer tuple byte-identical across every bot on one gateway, so the conservative fallback happily adopted a sibling profile's session — executing another persona's credentials and filesystem scope. Fencing by the agent:<ns>: namespace with fail-closed-to-fresh-mint, extending the same fence to profile_name inheritance in create_session, and preserving the legitimate keyless-lineage recovery are all right. Points:

  1. hermes_state.py:~5260 — the fence is skipped entirely when the requested session_key doesn't start with agent: (_req_prefix = None → `CASE ... WHEN ? IS NULL THEN 1` admits everything). Verify every caller of find_latest_gateway_session_for_peer passes agent-namespaced keys; if cron or other surfaces can reach it with foreign key shapes, they bypass the fence exactly like pre-fix behavior. One assertion/comment pinning "agent-keys only" would close the question.
  2. The LIKE pattern _ns_prefix embeds a slice of session_key verbatim into a LIKE clause. Safe today because profile namespaces are validated [a-z0-9_-] at creation — but that invariant lives in a different module; one comment noting "no wildcards reachable here because of profile-name validation" prevents a future loosening from becoming a query-shape change. (nit)
  3. The inheritance guard's substr(...,7)/instr(...)+6 arithmetic computes the agent:<ns>: prefix positionally — correct for agent-shaped keys and structurally requiring equality otherwise, but it's the least readable SQL in the file; a small Python-side helper computing the namespace once (as the lookup path already does) would read far better than inline string surgery. (nit)
  4. Test coverage hits all three critical shapes: sibling keyed row rejected, sibling keyless row rejected by profile, own keyless row still recovered — plus lineage mislabeling. Exactly the incident surface. (positive)

No blocking issues found beyond item 1's caller-scope verification.

@teknium1

teknium1 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this PR. Merged via #101248 (ad8b9e0) on current main — per-profile /voice state, off-loop startup hydration, session-recovery owner fence, int route ids.

#101248 won as the consolidated fix because it covers the whole multiplex-profile bug class in one change (with tests) rather than the single symptom addressed here; this PR is superseded by it.
You are credited via Co-authored-by / in the PR body of #101248 as noted there.

If anything from your original change is still missing on main >= ad8b9e0, please open a fresh PR/issue against main and tag it. Thanks again.

@teknium1 teknium1 closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/telegram Telegram bot adapter 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.

4 participants