Conversation
Once profile isolation is active (multiple gateway profiles / room->profile multiplexing), get_secret() fails closed outside an installed scope. The kanban auto-decompose tick fires from asyncio.to_thread with no per-turn scope, so decompose_task()'s auxiliary LLM call died in resolve_runtime_provider() with UnscopedSecretError before model selection — silently no-op'ing auto-decompose under multiplex every tick (the failure is swallowed by a broad except and only logged at debug). Same gap as the cron ticker (fdab380, merged today) hitting the identical UnscopedSecretError from the same thread-with-no-scope shape. Wrap _auto_decompose_tick in set_secret_scope(build_profile_secret_scope(...)) with a finally-reset, mirroring that fix exactly. Single-profile installs are unaffected (the scope is just the profile's own .env).
Related: mirrors the same-day cron ticker fix (fdab380) for the identical "background thread with no per-turn secret scope -> UnscopedSecretError under profile multiplex" shape. Same file as merged #50358 (live auto_decompose toggle) but a different bug. #36814 tracks a different auto-decompose silent-stall mechanism (claim/decompose ordering). Not a duplicate. |
|
Thanks for the focused regression fix. Current main still dispatches The proposed scoped Automated hermes-sweeper review. |
|
Field report — confirming this bug and validating the fix approach. Reproduction (production, multiplex gateway): with profile isolation active, kanban cards sat in Validation: applied the equivalent fix locally — wrapping the Tests: Thanks for the PR — this is the piece that unblocks multiplex kanban boards. Looking forward to the merge. |
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.
|
Thanks @srojk34 — you were the earliest (Jul 3) to report and fix the unscoped auto-decompose tick. The fix landed on main via #110226 (merge |
* 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…
Summary
get_secret()fails closed outside an installed scope (UnscopedSecretError)._auto_decompose_tick(the kanban auto-decomposer, enabled by default viakanban.auto_decompose: true) fires viaasyncio.to_threadwith no per-turn scope installed.decompose_task()'s auxiliary LLM call resolves credentials throughresolve_runtime_provider()→get_secret(), which raisesUnscopedSecretErrorbefore model selection under multiplex.except Exception: logger.exception(...); continueper-task, so auto-decompose silently no-ops every tick under multiplex with no user-visible error — it just looks like the feature stopped working.UnscopedSecretErrorfrom the same "background thread with no per-turn scope" shape.Fix
Wrap
_auto_decompose_tick's work inset_secret_scope(build_profile_secret_scope(get_hermes_home()))with afinally-reset, mirroring the cron fix exactly. Single-profile installs are unaffected — the installed scope is just the profile's own.env, same valueget_secretwould've read fromos.environanyway.Test plan
test_auto_decompose_tick_installs_secret_scope_under_multiplex, mirroring the cron regression test (def6d6fe1): drives_kanban_dispatcher_watcherthrough one real tick with mockedkanban_db/kanban_decompose/config, and asserts a secret scope is installed and a real.envsecret resolves duringdecompose_task, then is torn down afterward.gateway/kanban_watchers.pyfix makes the test fail with the exactUnscopedSecretErrorthis PR closes; passes with the fix applied.tests/gateway/test_kanban_watchers_mixin.py,test_kanban_notifier_watcher_dispatch_gate.py,test_kanban_auto_decompose_live.py,test_kanban_notifier.py,tests/hermes_cli/test_kanban_decompose.py,test_kanban_decompose_db.py— 42 passed.ty check gateway/kanban_watchers.py— identical diagnostic set before/after (16/16, verified viagit stashdiff).