fix(gateway): secondary-profile adapters no longer inherit the default's allow-all / allowlists
Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env. Several
adapter-owned authorization gates still read GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS
or their platform allowlist/allow-all raw from os.environ, so the default profile opting
into open access opened every secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk
bot to any sender (email additionally skipped From: authentication), the default's Matrix
allowlist decided who may approve tool calls on a secondary bot, and a secondary that
opted in only in its own .env was silently deny-all.
Every such read now goes through the adapter's existing module-local scoped reader
(gateway.platforms._shared.get_scoped_secret / matrix _startup_env_secret): profile
scope first, scoped miss = default, never os.environ; the unscoped default-profile and
single-profile paths keep the environ read, where it IS the profile's own value.
Sites: email _allow_all_senders/_allowlist_in_effect; qqbot _open_dm_opted_in;
whatsapp_common _open_dm_opted_in/_live_dm_allow_from; teams _card_action_denied;
matrix _is_authorized_user, MATRIX_ALLOWED_USERS, MATRIX_IGNORE_USER_PATTERNS,
_extra_csv_set (allowed/free-response rooms); slack _slack_allow_bots/_slack_api_human_users;
line _truthy_env/allowlist (allow-all, user/group/room allowlists); dingtalk _extra_get
(allowed_users/chats, free-response chats, require_mention).
Live repro (temp HERMES_HOME, multiplex on, default env GATEWAY_ALLOW_ALL_USERS=true,
secondary scope without opt-in): EmailAdapter._allow_all_senders() True -> False,
QQAdapter._open_dm_opted_in() True -> False, Matrix _is_authorized_user('@stranger')
True -> False, Teams card action allowed -> denied.
Co-authored-by: Drexuxux <drexux0@gmail.com>
Co-authored-by: MoonsvnLyn <FirmamentalSpring@users.noreply.github.com>
Co-authored-by: svector-anu <anuoluwakolapo94@gmail.com>
Co-authored-by: babatorik <durgun.ismail@gmail.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
This commit is contained in:
@@ -1651,9 +1651,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
return re.sub(r"^@\S+\s*", "", content.strip())
|
||||
|
||||
def _open_dm_opted_in(self) -> bool:
|
||||
# Both names via the scoped reader: under multiplex os.environ is the DEFAULT profile's
|
||||
# opt-in, which must not open a secondary bot's DMs.
|
||||
truthy = {"true", "1", "yes"}
|
||||
return (os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in truthy
|
||||
or _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in truthy)
|
||||
return any(_resolve_qq_secret(name, "").lower() in truthy
|
||||
for name in ("GATEWAY_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS"))
|
||||
|
||||
def _is_dm_allowed(self, user_id: str) -> bool:
|
||||
if self._dm_policy == "allowlist":
|
||||
|
||||
@@ -132,7 +132,10 @@ class WhatsAppBehaviorMixin:
|
||||
set (pairing revoke purges it in place) — a stale env value must not broaden access."""
|
||||
source = getattr(self, "_dm_allowlist_source", None)
|
||||
if isinstance(source, str) and source != "config":
|
||||
return self._coerce_allow_list(os.environ[source]) if source in os.environ else set()
|
||||
# Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist. None = key
|
||||
# absent (revoked) → empty, never the construction snapshot.
|
||||
live = _get_wsecret(source)
|
||||
return self._coerce_allow_list(live) if live is not None else set()
|
||||
return set(self._allow_from or ())
|
||||
|
||||
# ------------------------------------------------------------------ JID helpers
|
||||
@@ -154,9 +157,10 @@ class WhatsAppBehaviorMixin:
|
||||
|
||||
# ------------------------------------------------------------------ gating
|
||||
def _open_dm_opted_in(self) -> bool:
|
||||
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in _OPTIN_TRUTHY:
|
||||
return True
|
||||
return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in _OPTIN_TRUTHY
|
||||
# Both names via the scoped reader — the DEFAULT profile's os.environ opt-in must not open
|
||||
# a secondary bot's DMs.
|
||||
return any((_get_wsecret(name, default="") or "").lower() in _OPTIN_TRUTHY
|
||||
for name in ("GATEWAY_ALLOW_ALL_USERS", "WHATSAPP_ALLOW_ALL_USERS"))
|
||||
|
||||
@staticmethod
|
||||
def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool:
|
||||
|
||||
@@ -251,9 +251,11 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
def _extra_get(self, key: str, env_name: str = "", env_default: str = ""):
|
||||
"""config.extra[key]; when *env_name* is given, absent keys fall back to the env var."""
|
||||
"""config.extra[key]; when *env_name* is given, absent keys fall back to the env var.
|
||||
|
||||
Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist/policy."""
|
||||
value = self.config.extra.get(key) if self.config.extra else None
|
||||
return os.getenv(env_name, env_default) if value is None and env_name else value
|
||||
return _get_scoped_secret(env_name, env_default) if value is None and env_name else value
|
||||
|
||||
def _csv_setting(self, key: str, env_name: str) -> Set[str]:
|
||||
"""List/CSV setting from config.extra[key], falling back to the env var."""
|
||||
|
||||
@@ -589,13 +589,17 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _allow_all_senders() -> bool:
|
||||
"""True when the operator opted into any sender (EMAIL_ or GATEWAY_ALLOW_ALL_USERS)."""
|
||||
return (_get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY)
|
||||
"""True when the operator opted into any sender (EMAIL_ or GATEWAY_ALLOW_ALL_USERS).
|
||||
|
||||
Both names go through the scoped reader: under multiplex ``os.environ`` is the DEFAULT
|
||||
profile's opt-in, and borrowing it opened every secondary mailbox to any sender."""
|
||||
return any(_get_secret(name, "").strip().lower() in _TRUTHY
|
||||
for name in ("EMAIL_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS"))
|
||||
|
||||
@staticmethod
|
||||
def _allowlist_in_effect() -> bool:
|
||||
"""True when EMAIL_/GATEWAY_ALLOWED_USERS gates access (without one the gateway default-denies, so the spoofable From: grants nothing)."""
|
||||
return bool(_get_secret("EMAIL_ALLOWED_USERS", "").strip() or os.getenv("GATEWAY_ALLOWED_USERS", "").strip())
|
||||
return any(_get_secret(name, "").strip() for name in ("EMAIL_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS"))
|
||||
|
||||
def _sender_accepted(self, sender_addr: str, msg_data: Dict[str, Any]) -> bool:
|
||||
"""Pre-dispatch sender gate: self, automated, allowlist, From: authentication."""
|
||||
|
||||
@@ -345,7 +345,8 @@ def _csv_set(value: str) -> Set[str]:
|
||||
|
||||
|
||||
def _truthy_env(name: str, default: bool = False) -> bool:
|
||||
v = os.getenv(name)
|
||||
# Scoped read: under multiplex os.environ is the DEFAULT profile's allow-all flag.
|
||||
v = _get_scoped_secret(name)
|
||||
return default if v is None else v.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@@ -391,7 +392,8 @@ class LineAdapter(BasePlatformAdapter):
|
||||
return os.getenv(env) or extra.get(key, default)
|
||||
|
||||
def allowlist(env: str, key: str) -> Set[str]:
|
||||
return _csv_set(os.getenv(env, "")) | set(extra.get(key, []))
|
||||
# Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist.
|
||||
return _csv_set(_get_scoped_secret(env, "")) | set(extra.get(key, []))
|
||||
|
||||
self.channel_access_token, self.channel_secret = _credentials(config)
|
||||
# Host ``None`` → dual-stack bind (see DEFAULT_HOST); empty string collapses to None.
|
||||
|
||||
@@ -499,7 +499,8 @@ def _extra_csv_set(config, key: str, env_name: str) -> Set[str]:
|
||||
"""Resolve a room/user list from config.extra[key], else the env var."""
|
||||
raw = config.extra.get(key)
|
||||
if raw is None:
|
||||
raw = os.getenv(env_name, "")
|
||||
# Scoped read: under multiplex os.environ is the DEFAULT profile's room/user list.
|
||||
raw = _startup_env_secret(env_name)
|
||||
return _csv_set(raw)
|
||||
|
||||
|
||||
@@ -882,10 +883,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._approval_timeout_seconds = _env_number("MATRIX_APPROVAL_TIMEOUT_SECONDS", 300, int)
|
||||
self._model_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {}
|
||||
self._choice_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {}
|
||||
self._allowed_user_ids: Set[str] = _csv_set(os.getenv("MATRIX_ALLOWED_USERS", ""))
|
||||
# Authz lists via the scoped reader: under multiplex os.environ is the DEFAULT profile's
|
||||
# allowlist, which must not decide who approves tool calls on a secondary bot.
|
||||
self._allowed_user_ids: Set[str] = _csv_set(_startup_env_secret("MATRIX_ALLOWED_USERS"))
|
||||
self._allowed_room_ids: Set[str] = set(self._allowed_rooms)
|
||||
self._ignored_user_patterns: list[re.Pattern[str]] = []
|
||||
for pattern in (p.strip() for p in os.getenv("MATRIX_IGNORE_USER_PATTERNS", "").split(",") if p.strip()):
|
||||
for pattern in (p.strip() for p in _startup_env_secret("MATRIX_IGNORE_USER_PATTERNS").split(",") if p.strip()):
|
||||
try:
|
||||
self._ignored_user_patterns.append(re.compile(pattern))
|
||||
except re.error as exc:
|
||||
@@ -2410,7 +2413,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
def _is_authorized_user(self, user_id: str) -> bool:
|
||||
"""GATEWAY_ALLOW_ALL_USERS, or membership in MATRIX_ALLOWED_USERS."""
|
||||
return _env_truthy("GATEWAY_ALLOW_ALL_USERS") or bool(
|
||||
# Scoped read — the DEFAULT profile's os.environ opt-in must not authorize on a secondary bot.
|
||||
return _startup_env_secret("GATEWAY_ALLOW_ALL_USERS").lower() in ("true", "1", "yes") or bool(
|
||||
self._allowed_user_ids and user_id in self._allowed_user_ids)
|
||||
|
||||
async def _validate_matrix_prompt_reactor(
|
||||
|
||||
@@ -35,6 +35,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
|
||||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
|
||||
from gateway.platforms.base import (
|
||||
gateway_trust_env, BasePlatformAdapter,
|
||||
SendResult, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, _TEXT_INJECT_EXTENSIONS,
|
||||
@@ -2542,7 +2543,8 @@ class SlackAdapter(SlackWisdomMixin, BasePlatformAdapter):
|
||||
|
||||
def _slack_allow_bots(self) -> str:
|
||||
"""Return normalized Slack bot-message policy."""
|
||||
raw = self.config.extra.get("allow_bots", "") or os.getenv("SLACK_ALLOW_BOTS", "none")
|
||||
# Scoped read: under multiplex os.environ is the DEFAULT profile's bot-admission policy.
|
||||
raw = self.config.extra.get("allow_bots", "") or _get_scoped_secret("SLACK_ALLOW_BOTS", "none")
|
||||
value = str(raw).lower().strip()
|
||||
if value not in {"none", "mentions", "all"}:
|
||||
logger.warning("[Slack] Unknown allow_bots=%r; treating as 'none'", raw)
|
||||
@@ -2564,7 +2566,7 @@ class SlackAdapter(SlackWisdomMixin, BasePlatformAdapter):
|
||||
if cached is None:
|
||||
raw = self.config.extra.get("api_human_users")
|
||||
if raw is None:
|
||||
raw = os.getenv("SLACK_API_HUMAN_USERS", "")
|
||||
raw = _get_scoped_secret("SLACK_API_HUMAN_USERS", "")
|
||||
parts = raw if isinstance(raw, (list, tuple, set)) else str(raw).split(",")
|
||||
cached = self._api_human_users_cache = frozenset(
|
||||
str(p).strip() for p in parts if str(p).strip())
|
||||
|
||||
@@ -605,9 +605,10 @@ class TeamsAdapter(BasePlatformAdapter):
|
||||
"""Default-deny gate for approval clicks: require TEAMS_ALLOWED_USERS or an explicit
|
||||
TEAMS_ALLOW_ALL_USERS=true opt-in, else anyone who can message the bot could approve.
|
||||
Returns the user-facing denial text, or ``None`` when allowed."""
|
||||
if os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"}:
|
||||
# Scoped reads: under multiplex os.environ is the DEFAULT profile's allow-all/allowlist.
|
||||
if _get_scoped_secret("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"}:
|
||||
return None
|
||||
allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip()
|
||||
allowed_csv = _get_scoped_secret("TEAMS_ALLOWED_USERS", "").strip()
|
||||
if not allowed_csv:
|
||||
logger.warning(
|
||||
"[teams] card action rejected: TEAMS_ALLOWED_USERS not configured "
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Adapter authz gates must read GATEWAY_/PLATFORM_ allow-all + allowlists through the profile
|
||||
secret scope (#77548 cluster; #72348 / #86905 precedent).
|
||||
|
||||
Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's values. A raw
|
||||
``os.getenv`` in an adapter's own gate let the default's ``GATEWAY_ALLOW_ALL_USERS=true`` open every
|
||||
secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk bot, and the default's allowlist decide
|
||||
who may approve on a secondary bot. Two invariants per adapter: the default env never answers a
|
||||
scoped gate; the scope's own opt-in/allowlist does.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import secret_scope as ss
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
_DEFAULT_ENV = {
|
||||
"GATEWAY_ALLOW_ALL_USERS": "true", "GATEWAY_ALLOWED_USERS": "default-admin",
|
||||
"TEAMS_ALLOW_ALL_USERS": "true", "TEAMS_ALLOWED_USERS": "default-admin",
|
||||
"MATRIX_ALLOWED_USERS": "@default-admin:example.org", "MATRIX_IGNORE_USER_PATTERNS": r"^@spam:.*",
|
||||
"WHATSAPP_ALLOWED_USERS": "+15550001111", "SLACK_ALLOW_BOTS": "all", "SLACK_API_HUMAN_USERS": "U0DEFAULT",
|
||||
"LINE_ALLOW_ALL_USERS": "true", "LINE_ALLOWED_USERS": "Udefault", "DINGTALK_ALLOWED_USERS": "default-admin",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_env(monkeypatch):
|
||||
"""Multiplex on; os.environ carries the DEFAULT profile's permissive authz."""
|
||||
for name, value in _DEFAULT_ENV.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _scope(secrets):
|
||||
token = ss.set_secret_scope(secrets)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
ss.reset_secret_scope(token)
|
||||
|
||||
|
||||
def _matrix(extra=None):
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
return MatrixAdapter(PlatformConfig(enabled=True, token="t", extra={"homeserver": "https://m.example",
|
||||
"user_id": "@bot:m.example", **(extra or {})}))
|
||||
|
||||
|
||||
def _whatsapp():
|
||||
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
|
||||
|
||||
return SimpleNamespace(_dm_allowlist_source="WHATSAPP_ALLOWED_USERS", _allow_from=set(),
|
||||
_coerce_allow_list=WhatsAppBehaviorMixin._coerce_allow_list)
|
||||
|
||||
|
||||
def _slack():
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
|
||||
adapter = SlackAdapter.__new__(SlackAdapter)
|
||||
adapter.config = PlatformConfig(enabled=True, extra={})
|
||||
return adapter
|
||||
|
||||
|
||||
def _dingtalk():
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter.__new__(DingTalkAdapter)
|
||||
adapter.config = PlatformConfig(enabled=True, extra={})
|
||||
return adapter
|
||||
|
||||
|
||||
def _line():
|
||||
from plugins.platforms.line.adapter import LineAdapter
|
||||
|
||||
return LineAdapter(PlatformConfig(enabled=True, extra={"channel_access_token": "t", "channel_secret": "s"}))
|
||||
|
||||
|
||||
# (label, opt-in the SECONDARY scope grants, gate(scope) -> observed value, closed value, open value)
|
||||
_GATES = [
|
||||
("email.allow_all", {"GATEWAY_ALLOW_ALL_USERS": "true"},
|
||||
lambda: __import__("plugins.platforms.email.adapter", fromlist=["EmailAdapter"]).EmailAdapter._allow_all_senders(),
|
||||
False, True),
|
||||
("email.allowlist", {"GATEWAY_ALLOWED_USERS": "bot2-admin"},
|
||||
lambda: __import__("plugins.platforms.email.adapter", fromlist=["EmailAdapter"]).EmailAdapter._allowlist_in_effect(),
|
||||
False, True),
|
||||
("qqbot.open_dm", {"QQ_ALLOW_ALL_USERS": "true"},
|
||||
lambda: __import__("gateway.platforms.qqbot.adapter", fromlist=["QQAdapter"]).QQAdapter._open_dm_opted_in(SimpleNamespace()),
|
||||
False, True),
|
||||
("whatsapp.open_dm", {"GATEWAY_ALLOW_ALL_USERS": "true"},
|
||||
lambda: __import__("gateway.platforms.whatsapp_common", fromlist=["WhatsAppBehaviorMixin"]).WhatsAppBehaviorMixin._open_dm_opted_in(_whatsapp()),
|
||||
False, True),
|
||||
("whatsapp.live_allow_from", {"WHATSAPP_ALLOWED_USERS": "+15559998888"},
|
||||
lambda: __import__("gateway.platforms.whatsapp_common", fromlist=["WhatsAppBehaviorMixin"]).WhatsAppBehaviorMixin._live_dm_allow_from(_whatsapp()),
|
||||
set(), {"+15559998888"}),
|
||||
("teams.card_action", {"TEAMS_ALLOWED_USERS": "clicker"},
|
||||
lambda: __import__("plugins.platforms.teams.adapter", fromlist=["TeamsAdapter"]).TeamsAdapter._card_action_denied(
|
||||
SimpleNamespace(aad_object_id="clicker")) is None,
|
||||
False, True),
|
||||
("matrix.authorized_user", {"MATRIX_ALLOWED_USERS": "@bot2-admin:example.org"},
|
||||
lambda: _matrix()._is_authorized_user("@bot2-admin:example.org"),
|
||||
False, True),
|
||||
("matrix.ignored_patterns", {"MATRIX_IGNORE_USER_PATTERNS": r"^@bot2-spam:.*"},
|
||||
lambda: [p.pattern for p in _matrix()._ignored_user_patterns],
|
||||
[], [r"^@bot2-spam:.*"]),
|
||||
("slack.allow_bots", {"SLACK_ALLOW_BOTS": "mentions"}, lambda: _slack()._slack_allow_bots(), "none", "mentions"),
|
||||
("slack.api_human_users", {"SLACK_API_HUMAN_USERS": "U0BOT2"},
|
||||
lambda: set(_slack()._slack_api_human_users()), set(), {"U0BOT2"}),
|
||||
("line.allow_all", {"LINE_ALLOW_ALL_USERS": "true"}, lambda: _line().allow_all, False, True),
|
||||
("line.allowed_users", {"LINE_ALLOWED_USERS": "Ubot2"}, lambda: _line().allowed_users, set(), {"Ubot2"}),
|
||||
("dingtalk.allowed_users", {"DINGTALK_ALLOWED_USERS": "Bot2Admin"},
|
||||
lambda: {u.lower() for u in _dingtalk()._csv_setting("allowed_users", "DINGTALK_ALLOWED_USERS")}, set(), {"bot2admin"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,scope_opt_in,gate,closed,opened", _GATES, ids=[g[0] for g in _GATES])
|
||||
def test_default_env_never_answers_a_scoped_gate(default_env, label, scope_opt_in, gate, closed, opened):
|
||||
"""Secondary scope with NO opt-in: the default profile's permissive os.environ must not open it."""
|
||||
with _scope({}):
|
||||
assert gate() == closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,scope_opt_in,gate,closed,opened", _GATES, ids=[g[0] for g in _GATES])
|
||||
def test_scope_opt_in_opens_the_gate(default_env, monkeypatch, label, scope_opt_in, gate, closed, opened):
|
||||
"""The secondary's own .env decides — even when the default profile's env is silent."""
|
||||
for name in _DEFAULT_ENV:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
with _scope(scope_opt_in):
|
||||
assert gate() == opened
|
||||
@@ -236,7 +236,11 @@ run under another profile's sandbox policy. The media-delivery credential
|
||||
guard (the denylist behind `MEDIA:` attachments — `.env`, `auth.json`,
|
||||
`config.yaml`, `state.db`, session transcripts, OAuth token stores) covers every
|
||||
profile under `profiles/`, so no profile's turn can attach another profile's
|
||||
secrets or chat history to a reply. Kanban,
|
||||
secrets or chat history to a reply. Authorization is per profile too:
|
||||
`GATEWAY_ALLOW_ALL_USERS`, `GATEWAY_ALLOWED_USERS` and every platform allowlist
|
||||
or allow-all opt-in are read from the owning profile's `.env` — the default
|
||||
profile opting into open access never opens a secondary profile's bot, and a
|
||||
secondary that opts in only in its own `.env` is honored. Kanban,
|
||||
profile-scoped skills/memory/SOUL, and model routing all behave per-profile
|
||||
exactly as they do with separate gateways.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user