209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# Multiplexing Gateway
|
|
|
|
One gateway process can serve every profile in the install. The mode is opt-in
|
|
(`gateway.multiplex_profiles`, default `false`), and everything it changes
|
|
reverts the moment the flag is off. This document is the design rationale
|
|
referenced from `agent/secret_scope.py` ("Workstream A"): what is isolated per
|
|
profile, the mechanism that isolates it, and what deliberately stays
|
|
process-global.
|
|
|
|
## Overview
|
|
|
|
Without multiplexing, one gateway process serves exactly one profile — its
|
|
`.env`, sessions, skills, and platform adapters — and multi-profile installs
|
|
run one process per profile. Multiplexing collapses that into a single
|
|
process: the default profile plus every served named profile get their own
|
|
adapters, secrets, sessions, and cron ticks, while sharing one event loop, one
|
|
HTTP listener, one process lock, and one status surface.
|
|
|
|
The design constraint that shapes everything below: **profile A's turns must
|
|
never observe profile B's state**. Secrets, homes, sessions, and adapter lanes
|
|
are isolated per profile; anything that cannot yet be isolated fails closed or
|
|
is documented as a known limitation at the end of this document.
|
|
|
|
## The mode flag
|
|
|
|
- Config: `gateway.multiplex_profiles: true` (also accepted at top level).
|
|
Parsed in `gateway/config.py` with precedence env > config > default.
|
|
- Env override: `GATEWAY_MULTIPLEX_PROFILES` accepts explicit truthy/falsy
|
|
tokens only; a blank or unrecognized value returns "no override" so an empty
|
|
deployment secret cannot shadow a config opt-in.
|
|
- At startup, `GatewayRunner.__init__` calls
|
|
`agent.secret_scope.set_multiplex_active(...)` once. `_MULTIPLEX_ACTIVE` is
|
|
a plain module global, not a contextvar: it describes the deployment mode,
|
|
not a per-task value. Its only job is to arm the fail-closed behavior in
|
|
`get_secret()`.
|
|
|
|
## Scope composition
|
|
|
|
Every inbound event composes the same two context-local scopes before any
|
|
profile-owned code runs:
|
|
|
|
```
|
|
platform event
|
|
│
|
|
▼
|
|
profile_routes match ──► served-set check ──► SessionSource.profile stamped
|
|
│ (gateway/profile_routing.py)
|
|
▼
|
|
_profile_runtime_scope(profile_home) (gateway/run.py)
|
|
├── set_hermes_home_override(home) config / state.db / skills /
|
|
│ memory / sessions resolve here
|
|
└── set_secret_scope(profile .env + secret sources)
|
|
│ provider keys, platform tokens
|
|
▼
|
|
agent turn (worker thread via copy_context())
|
|
│
|
|
▼
|
|
scope unwound in finally
|
|
```
|
|
|
|
`_profile_runtime_scope` wraps every seam where profile-owned code executes:
|
|
secondary adapter startup, connect and reconnect, the primary platform event
|
|
handler, inbound preprocessing, `/model` and session-info resolution,
|
|
background tasks, and the agent turn itself. Config reloads run under the
|
|
default profile's scope so global gateway settings (`#64674`) resolve
|
|
consistently.
|
|
|
|
Both scopes are `contextvars`, so they propagate into executor worker threads
|
|
via `copy_context()` and unwind deterministically — nothing is written to
|
|
`os.environ`, ever.
|
|
|
|
## Workstream A: context-local secret scope
|
|
|
|
`agent/secret_scope.py` exists because the obvious implementation — union all
|
|
profile `.env` files into `os.environ` — leaks profile A's keys into profile
|
|
B's turns and into every subprocess spawned with `env=dict(os.environ)`.
|
|
|
|
- `build_profile_secret_scope(home)` merges the profile's `.env` with its
|
|
configured secret sources, skipping globals.
|
|
- `set_secret_scope(mapping)` installs it for the current task.
|
|
- `get_secret(name)` resolves: global allowlist → active scope → fallback.
|
|
The fallback is the load-bearing part:
|
|
- multiplexing **off**: reads `os.environ`, so single-profile gateways and
|
|
every non-gateway caller behave exactly as before;
|
|
- multiplexing **on**, no scope installed: **raises `UnscopedSecretError`**
|
|
rather than silently reading the process environment. An un-migrated call
|
|
site fails loud at that exact line instead of leaking another profile's
|
|
value.
|
|
- A small allowlist (`HERMES_HOME`, `HERMES_PROFILE`, proxy settings,
|
|
`API_SERVER_*` listener settings — but deliberately not `API_SERVER_KEY`)
|
|
stays global because those describe the process, not a profile.
|
|
|
|
Because the per-turn `.env` reload is a no-op under multiplexing, rotated
|
|
credentials are picked up through the profile scope on the next turn — never
|
|
via `os.environ`. This holds at the loader boundary, not just the gateway's
|
|
reload helper: `hermes_cli.env_loader.load_hermes_dotenv` skips the
|
|
process-global load whenever multiplexing is active *and* a profile-home
|
|
override is installed (import-time and cron callers hit it mid-turn), while
|
|
still hydrating the profile's external secret sources into its private
|
|
snapshot (`#77562`). The unscoped startup load is unchanged.
|
|
|
|
The same scope-authoritative rule covers the other `os.environ` seams a
|
|
routed turn can reach: `${VAR}` / `${env:VAR}` references in a profile's
|
|
`config.yaml` resolve through `get_secret` when a scope is installed
|
|
(`#84079`), and `.env` writes made under a scope (`save_env_value`, e.g. a
|
|
`/pair` grant mirror) update the installed scope mapping instead of the
|
|
process environment (`#88441`).
|
|
|
|
## The HERMES_HOME override
|
|
|
|
`hermes_constants.py` holds a context-local override consulted by
|
|
`get_hermes_home()` before the `HERMES_HOME` env var. Everything that resolves
|
|
paths through it — config, `state.db`, skills, memory, SOUL, sessions, kanban,
|
|
goals, plugin discovery, MCP startup — follows the active profile
|
|
automatically. `get_process_hermes_home()` exists for the few machine-level
|
|
assets that must not follow the override. `hermes_home_key()` gives
|
|
per-home registries a stable scope key. A one-shot warning (`#18594`) fires if
|
|
profile-scoped code runs without the override where one is expected.
|
|
|
|
## Inbound routing
|
|
|
|
`gateway.profile_routes` maps `(platform, guild_id, chat_id, thread_id)` to a
|
|
profile; matching is conjunctive, most-specific-first, with parent-chain chat
|
|
matching for threads. Routing only runs when multiplexing is active, and a
|
|
matched route whose target is outside the served set is rejected (the event is
|
|
dropped, not misdelivered). Full schema and matching rules:
|
|
`docs/profile-routing.md`.
|
|
|
|
## Serving selected profiles
|
|
|
|
`profiles_to_serve(multiplex, profile_allowlist)` in `hermes_cli/profiles.py`
|
|
is the single chokepoint for which profiles a multiplexer serves: default plus
|
|
every valid profile directory, optionally filtered by allowlist. A malformed
|
|
allowlist fails safe to default-only. The served set gates adapter startup,
|
|
cron ticking (`#69377`), `/p/<profile>/` HTTP admission, route eligibility,
|
|
and the runtime status surface. An excluded profile stays installed and can
|
|
still run its own standalone gateway.
|
|
|
|
## Per-profile persistence
|
|
|
|
`SessionStore` binds no database handle at construction (`#88532`). Session
|
|
DB handles are resolved at call time through the active HERMES_HOME override —
|
|
one cached handle per resolved `profiles/<name>/state.db` — so sessions land
|
|
in the owning profile's store even when the store object itself is shared.
|
|
Pairing stores are constructed per served profile.
|
|
|
|
## Per-bot session lanes
|
|
|
|
Session keys are namespaced by profile (`agent:main` for default,
|
|
`agent:<name>` for named profiles). Adapters carry `_owner_profile`
|
|
(installed at adapter configuration time, before any inbound event) because
|
|
adapter ingress runs before `SessionSource.profile` is stamped;
|
|
`_session_key_profile` resolves source stamp → owner profile → store
|
|
resolver. Text/media batching, active-session tracking, and the busy-session
|
|
guard are all keyed per lane, so two bots sharing a chat do not share a
|
|
session lane.
|
|
|
|
## Control plane
|
|
|
|
Desktop plugins reach the gateway only through the ws JSON-RPC door, so
|
|
profile enumeration and configuration live in
|
|
`tui_gateway/methods_profiles.py`: `profiles.list`, `profiles.create`,
|
|
`profiles.describe`, `profiles.configure`, `profiles.set_asset`,
|
|
`profiles.get_asset`. Reads and writes run under the target profile's
|
|
HERMES_HOME override. Asset writes are atomic, type- and size-capped.
|
|
|
|
## Failure modes
|
|
|
|
- Fatal at startup: multiplex config errors and a secondary profile enabling a
|
|
port-binding platform (`MultiplexConfigError`,
|
|
`SecondaryPortBindingConfigError`) — one shared HTTP listener is owned by
|
|
the default profile.
|
|
- Skipped, not fatal: a single misconfigured secondary adapter is skipped with
|
|
a warning rather than taking down the multiplexer.
|
|
- Fail-closed: unscoped `get_secret()` under multiplexing raises; a routed
|
|
event targeting an unserved profile is dropped; an unscoped `/p/` request
|
|
enters the default profile's scope (`#61276`) rather than an undefined one.
|
|
- Fallback: an external `cron.provider` does not support multiplexing and
|
|
falls back to the built-in ticker with a warning.
|
|
|
|
## Known limitations
|
|
|
|
Process-global state that is not yet profile-scoped:
|
|
|
|
| Surface | State at time of writing |
|
|
| --- | --- |
|
|
| MCP discovery and tool registration | Process-global; the first profile to build an agent wins the discovery slot. Full per-profile MCP registries are tracked in `#67605`. |
|
|
| Terminal / sandbox env (`TERMINAL_*`) | Global by allowlist; tools read it from the process environment. |
|
|
| Built-in tool registry | Built-ins are process-global; plugin-registered tools are overlaid per profile via `hermes_home_key()`. |
|
|
| Provider/capability registries | Same hybrid overlay pattern (browser, image-gen, TTS, transcription, video-gen, web-search, secret sources). |
|
|
| HTTP listener, relay ingress, process lock | One per process, owned by the default/active profile. Per-profile `runtime_status.json` is still written. |
|
|
|
|
## Non-goals
|
|
|
|
Multiplexing isolates *profiles*; it does not authenticate or authorize *end
|
|
users*. A profile is a configuration, not a person: the gateway trusts its
|
|
transport and its routing table to decide which profile an event belongs to.
|
|
Request-level identity and per-user authorization above the profile layer are
|
|
out of scope for this document.
|
|
|
|
## Related
|
|
|
|
- `docs/profile-routing.md` — inbound routing schema and matching rules.
|
|
- `website/docs/user-guide/multi-profile-gateways.md` — user-facing guide,
|
|
including the standalone one-gateway-per-profile alternative.
|
|
- `agent/secret_scope.py`, `hermes_constants.py`, `gateway/profile_routing.py`,
|
|
`gateway/run.py` (`_profile_runtime_scope`), `hermes_cli/profiles.py`
|
|
(`profiles_to_serve`), `gateway/session.py`, `tui_gateway/methods_profiles.py`.
|