Skip to content

feat(gateway): per-user profile isolation for a single bot (per_user_profiles) - #65571

Open
otopba wants to merge 3 commits into
NousResearch:mainfrom
otopba:feat/per-user-profiles
Open

feat(gateway): per-user profile isolation for a single bot (per_user_profiles)#65571
otopba wants to merge 3 commits into
NousResearch:mainfrom
otopba:feat/per-user-profiles

Conversation

@otopba

@otopba otopba commented Jul 16, 2026

Copy link
Copy Markdown

What does this PR do?

Adds an opt-in gateway.per_user_profiles (default off) that isolates the
workspace of each user talking to a single bot. When enabled, the gateway
derives a profile from the message sender (<prefix>-<platform>-<uid>) and
runs each turn under that user's own HERMES_HOME, so skills, cron jobs.json,
native MEMORY.md / USER.md, and workspace files isolate per user — while
service credentials stay shared.

Why: a single gateway serves every user of one bot from one HERMES_HOME.
Conversation history is already isolated per chat and memory providers per user,
but the shared workspace is not: through skill_manage, cronjob, or the native
memory tool, one user can read, tamper with, or delete another user's skills,
scheduled jobs, and memory files. multiplex_profiles doesn't solve this — it
stamps a profile per bot credential (_make_profile_message_handler; two
profiles can't poll the same token), so every user of one bot lands in the
default profile. Isolating N users would need N bot tokens.

This is the minimal isolation primitive requested by the per-user identity
RFC (#21574): reuse the existing profile machinery, keyed by sender instead of
by adapter. Role/permission tiers (RBAC) are intentionally out of scope and layer
on top of a per-user identity→home that now exists.

Related Issue

Related: #21574 (per-user identity & isolation RFC), #63746 (cron.run_as_creator
— threads sender identity into cron; complementary), #11448 (per-user memory
dirs — subsumed for the single-bot case, its migration logic still useful).

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Design decisions worth reviewer attention

  • Reuse, don't rebuild. The turn runs through the same seams multiplexing
    already uses — _resolve_profile_home_for_source, a per-turn home scope, and
    the agent:<profile>:… session-key namespace. The feature is essentially "a
    new way to name the profile (from the sender) + lazy provisioning + a
    shared-secret scope variant."
  • Shared credentials, on purpose. Per-user turns run under a new
    _profile_home_only_scope that overrides get_hermes_home() but does not
    install a per-profile secret scope (and set_multiplex_active stays False), so
    get_secret keeps reading the process-global os.environ. A per-user .env
    would be empty and break every credentialed tool; the template's .env is
    dropped at provision time so no stale secret sits in each user dir.
  • Collision-safe derivation. <prefix>-<platform>-<uid> is used only when
    sanitizing the uid was lossless — the uid was already a non-empty lowercase
    profile-id charset (e.g. a numeric platform id), compared against the
    case-preserving uid. Otherwise the raw uid is SHA1-hashed. This closes three
    collision classes that each silently merge two distinct senders into one
    profile: character drop (a.b/abab), case fold (Bob/bobbob),
    and empty/degenerate segments (whitespace uid → None shared default;
    punctuation-only !!! → hashed, never u-<platform>-). Platform is part of the
    id so a Telegram and a SpaceChat uid never collide. All pinned by tests.
  • Reply delivery works end-to-end. A stamped u-… profile is a workspace
    namespace, not a bot-routing key, so adapter resolution (_authorization_adapter
    / _adapter_for_source) is made per-user-aware: a derived profile falls through
    to the single shared bot adapter instead of the multiplex fail-closed None
    (which would drop every reply). Session recovery
    (_recovered_row_allowed_for_active_profile) compares the recovered row against
    the requested key's profile, not the operator's active profile, so a user's
    own differently-keyed resumable row isn't wrongly rejected under per-user keys.
  • Lazy, race-safe provisioning. A user's profile is created on their first
    message (create_profile(clone_config=True), seeded from
    per_user_profile_template, default the active profile) under a per-name lock
    with a double-checked profile_exists; concurrent first messages resolve to one
    profile (FileExistsError swallowed).
  • Composition. Independent of multiplex_profiles. A configured
    profile_routes match still wins over per-user derivation (pin a group to a
    shared profile; every DM user still gets their own). If both flags are on,
    multiplex — with its stronger per-profile credential isolation — takes
    precedence for the scope.
  • Config drift caveat (documented). Each per-user profile is a self-contained
    copy seeded once, so later edits to the base config.yaml / SOUL.md / model
    don't reach already-provisioned users. The managed overlay (re-applied every
    turn) is the lever for anything that must stay operator-authoritative; a re-seed
    is the alternative.
  • Not a permission system. This isolates the workspace; it does not by itself
    stop a user from running the terminal tool or admin slash commands in their own
    sandbox. Pair with allow_admin_from / user_allowed_commands and a restricted
    toolset. (Noted in the docs.)

