fix(gateway): multiplexed profiles no longer poison each other via config.yaml→env bridges, TERMINAL_* latch and path memos (#80099, #107422, #107327; salvage #100604 #100435 #100448 #91198 #107442 #107335) - #108440
Conversation
config.yaml discord.allow_bots was accepted but ignored because _get_allow_bots() only read DISCORD_ALLOW_BOTS. Seed the key through _apply_yaml_config like the other Discord gates, then resolve via _gate_raw so env still wins over YAML. (cherry picked from commit ddeb1bd)
…ride A multiplexed dashboard can call _ensure_terminal_env_bridged while a secondary profile's HERMES_HOME override is active. The one-shot latch then wrote that profile's docker policy into process-global os.environ and poisoned later unscoped launch-profile tool calls (#107422). Skip the ambient bridge whenever a context-local home override is set — ambient env is launch-profile authority only; routed profiles must use terminal_scope (same rule as env_loader._reapply_terminal_config_bridge). (cherry picked from commit 2050efb)
After any secondary profile home is served, launch-profile turns used to stay unscoped and fall back to ambient os.environ. Bind the launch home's own terminal policy in that case so a poisoned ambient bridge can never become the launch turn's authority (#107422 residual of #68559). (cherry picked from commit f81147c)
…ofiles don't poison the write guards (#107327) In a multiplexed gateway (`gateway.multiplex_profiles: true`) each profile turn scopes `HERMES_HOME` through a per-turn contextvar. But `tools/file_tools_write_guards.py` memoised the resolved home and config path in process-global module state, filled once by whichever profile ran first. Both the protected agent-instruction approval gate (`_get_real_hermes_home` → exemption for a profile's own home) and the `config.yaml` hard-block (`_get_hermes_config_resolved`) therefore became order-dependent: a later profile's own `workspace/AGENTS.md` was gated against a *sibling* profile's home, and — worse — its own `config.yaml` stopped matching the block, so a prompt-injected agent could rewrite the very file the block exists to protect (reproduced end-to-end in #107327). Resolve both values per call instead. `get_hermes_home()` / `get_config_path()` are contextvar-scoped, so the getters now track the active profile; the guard already pays a `realpath` per call, so the extra cost is negligible. The two module slots are kept purely as a test-override surface (set the slot + its `_loaded` flag to pin a value); production leaves them unset and resolves live, which also removes the cross-test poisoning the process memo could cause. Adds regression coverage: both getters track the active profile after a prior profile's scope, and the `config.yaml` hard-block fires for beta's own config even after an alpha turn ran first. (cherry picked from commit 36b2573)
…e profile
The per-call home/config getters fell back to `_expand_tilde("~/.hermes...")`
when the primary resolver raised. `_expand_tilde` follows the subprocess-HOME
contract, which under host `auto` mode can be the real/default user home rather
than the active multiplex `HERMES_HOME`. So on the exception path the guards
recreated the very cross-profile authority bug the happy path fixed: beta's
`config.yaml` was compared against the default/root config (hard-block fails
open), and the protected-instruction exemption resolved against the wrong home.
Re-derive both fallbacks from the same `get_hermes_home()` key the happy path
uses (`Path(home)/config.yaml`, `realpath(home)`), and substitute no unrelated
home if the active security path cannot be established — a `None` fails closed
at the protected-instruction consumer (exemption skipped, gate runs).
Adds opposite-side regressions: forcing the primary config resolver to raise
keeps beta's own config refused (and does not spuriously protect alpha's under
beta's scope); forcing the primary home resolver to raise keeps beta's
instruction-file exemption resolved against beta.
Addresses the fallback-authority review on #107335 (thanks @andrexibiza).
(cherry picked from commit 119d88b)
… process env under multiplex Under gateway.multiplex_profiles every secondary profile's config loads inside _profile_runtime_scope, yet every apply_yaml_config_fn hook (feishu, matrix, whatsapp, slack, dingtalk, discord non-gate keys, telegram non-gate keys) and gateway/config_loader.py::bridge_core_env_settings still wrote os.environ there. First-writer-wins: the first secondary with a require_mention / allowlist / allow_bots / reactions block made that policy the DEFAULT profile's (live: TELEGRAM_REQUIRE_MENTION written from a secondary load), and secondaries read the default's env for the same keys. - gateway/platforms/_shared.py::yaml_env_setter: the one env-write shape for YAML->env bridges — env wins, skipped under an active secondary scope. - Every hook now seeds its values into the profile's PlatformConfig.extra and uses yaml_env_setter; bridge_core_env_settings seeds telegram/signal require_mention into extra and skips the env write under scope. - Readers that bypassed extra/scope now consult extra first (matrix flags + session_scope, slack reactions, telegram reactions/mention_patterns/_extra_bool, discord reactions/auto_thread/history_backfill/approval_mentions/allow_mentions, feishu allow_bots, dingtalk mention_patterns, signal require_mention). Salvages the shape of PR #100604 (whatsapp, earliest report #80099), #100435 (discord) and #100448 (telegram) by @nftpoetrist on top of current main. Fixes #80099 Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
…st and Slack ignored-channel guard under multiplex - gateway/platforms/yuanbao.py::AutoSetHomeMiddleware: the first authorized DM to a SECONDARY Yuanbao bot wrote YUANBAO_HOME_CHANNEL into os.environ, making that tenant's chat the default profile's cron/notification home. The write now only happens unscoped; reads go through the scoped reader + config. - tools/env_passthrough.py::_config_passthrough: one module slot froze the first profile's terminal.env_passthrough for every profile's sandbox children; keyed by hermes_home_key(). - gateway/run.py::_slack_ignored_channels_from_gateway_config: the runner-level fail-safe only had the DEFAULT profile's GatewayConfig, so a secondary Slack bot's traffic was judged by the default's ignored list. It now takes the source's routed adapter (whose extra is the secondary's own config) and reads the env fallback through the scoped gate reader.
૮ >ﻌ< ა ci reviewran on b6993f5 — ci: resync PR head (GitHub PR record lagged the branch tip)
|
…llowlist cache _make_run_env runs with a stripped environ on Windows children; hermes_home_key() raises RuntimeError there (no HOME/USERPROFILE). Fall back to an unkeyed slot instead of failing the sandbox env build.
andrexibiza
left a comment
There was a problem hiding this comment.
Request changes at 1ac2a7f528e3061ad70a9e20669bb270cc8b4de5. This review covers the 29-file change against merge base ad03f20dd61919ca2135d6904e787a94284aacaf, including the YAML loader → adapter consumers → gateway authorization path, terminal build/turn/side-worker scopes, per-home passthrough caching, write guards, and home-channel persistence. The last commit only maps see-k's contributor email; the behavior probes and focused suite were rerun on that head.
Findings
1. P1 — Bind terminal policy at every secondary worker/build entrypoint
The new no-bridge guard leaves secondary side workers and branch/eager-resume builds without terminal scopes. Real worker/build probes choose launch local for a secondary configured as docker; both choose docker on base. Bind the missing scopes rather than restoring ambient writes.
Location: tools/terminal_tool.py:L560. Reproducer: terminal_side, terminal_build.
2. P1 — Preserve the launch profile’s legitimate environment-only backend
A launch profile using environment-only SSH becomes local after multiplexing starts: the new file-backed scope loses the trusted launch environment. Preserve a pre-multiplex launch-policy snapshot; do not substitute today's ambient state.
Location: tui_gateway/prompt_turn.py:L459. Reproducer: terminal_launch.
3. P1 — Keep explicit Discord mention denials ahead of YAML
The new extra-first mention builder turns an explicit DISCORD_ALLOW_MENTION_EVERYONE=false into an AllowedMentions object permitting everyone when YAML says true. The same test denies everyone on base. Restore owning-profile env-first precedence.
Location: plugins/platforms/discord/adapter.py:L623. Reproducer: discord_precedence.
4. P1 — Scoped misses must not fall back to the launch environment
Secondary adapters that omit a key still inherit launch YAML through raw environment fallbacks. The composed probe reproduces Matrix routing/notice inheritance, Discord mass-mention permission, and Slack channel/reaction inheritance. This baseline defect remains inside the PR’s claimed isolation boundary.
Location: plugins/platforms/matrix/adapter.py:L917. Reproducer: ambient_readers.
5. P1 — Consume the Matrix lists that the hook now seeds
Matrix’s newly seeded allowed_users and ignore_user_patterns never reach the constructor’s env-only consumers. The configured ignored sender reaches the text-dispatch boundary, while the listed owner cannot approve. This is a reproduced, pre-existing gap left unresolved by the migration.
Location: plugins/platforms/matrix/adapter.py:L3063. Reproducer: matrix_readers.
6. P1 — Migrate Telegram’s proxy consumer before suppressing its only bridge
The shared setter now drops a secondary’s telegram.proxy_url, but the HTTP client still reads only the old environment bridge. Both real request-construction paths select no proxy at this head and the configured proxy on base. Migrate the consumer without restoring shared writes.
Location: plugins/platforms/telegram/adapter.py:L6510. Reproducer: telegram_proxy.
7. P2 — Carry YAML allow_bots through the central authorization gate
Discord/Slack adapters report YAML allow_bots=all, but the central gateway still reads only scoped env and denies the same bot traffic. Resolve one effective owning-adapter policy through both gates, preserving mention and loop controls.
Location: plugins/platforms/slack/adapter.py:L6475. Reproducer: bots_auth.
Prior findings and related work
There were no earlier formal reviews on this PR at inspection. The relevant inherited obligations are not all resolved:
- #107442, ehz0ah's review: its secondary side-worker and build-scope findings reproduce here (finding 1), as does its launch environment-only policy regression (finding 2). The reported
FileExistsErrorin the launch test did not reproduce in this carrier's focused run; the remaining coverage limitation is real: that test callsinstall_profile_terminal_scopedirectly instead of the changed_prepare_turn_inputboundary. The reproducer below exercises that boundary. - #107335, the active-profile fallback finding: the follow-up is present. With alpha warmed first, forcing both primary path resolvers to raise still resolves beta's home and refuses beta's own config at this head; the identical probe fails on base. The final test trim removed the dedicated forced-fallback tests from the source PR, so retain that invariant in the two surviving tests rather than losing it.
- Credit and composition: see-k's Discord YAML bot gate, infinitycrew39's terminal changes, and PRATHAMESH75's normal/fallback guard changes retain their authored commits. Teknium's broader bridge changes credit nftpoetrist as co-author and derive from #100604/#100435/#100448. Their changed files/hunks were inspected: notably, #100435 already has scoped-env-first mention resolution, and #100448 deliberately excludes Telegram proxy scoping until its consumer is migrated. Findings 3 and 6 identify behavior lost in adaptation, not missing credit. The new contributor mapping fixes the previous unmapped-email gate; current-head attribution passes.
- #107434 changes dashboard action child environments in
hermes_cli/web_server_gateway.py; that is a different path from these terminal scopes. #94206 is the alternate home-keyed terminal latch, not a reason to restore ambient bridging here. No repository-wide absence claim is made.
The shared no-write setter, per-home allowlist cache, and per-call guard ownership are useful corrections. In particular, the new secondary YAML propagation test passes and the forced guard-fallback probe demonstrates a real repair. Those controls need the missing consumer and entrypoint work above; suppressing writes alone is not equivalent to profile isolation.
Verification
Local, native Windows, CPython 3.11.15 / pytest 9.1.1: the canonical runner, with its clean environment and per-file isolation, ran the following focused selection at the reviewed head:
HERMES_PYTHON=<python-with-dev-dependencies> bash scripts/run_tests.sh -j 4 \
tests/gateway/test_config.py \
tests/gateway/test_multiplex_profile_authz.py \
tests/gateway/test_multiplex_credential_isolation.py \
tests/gateway/test_slack_runner_ignored_channels.py \
tests/gateway/test_matrix_mention.py \
tests/gateway/test_telegram_reactions.py \
tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py \
tests/plugins/platforms/test_discord_gate_isolation.py \
tests/tui_gateway/test_local_model_session_identity.py \
tests/gateway/test_multiplex_yaml_env_bridge_isolation.py \
tests/gateway/test_multiplex_process_global_sinks.py \
tests/gateway/test_discord_allow_bots_yaml_config.py \
tests/tools/test_env_passthrough.py tests/tools/test_file_write_safety.py \
tests/tools/test_skill_env_passthrough.py tests/tools/test_terminal_env_bridge.py \
tests/tools/test_terminal_scope_multiplex.py17 files: 244 passed, 6 failed. The six are the five POSIX-sensitive-path assertions and the POSIX 0o600 assertion in test_file_write_safety.py; all six also fail on the exact base with the same file/runner command. They are not attributed to this PR. Ruff on all changed Python files and git diff --check passed on the behavior-identical preceding head.
Separately, the exact Windows CI failing test is reproducible:
HERMES_PYTHON=<python-with-dev-dependencies> bash scripts/run_tests.sh \
tests/tools/test_local_env_blocklist.py \
-k test_make_run_env_preserves_windows_mixed_case_path_keyIt passes on base and fails on the behavior-identical preceding head: the new unconditional hermes_home_key() call in _load_config_passthrough raises RuntimeError: Could not determine home directory when the test substitutes a minimal environment containing only Path. This is a confirmed CI/fixture compatibility regression, not evidence that normal Windows installations lack a home. Preserve the per-home cache while fixing the test's profile setup or making an unresolved optional passthrough lookup return no grant; never fall back to another profile's cached allowlist.
Hosted checks at the reviewed head:
- Python tests / Run tests: SUCCESS.
- Python tests / e2e: SUCCESS.
- OS-specific tests / Windows-only tests: FAILURE.
- Python lints / ruff enforcement (blocking): SUCCESS.
- Check contributors / check-attribution: SUCCESS.
- All required checks pass: FAILURE.
Local focused results are not hosted CI, and no live Telegram/Discord/Slack/Matrix traffic, SSH session, Docker execution, or complete application session was exercised by the counterexamples.
Structure: nine touched files remain over 2,000 lines. Seven grow: gateway/platforms/yuanbao.py 3,006→3,010; gateway/run.py 5,544→5,550; gateway/run_inbound.py 2,062→2,067; Discord 7,104→7,111; Feishu 4,331→4,333; Matrix 3,110→3,124; Slack 6,519→6,527. Telegram shrinks to 6,603 and tui_gateway/server.py to 3,243. These are pre-existing oversized files, not nine newly created ones. The configuration/accessor additions should be placed in focused siblings instead of increasing the oversized owners; the reductions do not yet satisfy the line ceiling.
Runnable counterexamples
Save the following as contract_probes.py outside the checkout, in a writable temporary directory. Use a credential-free development environment with the repository's test dependencies and discord.py/python-telegram-bot available. Run each case in a fresh process:
python contract_probes.py <exact-checkout> terminal_side
python contract_probes.py <exact-checkout> terminal_build
python contract_probes.py <exact-checkout> terminal_launch
python contract_probes.py <exact-checkout> discord_precedence
python contract_probes.py <exact-checkout> ambient_readers
python contract_probes.py <exact-checkout> matrix_readers
python contract_probes.py <exact-checkout> telegram_proxy
python contract_probes.py <exact-checkout> bots_auth
python contract_probes.py <exact-checkout> guard_fallbackAt this head, the first eight cases print the observed violation and exit with an assertion failure; guard_fallback passes. At base, terminal_side, terminal_build, terminal_launch, discord_precedence, and telegram_proxy pass; the three unresolved gateway cases and guard_fallback fail. All cases use temporary home directories. The worker/build probes deliberately replace model/DB work, and the messaging probes replace external transport boundaries; those substitutions are visible in the code.
Offline reproduction code
"""Offline, real-import exact-head contract probes. Run one CASE in each fresh process.
Usage: python contract_probes.py CHECKOUT CASE
Transport/model substitutions are explicit; no network or real user state is used.
"""
import os,sys,pathlib,tempfile,contextlib,json,asyncio,threading,types
from unittest.mock import patch,AsyncMock
REPO=pathlib.Path(sys.argv[1]);sys.path.insert(0,str(REPO))
SCRATCH=pathlib.Path(tempfile.mkdtemp(prefix='contract-',dir=pathlib.Path(__file__).parent)).resolve()
os.environ.update(HERMES_HOME=str(SCRATCH),HOME=str(SCRATCH),USERPROFILE=str(SCRATCH))
from hermes_constants import set_hermes_home_override,reset_hermes_home_override,get_hermes_home
from agent.secret_scope import set_multiplex_active,set_secret_scope,reset_secret_scope
from gateway.config import load_gateway_config,Platform,PlatformConfig
from hermes_cli.plugins import discover_plugins
discover_plugins()
def home(name,text):
p=SCRATCH/name;p.mkdir();(p/'config.yaml').write_text(text,encoding='utf-8');return p
@contextlib.contextmanager
def scope(p):
from gateway.run import _profile_runtime_scope
set_multiplex_active(True)
try:
with _profile_runtime_scope(p,prepared_secret_scope={}):yield
finally:set_multiplex_active(False)
def report(**k):print(json.dumps(k,default=str),flush=True)
def discord_precedence():
from plugins.platforms.discord.adapter import _build_allowed_mentions
p=home('launch','discord:\n allow_mentions:\n everyone: true\n')
os.environ.update(HERMES_HOME=str(p),DISCORD_ALLOW_MENTION_EVERYONE='false')
cfg=load_gateway_config();extra=cfg.platforms.get(Platform.DISCORD,PlatformConfig()).extra
import inspect
m=_build_allowed_mentions(extra) if 'extra' in inspect.signature(_build_allowed_mentions).parameters else _build_allowed_mentions()
report(env=os.environ['DISCORD_ALLOW_MENTION_EVERYONE'],yaml=True,allowed_mentions=m.to_dict())
assert m.everyone is False,'explicit env denial was overridden by YAML'
def matrix_readers():
from plugins.platforms.matrix.adapter import MatrixAdapter
p=home('secondary','matrix:\n enabled: true\n user_id: "@bot:example.org"\n allowed_users: ["@owner:example.org"]\n ignore_user_patterns: ["^@ignored:"]\n')
with scope(p):
cfg=load_gateway_config();a=MatrixAdapter(cfg.platforms[Platform.MATRIX]);a._user_id='@bot:example.org'
# Substitute only transport-facing room lookup / send and the downstream dispatch sink.
a._is_allowed_matrix_room_event=AsyncMock(return_value=True)
a._handle_text_message=AsyncMock()
e=types.SimpleNamespace(room_id='!room:example.org',sender='@ignored:example.org',event_id='$one',content={'msgtype':'m.text','body':'hello'})
asyncio.run(a._on_room_message(e))
a.send=AsyncMock()
prompt=types.SimpleNamespace(requester_user_id='@owner:example.org')
allowed=asyncio.run(a._validate_matrix_prompt_reactor('!room:example.org','$approval','@owner:example.org',prompt,'approval'))
report(extra=cfg.platforms[Platform.MATRIX].extra,ignored_sender_dispatched=a._handle_text_message.await_count,owner_can_approve=allowed,pattern_count=len(a._ignored_user_patterns),allowed_user_ids=sorted(a._allowed_user_ids))
assert a._handle_text_message.await_count==0 and allowed,'YAML deny and approval lists never reached the adapter readers'
def ambient_readers():
from plugins.platforms.matrix.adapter import MatrixAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from plugins.platforms.slack.adapter import SlackAdapter
p=home('launch','matrix:\n process_notices: true\n session_scope: room\n auto_thread: false\ndiscord:\n auto_thread: false\n reactions: false\n allow_mentions:\n everyone: true\nslack:\n reactions: false\n ignored_channels: [C_LAUNCH]\n')
os.environ['HERMES_HOME']=str(p);load_gateway_config()
q=home('secondary','matrix:\n enabled: true\ndiscord:\n enabled: true\nslack:\n enabled: true\n')
with scope(q):
cfg=load_gateway_config();m=MatrixAdapter(cfg.platforms[Platform.MATRIX]);d=DiscordAdapter(cfg.platforms[Platform.DISCORD]);s=SlackAdapter(cfg.platforms[Platform.SLACK])
got={'matrix_notice':m._process_notices,'matrix_scope':m._matrix_session_scope,'matrix_thread':m._auto_thread,'discord_thread':d._extra_or_env_flag('auto_thread','DISCORD_AUTO_THREAD','true',truthy=True),'discord_reactions':d._reactions_enabled(),'slack_reactions':s._reactions_enabled(),'slack_ignored':sorted(s._slack_ignored_channels())}
from plugins.platforms.discord.adapter import _build_allowed_mentions
import inspect
am=_build_allowed_mentions(d.config.extra) if 'extra' in inspect.signature(_build_allowed_mentions).parameters else _build_allowed_mentions()
got['discord_everyone']=am.everyone
report(**got)
assert got=={'matrix_notice':False,'matrix_scope':'auto','matrix_thread':True,'discord_thread':True,'discord_reactions':True,'slack_reactions':True,'slack_ignored':[],'discord_everyone':False},'secondary omitted settings inherited launch YAML'
def bots_auth():
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from plugins.platforms.discord.adapter import DiscordAdapter
from plugins.platforms.slack.adapter import SlackAdapter
p=home('secondary','discord:\n enabled: true\n allow_bots: all\nslack:\n enabled: true\n allow_bots: all\n')
with scope(p):
cfg=load_gateway_config();d=DiscordAdapter(cfg.platforms[Platform.DISCORD]);s=SlackAdapter(cfg.platforms[Platform.SLACK])
r=object.__new__(GatewayRunner);r.config=cfg;r._primary_profile_name='default';r.adapters={};r._profile_adapters={'secondary':{Platform.DISCORD:d,Platform.SLACK:s}}
got={}
for plat,adapter in [(Platform.DISCORD,d),(Platform.SLACK,s)]:
src=SessionSource(platform=plat,chat_id='C_TEST',chat_type='group',user_id='123' if plat==Platform.DISCORD else None,is_bot=True,profile='secondary')
got[plat.value]={'adapter_mode':adapter._get_allow_bots() if plat==Platform.DISCORD else adapter._slack_allow_bots(),'gateway_authorized':r._is_user_authorized(src)}
report(**got)
assert all(v['adapter_mode']=='all' and v['gateway_authorized'] for v in got.values()),'adapter and central bot authorization disagree'
def terminal_side():
from tui_gateway import server
from tools import terminal_tool as tt
from tools.terminal_scope import get_terminal_scope
p=home('secondary','terminal:\n backend: docker\n docker_image: secondary:test\n')
os.environ['TERMINAL_ENV']='local';tt._terminal_config_bridge_attempted=False
done=threading.Event();got={}
def body():
got.update(home=str(get_hermes_home()),scope_bound=get_terminal_scope() is not None,backend=tt._get_env_config()['env_type']);return 'done'
def emitted(*a,**k):done.set()
with patch.object(server,'_emit',emitted):
server._spawn_side_agent(1,{'profile_home':str(p),'cwd':str(SCRATCH)},'probe-side','parent','background.complete',body,cwd=str(SCRATCH))
assert done.wait(15),'worker did not finish'
report(**got)
assert got.get('backend')=='docker','secondary side worker selected launch local backend'
def terminal_build():
from tui_gateway import server
from tools import terminal_tool as tt
from tools.terminal_scope import get_terminal_scope
p=home('secondary','terminal:\n backend: docker\n docker_image: secondary:test\n')
os.environ['TERMINAL_ENV']='local';tt._terminal_config_bridge_attempted=False
got={}
def build(*a,**k):
got.update(home=str(get_hermes_home()),scope_bound=get_terminal_scope() is not None,backend=tt._get_env_config()['env_type']);return types.SimpleNamespace()
with patch.object(server,'_profile_session_db',return_value=(None,False)),patch.object(server,'_make_agent_in_context',build),patch.object(server,'_init_session'),patch.object(server,'_transfer_db_to_agent'):
server._build_branch_agent({'profile_home':str(p),'cwd':str(SCRATCH)},'probe-build','probe-build-key',[],'gui')
report(**got)
assert got.get('backend')=='docker','secondary branch build selected launch local backend'
def terminal_launch():
from tui_gateway import server
from tools import terminal_tool as tt
from tools.terminal_scope import get_terminal_scope,reset_terminal_scope
from tools.approval_context import reset_current_session_key
p=home('launch','{}\n')
os.environ.update(HERMES_HOME=str(p),TERMINAL_ENV='ssh',TERMINAL_SSH_HOST='example.test');tt._terminal_config_bridge_attempted=False
st=server._TurnRun(agent=None,one_turn_restore=None,terminal_callback=None,receipt_committed=False)
class StopAfterPolicy(Exception):pass
def stop(*a):raise StopAfterPolicy()
try:
with patch.object(server,'_hermes_home',p),patch.object(server,'_served_profile_homes',{'secondary':str(SCRATCH/'other')}),patch.object(server,'_wire_callbacks',stop):
try:server._prepare_turn_input('probe',{'session_key':'probe-launch'},st,'hello',[])
except StopAfterPolicy:pass
cfg=tt._get_env_config();report(scope_bound=get_terminal_scope() is not None,backend=cfg['env_type'],ssh_host=cfg.get('ssh_host'),ambient=os.environ['TERMINAL_ENV'])
assert cfg['env_type']=='ssh' and cfg.get('ssh_host')=='example.test','launch env-only SSH policy became local'
finally:
if st.scopes.terminal is not None:reset_terminal_scope(st.scopes.terminal)
if st.scopes.approval is not None:reset_current_session_key(st.scopes.approval)
server._clear_session_context(st.scopes.session_tokens)
def guard_fallback():
from tools import file_tools_write_guards as g
a=home('alpha','model: original\n');b=home('beta','model: original\n')
with scope(a):g._get_hermes_config_resolved();g._get_real_hermes_home()
with scope(b),patch.object(g,'_config_path_resolved',side_effect=OSError('synthetic failure')),patch.object(g,'_hermes_home_real',side_effect=OSError('synthetic failure')):
err=g._check_sensitive_path(str(b/'config.yaml'));resolved=g._get_real_hermes_home();report(refused=bool(err),home_matches=resolved==os.path.realpath(b))
assert err and resolved==os.path.realpath(b),'fallback lost active profile'
def telegram_proxy():
from plugins.platforms.telegram import adapter as tg
p=home('secondary','telegram:\n enabled: true\n proxy_url: http://127.0.0.1:18080\n')
os.environ['HERMES_TELEGRAM_DISABLE_FALLBACK_IPS']='true'
with scope(p):
cfg=load_gateway_config();a=tg.TelegramAdapter(cfg.platforms[Platform.TELEGRAM])
built=[]
def request(**kw):built.append(kw);return types.SimpleNamespace()
with patch.object(tg,'HTTPXRequest',request),patch.object(a,'_instrument_polling_request',side_effect=lambda r:r):
asyncio.run(a._build_ptb_requests())
proxies=[v.get('proxy') for v in built]
report(extra=cfg.platforms[Platform.TELEGRAM].extra,request_proxies=proxies)
assert proxies==['http://127.0.0.1:18080']*2,'secondary Telegram YAML proxy was lost before request construction'
if __name__=='__main__':globals()[sys.argv[2]]()| # Never write a secondary profile's terminal.* into process-global env. | ||
| from hermes_constants import get_hermes_home_override | ||
|
|
||
| if get_hermes_home_override() is not None: |
There was a problem hiding this comment.
P1 — Bind terminal policy at every secondary worker/build entrypoint
Skipping the ambient bridge is necessary, but the callers have not all been migrated to a terminal scope. tui_gateway.methods_prompt._spawn_side_agent() binds only the home override; _profile_build_scope() in methods_session.py binds home and secrets, not terminal policy. The latter encloses eager resume and _build_branch_agent(). With this new early return, _get_env_config() in those paths falls through to the launch process's TERMINAL_* instead of the secondary's YAML.
Reproduced at this head: a temporary secondary with terminal.backend: docker, launch TERMINAL_ENV=local, and a fresh bridge latch produced scope_bound=false, backend=local both inside the real side-worker thread and inside the real branch-build wrapper. The identical probes at the merge base returned docker. The side-worker probe substitutes its body with the real configuration reader; the branch probe substitutes model/DB construction, not the scope or configuration resolver. No Docker container, model request, or host command was executed. This proves wrong backend selection, not an observed production escape.
The side-worker wrapper is used by prompt.background, prompt.btw, and preview.restart; code paths that execute tools can consequently select host execution for a Docker-configured profile. Agent construction can also discover tools against the wrong backend. These are the first two unresolved items in ehz0ah's review on #107442.
Repair: retain the no-ambient-write guard, but install/reset the owning terminal scope around the entire side-worker lifetime and in the common profile-build scope, including the refusal scope on policy failure. Do not restore the process-global bridge. Regression: exercise the actual worker and branch/eager-resume boundaries with conflicting launch/secondary policies, assert secondary docker (and its image/volumes), unchanged ambient environment, and scope cleanup on success and exception. Run the terminal_side and terminal_build probes below.
| # has been served, bind the launch home's own terminal policy so a | ||
| # poisoned ambient bridge can never become the launch turn's authority. | ||
| from tools.terminal_scope import install_profile_terminal_scope | ||
| scopes.terminal = install_profile_terminal_scope(Path(_hermes_home)) |
There was a problem hiding this comment.
P1 — Preserve the launch profile’s legitimate environment-only backend
tui_gateway/prompt_turn.py:L459
install_profile_terminal_scope() reconstructs policy from defaults, the home's .env, and config.yaml; it does not preserve the process's launch-time terminal configuration. Here it is newly applied to every launch-profile turn as soon as _served_profile_homes is nonempty, including launches whose legitimate SSH/Docker policy came only from the process environment.
Repro: start with a temporary launch config.yaml containing {}, TERMINAL_ENV=ssh, and TERMINAL_SSH_HOST=example.test. Enter the real _prepare_turn_input() with a served secondary and no session.profile_home. At this head the installed scope yields backend=local, ssh_host="" while ambient remains ssh; the identical command on base yields ssh, example.test. The probe stops at _wire_callbacks after actual scope installation, before unrelated turn/model work. No SSH connection or command is made.
This silently redirects tool execution from the configured remote host to the dashboard host. It is not merely ignoring a poisoned secondary value: the input is a clean, operator-supplied launch policy. This also reproduces the third finding on #107442.
Repair: capture the launch profile's effective terminal policy before any secondary can mutate shared state and use that trusted policy for launch turns, with the existing config precedence. Do not recover it from potentially poisoned ambient state later. Regression: drive _prepare_turn_input() across the first secondary being served; preserve environment-only SSH/Docker settings, still reject secondary poisoning, and restore the scope after the turn. terminal_launch below fails here and passes on base.
| configured = configured if isinstance(configured, dict) else {} | ||
|
|
||
| def _b(name: str, key: str, default: bool) -> bool: | ||
| if (raw := configured.get(key)) is not None: |
There was a problem hiding this comment.
P1 — Keep explicit Discord mention denials ahead of YAML
plugins/platforms/discord/adapter.py:L623
This new configured early return reverses the documented env-over-YAML contract. The setter leaving an explicit environment value untouched does not preserve precedence if the consumer then always chooses the seeded YAML.
Repro: in a single-profile, credential-free temporary home, set DISCORD_ALLOW_MENTION_EVERYONE=false and load discord.allow_mentions.everyone: true through load_gateway_config(). Passing the resulting extra into the actual AllowedMentions builder produces parse=["everyone", "users"] at this head; base produces parse=["users"]. The explicit environment value remains false in both. This is the object supplied to commands.Bot by connect(), so messages containing @everyone/@here become eligible to ping where Discord permissions permit them. No message was sent in this probe.
Repair: resolve the owning profile's explicit scoped env/snapshot first, then YAML, then the safe default, consistently for everyone, roles, users, and replied-user. Use the scoped reader, not os.getenv, so fixing precedence does not reintroduce cross-profile inheritance. nftpoetrist's source #100435 already uses that scoped-env-first ordering; the adapted version loses it.
Regression: test explicit env false against YAML true and the inverse, in both single-profile and secondary-scope configurations, plus a secondary with no override and a permissive launch environment. discord_precedence below reproduces the introduced failure.
| """``config.extra[key]`` (YAML-bridged, per profile) else the env var, true/1/yes semantics.""" | ||
| configured = config.extra.get(key) | ||
| if configured is None: | ||
| return _env_truthy(env_name, default) |
There was a problem hiding this comment.
P1 — Scoped misses must not fall back to the launch environment
plugins/platforms/matrix/adapter.py:L917
The new extra-first helper still calls _env_truthy(), which reads raw os.getenv. That makes the isolation conditional on every secondary explicitly repeating every setting. session_scope has the same raw fallback at line 862. The migrated Discord flag helper and allowed-mentions fallback, and Slack reactions/channel-set readers, retain the same pattern.
Repro through real config loading and adapter construction: load launch YAML with Matrix process_notices: true, session_scope: room, auto_thread: false; Discord auto_thread: false, reactions: false, allow_mentions.everyone: true; and Slack reactions: false, ignored_channels: [C_LAUNCH]. Construct the secondary adapters inside the real _profile_runtime_scope with an empty prepared secret scope and only enabled: true in their YAML. The secondary inherits all those values, including Discord everyone=true and Slack's launch-only ignored channel. ambient_readers below prints the values before its failing assertion. The same leakage exists on base: this is an unresolved part of the isolation boundary this PR claims to close, not newly introduced behavior.
Consequences are bounded to the affected policies: secondary Discord can enable mass mentions, Matrix can process notices despite its default and select room-level rather than synthetic-thread session routing, and secondary Slack can suppress traffic because of another profile's channel list. No live platform traffic was sent.
Repair: resolve fallbacks through the owning profile's scope/snapshot and return each setting's default on a scoped miss. Keep unscoped launch/single-profile behavior, and migrate the sibling readers rather than requiring users to duplicate defaults in YAML. Regression: cover both configured and omitted keys, A→B and B→A initialization, and a conflicting value in B's own .env. The existing writer-only test cannot establish this reader-side property.
| os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"]) | ||
| return None | ||
| if value is not None: | ||
| seeded[key] = value |
There was a problem hiding this comment.
P1 — Consume the Matrix lists that the hook now seeds
plugins/platforms/matrix/adapter.py:L3063
Returning these lists in extra does not make the two remaining consumers use them. MatrixAdapter.__init__ initializes _allowed_user_ids and _ignored_user_patterns solely from _startup_env_secret("MATRIX_ALLOWED_USERS") / _startup_env_secret("MATRIX_IGNORE_USER_PATTERNS") (lines 891–898). A secondary's YAML is not in its secret scope, and this hook correctly stops exporting it to the process environment, so both readers resolve empty even when the seeded lists are present.
Repro: under the real secondary runtime scope, load matrix.allowed_users: ["@owner:example.org"] and matrix.ignore_user_patterns: ["^@ignored:"]. Construct the real adapter. Both keys exist in config.extra, but its ID set and regex list are empty. A synthetic @ignored:example.org room event reaches _handle_text_message through _on_room_message, and _validate_matrix_prompt_reactor rejects @owner:example.org. Only transport-facing room lookup/send and the downstream dispatch sink are substituted; the actual intake and approval checks run. No live approval or message is performed. The same outcomes exist on base, so this is an incomplete migration, not a new authorization bypass attributed to this PR.
The ignored identity is no longer excluded before pairing/dispatch; separately, a YAML-authorized owner cannot use approval/model-picker reaction controls. This does not prove arbitrary users pass the central gateway allowlist.
Repair: initialize both consumers from the owning profile's effective env/YAML lists, preserving intended precedence and regex validation. Regression: enter via load_gateway_config → actual constructor → room-event and prompt-reactor checks; assert the ignored sender never reaches dispatch, the listed requester can approve, and an unlisted requester cannot. matrix_readers below is the failing case.
| # Under multiplex a secondary profile's settings must NOT hit the process-global env (first-writer-wins | ||
| # would pin them for every profile, #72348); yaml_env_setter skips the write under its scope and the | ||
| # values flow via extra/secret scope instead. | ||
| _set_env = yaml_env_setter() |
There was a problem hiding this comment.
P1 — Migrate Telegram’s proxy consumer before suppressing its only bridge
plugins/platforms/telegram/adapter.py:L6510
Replacing _set_env here also suppresses the TELEGRAM_PROXY write for telegram.proxy_url, but that key is not seeded into adapter configuration and _build_ptb_requests() still calls resolve_proxy_url("TELEGRAM_PROXY"). The shared resolver reads raw process environment, not the profile's YAML or secret scope. Thus this blanket writer change removes the secondary's configured proxy without giving the transport a replacement source.
Reproduced at the real request-construction boundary: a secondary with telegram.proxy_url: http://127.0.0.1:18080, no ambient proxies, and fallback-IP discovery disabled constructs both HTTPXRequest argument sets with no proxy ([null, null]). The identical telegram_proxy probe on base passes the configured URL to both. Only the HTTPXRequest constructor/polling instrumentation are replaced to record arguments and avoid network I/O; the real config loader, runtime scope, adapter, and _build_ptb_requests selection run.
This drops the configured network route: it can break Telegram connectivity or make it attempt direct access instead of the operator's proxy. This is route-selection evidence, not a claim that real traffic or credentials were leaked. The original #100448 explicitly left this write unscoped because the shared consumer had not been migrated; this version removes that exception without completing the consumer.
Repair: carry the profile's proxy configuration to the request builder and make resolution honor the owning profile, without restoring shared env mutation. Preserve explicit per-platform proxy versus ambient proxy/NO_PROXY semantics. Regression: assert both normal and getUpdates requests receive B's proxy when A has none or a different one, and that neither profile changes process env. Also cover B omitting its proxy.
| if key in slack_cfg and not os.getenv(env): | ||
| os.environ[env] = str(slack_cfg[key]).lower() | ||
| if key in slack_cfg: | ||
| seeded[key] = slack_cfg[key] # original type: the shared-key loop already seeded bools as bools |
There was a problem hiding this comment.
P2 — Carry YAML allow_bots through the central authorization gate
plugins/platforms/slack/adapter.py:L6475
The adapter now receives YAML allow_bots, but the gateway's second gate still does not. GatewayAuthorizationMixin._chat_scoped_grant() checks only _platform_gate_env(_ALLOW_BOTS_ENV[platform], "none"); it never consults the routed adapter's resolved mode or extra.allow_bots. Since the secondary's YAML is intentionally no longer bridged into process env and is not a scoped .env value, the central gate treats it as none.
Repro: load temporary secondary Discord and Slack configs with allow_bots: all inside the real runtime scope, construct both real adapters, and route them through a bare GatewayRunner using the real _is_user_authorized. Both adapters report mode all, but the central verdict is false for a Discord bot with an ID and a Slack Workflow-style bot with user_id=None, absent separate human/global grants. The latter is refused before user-ID-based grants can apply. bots_auth below fails at this head; base also denies the traffic, but even the adapter there reported none. No external bot conversation was made.
Repair: use the transport-owning adapter's effective bot policy at the central bot grant, with the same scoped env/YAML precedence as intake. Keep the mention requirement enforced at intake and retain the loop guard; do not fix this by enabling allow-all users. Feishu and Telegram share _ALLOW_BOTS_ENV and need the same central treatment.
Regression: drive YAML-configured bot traffic through both gates with no unrelated allowlist grant, including Slack's missing-user-ID shape; test none, mentions, and all, and ensure launch-only opt-ins cannot authorize secondary traffic.
| # The profile's config.yaml (scoped home above) is the durable record. Under a multiplexed | ||
| # secondary's scope the process env is the DEFAULT profile's; writing there would make this | ||
| # tenant's chat the default profile's cron/notification home. | ||
| if not _profile_scoped(): |
There was a problem hiding this comment.
[Bug] (blocking) For a multiplexed secondary profile, this branch skips the only live YUANBAO_HOME_CHANNEL update after persisting the value as a top-level YAML key. load_gateway_config() does not bridge that top-level key, and Yuanbao's PlatformConfig.home_channel is populated only from scoped env. The existing adapter config is not updated either. After an authorized first DM, cron and home-channel notifications therefore still have no target in the current process, and reloading the saved profile also returns no home channel. Persist this through the profile-aware home-channel config path and update the effective runtime config without writing to process-global env.
|
|
||
| Mirrors the legacy whatsapp_cfg block from gateway/config.py::load_gateway_config(). Env vars take | ||
| precedence over YAML. Returns None — everything flows through env. See #24849. | ||
| precedence over YAML. The env write is skipped under a multiplexed secondary profile's scope (#80099); |
There was a problem hiding this comment.
[Bug] (blocking) Skipping the YAML-to-env write for a secondary profile leaves the Node bridge with a different access policy from the Python adapter. The Python adapter reads dm_policy and allow_from from PlatformConfig.extra, but _bridge_env() sends only ambient env plus _wenv() values. bridge.js reads WHATSAPP_DM_POLICY and WHATSAPP_ALLOWED_USERS only from that child env and can reject the message before Python sees it. For example, a secondary configured with YAML dm_policy: pairing can still launch its bridge with the default profile's allowlist policy and drop valid pairing DMs. Build the child environment from the adapter's effective profile policy so YAML and scoped env precedence match both gates.
…own terminal scope Under multiplexing tools/terminal_tool.py no longer bridges a profile's terminal.* into os.environ while a home override is bound (#108440), so any secondary-profile entrypoint that binds only the home (+ secrets) leaves terminal_tool on the launch process's ambient TERMINAL_*: a secondary with `terminal.backend: docker` ran its prompt.background / prompt.btw / preview.restart side agents, its eager resume and its session.branch build on the launch `local` backend. _spawn_side_agent and _profile_build_scope now enter _session_profile_runtime_scope, the same home -> secrets -> terminal composition a prompt turn binds (and gateway/run.py::_profile_runtime_scope mirrors). The terminal scope is installed for the whole worker/build lifetime and reset on success and on exception; a malformed secondary config still yields the fail-closed refusal scope. No ambient os.environ write is restored. Refs #108440 review (andrexibiza), #107442 (ehz0ah).
…olicy once multiplexing is active 606903b made launch-profile turns bind a file-backed terminal scope as soon as any secondary home is served, so a poisoned ambient bridge can never be the launch turn's authority. That scope is rebuilt from defaults + <home>/.env + config.yaml only, which drops the launch process's legitimate env-only policy: TERMINAL_ENV=ssh TERMINAL_SSH_HOST=example.test with a `{}` config.yaml became backend=local, ssh_host='' the moment a second profile was served. tui_gateway/launch_terminal_policy.py freezes the process TERMINAL_* once, in _profile_home right before the first secondary home is registered as served — the last moment ambient env is provably the launch profile's own. build_profile_terminal_scope takes that snapshot as a trusted env_overlay sitting where the process env sits in the standalone bridge (explicit YAML keys still win). Launch turns overlay the snapshot; ambient os.environ is never re-read after activation, so a later secondary write is still rejected, and the scope is reset after the turn as before. Refs #108440 review (andrexibiza), #107442 (ehz0ah).
…fault, per profile One reader (gateway.platforms._shared.extra_or_secret) now implements the precedence every per-profile setting follows for the OWNING profile: explicit scoped env/.env → that profile's config.yaml (PlatformConfig.extra) → the adapter's default. A scoped miss returns the default, never the launch process's os.environ; single-profile / default-profile installs keep the documented env-over-YAML contract. Why: 545e74d (#108705) stopped bridging a secondary's YAML into the process env and moved readers to config.extra, but the shared reader and the hand-rolled helpers in Discord/Slack/Matrix/Telegram consulted YAML FIRST and then fell back to a scoped env read. Two bug classes followed (#108440 post-merge review by andrexibiza, #109032): - an explicit env value could no longer beat YAML for the owning profile (DISCORD_ALLOW_MENTION_EVERYONE=false lost to allow_mentions.everyone: true; TELEGRAM_REACTIONS=true lost to the stock reactions: false); - a secondary that OMITTED a key inherited the launch profile's bridged env through the fallback (Matrix process_notices/session_scope, Discord auto_thread/reactions/mentions, Slack reactions/ignored_channels). Consumers migrated to the shared reader: Discord _build_allowed_mentions and _extra_or_env_flag; Slack _slack_allow_bots, _reactions_enabled (the _extra_or_env_* getters already used it); Matrix _extra_truthy, _extra_csv_set, session_scope, reactions, require_mention parsers, and — new — the allowed_users / ignore_user_patterns consumers that never read the seeded YAML lists; Telegram _extra_bool, _extra_str_set, _reactions_enabled; Feishu allow_bots; WhatsApp dm_policy/group_policy. Refs #108440, #109032
…onstruction without an env bridge 545e74d correctly stopped writing telegram.proxy_url into TELEGRAM_PROXY for a multiplexed secondary, but _build_ptb_requests still resolved the proxy only from that env var, so the secondary silently connected direct (or via the default's proxy). #100448 had deliberately left this bridge unscoped for that reason; this finishes the consumer migration instead. _apply_yaml_config seeds proxy_url into extra and resolve_proxy_url gains a `configured` rung: scoped TELEGRAM_PROXY → the profile's YAML → HTTPS_PROXY/ HTTP_PROXY/ALL_PROXY (trust_env) → macOS system proxy, with NO_PROXY semantics unchanged. Refs #108440 (finding 6)
…e's YAML policy
GatewayAuthorizationMixin._chat_scoped_grant read only the scoped
{PLATFORM}_ALLOW_BOTS env var, so a secondary whose config.yaml said
`allow_bots: all` was admitted by its own adapter and then denied centrally
(Discord, Slack Workflow posts with user=None, Feishu, Telegram). The gate now
resolves the routed adapter's effective policy with the same reader as intake:
scoped env → adapter YAML → none. Mention requirement and loop guard are
unchanged.
Refs #108440 (finding 7)
…d updates the live config For a multiplexed secondary the middleware wrote a top-level YUANBAO_HOME_CHANNEL key that load_gateway_config never reads and skipped the (correctly suppressed) process-env write, so cron and home-channel delivery had no target in-process and none after a reload either. Persist through the gateway's persist_home_channel (the profile-aware config path every /sethome uses) and set the live PlatformConfig.home_channel; the process env is still untouched under a secondary's scope. Refs #108440 (ehz0ah inline, gateway/platforms/yuanbao.py)
…ow_from, not the launch env's _bridge_env copied os.environ (the default profile's WHATSAPP_* values under multiplex) and only overlaid scoped hits, so a secondary with YAML `dm_policy: pairing` launched its Node bridge under the default profile's `allowlist` policy and the bridge rejected valid pairing DMs before Python saw them. The child env now carries the values the adapter resolved (scoped env → own YAML → default); a scoped miss removes the key rather than inheriting it. Refs #108440 (ehz0ah inline, plugins/platforms/whatsapp/adapter.py)
… own YAML like every sibling Rebase reconciliation with main's JSON-allowlist decoding (#109423): the two remaining readers that consulted config.extra before the env var now follow the per-profile precedence rule (explicit scoped env → the profile's YAML → default), and ignored_threads still decodes a JSON-string list after the read. The Matrix blank-YAML test asserted YAML-over-env, the old precedence #108440's review flagged; it now pins the contract: explicit env beats YAML, a blank env value is unset (YAML applies), YAML beats the default, and an explicit empty list is a real "no rooms" value.
…fault, per profile One reader (gateway.platforms._shared.extra_or_secret) now implements the precedence every per-profile setting follows for the OWNING profile: explicit scoped env/.env → that profile's config.yaml (PlatformConfig.extra) → the adapter's default. A scoped miss returns the default, never the launch process's os.environ; single-profile / default-profile installs keep the documented env-over-YAML contract. Why: 545e74d (#108705) stopped bridging a secondary's YAML into the process env and moved readers to config.extra, but the shared reader and the hand-rolled helpers in Discord/Slack/Matrix/Telegram consulted YAML FIRST and then fell back to a scoped env read. Two bug classes followed (#108440 post-merge review by andrexibiza, #109032): - an explicit env value could no longer beat YAML for the owning profile (DISCORD_ALLOW_MENTION_EVERYONE=false lost to allow_mentions.everyone: true; TELEGRAM_REACTIONS=true lost to the stock reactions: false); - a secondary that OMITTED a key inherited the launch profile's bridged env through the fallback (Matrix process_notices/session_scope, Discord auto_thread/reactions/mentions, Slack reactions/ignored_channels). Consumers migrated to the shared reader: Discord _build_allowed_mentions and _extra_or_env_flag; Slack _slack_allow_bots, _reactions_enabled (the _extra_or_env_* getters already used it); Matrix _extra_truthy, _extra_csv_set, session_scope, reactions, require_mention parsers, and — new — the allowed_users / ignore_user_patterns consumers that never read the seeded YAML lists; Telegram _extra_bool, _extra_str_set, _reactions_enabled; Feishu allow_bots; WhatsApp dm_policy/group_policy. Refs #108440, #109032
…onstruction without an env bridge 545e74d correctly stopped writing telegram.proxy_url into TELEGRAM_PROXY for a multiplexed secondary, but _build_ptb_requests still resolved the proxy only from that env var, so the secondary silently connected direct (or via the default's proxy). #100448 had deliberately left this bridge unscoped for that reason; this finishes the consumer migration instead. _apply_yaml_config seeds proxy_url into extra and resolve_proxy_url gains a `configured` rung: scoped TELEGRAM_PROXY → the profile's YAML → HTTPS_PROXY/ HTTP_PROXY/ALL_PROXY (trust_env) → macOS system proxy, with NO_PROXY semantics unchanged. Refs #108440 (finding 6)
…e's YAML policy
GatewayAuthorizationMixin._chat_scoped_grant read only the scoped
{PLATFORM}_ALLOW_BOTS env var, so a secondary whose config.yaml said
`allow_bots: all` was admitted by its own adapter and then denied centrally
(Discord, Slack Workflow posts with user=None, Feishu, Telegram). The gate now
resolves the routed adapter's effective policy with the same reader as intake:
scoped env → adapter YAML → none. Mention requirement and loop guard are
unchanged.
Refs #108440 (finding 7)
…d updates the live config For a multiplexed secondary the middleware wrote a top-level YUANBAO_HOME_CHANNEL key that load_gateway_config never reads and skipped the (correctly suppressed) process-env write, so cron and home-channel delivery had no target in-process and none after a reload either. Persist through the gateway's persist_home_channel (the profile-aware config path every /sethome uses) and set the live PlatformConfig.home_channel; the process env is still untouched under a secondary's scope. Refs #108440 (ehz0ah inline, gateway/platforms/yuanbao.py)
…ow_from, not the launch env's _bridge_env copied os.environ (the default profile's WHATSAPP_* values under multiplex) and only overlaid scoped hits, so a secondary with YAML `dm_policy: pairing` launched its Node bridge under the default profile's `allowlist` policy and the bridge rejected valid pairing DMs before Python saw them. The child env now carries the values the adapter resolved (scoped env → own YAML → default); a scoped miss removes the key rather than inheriting it. Refs #108440 (ehz0ah inline, plugins/platforms/whatsapp/adapter.py)
… own YAML like every sibling Rebase reconciliation with main's JSON-allowlist decoding (#109423): the two remaining readers that consulted config.extra before the env var now follow the per-profile precedence rule (explicit scoped env → the profile's YAML → default), and ignored_threads still decodes a JSON-string list after the read. The Matrix blank-YAML test asserted YAML-over-env, the old precedence #108440's review flagged; it now pins the contract: explicit env beats YAML, a blank env value is unset (YAML applies), YAML beats the default, and an explicit empty list is a real "no rooms" value.
|
Post-merge follow-up on the reviews here, @andrexibiza and @ehz0ah — every finding was reproduced on
Four regressions from this PR (two terminal, two adapter), five older gaps — all closed. #110293 also carries the same-class community fixes (#109036 @liuhao1024, #109477 @benjamin-rousseau-shift, #110109/#110111/#110131 @EloquentBrush0x). Thanks both — the contract probes are now the invariant tests. |
* test(desktop): trim #102840 salvage to two invariant tests
Keep the null-route stub test (the #108369 / #102792 trigger) and the
runtime-id session.control.read parity test; drop the routed-connection
and stub-eviction cases (the routed path already had owner hints on main,
and eviction is an implementation detail of the stub atom).
* fix(auth): a configured custom endpoint counts as a provider in auto resolution (#108383)
`resolve_provider("auto")` only honoured `model.provider` when it named a
registry provider, so `provider: custom` (llama.cpp / vLLM / ollama, or a
loopback `base_url` alone) fell straight through to "No inference provider
configured". The boot inventory in free_tier_bootstrap asks exactly that
question, records provider_configured=False, and every `setup.status` in
`hermes serve` answers from that record — the dashboard's Ink chat then
parked each new session on "Setup Required" while `hermes chat` (which
builds the runtime through resolve_runtime_provider) worked on the same
config. Merged in v0.21.2 (#107697 4bdd64b334ad); reporters on custom and
on named registry providers were hit by the same record path.
Recognise `custom` / `custom:<name>` / local-server aliases, and a
base_url the bare-custom runtime rung already trusts
(_config_base_url_trustworthy_for_bare_custom), as explicit intent in
_config_model_provider.
Probe (temp HERMES_HOME, provider: custom, base_url 127.0.0.1:8000):
before record.provider_configured=False setup.status.provider_configured=False
after record.provider_configured=True setup.status.provider_configured=True
* feat: background-process completions paint a compact title, not the raw notification wall
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.
Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
* fix(desktop): preserve owner for unlisted profile tabs
* fix(desktop): own tab-strip drafts from the draft profile
Tab-strip new tabs omit options.profile; record the draft or active
profile as the tile owner so session.control.read can resolve.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): a tab promoted into MAIN keeps its owner for session.control.read
KoNit-K's two commits stamp an owner on tab-strip `+` drafts at create
time, which clears the banner ON the draft tile. Promoting that draft into
main (⌘W on the workspace tab, or a tab dragged out of main) still raised
"Session controls unavailable": closeSessionTile drops the tile AND evicts
its $sessionStates mirror in one tick, resumeSession then makes the
runtime active before the view republishes, and the composer's control
read lands in that gap — storedSessionIdForRuntimeId had no tile, no
mirror, and so never reached the stored-id hint that was there all along.
Give the translation one more rung: the active runtime maps to the
selected stored id. Only main's own binding qualifies; unrelated runtime
ids stay unknown and keep failing closed.
Live (Electron + mock gateway, bot chat in main, `+` draft, close main's
tab so the draft promotes): main → banner; KoNit-K alone → banner on
promote; with this rung → no banner at any step, send works.
Invariant test red on main, green here.
* fix(kanban): stage review-bound handoff artifacts in request_review
request_review ignored artifacts entirely, so a review-bound card lost every
file its handoff named: the reviewer's complete_task is what runs
_cleanup_workspace over the managed scratch workspace.
Stage declared (explicit artifacts argument or metadata["artifacts"]) and
prose-referenced files into the task's durable attachments dir at the review
handoff, exactly as complete_task already does, carry the staged paths in the
review_requested event payload, and let the gateway notifier upload them (its
guard widens from completed to review_requested). ArtifactPreservationError
still rolls the whole transition back: the task stays running and retryable
with no attachments and no event.
* refactor(kanban): trim review-artifact salvage to metadata path, 2 invariant tests
Drop the new `artifacts=` keyword on `request_review()` and its
`_declare_handoff_artifacts` helper: the tool layer already folds the
model-facing `artifacts` list into `metadata["artifacts"]` via the
existing `_merge_artifacts`, so the DB layer needs only the one input it
already honours. Keep two invariant tests (declared artifact survives the
reviewer's completion; notifier uploads the staged copy on
`review_requested`); the prose-reference and rollback variants are
covered by the same helpers `complete_task` already exercises.
Salvage of #109276 by @yoyodine-industries.
* docs(kanban): review handoffs also stage declared artifacts
`kanban_request_review(artifacts=[...])` now preserves scratch deliverables
the same way `kanban_complete` does; say so where the scratch-workspace
lifecycle is documented.
* fix(kanban): review handoff rollback discards staged copies; no double upload
Two review follow-ups on request_review's artifact staging.
Staging copies files into attachments/<tid>/ inside the write txn, but
the copy is a filesystem side effect the rollback cannot undo. When a
later step in the same txn raised (anything other than
ArtifactPreservationError, e.g. run bookkeeping), the task correctly
stayed `running` but the copy leaked, so the retry staged `a_1.txt`
beside an orphan `a.txt`. _stage_completion_artifacts now returns the
copies and request_review discards them on any exception around the
txn, reusing the same unlink/rmdir logic the staging helper already
had. complete_task is left alone: its txn has a different shape (the
early-return paths and acceptance recording) and its cleanup runs the
scratch workspace anyway, so it was not the identical one-line change.
The notifier unions payload['artifacts'] with paths parsed from the
summary prose and dedupes by full path only. For `review_requested` the
scratch original still exists (the reviewer's completion is what
deletes it), so a summary naming the original uploaded the file twice:
staged copy and original. Prose-parsed paths whose basename matches a
staged artifact are now skipped; `completed` delivery is unaffected in
practice because there the original is already gone by delivery time.
* fix(cron): allow cold external worker startup
* fix(cron): external worker ack deadline equals the handoff adoption grace
The dispatch path abandoned a handoff after a fixed 5s while the dead-owner
recovery ledger already tolerates HANDOFF_ADOPTION_GRACE_SECONDS (30s) for the
same pending handoff. Field measurements (issue #109243: p90 claimed->started
10.7s, cold worker starts 9-12s dominated by imports plus secret hydration)
put the cold mode squarely inside the old deadline, so healthy handoffs were
logged as ownership-uncertain and never had their worker pid recorded. Use the
one constant the ledger already defines instead of a second, separately tuned
number, so the two guards around one event cannot disagree again.
Reshapes the salvaged test from #109252 to the current
restart_safe_gateway_child_argv signature and asserts the acknowledged-path
side effect (worker pid recorded) rather than clock progress.
Fixes #109243
* fix(mcp): isolate OAuth connections by profile
* fix(mcp): isolate mTLS connection identities
* test(mcp): drive OAuth profile isolation through register_mcp_servers()
The regression only exercised register_connected_into_current_scope() and
_select_new_servers() directly. Closed #109430 covered the user-visible
path: profile B, driven through the public register_mcp_servers() entry
point, must open its own connection (its own OAuth token) instead of
adopting A's session. Fold that drive into the existing test rather than
adding a second one.
The direct _select_new_servers() assertion is dropped because it marks
B's key as connecting as a side effect, which would make the subsequent
entry-point drive skip the server; the end-to-end drive subsumes it.
* refactor(mcp): read the key's scope and the identity's auth type instead of re-deriving them
_key_scope(key) already answers 'owned by another scope'; rebuilding the
tuple via _server_key said the same thing less directly. The OAuth check
re-normalised both configs' auth strings although the normalised value is
the last element of _connection_identity, which the route test compares
anyway — one side suffices once identities match.
* fix(mcp): read the OAuth auth type by name, not by tuple position
The salvaged refactor tested `ident[-1] == "oauth"`; once the mTLS fields
were appended to `_connection_identity()` the last element became the
frozen `client_key` and the cross-profile OAuth refusal silently stopped
firing (the live probe showed B adopting A's OAuth session again). Name
the auth-type accessor so the tuple can grow without moving the check.
* docs(mcp): OAuth servers are never shared across profiles
* docs(mcp): mTLS credentials count toward connection sharing; OAuth token path is per profile
The multiplex guide now says client_cert/client_key are part of the
"same credentials" test and states the OAuth rule as its own sentence;
the MCP config reference names the per-profile token directory and the
never-shared-across-profiles rule next to the OAuth behaviour list.
Co-authored-by: ly6751 <99090550+ly6751@users.noreply.github.com>
* fix(config): localize desktop settings copy
* test: satisfy padding-line rule in settings i18n test
The desktop eslint gate (padding-line-between-statements) flagged the
salvaged test file; a blank line before the `cases` declaration keeps
`npm run check:lint` at zero problems for the touched files.
* fix: use an example name for zh custom endpoint placeholder
The zh and zh-hant values for settings.customEndpoints.namePlaceholder
were meta-text ('示例代理(占位符)' / '範例代理(預留位置)') — literally
"example proxy (placeholder)". A placeholder should show what the user
would actually type, matching the en locale's concrete example name
('Axet Proxy'). Both scripts now use '我的代理' ("my proxy").
* fix(voice): retry a timed-out audio input stream start once
On WSL2 the only input device is the ALSA->PulseAudio bridge; with the
WSLg RDP source SUSPENDED the first InputStream.start() can exceed
PortAudio's 1 s thread-start window and fail with paTimedOut (-9987).
The failed open itself wakes the bridge, which is why the user's second
key press always worked. Retry the open exactly once when the error is a
timeout, on every platform: no WSL detection, no external parecord
warm-up. Any other error, or a second timeout, raises the same
RuntimeError as before.
Generic slim redo of #109313 by @liuhao1024 (WSL-gated parecord warm-up
and retry); diagnosis by @rugscan2021 in #109303.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* fix(profiles): rename under a live multiplexer no longer resurrects the old name
A multiplexed secondary profile has no gateway.pid of its own, so
rename_profile's _check_gateway_running(old_dir) reported it stopped and
skipped teardown. Unlike delete_profile, rename never tombstoned the old
name nor notified the multiplexer, so at the moment old_dir.rename(new_dir)
ran the default gateway still held the old profile's adapters, cron ticker,
logging and SQLite handles. Those live components immediately re-mkdir'd the
old home (no .deleted tombstone -> mkdir_under_hermes_home does not refuse
it) and the periodic reconcile re-adopted the resurrected dir as a ghost
served profile.
Give rename the same unroute-before-mutate discipline delete already has:
when the old name is served by a live multiplexer, tombstone + notify before
the move so its adapters stop and handles release into old_dir; clear the
stale tombstone after the move; then notify for the new name to hot-serve it
(mirrors create). Non-multiplexed renames are untouched.
Fixes #109267
* fix(profiles): roll back the unroute if a multiplexed rename fails
If old_dir.rename(new_dir) raises (cross-device EXDEV, permissions, a
racing writer) after the pre-move tombstone + unroute, the profile was
left tombstoned-but-present — enumeration treats it as deleted, so the
profile silently vanishes (worse than the ghost this PR fixes). Undo the
unroute on failure: clear the tombstone and re-notify the multiplexer to
re-serve the old name before re-raising. Adds a regression test (proven
red on the base of this branch).
* docs: rename under a live multiplexer unroutes the old name
The served-set paragraph documented create and delete as live operations; rename now
follows the same unroute-before-mutate protocol, so say so where operators look for it.
* fix(gateway): preserve profile config changes during connection
* fix(telegram): decode JSON-encoded allowlist strings before comma-split
* test(telegram): two invariant tests for JSON-string allowlists, under the plugin's test mirror
Trim the salvaged suite to the two invariants the fix guarantees: every Telegram allowlist
key (the five `_extra_str_set` readers plus `ignored_threads`) decodes a JSON-encoded string
and the group gate then admits the listed chat; comma strings, native lists and malformed
JSON keep the legacy split. Moved from tests/gateway/ to tests/plugins/platforms/telegram/ to
mirror the source path.
* fix(telegram): runner-side allowlist gate decodes JSON list strings too
The adapter fix decoded `'["-100","-200"]'` before comma-splitting, but
the runner's central gate in gateway/authz_mixin.py::_coerce_allow_set
reads the same YAML-bridged env chain (TELEGRAM_GROUP_ALLOWED_CHATS,
TELEGRAM_ALLOWED_USERS via _auth_env) and still produced
{'["1"', '"2"]'}, so a group message admitted by the adapter could
still be rejected upstream.
Move the decoder to gateway/platforms/_shared.py, which both the adapter
and authz_mixin already import from (no plugin -> gateway cycle), and
route _coerce_allow_set through it. One invariant test on the runner
side, red before this change.
* fix(memory/hindsight): resolve retain shaping through the profile scope, never os.environ
_load_config() reads the Hindsight bank, mode and retain tags through the profile
secret scope, but _apply_retain_settings() then discarded that answer and re-read
os.environ whenever the config value was falsy:
return cfg.get(key) or os.environ.get(env_var, default)
Under gateway.multiplex_profiles os.environ holds the DEFAULT profile's .env, so a
secondary profile's scoped miss came back as the default profile's retain tags,
observation scopes, source and speaker prefixes — the fallback-after-miss shape
gateway/AGENTS.md forbids. Tags are Hindsight's retrieval partition and
metadata.source is opt-in by design, so the secondary's memories were both
mislabelled and selectable by the default profile's tag filters.
Both halves now go through _scoped_setting(), which resolves the value with
get_secret() and falls back to the provider's OWN default — a miss is a miss, the
same rule embedded.py already applies to the daemon's key and base URL. The three
raw reads left inside _load_config() (retain_source, retain_user_prefix,
retain_assistant_prefix), directly under the comment declaring them per-profile,
go through it too.
Single-profile deployments are unchanged: with no scope installed get_secret()
still reads the process env, where the value IS this profile's own.
Fixes #108865
* fix(memory/hindsight): pin the isolation-vs-shaping split for scoped reads
`langfuse._secret` and `azure_identity_adapter._scoped_env` were changed to
raise rather than fall back, because swallowing `UnscopedSecretError` hides the
spawn-site bug the exception exists to surface. `_scoped_setting` looked like it
contradicted that, so make the split explicit and pin it.
Hindsight already follows the contract for everything that decides WHERE data
goes: `mode`, `apiKey` and the `bankId` partition read through bare
`get_secret`, so a scopeless multiplexed read raises. In `_load_config` that
raise happens on `HINDSIGHT_MODE` before any shaping value is reached, so the
swallow below cannot mask an isolation failure.
Presentation shaping is deliberately not in that class. `MemoryManager._each_provider`
logs an `initialize` failure at WARNING and drops the provider for the session,
so raising there would cost the whole memory provider because a speaker prefix
could not be resolved. It degrades to the provider's own default instead —
never to `os.environ`, which under multiplex is the default profile's.
The test names the offending key rather than asserting that something raised:
routing `mode` through the shaping helper shifts the failure to
`HINDSIGHT_API_KEY`, which a bare `pytest.raises` would still accept.
* fix(gateway): retry failed profile secret hydration
* fix(gateway): clear stale profile secret snapshots
* fix(gateway): revoke stale profile secret snapshots
* fix(browser): resolve the Nous gateway from the picker selection, not only use_gateway
browser_exec with browser.cloud_provider: nous (the hermes tools picker row) fell into
the direct-API Browser Use branch and reported chrome-not-running, because
_resolve_backend_cdp gated on _use_gateway(), which only read the pre-picker
use_gateway: true flag. Recognize the picker selection too.
* fix(memory/byterover): brv child carries the served profile's cloud key, never the launch profile's
Under gateway.multiplex_profiles os.environ holds the default profile's .env, so `_run_brv`
building the child env from raw os.environ curated a secondary profile's turns into the DEFAULT
profile's ByteRover cloud account (and prefetched the default's memories into the secondary's
context). The local half was already profile-scoped (`_get_brv_cwd`).
The child env now comes from `build_subprocess_env` and, under multiplex, strips the launch
profile's residue and sets BRV_API_KEY only from the served profile's secret scope — a miss means
no cloud key. Single-profile installs pass the process env through unchanged.
Closes #108993 (report and fix direction by @jonpol01).
* test(secrets): trim salvaged #108446 coverage to the two invariants (retry after failure; snapshot replaced on retry)
* test(hindsight): trim salvaged #108866 coverage to two invariants (secondary keeps own shaping; single-profile reads process env)
* fix(tools): browser_exec and computer_use caches are namespaced by the served profile
Both process-global caches were keyed by the caller's session/task id alone, so under
gateway.multiplex_profiles two profiles using the same id — a shared `browser_exec session=`
name, or two Hermes sessions whose screens report the same DISPLAY — resolved to the FIRST
profile's cloud browser / cua-driver, and a command issued in one bot's chat could act on
another bot's screen.
The key now carries the routed profile's home key whenever a served-profile scope is active
(`get_hermes_home_override()` set), the same shape `tools/approval.py::_baseline_key` and the
camofox/cloud caches already use; outside a scope every key is byte-identical to before. The
computer_use lookup, install and release paths all go through one `_scoped_sid`, so a release
under profile B never stops profile A's driver; approval-bypass state keeps the bare session id.
Fixes #110032 (report by @wolfyy970, from @vandaimer's manual test on #108914).
* test(byterover): write the fixture .env with an explicit utf-8 encoding
* fix: Star Map node menu stays inside the viewport near window edges
The Star Map right-click menu was a hand-rolled `position: fixed` card
placed at the raw `clientX/clientY`, so a star within ~75px of the bottom
(or ~144px of the right) edge clipped the `Delete memory` / `Archive skill`
row off-window while `Edit …` stayed visible — the destructive action
silently disappeared.
Reuse the shared Radix `DropdownMenu` anchored to a zero-size fixed span at
the click point — the exact pattern `AppContextMenu` already uses — so the
menu gets the same flip/shift collision handling (and `collisionPadding`,
keyboard navigation, Escape/outside-click dismissal) as every other menu in
the app, instead of adding a second bespoke measure-and-clamp path.
`Edit …` keeps the menu open while the node content loads (`onSelect`
`preventDefault`) exactly as before; `openEdit` closes it on success.
Refs #109288. Supersedes the measure+clamp approach of #109301 (credit
@KoNit-K for the diagnosis). #100894 routes the gesture to this menu and is
untouched.
* fix: ignore star map playback hotkeys inside context menu
The node context menu now uses Radix, whose menu items are focusable
`div[role=menuitem]` elements. The window-level Space handler in
star-map.tsx only skipped INPUT/TEXTAREA/BUTTON/contentEditable, so
pressing Space on a focused menu item both activated the item and toggled
playback.
Extract the guard into `shouldIgnorePlaybackHotkey`, which additionally
bails when the event was already `defaultPrevented` or when the target or
active element sits inside a `[role=menu]`, and cover the menuitem case
with a small vitest.
* fix(desktop): preserve routed transcript during selection churn
* fix: keep same-session route during context switch
The contextSwitching early return in isRouteSessionMismatch sat above the
same-id short-circuit, so a profile or connection switch while the route
already pointed at the selected session reported a mismatch and blanked the
chat to the splash. On main that call returned false.
Move the selected-session check ahead of the contextSwitching guard: when the
selected view already owns the routed conversation there is no prior context
to leak, so nothing needs hiding. The guard still denies only the
transcript-retention fallback, which is the case it was added for.
Adds the exact regression to route-session-state.test.ts.
* fix(matrix): restore env fallback for blank room config
* fix(matrix): blank YAML values fall through to env at every extra-first reader
545e74d0 (post-0.21.2) made the Matrix YAML bridge seed its values into
PlatformConfig.extra so secondary multiplex profiles read their own config. The
"csv" bridge kind seeds any non-None value, so `free_response_rooms: ''` now
reaches extra as '' — and the readers' `if raw is None` fallback no longer fires,
so MATRIX_FREE_RESPONSE_ROOMS is ignored and require_mention drops every
un-mentioned message. Before that commit the bridge only wrote env and the key
was absent from extra, so the env value applied.
Route the three identity-check readers (_extra_csv_set, _extra_truthy,
_resolve_max_message_length — the last a three-tier chain where '' also
short-circuited the plugin-registry default) through the shared
gateway.platforms._shared.extra_or_secret, whose default already treats a blank
string as unset (the idiom mattermost/dingtalk/slack readers use). Explicit
scalars, bools and lists (including []) stay authoritative.
Two invariant tests replace the salvaged suite (moved to
tests/plugins/platforms/matrix/ to mirror the source path): blank falls through
for all three readers; explicit values still beat env.
Fixes #109358
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
* fix(whatsapp): blank free_response_chats in YAML falls through to the env CSV
Same class as the Matrix readers: since 545e74d0 the WhatsApp YAML bridge seeds
`free_response_chats` into extra via the "csv" kind (any non-None value), so a
present-but-blank `free_response_chats: ''` reaches `_whatsapp_free_response_chats`
as '' and its `if raw is None` fallback never reads WHATSAPP_FREE_RESPONSE_CHATS.
Before 545e74d0 the hook returned None and the key never reached extra, so the
env CSV applied. Route it through the shared extra_or_secret reader (blank =
unset; an explicit empty list stays "no chats").
Sibling sweep of every "csv"-kind bridged key: dingtalk, mattermost and slack
readers already go through extra_or_secret and their bridges seeded extra before
0.21.2, so 008caa88 deliberately kept blank-means-clear there; whatsapp allow_from
uses key-presence semantics by design (_select_dm_allowlist); buzz reads env
first. Telegram allowed_chats: '' shadowing the env var is pre-existing (identical
on v2026.9.7, via the shared-key bridge) and left as is.
* fix(plugins): stop the security scanner from reading test trees
plugin_guard walks the whole plugin clone, and EXCLUDED_DIRS skipped
caches and vendored dirs but not tests/. A security-conscious plugin's
test suite SHOULD contain adversarial fixtures — a test asserting the
trust boundary holds round-trips the injection string verbatim — and
any single critical finding makes the verdict dangerous, which --force
explicitly cannot override. Scanning tests therefore made exactly the
plugins that test their security unconditionally uninstallable, and the
only workaround was obfuscating the payload strings, weakening the
tests and inverting the incentive. Fixtures are never loaded into an
agent's context at runtime the way README/plugin.yaml are.
Add the conventional test/spec/fixture directory names to
EXCLUDED_DIRS, alongside the existing cache/vendored skips.
* fix(plugin): identify critical findings in install blocks
* docs(plugins): document skipped test trees and the critical-finding block reason
User-visible scanner behaviour changed in this PR (test trees skipped, the block
reason names the critical rule ids), so the plugin docs say so in the same PR.
* fix: scan plugin test trees again, cap their criticals at caution
Skipping `tests/`, `spec/`, ... in EXCLUDED_DIRS made those trees
invisible to the guard, but `plugins_loader._load_directory_module`
sets `submodule_search_locations=[plugin_dir]`, so a plugin
`__init__.py` doing `from .tests import evil` imports and runs whatever
lives there: a `tests/evil.py` with a destructive root remove scanned
`dangerous` on main and `safe` on this branch. `_walk` also matched the
names at any depth, so `src/spec/handler.py` — plain runtime code — went
unscanned.
Keep scanning everything; instead cap a critical finding located under a
ROOT-level test dir at `high`, so the verdict is `caution` (confirmation
required, `--force` overridable) rather than the un-overridable
`dangerous`. Fixture strings still cannot brick an install, which was
the reported problem, while a critical in any runtime file (`setup.sh`,
`src/spec/...`) still yields `dangerous`. Trade-off stated in the PR
body: hostile code deliberately placed under `tests/` is now
force-installable rather than blocked outright.
Docs no longer claim test code never runs.
* fix(kanban): scope the auto-decompose tick to the default profile under multiplex
With gateway.multiplex_profiles on, agent.secret_scope.get_secret() fails closed
whenever no profile secret scope is installed. auto_decompose_tick runs through
_to_thread_process_service in a fresh context, so the decomposer's credential
read raised UnscopedSecretError on every tick before the aux LLM was called,
and every triage card stayed in triage forever (logged at INFO only).
Wrap the tick in _default_profile_secret_scope(): when multiplexing is active
and no scope is installed, build the gateway default profile's scope (the same
home load_gateway_config_for_runner uses) for the duration of the tick. No-op
for single-profile gateways and when a scope is already active.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c47e3ea6f8a5750182b7534160184210b8d5a110)
* fix(kanban): install the assignee's secret scope before scrubbing worker env
_default_spawn() called build_subprocess_env(scrub_secrets=is_multiplex_active())
with no profile secret scope installed. Under multiplex, any name registered via
terminal.env_passthrough makes _filter_secret_env's resolve_passthrough_value()
call get_secret() with no scope active, which fails closed with
UnscopedSecretError -- crashing every Kanban worker spawn, for every profile, as
soon as env_passthrough is configured anywhere.
Mirror _resolve_worker_cli_toolsets's existing scope-then-read ordering a few
functions up in the same file: resolve the assignee's HERMES_HOME first, install
build_profile_secret_scope() around the env build, then set env["HERMES_HOME"]
from the value already resolved instead of calling resolve_profile_env() twice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(kanban): trim auto-decompose scope shim and add an invariant test
Salvage follow-up to #107955 (Alex Tu) and #109494 (EloquentBrush0x):
- _default_profile_secret_scope: drop the import try/except, the
current_secret_scope() short-circuit and the build-failure fallthrough.
The tick always runs in a fresh Context (no scope can be present) and a
failure to build the launch profile scope must surface, not silently
degrade to an unscoped tick.
- Regression test proven red on origin/main: run the real
auto_decompose_tick through _to_thread_process_service under multiplex
and assert the decomposer reads the launch profile .env value; ports the
contract from #57837 (srojk34) to the post-refactor dispatcher.
- Trim the #109494 test docstring to the invariant.
* fix(kanban): gate gateway notifier polling
* fix(cron): block a job whose requested MCP server resolves to zero tools
Under a multiplexer MCP tools are registered per profile overlay while the
server toolset alias is process-global, so a cron job naming a server in
enabled_toolsets that is connected only for another profile validated as a
known toolset, resolved to zero tools, and ran tool-less with quiet_mode
hiding the only diagnostic; the run was booked success (#109050).
After cron MCP discovery, an explicitly requested enabled MCP server that
resolves empty in this profile scope now takes the existing blocked_config
path (incident, alert-once, visible last_status). The implicit merge of
all enabled servers is not judged; only servers the job asked for.
* chore(contributors): map EloquentBrush0x, benjamin-rousseau-shift, Mengchee118 emails
* fix(a2a): preserve live waiters during orphan cleanup
* fix(a2a): retain task ownership through completion
* fix(a2a): finalize tasks after stream disconnect
* docs(a2a): say the orphan sweep follows A2A_REPLY_TIMEOUT and live waiters
The troubleshooting entry told users to raise A2A_REPLY_TIMEOUT for long tasks,
which did nothing against the hardcoded 300s orphan sweep (#106972). Now that the
sweep derives its grace from the reply window and skips tasks with a live waiter,
state that contract next to the variable.
* fix: bound A2A orphan grace and clear _active_tasks on disconnect
`_orphan_timeout()` was `max(300, A2A_REPLY_TIMEOUT)` with no ceiling, so
an absurd value (1e18) meant the watchdog sweep could never fail an
orphan — the reply window is a floor for the grace, not a licence to
disable the sweep. Cap it at 86400s.
`disconnect()` failed and cleared `_pending`/`_pending_order` but left
`_active_tasks` populated, so a reconnected adapter would keep excluding
dead task ids from the orphan sweep forever. Clear it in the same locked
block.
* fix(cli): resolve .env-only key_env credentials for the /model probe
`/model` fed `validate_requested_model()` a key resolved through
`agent.secret_scope.get_secret`, which (multiplexing off) reads only
`os.environ`. Hermes does not export `$HERMES_HOME/.env` into the process
environment, so a `custom_providers` entry whose `key_env` lives only in `.env`
probed `/v1/models` unauthenticated, got 401 and printed a spurious "could not
reach this custom endpoint's model listing" note while chat worked fine.
Resolve through `get_env_prefer_dotenv` — the chain `client_lifecycle` uses for
the real request — when no profile scope is installed. With a scope installed or
multiplexing active the scope stays authoritative: a scoped miss still returns
"" and never borrows another profile's `.env`/process value.
Slimmed from the contributor's two commits (same mechanism, fewer branches,
tests trimmed to two invariants).
Fixes #109315
* fix(updater): finish Node phase after Windows handoff
* fix(gateway): /save delivers the export document instead of crashing on get_adapter
`GatewayRunner` never had a `get_adapter` method, so every gateway `/save`
(Telegram, Discord, ...) rendered the file and then failed with
"'GatewayRunner' object has no attribute 'get_adapter'". Resolve the adapter
through `_adapter_for_source`, the profile-aware lookup the rest of the runner
uses, so multiplex secondaries deliver through their own bot rather than a
missing key on the default map.
Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Baophan00 <109447498+Baophan00@users.noreply.github.com>
* fix(cli): sessions export accepts a directory for single-file formats
`hermes sessions export --session-id X <dir>/` crashed with IsADirectoryError
because jsonl/html/trace opened the positional as a file while --help called it
an "output path" and md/qmd really do take a directory. An existing directory
(or one spelled with a trailing separator) now receives a default-named file
(`hermes_session_<id>.<fmt>`), and the help text spells out per-format what
OUTPUT means.
* fix(gateway): report the most recent status model
* fix(gateway): prefer active status model override
* fix(tui): report live compute-host model in status
* fix(gateway): /usage billing route follows the most recent model too
`_persisted_billing_route` (idle `/usage` account-limits lookup) was the last
reader of the lifetime-dominant route, so it queried the retired provider's
account after a switch. Point it at `get_recent_session_model_route` and
delete the dominant query, which no longer has a caller.
* fix: status falls back to the live agent before the first host frame; recent route ties break deterministically
Under turn isolation `session.status` passed agent=None and only the metadata
mirror's model/provider, so until the compute host sent its first frame the
mirror was empty and the TUI rendered "Model: (unknown) (unknown)" where main
showed the in-process agent's route. Fall back to the live agent's model and
provider like `server._session_info` already does.
`get_recent_session_model_route` ordered by `last_seen DESC` alone; two rows
stamped in the same flush tie and SQLite's temp-sort order is unspecified, so
the retired route could be reported as current. Order by `rowid DESC` as the
secondary key so the route that appeared later wins.
* fix(tui): reload.mcp refreshes every live session's tools, not just the requester's
The MCP pool is process-global but each agent snapshots `agent.tools` at build
time, so `/reload-mcp` from session A left session B's agent on the old tool
list until `/new` (losing its history); a request without a resolvable
`session_id` (desktop sends `activeSessionId ?? undefined`) refreshed zero agents
while still answering `reloaded`. After the pool rebuild, iterate every session
with a built agent under its own profile scope and push `session.info` to each.
Slim redo of PR #109383 by @nikkoxgonzales: the fan-out only, without the
mid-turn deferral, per-profile rediscovery loop and compute-host forwarding
changes that PR bundled.
Co-authored-by: nikkoxgonzales <nikkoxgonzales@gmail.com>
* fix: reload.mcp rediscovers under every live session's profile scope
`_do_full_reload` calls `shutdown_mcp_servers()` unscoped, which tears down
every profile's servers, but `discover_mcp_tools()` ran only under the launch
home. The all-sessions refresh then rebuilt a secondary-profile session's tool
snapshot under its own scope against a registry whose overlay was deregistered
and never rediscovered, so that session lost its MCP tools until its own
reload (main at least left its stale snapshot intact). After the pool rebuild,
rediscover once per distinct live `profile_home` under that profile's runtime
scope before refreshing the sessions.
* fix: don't flag auxiliary tasks using the 'main' provider alias as stale
Both stale-pin detections exempt only '' and 'auto':
- desktop persistentStaleAux banner (model-settings.tsx)
- switch-time stale_aux response (hermes_cli/web_server.py)
'main' is a backend-supported alias (auxiliary_client._normalize_aux_provider)
meaning "follow the active main provider", so aux slots pinned to it can
never be stale. The false positive fires for users following Moonshot's
official Hermes integration guide, which prescribes
auxiliary.vision.provider: main.
Exempt the alias in both places and add a regression test.
* test: main-alias aux pin is not stale in the backend switch report
Backend half of #97310 (the desktop banner has its own vitest case in the salvaged commit).
* fix(vision): advertise vision_analyze/browser_vision when the main model sees natively
check_vision_requirements only asked the auxiliary resolver, so a vision-capable
main model on a provider the resolver cannot serve (minimax-oauth, local vLLM,
anything uncatalogued) lost vision_analyze and browser_vision from the tool list
even though both handlers already route to the native fast path and work when
called. The image gate now accepts the native fast path OR an aux client; the
aux-only probe becomes check_video_requirements and stays on video_analyze,
whose handler has no native path.
Fixes #47149.
* chore: map tutan0558@users.noreply.github.com to @tutan0558 for contributor attribution
* fix(agent): return reasoning-only clean stops
* fix(agent): persist promoted clean-stop reasoning; pin the length negative
Follow-up to KoNit-K's commit: rebuild the promotion on the existing
`agent._extract_reasoning` helper (the same reader the ladder terminal and
`build_assistant_message` use) and write the promoted text back onto
`assistant_message.content` so the persisted assistant row carries the answer
as ordinary content. Without that the transcript tail was an assistant row with
empty content and only `reasoning`, which `drop_thinking_only_and_merge_users`
strips from the next request — the model would see its own answer vanish on a
"continue" turn.
Tests: trim to the two invariants (clean stop → one API call, persisted as
content; `finish_reason == "length"` → never promoted, continuation still
owns it) and keep the truly-empty terminal case. The prefill wire-payload
regression test now drives a non-clean-stop reasoning-only reply, which is the
only shape that still reaches the prefill rung.
* fix: treat a reasoning-only stream drop as a drop, not a clean stop
The text-only drop guard in _finish_chat_stream required content_parts,
so a stream that died while still emitting delta.reasoning (no
finish_reason, no usage) fell through to the synthesized "stop". With
the reasoning-only clean-stop promotion in finish_text_response that
stamped "stop" turned the truncated thought into the final answer,
where main entered the continuation ladder. Extend the guard with
reasoning_parts so the drop yields the partial-stream stub and the
ladder still runs; a real clean stop carries finish_reason="stop" and
is unaffected.
Review follow-up on #110227.
* fix(security): redact secrets from config file reads
* fix(redact): recognize quoted HERMES_HOME config reads
Keep the narrow basename allowlist, but do not treat $HERMES_HOME as an
unresolved path, and split pipelines only on unquoted |;&.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(redact): one secret-file predicate for .env, shell rc and Hermes config.yaml
Fold KoNit-K's `_command_reads_secret_bearing_file` and the pre-existing
`_command_reads_env_file` into a single `_command_reads_secret_file` so the
`code_file` gate in `redact_terminal_output` has one owner: `.env`-style
basenames and shell rc/profile files anywhere, `config.yaml` only under a
`.hermes` directory or `$HERMES_HOME` (arbitrary project YAML stays on the
code_file path). `grep`/`awk`/`sed` join the reader set instead of a second
table with a positional-argument special case: on a file read, any non-flag
operand that names a secret-bearing file is enough — the pattern/program
operand never matches a basename, so the extra rule bought nothing.
Tests: the negative parametrization now uses an opaque credential-shaped value
(the placeholder it used before would never have been masked on either path,
so the "stays unredacted" half proved nothing) and asserts the same value IS
masked under `cat .env` in the same test.
* fix: grep/awk/sed gate on file operands only; strip $HOME prefixes
Adding grep/awk/sed to _FILE_READ_COMMANDS made the PATTERN operand
participate in the secret-file predicate, so `grep .bashrc app.py` or
`grep -n .env src/settings.py` — reads of SOURCE files — ran the
ENV/YAML assignment pass and masked opaque values that main leaves
alone. Skip the first non-flag positional for the pattern-first
readers, as #109369 originally did, so only real file operands gate.
`cat $HOME/.hermes/config.yaml` was ungated because the `$` bail-out
fired before the `.hermes` segment was inspected; strip `$HOME/` and
`${HOME}/` like the HERMES_HOME prefixes.
Review follow-up on #110228.
* fix(gateway): guard auto migration service boundaries
* feat(gateway): let an install opt out of the automatic multiplex migration
`hermes update` folds an eligible multi-profile install onto one multiplexed
gateway on its own, and there is currently no way to say no. The only lever,
`gateway.multiplex_profiles: false`, is also the default: `_read_multiplex_flag`
returns `False` for "absent" and for an explicit `false` alike, so an operator
who has already decided to stay on per-profile gateways has no way to record
that decision. The migration runs again on the next update.
Add `gateway.auto_migrate` (bool, default `true`). Read from the default
profile's config, it gates the automatic path only:
- absent or `true` -> today's behaviour exactly, no change
- `false` -> `maybe_auto_migrate_after_update()` returns before
building a plan; no output, no changes
`hermes gateway migrate --multiplex` is an explicit request and still migrates
regardless of the flag, so it stays the supported way to opt back in.
One early return, one schema entry with the reasoning inline, one invariant
test (opt-out blocks the hook, absent/true do not, explicit command still
applies), one section in the multi-profile gateways guide.
* fix(migrate): hermes update refuses to fold cross-user / cross-scope gateways; auto_multiplex_migration opt-out
Reshape the two salvaged commits onto current main (#109954):
- Move the boundary guard out of the gateway_migrate facade into a new sibling
hermes_cli/gateway_migrate_guards.py as a table of guard functions
(_AUTO_MIGRATION_GUARDS: service domain, UNIX user, HERMES_HOME tree) plus the
identity resolver. The facade grows by ~20 lines only (uid/runtime_home on
ProfileGateway, one seam, the hook wiring).
- Compare uids, not strings: live pid owner via /proc (ps fallback only on
macOS, where /proc does not exist), else the system unit's User= via
_read_systemd_user_from_unit (root when absent), else the home directory's
owner. None means unknown and never blocks.
- The home-tree guard reads the HERMES_HOME the installed unit pins, not the
directory the plan enumerated: that is where the gateway really runs and is
exactly the "stale copies under profiles/" shape from the report.
- When the default is detached, a service-managed secondary is a different
domain for the AUTO path (it must not elect the secondary's manager); the
explicit command keeps electing it as before.
- The explicit command surfaces the same findings as notices (dry run shows
them) and is never blocked by them; only the update hook refuses.
- Rename the opt-out key to gateway.auto_multiplex_migration (nested only, no
top-level alias) and read it before a plan is built, so false prints nothing
and touches nothing. The explicit command ignores it.
- Tests trimmed to the invariants: one parametrized boundary test that exercises
the real hook end to end (refuses, touches nothing, dry run shows the notice),
one "same user / same scope still migrates" control, one opt-out test.
- Docs: boundary table + renamed opt-out section in multi-profile-gateways.md;
one line in hermes_cli/AGENTS.md.
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Athena <athena@olympus.local>
* fix(gateway): polish background process notifications
The raw-output watcher modes (all/result/error) and the interim running
update sent the bracketed debug wrapper with the internal process id
(`[Background process proc_… finished with exit code N~ Here's the final
output: …]`) to Telegram/Discord/Slack chats. Reuse the concise one-line
status header for every mode and append the bounded, ANSI-stripped output
tail in a code block; the running update gets the same shape.
Salvaged from #54266 (rebased onto the post-#102117 run_notifications
sibling; the concise mode had landed in between, so the header is shared
rather than reimplemented). Also covers #13122 (ANSI stripping).
* test(gateway): raw-output watcher messages are human-facing
One invariant over all/result/error + interim: status header, output
present, no proc_* id, no bracket wrapper, no ANSI. Red on main.
* fix(telegram): let an explicit TELEGRAM_REACTIONS beat the materialized YAML default
545e74d0ea made _reactions_enabled consult extra.reactions before the env
var, and _apply_yaml_config seeds extra["reactions"] whenever the YAML key
is present — including the stock reactions: false every install
materializes. The documented TELEGRAM_REACTIONS=true switch therefore
became a silent no-op after the 0.21.2 update (#109032), contradicting
yaml_env_setter's "explicit env wins over YAML" contract.
Read the scoped env first and fall back to the profile's own YAML: under
multiplex a scoped miss returns the default instead of another profile's
process-env value (#72348), so only a scoped/env hit counts as explicit
and per-profile isolation is unchanged.
Fixes #109032
(cherry picked from commit 2bd5a0a5c0a5f9630fd82f133def65f225f653a3)
* fix(matrix): scope MATRIX_RECOVERY_KEY_OUTPUT_FILE under multiplex profiles
#69090 scoped MATRIX_RECOVERY_KEY itself (via _scoped_recovery_key())
so a secondary profile resolves its own recovery key under multiplex,
but left its sibling, MATRIX_RECOVERY_KEY_OUTPUT_FILE, on a bare
os.getenv(). _recovery_key_output_path() is called from inside
_verify_or_bootstrap_cross_signing(), which runs fully inside
_profile_runtime_scope for a secondary profile: when that profile
bootstraps a new recovery key, it either doesn't get written to a
file at all, or gets written to the default profile's configured
path, depending on which one has the env var set.
Route it through the same _get_scoped_secret() helper _scoped_recovery_key()
already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit fb765ee49b2f1a1853e52fb901d25f767180a2fc)
* fix(weixin): scope split_multiline_messages under multiplex profiles
Every other WEIXIN_* tunable in this __init__ block (dm_policy,
group_policy, rate_limit_circuit_*, send_chunk_*) already reads
extra-first with a scoped-secret fallback via _extra_or_secret(). This
one field was missed and still fell back to a bare os.getenv(), so a
secondary profile without its own split_multiline_messages setting
silently inherited the default profile's process-env value instead of
the coded default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 44e3c0d5cf276a4ef78e676f2631fe89b11b5893)
* fix(a2a): scope A2A_PUBLIC_URL per multiplex profile
A2A_PORT and A2A_ADVERTISED_TOOLSETS are already captured at
construction time (inside _profile_runtime_scope) via
_get_scoped_secret(), but A2A_PUBLIC_URL was still read with a bare
os.getenv() inside A2ARequestHandler._request_public_url() - which
runs on ThreadingHTTPServer's per-connection OS thread, not the
constructing thread.
Raw threading.Thread never inherits contextvars, so even swapping the
reader to _get_scoped_secret() at that call site would not help: the
request thread has no scope, secret_scope falls back to os.environ
either way. The value must be captured once at construction time
(which does run in profile scope) and threaded through as instance
state instead - same fix shape as A2A_PORT above.
A secondary multiplex profile without its own A2A_PUBLIC_URL now
falls back to the X-Forwarded-Host/Host-derived URL (or the bind
host) instead of silently advertising the default profile's public
URL in its Agent Card / discovery response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 0c36aca5de53d88bbbc0b4cfceaed8307c744f7a)
* fix(discord): preserve transport owner for thread renames
(cherry picked from commit b3e23293ed8b0f6597d442579304ea5095a9029c)
* style(discord): trim rename comments
(cherry picked from commit c98bba083850baef0e0ba1d3844703fe8c795924)
* fix(platforms): adapter settings resolve explicit env → own YAML → default, per profile
One reader (gateway.platforms._shared.extra_or_secret) now implements the
precedence every per-profile setting follows for the OWNING profile:
explicit scoped env/.env → that profile's config.yaml (PlatformConfig.extra)
→ the adapter's default. A scoped miss returns the default, never the launch
process's os.environ; single-profile / default-profile installs keep the
documented env-over-YAML contract.
Why: 545e74d0eaf4 (#108705) stopped bridging a secondary's YAML into the
process env and moved readers to config.extra, but the shared reader and the
hand-rolled helpers in Discord/Slack/Matrix/Telegram consulted YAML FIRST and
then fell back to a scoped env read. Two bug classes followed (#108440
post-merge review by andrexibiza, #109032):
- an explicit env value could no longer beat YAML for the owning profile
(DISCORD_ALLOW_MENTION_EVERYONE=false lost to allow_mentions.everyone: true;
TELEGRAM_REACTIONS=true lost to the stock reactions: false);
- a secondary that OMITTED a key inherited the launch profile's bridged env
through the fallback (Matrix process_notices/session_scope, Discord
auto_thread/reactions/mentions, Slack reactions/ignored_channels).
Consumers migrated to the shared reader: Discord _build_allowed_mentions and
_extra_or_env_flag; Slack _slack_allow_bots, _reactions_enabled (the
_extra_or_env_* getters already used it); Matrix _extra_truthy, _extra_csv_set,
session_scope, reactions, require_mention parsers, and — new — the
allowed_users / ignore_user_patterns consumers that never read the seeded YAML
lists; Telegram _extra_bool, _extra_str_set, _reactions_enabled; Feishu
allow_bots; WhatsApp dm_policy/group_policy.
Refs #108440, #109032
* fix(telegram): a secondary profile's YAML proxy_url reaches request construction without an env bridge
545e74d0eaf4 correctly stopped writing telegram.proxy_url into TELEGRAM_PROXY
for a multiplexed secondary, but _build_ptb_requests still resolved the proxy
only from that env var, so the secondary silently connected direct (or via the
default's proxy). #100448 had deliberately left this bridge unscoped for that
reason; this finishes the consumer migration instead.
_apply_yaml_config seeds proxy_url into extra and resolve_proxy_url gains a
`configured` rung: scoped TELEGRAM_PROXY → the profile's YAML → HTTPS_PROXY/
HTTP_PROXY/ALL_PROXY (trust_env) → macOS system proxy, with NO_PROXY semantics
unchanged.
Refs #108440 (finding 6)
* fix(gateway): the central allow_bots grant honours a secondary profile's YAML policy
GatewayAuthorizationMixin._chat_scoped_grant read only the scoped
{PLATFORM}_ALLOW_BOTS env var, so a secondary whose config.yaml said
`allow_bots: all` was admitted by its own adapter and then denied centrally
(Discord, Slack Workflow posts with user=None, Feishu, Telegram). The gate now
resolves the routed adapter's effective policy with the same reader as intake:
scoped env → adapter YAML → none. Mention requirement and loop guard are
unchanged.
Refs #108440 (finding 7)
* fix(yuanbao): auto-sethome persists platforms.yuanbao.home_channel and updates the live config
For a multiplexed secondary the middleware wrote a top-level
YUANBAO_HOME_CHANNEL key that load_gateway_config never reads and skipped the
(correctly suppressed) process-env write, so cron and home-channel delivery had
no target in-process and none after a reload either. Persist through the
gateway's persist_home_channel (the profile-aware config path every /sethome
uses) and set the live PlatformConfig.home_channel; the process env is still
untouched under a secondary's scope.
Refs #108440 (ehz0ah inline, gateway/platforms/yuanbao.py)
* fix(whatsapp): bridge.js runs the adapter's effective dm_policy / allow_from, not the launch env's
_bridge_env copied os.environ (the default profile's WHATSAPP_* values under
multiplex) and only overlaid scoped hits, so a secondary with YAML
`dm_policy: pairing` launched its Node bridge under the default profile's
`allowlist` policy and the bridge rejected valid pairing DMs before Python saw
them. The child env now carries the values the adapter resolved (scoped env →
own YAML → default); a scoped miss removes the key rather than inheriting it.
Refs #108440 (ehz0ah inline, plugins/platforms/whatsapp/adapter.py)
* test(gateway): invariant tests for per-profile setting precedence and its consumers
Real loader + real adapter constructors under _profile_runtime_scope: a
secondary reads its own YAML lists/flags and never the launch env on a miss;
explicit env beats YAML for the owning profile; the central allow_bots gate
agrees with the adapter; Matrix YAML lists gate intake and approval; Yuanbao
home channel is live and reloadable; the WhatsApp bridge env carries the
secondary's policy. All eight cases red on origin/main.
* test: trim salvaged test additions to two invariants each
#109036 added six TELEGRAM_REACTIONS cases and #110111 five recovery-key-path
cases; keep the two contracts per fix (explicit env beats YAML; a scoped miss
returns the default) and drop the change-detector permutations.
* docs: state the per-profile setting precedence rule (env → own YAML → default)
Multi-profile guide gains the rule and the consumers it covers; the adapter
authoring guide and the Slack allow_bots page no longer claim YAML wins.
* fix(telegram): ignored_threads and mention_patterns read scoped env → own YAML like every sibling
Rebase reconciliation with main's JSON-allowlist decoding (#109423): the two
remaining readers that consulted config.extra before the env var now follow the
per-profile precedence rule (explicit scoped env → the profile's YAML → default),
and ignored_threads still decodes a JSON-string list after the read.
The Matrix blank-YAML test asserted YAML-over-env, the old precedence #108440's
review flagged; it now pins the contract: explicit env beats YAML, a blank env
value is unset (YAML applies), YAML beats the default, and an explicit empty
list is a real "no rooms" value.
* test(whatsapp): an explicit empty free_response_chats list is asserted without an explicit env value
Under env → YAML → default an explicit env CSV beats the YAML list; the test now
blanks the env (blank env = unset) before asserting that [] is a real 'no chats'
value.
* fix(goals): evict a registry-torn-down handle from _DB_CACHE
hermes profile delete calls hermes_state_registry.close_all_under(profile_dir)
before rmtree, which force-closes the shared handle goals.py cached for that
home. A same-name recreate in the long-lived dashboard process then reused the
closed object: save_goal swallowed the closed-db error and the replacement
state.db was never created. Drop the cache entry once the registry has torn
the handle down (it clears _shared_registry_owned at teardown) so the next
call acquires a live generation for the recreated profile.
* fix(tui_gateway): prompt.background side agent holds its own registry reference
e7136f1694db made the side agent persist into the parent's dedicated profile
store by handing it the parent's registry-held SessionDB object, without a
reference of its own. The parent releases that reference from AIAgent.close()
or a session reset; when it was the last holder the registry tore the
connection down under the still-running background turn and later bg_* writes
hit a closed handle (the #94736 emergency reopen at best). Acquire a separate
reference on the same file for the turn — the shape tools/delegate_tool
already uses for delegated children — and release it when the turn ends.
* fix(tui_gateway): foreign-profile pollers hand back events another profile's lineage owns
Every TUI session poller drains the one process-wide completion queue, but
e7136f1694db resolves compression lineage only in the dequeuing session's own
profile store. When profile B dequeued an event keyed on profile A's
compressed parent (A's original tab gone, its continuation live), B could
resolve nothing: belongs_elsewhere and owns were both false and
_notif_handle_event dropped the event permanently. Before returning "unowned",
ask the live sessions on other profile stores whether one of them provably
owns the event through its own lineage; if so it belongs elsewhere and is
requeued for that poller.
* fix(gateway): a profile named 'main' gets its own session namespace
`main` is a valid profile name (only hermes/default/test/tmp/root/sudo are
reserved), but _session_key_namespace mapped it to `agent:main` — the default
profile's namespace. Both profiles then built byte-identical keys: one routing
entry, one cached agent, and, since 75ae2859b9e3 pinned default-namespace
keys to the launch store, profiles/main's scoped sessions were written into
the ROOT state.db instead of profiles/main/state.db.
Key the `main` profile as `agent:main~` (`~` is outside the profile-id
alphabet, so the marked form cannot be any other profile's id) and give the
namespace slot one inverse, profile_from_session_key_namespace, used by the
store's key parser, _parse_session_key, the update-marker profile reader and
the profile-delete eviction prefix. Default keys stay byte-identical.
* fix(gateway): restore served-profile liveness when gateway.pid is gone
`live_default_gateway_pid()` (hermes_cli/gateway_multiplex_served.py) read only the
pid record, so it returned None for a gateway that is alive but has no gateway.pid.
Consumers of the helper then reported the gateway as down:
- `hermes -p <profile> cron list` printed "Gateway is not running" with "jobs won't
fire automatically" while the multiplexer was firing that profile's jobs
- `hermes -p <profile> status` dropped its "running (via the default-profile
multiplexer)" line
- `named_profile_served_by_running_multiplexer()` returned False for a profile the
live gateway serves
The rest of the liveness surface already handles a missing pid file: the
`runtime_pid_probe` seam of `resolve_gateway_liveness()` exists for "launch-service
gateways with no live PID file" (hermes_cli/profiles.py, hermes_cli/web_routers/),
and `hermes_cli/gateway_migrate._live_gateway_pid()` reads "pid file, then runtime
status". This probe was the one call site that never got either.
Read the pid record first, then the PID in `gateway_state.json` validated against the
process table, matching `_live_gateway_pid()`. A record naming a dead pid still
resolves to None, so a stopped gateway keeps reporting stopped and cron keeps warning.
Related to #99631.
* fix(gateway): served_profiles bind to a verified gateway identity, not bare PID existence
`live_default_gateway_pid()` trusted `gateway.pid` + `_pid_exists`, so a stale
default record whose PID an unrelated process had recycled kept its old
`served_profiles` authoritative: `hermes -p X gateway start` exited 78 and
`status` said "running via multiplexer" for a gateway long gone (review of
#108352, finding D). The salvaged #110167 fallback inherited the same bare
check for the pid-file branch.
One helper now answers "which live gateway owns this home?" for every reader:
`gateway.status.live_gateway_pid_for_home` = scoped `get_running_pid` (pid file
+ runtime lock, start-time reuse guard, live gateway command line, home match)
then `get_runtime_status_running_pid(..., expected_home=home)` (honours
`gateway_state` stopped/startup_failed). `gateway_multiplex_served`,
`gateway_migrate._live_gateway_pid` and the `hermes update` inventory's
gateway_state.json fallback (#109680: a `stopped` record + recycled PID
fabricated a phantom runtime, so the update exited partial) all route through
it. Tests that impersonated a gateway with this pytest PID now wear a gateway
command line instead of stubbing `_pid_exists`.
* fix(gateway): `--profile=ops` gateway is never matched as the default profile's
Both default-profile process matchers (`gateway.status._command_line_belongs_to_profile`
and `hermes_cli.gateway._scan_gateway_pids._matches_current_profile`) rejected a named
gateway with a substring test for `--profile ` / ` -p `, which the equals spelling the
CLI pre-parser accepts (`--profile=ops`) slipped past. The default home's identity check
then adopted that gateway's PID, and a default-profile `gateway stop` with no pid file
scanned the process table and could SIGTERM the named gateway (review of #108352,
finding E). Both sites now ask `profile_flag_value()`, the same tokenizer the named
branch already uses.
* fix(gateway): a single-profile gateway start clears an inherited served_profiles list
`write_runtime_status` re-stamps the previous writer's `gateway_state.json` in place and
only `_record_served_profiles` (multiplex on) ever wrote `served_profiles`, so a
multiplexer's list survived into a later non-multiplex run of the same home. Every
`hermes -p X` surface then kept treating X as served by that live default gateway: exit
78 on start/install, "running via the default-profile multiplexer" on status (review of
#108352, finding D, second half). The secondary-profile phase now writes an empty list
when multiplexing is off; an empty list is the authoritative "serves nobody else" the
readers already honour.
* fix(dashboard): resolve MCP probe ${VAR} refs against the requested profile's secret scope
The /api/mcp/servers/{name}/test endpoint reads config and probes with no
profile secret scope installed, so config.yaml's ${VAR} expansion
(_env_ref_lookup) and the probe's interpolation resolve against the
dashboard process's own os.environ — the default profile's values (or
nothing) on a shared remote dashboard. A secondary profile whose
credential comes only from an external secret source (Bitwarden/
1Password) never resolves and the probe sends the literal placeholder,
so the server answers 400 while a fresh profile-scoped CLI process
works (#109901).
Wrap both the config read and the probe in _config_profile_scope +
hydrate_profile_secret_sources + set_secret_scope so refs resolve
against the requested profile's .env plus its per-home hydrated secret
sources, matching the multiplexed turn path (#84079 semantics).
* fix(dashboard): every MCP router site that expands ${VAR} refs runs under the requested profile's secret scope
Follow-up to the #109930 salvage (#109901). The probe endpoint was the reported
site, but the same class covers every router path that expands a secondary
profile's `${VAR}` refs while only a home override is installed:
`GET /api/mcp/servers` (a `${VAR}` in `url` expanded from this process's env)
and the `/auth` config read, whose expanded entry is handed to the OAuth worker.
Hoist the PR's inline wrapper into one `_profile_secret_scope` context manager
(mirrors `_run_dashboard_mcp_oauth`'s wrapping) and use it at all three sites.
Policy unchanged: scope miss still falls through to os.environ outside
multiplexing; under multiplexing a miss is a miss, never another profile's value.
Tests: the salvaged probe test now uses monkeypatch.setenv (no raw os.environ
mutation); one invariant test for the list endpoint, red on origin/main.
* fix(gateway): format scoped MCP server names during reload
* fix(mcp): an adopting profile keeps its own trust policy for a shared MCP connection
Under gateway.multiplex_profiles a profile whose mcp_servers entry has the
same route and cr…
A multiplexed gateway (
gateway.multiplex_profiles: true) no longer lets one profile'sconfig.yamlbecome another profile's runtime policy: every YAML→env bridge, the Yuanbao auto-home write, the TERMINAL_* ambient latch, the write-guard path memo, theterminal.env_passthroughallowlist and the runner-level Slack ignored-channel guard now resolve per profile.Root cause (one sentence)
_load_secondary_profile_configloads each secondary's config inside_profile_runtime_scope, but everyapply_yaml_config_fnhook,bridge_core_env_settings, the Yuanbao auto-sethome, the one-shot TERMINAL_* bridge and several module memos wrote or froze process-global state (os.environ, module slots) from whichever profile ran first — first-writer-wins in both directions.Changes
gateway/platforms/_shared.py::yaml_env_setter— the single env-write shape for YAML→env bridges: env wins over YAML, and the write is skipped while a secondary profile's scope is active. Every hook (feishu, matrix, whatsapp, slack, dingtalk, discord, telegram, plusconfig_loader::bridge_core_env_settingsfor telegram/signalrequire_mention) now seeds its values into that profile'sPlatformConfig.extraand uses it.extraunder scope — matrix flags +session_scope, slack/telegram/discordreactions, discordauto_thread/history_backfill/approval_mentions/allow_mentions, feishuallow_bots, dingtalk/telegrammention_patterns, telegram_extra_bool, signalrequire_mention.AutoSetHomeMiddleware:YUANBAO_HOME_CHANNELno longer written toos.environfrom a secondary's DM (config.yaml under the scoped home stays the record); reads go through the scoped reader.tools/terminal_tool.py::_ensure_terminal_env_bridged,tui_gateway/prompt_turn.py): no ambient bridge under a home override; launch-profile turns bind their own terminal scope once multiplexing is active — cherry-picked from fix(tools/tui): stop multiplex ambient TERMINAL_* latch from poisoning launch profile (#107422) #107442.tools/file_tools_write_guards.py): home/config paths resolved per call instead of a process memo — cherry-picked from fix(file-safety): resolve HERMES_HOME/config per call so multiplex profiles don't poison the write guards (#107327) #107335.tools/env_passthrough.py: config allowlist keyed byhermes_home_key().gateway/run.py::_slack_ignored_channels_from_gateway_config: takes the source's routed adapter (itsextrais the secondary's own list) and reads the env fallback through_platform_gate_env.allow_botsseeded intoextraand read via_gate_raw— cherry-picked from fix(discord): apply YAML allow_bots to ingress policy #91198.website/docs/user-guide/multi-profile-gateways.md"What does not change".Validation
Live repro (
/tmp/mux_audit/fix-env-bridging/repro.py, tempHERMES_HOME+profiles/bot2, realload_gateway_config()under the secondary's scope, then the launch profile):TELEGRAM_REQUIRE_MENTION,MATRIX_ALLOWED_USERS,SLACK_ALLOW_BOTS,WHATSAPP_DM_POLICY,FEISHU_ALLOW_BOTS,DISCORD_AUTO_THREAD,SIGNAL_REQUIRE_MENTION, …)poisoned=[]extramatrix.session_scope,slack.ignored_channels,feishu.allow_bots, …)YUANBAO_HOME_CHANNELafter secondary DM'dm:b2-user'in process envTERMINAL_ENVfor launch profile (local) after secondary (docker) bridged first'docker''local'…/profiles/bot2env_passthroughunder profile B['FOO_DEFAULT']['FOO_B2']Negative case (single-profile / unscoped default load): all YAML values still bridged into env (
TELEGRAM_REQUIRE_MENTION=false,SLACK_ALLOW_BOTS=all,MATRIX_SESSION_SCOPE=room, …) — legacy precedence unchanged.Tests:
tests/gateway/test_multiplex_yaml_env_bridge_isolation.py,tests/gateway/test_multiplex_process_global_sinks.py(4 tests, all red on base, green with fix).scripts/run_tests.sh tests/gateway/→ 8389 passed, 0 failed;tests/plugins/ tests/tui_gateway/ tests/tools/→ 12074 passed; the 6tests/tools/failures (test_modal_snapshot_isolation,test_web_tools_config,test_delegate_timeout_cleanup,test_execution_flag_detection) reproduce identically on unmodifiedorigin/main.Credits
Co-authored-byhermes_cli/web_server_gateway.py) — different sink (child-process env forhermes -pactions), left for its own review; fix(tools): scope the terminal env bridge one-shot to the Hermes home #94206 (home-keyed TERMINAL latch) — superseded by the no-bridge-under-override approach.Fixes #80099
Fixes #107422
Fixes #107327
Infographic