The #125530 guard let any load whose TARGET was the launch home through while a
routed override was active. Inside a foreign profile's turn that load ends in
_reapply_terminal_config_bridge(launch), whose read_raw_config()/load_config_readonly()
follow the override, so the ROUTED profile's terminal.cwd was bridged into the shared
TERMINAL_CWD and the launch profile's next turn and cron job ran in the other tenant's
workdir (tests/e2e/core/tenancy/test_two_tenant_gateway.py red on CI). Require both the
target and the active home to be the launch home; the launch profile's own scoped bodies
(override naming the launch home) still load, a routed profile's .env still never does.
tests/agent/test_skip_background_review.py: the fixture pointed HERMES_HOME at a named
profile dir that never existed and built an AIAgent inside it; that only worked because
setup_logging() used run_agent's import-time home. It now logs under the ACTIVE home, and
mkdir_under_hermes_home() refuses to materialize a missing named profile by design
(load_config()'s ensure_hermes_home() and SessionDB refuse the same home, #94590), so the
test creates the profile dir. No assertion changed.
The salvaged test wrote the live process home's .env; the replacement uses two tmp homes (launch loads, routed does not, launch loads again) under set_multiplex_active(True).
The multiplex guard in load_hermes_dotenv() skipped the dotenv load whenever
a routed profile-home override was active AND multiplexing was on, even when
the load's target home IS the process launch home. Under the desktop/serve
backend the launch profile's own scoped bodies bind an override naming the
launch home (tui_gateway.model_switch._profile_runtime_scope_tokens(None)),
so its .env never loaded: 'No .env file found. Using system environment
variables.' and launch-profile-only credentials (e.g. an OPENROUTER_API_KEY
backing fallback_providers) were invisible to the process, silently breaking
failover.
Restrict the skip to loads whose target is a FOREIGN (routed) profile home,
using the same override-immune launch-home comparator _reapply_terminal_config_bridge
already uses in this file. The routed profile's .env is still never copied
into the shared os.environ (isolation intent of 1aa62ceb45 preserved).
(cherry picked from commit 0c71a329f48195a5c242d09ca6e92bb1c2842640)
Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.
Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.
Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.
Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.
Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.
Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
restore_heartbeat_watches entered _profile_scope_for_source for every routed
session on every poll. Each entry hydrated the profile secret scope and rebuilt
the terminal policy, and both re-parsed the profile config.yaml from disk, so N
routed sessions cost 2N YAML parses per poll even though nothing changed.
- Group entries by resolved profile home and enter the scope once per group.
- Add utils.load_yaml_file_readonly (file_signature-keyed cache) and use it in
env_loader._load_secrets_config and terminal_scope.build_profile_terminal_scope,
which were both open()+fast_safe_load per scope entry. Present-but-unparseable
still fails closed: parse errors propagate and are never cached.
Measured on a 3-profile host: one _profile_runtime_scope enter/exit 1.80 ms -> 0.11 ms.
(cherry picked from commit c6b16629bd38799bbf166206c73a6140a9559a61)
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
A launcher that spawns `hermes dashboard` (Desktop shell, link-style
integrations) mints HERMES_DASHBOARD_SESSION_TOKEN into the child's
environment and keeps the same token for its own /api probes. Every
dotenv layer in load_hermes_dotenv() loads with override=True, so a
persisted HERMES_DASHBOARD_SESSION_TOKEN line in ~/.hermes/.env replaced
the injected token: the child authenticated with the persisted value and
the parent got HTTP 401 from its own child.
Treat the token as a spawn credential in the dotenv publisher: a value
that dotenv did not put into os.environ (tracked by _DOTENV_PUBLISHED)
is left alone, while a value an earlier pass published still reloads,
so .env edits and home switches behave as before. Other keys, including
the documented HERMES_DASHBOARD_PUBLIC_URL, keep .env-wins precedence.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: fangliquan <fangliquan@qq.com>
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
The per-home snapshot (`_SECRET_SOURCE_VALUES_BY_HOME`) deliberately also holds names the source
supplied but .env/shell WON (`skipped_existing`, #102041) — `build_profile_secret_scope` needs them.
Re-asserting the whole snapshot on a dotenv reload therefore froze a gap-filled or preserve-listed
name at its first-load value and a later .env edit lost, where a cold start would have honoured it.
`AppliedVar.authoritative` (= `override_existing` and not `preserve_existing`, computed where the
precedence is decided) feeds a per-home restore subset `_SECRET_SOURCE_RESTORE_BY_HOME`; the reload
re-asserts only that. The contributor's pre-load snapshot is dropped: nothing between the dotenv loads
writes the module dict, so reading the subset at the restore site is equivalent and skips the copy.
load_hermes_dotenv() snapshots the values that an external secret source
(Bitwarden, 1Password, ...) already resolved for THIS home before
load_dotenv(override=True) below can write a raw .env placeholder back
over them. After the dotenv loads, restore those values so BSM-resolved
secrets survive every reload (#74265).
Snapshot must come from the per-home get_secret_source_values(home_path)
and not from the process-global _SECRET_SOURCES map. The global map
records the latest source label for each key, but a value is only safe
to restore for the home that actually resolved it. In a multiplex
gateway, restoring from the global map would replace home B's freshly
loaded dotenv value with home A's external-secret snapshot, defeating
the per-HERMES_HOME isolation introduced in 0583692c2d (#74283).
Restore ordering follows get_secret_source_values()'s dict iteration
order so the protection is deterministic. Managed scope (applied last
by _apply_managed_env with override=True) still intentionally beats an
external-source value -- this only undoes the dotenv clobber.
Adds two focused regressions:
* test_multiplex_dotenv_value_not_clobbered_by_other_profile_external_secret
seeds home A's external snapshot and verifies home B's dotenv
value (a different key value) is preserved.
* test_multiplex_external_secret_survives_reload_for_own_home
proves the original #74265 protection still holds for the home
that actually resolved the secret, while a sibling home's dotenv
load does not clobber that home's value.
Scope: hermes_cli/env_loader.py, tests/test_env_loader_secret_sources.py.
Ported the useful logical behavior from the original PR onto the current
pinned-base architecture.
Gateway tests replace sys.modules["dotenv"] with a bare module exposing only
load_dotenv; gateway.run imports env_loader at import time, so the module-level
dotenv.main / dotenv.variables imports broke every one of those files in CI.
load_hermes_dotenv() runs per gateway turn, per cron fire, and from plugins /
MCP config / `hermes send`, each time re-applying .env with override. dotenv
interpolates `${VAR}` against the live os.environ, which already holds the
previous load's output, so `PATH=/x:${PATH}` gains one `/x:` per reload until
child spawns fail with E2BIG (#109902).
Resolve inside _load_dotenv_with_fallback — the one seam every caller goes
through — against a private copy of os.environ in which this process's own
earlier dotenv output is peeled back to the value the variable had before we
first published it. The record is one process-wide per-variable table, not a
per-home/per-project scope: os.environ is process-wide, so a gateway reload
(project .env) alternating with a cron reload (none), or home A then B, must
peel each other's output too. Only values that still hold exactly what we
published are peeled, so shell exports, the terminal config bridge, and
external secret sources changing a value between reloads are honoured, not
frozen. Layers within one load_hermes_dotenv share a pass so the project and
managed .env still build on the user .env as before.
Co-authored-by: Hukla <129692708+huklaa@users.noreply.github.com>
Co-authored-by: Sahilvishnaliya <141555468+salch-cred@users.noreply.github.com>
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
Widening secret_source_names() to include skipped_existing names silently changed
tools/mcp_tool_config.py::_build_safe_env, an untouched consumer that forwards every
returned name into MCP stdio child envs. That consumer wants only names a source
actually APPLIED (pre-stack semantics), so secret_source_names() goes back to
tuple(_SECRET_SOURCES).
The routed-child scrub in strip_launch_profile_env is the one site that must also see
names a source supplied but lost to a pre-existing process value, so it reads the new
source_supplied_names() accessor instead. tools/mcp_tool_config.py is byte-identical to
origin/main.
_hydrate_profile_secret_sources and _apply_external_secret_sources each
rebuilt the same "applied + skipped_existing" set and pushed it into
_SOURCE_SUPPLIED_NAMES; the routed-child scrub depends on both sites
agreeing, so give them one helper.
refresh_installed_secret_scope cleared the installed scope before
refilling it, which left a window where a concurrent reader of the same
fire saw no credentials at all. Update first, then pop the names the
rebuild no longer supplies; stale values still disappear.
Review findings on f5f88d5058. Three are defects the previous round introduced.
Managed keys were stripped as launch residue. Recording every dotenv load as
residue swept in the administrator-managed `.env`, which `_apply_managed_env`
applies LAST with override precisely so it beats the user's own `.env`. A
routed child then lost `ORG_POLICY_FLAG=managed-value` to the routed user's
`user-value`. Managed keys are now recorded separately, never enter the
residue set, and are re-applied over the routed scope in both child builders
(`scheduler_script`, the restart-safe handoff) so the child sees the same
precedence the launch process does. `kanban_db_dispatch` and
`scheduler_delivery` strip without any overlay, so for them the exclusion
alone is the guarantee; the test pins the case that exercises it — the same
key defined in both the user and the managed file.
Private hydration did not record supplied names. `_hydrate_profile_secret_sources`
now feeds `provenance` plus `skipped_existing` into the same ownership set the
process-global path uses; the provenance label map stays applied-only.
Removal cleanup cleared its marker before the fallible work. A raising
reload left the removed plugin's credential active with no retry, because the
next no-source discovery saw the flag already false. The marker is cleared
only after reset, reload and installed-scope refresh succeed.
Routed fire not multiplexed at the handoff. `run_one_job` enables the
context in `_install_fire_secret_scope`, which runs AFTER
`_launch_external_cron_worker`, so a routed desktop fire on the managed path
serialized `multiplex_active=False` and built the worker env with launch
residue and no scrub. The handoff now treats `routed_profile_fire()` as
multiplexed for exactly its own span; the worker re-establishes the state from
the payload as before.
Each fix was checked by reverting it and confirming its regression fails,
including the overlay half and the exclusion half of the managed fix
separately.
(cherry picked from commit 329cbd8963d68c45b425e95a5b11ade59f513960)
Review findings on d8c467f223, each reproduced through its production path.
Stale launch key. `strip_launch_profile_env` built its residue set from a
re-parse of the launch `.env`. A key removed or renamed in that file after
boot is still in `os.environ` with the old value (dotenv never unsets), and
the current file no longer names it, so it survived into the routed child.
`_load_dotenv_with_fallback` — the one chokepoint every dotenv load goes
through — now records the KEY names it put into the process env, additive for
the process lifetime (`launch_dotenv_keys()`), and the strip unions that record
with the current file.
Source name that lost to the process env. `_apply_external_secret_sources`
snapshots every name a source SUPPLIED (`provenance` + `skipped_existing`),
but `secret_source_names()` only exposed `_SECRET_SOURCES`, which is
provenance metadata and names applied values alone. A launch-profile source
that supplied `CUSTOM_VAULT_SECRET` while the process already had it was
therefore invisible to the scrub, and a routed child with an empty scope got
the launch value. Supplied names are tracked separately
(`_SOURCE_SUPPLIED_NAMES`) so the provenance labels stay honest, and
`secret_source_names()` returns the union.
Last plugin source removed. `_refresh_secret_sources_after_discovery`
returned before the cache reset and the installed-scope refresh whenever no
plugin source was enabled — and `discover_and_load(force=True)` unloads the
old registration first, so removing the final plugin source hit exactly that
return with the removed plugin's names still in the per-home snapshot and the
current scope. The manager now remembers that a discovery re-applied plugin
sources and, on the next discovery that finds none, reconciles once. A home
that never had a plugin source is still a no-op (pinned by the existing tests).
Regressions: the stale-key lifecycle and the skipped-existing case through
`_run_job_script` against a real routed child, and the removal case through
the manager. Each checked by reverting its fix and confirming the test fails.
(cherry picked from commit d464f5f6126a394cfb47937f683d3a5e2f141840)
The desktop backend ticks EVERY local profile's cron store from one process — its own docstring
says "like a multiplex gateway" (hermes_cli/web_server.py) — but never sets the process-global
multiplex flag, and cannot: its own chat turns are unscoped and would fail closed. Every
isolation in the tree keys on that flag — the guard that keeps a routed `.env` out of the shared
`os.environ`, `get_secret`'s fail-closed miss, passthrough resolution, the MCP and kanban
subprocess scrubs — so all of it was inert for a sibling profile's fire. Verified: a secondary
profile's API keys replaced the launch profile's in `os.environ` with `override=True` and stayed
there after the tick, and a scope miss read the launch profile's tokens (#107692).
Give multiplex mode a context-local counterpart. `set_multiplex_context` (agent/secret_scope.py)
is OR'd into `is_multiplex_active()`. `_profile_cron_scope` only MARKS a fire whose home is not
the process's own (`routed_profile_fire`, decided against `get_process_hermes_home()`, the
override-immune resolver); `_install_fire_secret_scope` in cron/scheduler.py installs the
profile's hydrated secret scope and, for a marked fire, the multiplex context — for exactly that
span, dropped again before the scope by `_reset_fire_secret_scope`. Multiplex semantics are
therefore never active in cron without a scope to read: `run_one_job`'s restart-safe handoff runs
before the body's scope and keeps today's semantics (its own scope is #107413 / #106050's seam,
left untouched so this composes with whichever lands). Every existing multiplex-keyed isolation
applies inside the routed fire with no per-site patching; the launch profile's own fires and the
backend's turns keep single-profile semantics; marker and override both reach the pool worker via
`copy_context()`. `get_secret` read the raw global in its miss branch; it now goes through
`is_multiplex_active()`. The dotenv guard keeps its pinned flag-only form (#77970).
Two consequences of suppressing the write are handled rather than left as regressions:
- a `no_agent` script's env is `os.environ.copy()`, which no longer carries the routed `.env`;
the runner overlays the installed scope onto the base BEFORE sanitizing, so the same scrub /
passthrough rules apply to those values and the parent process is never mutated;
- plugin secret sources are discovered on the fire's first agent build, after the scope froze,
and the post-discovery reload is hydrate-only under multiplex semantics; the refresh now folds
the values into the installed scope in place (`refresh_installed_secret_scope`, the pattern
`_publish_env_value` already uses for `.env` writes under multiplex).
And the profile's external secret sources are hydrated before the scope is frozen, the order
gateway/run.py and the external cron worker already use.
Tests pin each direction: the marker without the semantics before the scope, the semantics on and
off exactly with it, the marker reaching a copy_context worker; the process's own profile staying
single-profile; the restart-safe handoff's child env building without raising under a routed tick
with a passthrough key registered; a real child process receiving the routed values while
`os.environ` keeps the launch value; a source registered after the freeze reaching the fire
through the real PluginManager refresh. Reverting any one direction fails a distinct test.
(cherry picked from commit 2f87677425d2cca19286ac83bc45cab23e546669)
worktree_gc archived untracked files under ~/.hermes regardless of the active
profile or HERMES_HOME (and the Windows LOCALAPPDATA default); dashboard_procs'
remote-lock dir, both bot_mode `_default_home` copies, methods_bot_relay's
`_relay_root` (a hand copy of get_default_hermes_root's profiles/ strip) and
load_hermes_dotenv's default home all re-derived env-or-`~/.hermes` by hand and
so diverged from the platform default. Each now calls the canonical getter
with the intent it already had: get_hermes_home() where the active profile
matters (archive), get_process_hermes_home() where the process asset must stay
visible under a routed-profile override (locks, bot mode, startup .env),
get_default_hermes_root() for install-wide relay state.
Six independent line-parsers with three different quoting/comment semantics
read the same .env files: tools/skills_tool.load_env (strip("\"'"), no inline
comments), hermes_cli/managed_scope._parse_env (same, no export, no BOM),
web_server_cron._profile_env_value (plain utf-8, no BOM), profile_cmd
._env_file_has_key, env_loader._env_keys_defined_in_dotenv (utf-8, so a BOM'd
first key stayed "\ufeffKEY" and the dashboard profile scrub missed line 1),
mem0/_setup._prompt_api_key (startswith scan, no quote strip). The boundary
parsers (scrub key set, skill secret capture) therefore disagreed with the
parser that installs the profile scope.
Now every one is a 1-3 line forwarder onto load_env_file, and
hermes_cli.config.load_env is memo over it (public signature unchanged).
_parse_env_value moves next to its only caller in secret_scope.
load_env_file gains the same latin-1 fallback env_loader uses to install
into os.environ, so a mis-encoded file yields the same key set on both sides.
Managed .env keeps its fail-LOUD contract (decode error logs and ignores the
file) instead of load_env_file's fail-soft {}.
Behavior change: managed .env, skills_tool and mem0 setup now honour
`export`, quoted-value escapes and inline comments the way the profile scope
does; web_server_cron and the dashboard scrub tolerate a BOM.
Invariant test: a BOM'd/export/quoted/commented .env yields the same key set
via load_hermes_dotenv (installer), load_env_file (scope) and
_env_keys_defined_in_dotenv (scrub); fails with the old scrub parser.
Symptom (#102041): under a multiplex gateway the default profile's vault/1Password/
Bitwarden/plugin-sourced credentials vanished for the rest of the process after the first
cron fire or the post-discovery plugin refresh; with the key already in the process env
(systemd EnvironmentFile=) the scope was empty from boot. Every get_secret() read then
failed closed ("No usable credentials", every Telegram sender rejected).
Why: _apply_external_secret_sources marked the home applied after any real fetch, but only
snapshotted names in report.provenance — the NEWLY applied ones. On a re-apply the previous
apply's own write-back makes every key `skipped_existing`, so the snapshot latched to {} and
_hydrate_profile_secret_sources returned that empty snapshot forever. Separately,
reset_secret_source_cache() was process-wide, so one profile's cron re-pull dropped every
sibling's hydrated snapshot (1aa62ceb45 isolated the routed reload but not the reset).
Change:
- env_loader: snapshot every name a source supplied (provenance + skipped_existing) from
the home's effective environment, so a shadowed re-apply keeps the values it had.
- reset_secret_source_cache(hermes_home=None): optional per-home reset; global clear kept
for tests/config edits.
- cron per-fire re-pull and plugins._refresh_secret_sources_after_discovery reset + reload
only the home they resolve to.
- Two invariant tests (red on base) in tests/test_env_loader_secret_sources.py; docs note in
secret-source-plugin.md.
Reported-by: luochen1990
Addresses #102041
Under a multiplexed gateway, `_build_safe_env` forwarded `os.environ[name]` for
every name tagged in the process-global `_SECRET_SOURCES` map. That map is
filled by EVERY served profile's secret-source hydration, while `os.environ`
only ever holds the LAUNCH (default) profile's values — so once any profile's
1Password/Bitwarden source supplied e.g. GITHUB_TOKEN, every profile's stdio
MCP server was started with the default profile's token.
Resolve those names through the active profile's secret scope (`get_secret`)
instead: the routed profile's value, or omitted when that profile has none.
Under multiplex `get_secret` never falls through to environ; single-profile
runs keep the .env overlay + environ behaviour, so the existing "vault vars
reach MCP subprocesses" contract still holds there. `secret_source_names()`
exposes the tagged NAMES only — values are never read from the shared map.
Docs: the multi-profile guide's "MCP subprocesses only see their own profile's
secrets" claim is now true for source-injected names too; say so explicitly.
In a multiplex dashboard (one process serving every profile on the host),
a secondary profile's terminal.backend could leak into the shared
os.environ and silently override the launch profile's terminal backend.
A profile configured for terminal.backend: ssh would find itself running
every command locally because a sibling profile with backend: local had
polluted the shared environment.
The tell is an asymmetric env state on the launch session:
TERMINAL_ENV=local # leaked from a sibling profile
TERMINAL_SSH_HOST=10.10.0.103 # still the launch profile's, untouched
A restart does not help: as soon as the sibling profile is touched again
(a turn, a cron tick), the shared env is re-poisoned.
Root cause: env_loader._reapply_terminal_config_bridge() guards "only
re-bridge config into the shared os.environ when this load is for the
process's own profile" via _process_hermes_home(). That helper delegated
to get_hermes_home(), which follows the context-local
set_hermes_home_override a per-request task installs. When a per-turn
handler is scoped to a secondary profile and triggers a dotenv reload,
both sides of the comparison resolve to that secondary profile, the guard
passes, and the bridge writes the wrong profile's terminal.backend into
the shared environment. Because the secondary profile's config carries no
TERMINAL_SSH_* keys, only TERMINAL_ENV is overwritten, producing the
asymmetric state above.
The helper was introduced for the config-cache key (where following the
active home is correct) and later reused for the terminal bridge guard,
where the override-following semantics are wrong.
Fix: delegate _process_hermes_home() to get_process_hermes_home(), the
override-immune resolver built for exactly this. It reflects the scope the
process was launched under, ignoring per-task overrides. Both call sites
(the bridge guard and the secrets-cache reuse check) want the true process
home. Single-profile / CLI behaviour is unchanged: with no active
override the two resolvers are identical.
Complements the terminal_tool._active_environments session-key fix: that
scopes the per-session environment cache; this keeps the shared os.environ
the cache reads TERMINAL_ENV from uncontaminated in the first place.
Adds tests/hermes_cli/test_terminal_bridge_profile_scope.py covering both
the override-immune resolver and the no-leak reload behaviour.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
Follow-up to the #77592 salvage: emit a once-per-home debug line where the
multiplex guard skips the process-global dotenv load (requested on #77562),
and port the single-profile control test from #77970 so the guard is pinned
to the multiplex flag rather than the home override alone.
Co-authored-by: DonShelly <25538402+DonShelly@users.noreply.github.com>
Two upstream tests failed with the whitelist approach:
- test_real_plugin_source_discovery_applies_dotenv (plugin source named
HERMES_TEST_PLUGIN_BOOTSTRAP not in whitelist)
- test_external_secret_values_are_isolated_between_homes (test source
named test-source not in whitelist)
Fix: use 'v.get("enabled") is True' instead of 'v.get("enabled", True)'
on any dict value. This is stricter — only keys with an explicit
enabled: true pass — while remaining name-agnostic, so plugin and test
sources flow through without hardcoding a whitelist.
Refs #86781, #86782
Address review feedback from trevorgordon981 on PR #86782:
1. **Pre-register parsers at parse-time, lazy-import backends only**
- secrets_cli.register_cli() and onepassword_secrets_cli.register_cli()
now run eager at parser-build time (no deferral past parse_args)
- Only the agent.secret_sources.bitwarden/onepassword imports are lazy
(inside each cmd_* handler via _load_bitwarden()/_load_onepassword())
- This eliminates the 'invalid choice' and infinite-recursion risks
2. **Known-source-names gate for env_loader registry**
- Only keys in {bitwarden, onepassword, op, 1password, bw} trigger the
registry import; a generic dict entry no longer forces crypto load
- Prevents unrelated config dicts from paying crypto cost
3. **End-to-end tests for the real dispatch paths**
- test_bitwarden_setup_help: runs real CLI subprocess with --help
- test_bitwarden_status/disable/onepassword_status: run real handlers
- test_update_check_clean/no_self_lock: run real update --check
- test_update_check_no_cryptography: sys.modules inspection (backup)
4. **Fix flaky test_turn_lease.py** (unrelated pre-existing failure)
The architecture guarantees:
- parse-time: zero cryptography load (all backends lazy)
- dispatch-time: crypto loads exactly once per secrets command
- update path: completely clean of cryptography._rust mapping
Refs #86781, #86782
The env_loader eagerly imported agent.secret_sources.registry on every
startup, which loads agent/secret_sources/bitwarden.py and its
cryptography.* dependencies. On Windows, this causes the updater
process itself to map cryptography._rust.pyd before the self-lock
preflight runs, triggering a defer loop that blocks updates entirely.
Add an 'any_enabled' gate: scan the parsed config for actually-enabled
sources before paying for the registry import. A config with no enabled
sources costs one dict scan; a config with enabled sources pays the
crypto load exactly once, on demand.
Refs #86781, #83569, #83590
Test: 3 scenarios verified — main() clean, env_loader clean (no
enabled sources), env_loader loads crypto (enabled sources).
utf-8-sig only covers the primary decode. BOM + invalid UTF-8 (e.g.
PowerShell BOM + cp1252 body) forced latin-1, which kept EF BB BF as
part of the first key name and dropped the canonical name. Strip the
BOM before latin-1 decode and load via stream so override= is preserved.
PowerShell 5.1 Set-Content -Encoding UTF8 and Windows Notepad write a
UTF-8 BOM. load_dotenv(encoding="utf-8") kept U+FEFF on the first key
name, so the canonical name was absent from os.environ and Hermes looked
unconfigured with no error. utf-8-sig strips the BOM and is a no-op for
BOM-less UTF-8; latin-1 fallback unchanged.
A leftover TERMINAL_ENV in ~/.hermes/.env (written by `hermes setup` or
shell exports) was silently overriding terminal.backend in config.yaml,
so users switching from docker to local saw `hermes config show` agree
with their change while the gateway / cron / batch_runner still ran
against the old backend.
load_hermes_dotenv now re-applies config.yaml's terminal.* values on top
of whatever the .env files set, so the documented source of truth wins
for every entrypoint that goes through the loader.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
The salvaged hydrate_profile_secret_sources (#74549) seeded its
profile-local env from <home>/.env only, but the documented 1Password
bootstrap flow puts OP_SERVICE_ACCOUNT_TOKEN in the gitignored
<home>/.op.env (mirrored from load_hermes_dotenv). A cold profile using
that flow still failed 1Password hydration — the one unaddressed item
from the sweeper review on #74549. Seed .op.env via setdefault so .env
values win; never touches os.environ. Two regression tests.
The salvaged cleanup (#75197) scrubbed every known Hermes key absent from
the profile .env — deleting user-shell-exported credentials
(export OPENAI_API_KEY=...) on every hermes invocation, a documented flow
the author's own failing test_dump_flags_shell_only_key_not_in_dotenv
confirmed. A child process cannot distinguish shell exports from
parent-process leakage, so the scrub now covers ONLY
_PROFILE_MANAGED_ENV_KEYS (ACP routing keys: HERMES_ACP_*,
HERMES_COPILOT_ACP_*, COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) —
the vector from #75141. Cross-profile credential isolation is owned at
read time by agent.secret_scope.get_secret.
Adds shell-export survival regression + a scope-invariant test that fails
if the scrub set is ever widened toward credential-shaped keys.
Align load_hermes_dotenv() with reload_env() so known Hermes env vars
absent from the active profile .env are removed from os.environ instead
of leaking from a parent process / other profile.
Register ACP-related keys (HERMES_ACP_AUTH_METHOD, HERMES_COPILOT_ACP_*,
COPILOT_CLI_PATH, COPILOT_ACP_BASE_URL) in _EXTRA_ENV_KEYS so they
participate in known-key cleanup.
This is the same isolation gap class as #68367 / #66930, but:
- Not Desktop-only spawn scrub — CLI/gateway restart inheritance
- Not Matrix/messaging auto-enable only — copilot-acp provider/ACP config
- Startup dotenv clear so *any* inheritance path is covered
Example: HERMES_ACP_AUTH_METHOD=cursor_login leaking into a Claude Code
ACP profile caused authenticate -> Internal error -> Discord
'model provider failed after retries'.