Changes Made

  • gateway/user_profiles.py (new) — derive_user_profile_name (collision-safe,
    platform-namespaced, hashed fallback), is_user_profile_name, and
    ensure_user_profile (lazy, race-safe provisioning; cleans up a partially-built
    profile on failure, evicts the per-name lock, strips the seeded .env).
  • gateway/config.pyper_user_profiles / per_user_profile_template /
    per_user_profile_prefix fields, with from_dict / to_dict and yaml-load
    parity (top-level + nested gateway.*) mirroring multiplex_profiles.
  • gateway/run.py_profile_home_only_scope (home override without secret
    swap); _profile_scope_for_source (picks nullcontext / home-only / full scope);
    _run_agent, _prepare_profile_scoped_inbound_message_text, and the session-
    info scope now route through it; _profile_name_for_source derives a per-user
    profile when no route matches (suppressed under multiplex);
    _resolve_profile_home_for_source provisions a derived profile on first contact;
    _session_key_for_source and the agent.max_turns env bridge honor the per-user
    home too.
  • gateway/session.py_resolve_profile_for_key namespaces by the per-user
    profile (fallback stays None/shared for an unresolved sender);
    _recovered_row_allowed_for_active_profile compares against the requested key's
    profile so per-user session recovery isn't wrongly blocked.
  • gateway/authz_mixin.py_authorization_adapter falls through to the shared
    bot adapter for a per-user (home-only) profile instead of fail-closing.
  • tests/gateway/test_per_user_profiles.py (new) — 45 tests (derivation +
    collision hardening, provisioning + partial-failure cleanup + lock eviction,
    config round-trip, session-key isolation, runner wiring, adapter resolution &
    session recovery, multiplex precedence, home-only scope keeps secrets shared).
  • tests/gateway/test_profile_resolution.py — fixture now sets a concrete
    per_user_profiles=False (a bare MagicMock attr is truthy and would flip
    derivation on).
  • website/docs/user-guide/multi-profile-gateways.md, cli-config.yaml.example
    — document the feature, the shared-secret + drift caveats, and composition.

How to Test

  1. pytest tests/gateway/test_per_user_profiles.py tests/gateway/test_profile_resolution.py tests/gateway/test_profile_routing.py tests/gateway/test_multiplex_*.py tests/gateway/test_config.py tests/gateway/test_agent_cache.py tests/gateway/test_async_session_store.py tests/gateway/test_runtime_env_reload_config_authority.py tests/gateway/test_unauthorized_dm_behavior.py -q — passes (45 new + existing green).
  2. Manual: set gateway: { per_user_profiles: true }, start hermes gateway run, DM the bot from two Telegram accounts. Each gets ~/.hermes/profiles/u-telegram-<uid>/ on first message; a skill/cron job/MEMORY.md entry created by one is not visible to the other; both still answer (shared model key).
  3. Flip it back off → single-profile behavior is byte-identical (the scope is a nullcontext).

Checklist

Code

Documentation & Housekeeping

  • Updated docs (multi-profile-gateways guide, cli-config.yaml.example, docstrings)
  • Updated cli-config.yaml.example for the new keys
  • Considered cross-platform impact — pure-Python; profile dirs use pathlib
  • No tool schema changes

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery area/config Config system, migrations, profiles needs-decision Awaiting maintainer decision before any implementation 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 16, 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 building on the existing profile seams; the sender-derived-home gap is real on current main (gateway/run.py:17631-17642). I found three blocking gaps in the proposed isolation contract.

Problems

  • gateway/run.py:13879-13888 scopes _run_background_task only for multiplex_profiles. This PR updates foreground scope selection but not that sibling path, so a per-user /background task runs in the shared base home. Commit 8091c4405 added this wrapper specifically to preserve multiplex profile scope.
  • The documentation says workspace files isolate, but _profile_home_only_scope only changes a ContextVar. hermes_constants.py:23-30 deliberately leaves os.environ unchanged; gateway startup bridges terminal.cwd to process-global TERMINAL_CWD (gateway/run.py:1625-1684), which tools/terminal_tool.py:1394-1400 consumes. Cloned profiles therefore retain a shared terminal workspace.
  • gateway/user_profiles.py:108-111 truncates the digest away when per_user_profile_prefix is 64 valid characters, mapping every sender to one profile.

Suggested changes

  • Scope background tasks through _profile_scope_for_source and add coverage.
  • Define and test whether terminal workspaces are isolated; otherwise narrow the feature claim to HERMES_HOME state.
  • Bound the prefix or reserve digest space before truncation.

Automated hermes-sweeper review.

Comment thread gateway/user_profiles.py Outdated
if len(name) > _MAX_LEN:
# Pathological prefix/platform lengths — fall back to a fully hashed tail.
digest = hashlib.sha1(f"{plat_seg}:{uid}".encode("utf-8")).hexdigest()[:16]
name = f"{prefix_seg}-{digest}"[:_MAX_LEN]

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.

A valid 64-character configured prefix makes this slice discard the digest entirely, so every sender resolves to the same profile name. Validate the prefix against the available suffix budget, or construct the identifier so a sender-specific digest is always retained.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6868f1e1d. The fallback now reserves room for -<digest> before truncating and bounds only the prefix, so the sender-specific digest is always retained:

digest = hashlib.sha1(f"{plat_seg}:{uid}".encode("utf-8")).hexdigest()[:16]
prefix_bounded = prefix_seg[: _MAX_LEN - len(digest) - 1].rstrip("-_") or "u"
name = f"{prefix_bounded}-{digest}"

A 64-char prefix now yields <prefix[:47]>-<16-hex> (≤64, valid), so distinct senders can no longer collapse onto one profile. Added test_long_prefix_keeps_sender_digest asserting two distinct senders under a 64-char prefix stay distinct and each name still passes validate_profile_name.

@teknium1 teknium1 added 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:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 18, 2026
@otopba

otopba commented Jul 19, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review. Status on the three gaps:

3. Prefix truncation — fixed & pushed (6868f1e1d). The fully-hashed fallback now reserves room for -<digest> before truncating and bounds only the prefix, so the sender digest is always retained. Added test_long_prefix_keeps_sender_digest. Detail in the inline thread. pytest tests/gateway/test_per_user_profiles.py (46) + ruff green.

1. Background-task scope — confirmed, not yet pushed. Verified on the current branch: _run_background_task runs agent.run_conversation at gateway/run.py:13817 without wrapping it in _profile_scope_for_source(source), so a per-user /background turn resolves get_hermes_home() to the shared base home. The foreground paths (:11033, :12697, :17457) all scope correctly; this sibling doesn't. The fix is to wrap the run_sync body in self._profile_scope_for_source(source), but the agent runs via _run_in_executor_with_context, so I want to confirm the home ContextVar is actually copied onto the executor thread (not just entered on the loop thread) before pushing, plus add a background-path coverage test. Holding the push for a human check on the executor context-propagation seam rather than guessing.

2. Terminal workspace isolation — agreed, needs a decision. _profile_home_only_scope only moves the home ContextVar; hermes_constants.py deliberately leaves os.environ alone, and TERMINAL_CWD is process-global (bridged at startup and read by tools/terminal_tool.py), so cloned profiles share one terminal cwd. Two options: (a) narrow the documented isolation claim to HERMES_HOME state, or (b) derive a per-profile terminal cwd. This is a scope/design call for the maintainers — flagging rather than picking unilaterally.

Will follow up on (1) once the executor seam is confirmed.

@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Jul 19, 2026
otopba and others added 2 commits July 21, 2026 11:18
…profiles)

Opt-in gateway.per_user_profiles derives a profile from the message SENDER
and runs each turn under that user's own HERMES_HOME, so skills, cron
jobs.json, and native MEMORY.md/USER.md isolate per user while service
credentials stay shared (home-only scope). Reuses the existing profile
machinery (_profile_runtime_scope seam, agent:<profile> session-key
namespace); lazy race-safe provisioning seeded from a template; adapter
resolution and session recovery made per-user-aware so replies deliver and
resumable sessions aren't dropped. Default off — single-profile gateways are
byte-identical. 45 tests.

Related: NousResearch#21574 (per-user identity RFC), NousResearch#63746 (cron.run_as_creator).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er digest

The fully-hashed fallback in derive_user_profile_name did
`f"{prefix_seg}-{digest}"[:_MAX_LEN]`. A valid 64-char prefix filled the
whole budget, so the `[:_MAX_LEN]` slice discarded `-{digest}` entirely and
every sender collapsed onto one profile — defeating per-user isolation.

Reserve room for `-<digest>` before truncating and bound only the prefix, so
the sender-specific digest is always retained. Add a regression test asserting
distinct senders under a 64-char prefix stay distinct and valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@otopba
otopba force-pushed the feat/per-user-profiles branch from 689fd6d to 463cb54 Compare July 21, 2026 08:19
@otopba
otopba requested a review from teknium1 July 22, 2026 10:17
@otopba

otopba commented Aug 4, 2026

Copy link
Copy Markdown
Author

Status check: this one merges cleanly into current main (no conflicts — GitHub now reports it mergeable), so nothing is blocking on my side.

@teknium1 — you left a review here on 18 Jul and I replied the next morning; it's been quiet since. Ready for another pass whenever you have time, and happy to rework anything if the direction needs changing.

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 comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have 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/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants