Commit Graph

62 Commits

Author SHA1 Message Date
teknium1
23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00
kshitijk4poor
dcdbcb8a2b fix(env-loader): split source_supplied_names() out of secret_source_names()
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.
2026-09-15 11:03:39 +05:30
kshitijk4poor
0916191ca1 refactor(env-loader): one helper records source-supplied names; scope refresh never empties
_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.
2026-09-15 11:03:39 +05:30
John Paul Soliva
62b4488cb5 fix(cron): routed fires are multiplexed at the worker handoff; managed keys keep policy precedence
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)
2026-09-15 11:03:39 +05:30
John Paul Soliva
9d7de6c140 fix(cron): close three launch-residue leaks into a routed no_agent child
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)
2026-09-15 11:03:39 +05:30
John Paul Soliva
dbede34f6e fix(cron): a routed profile's cron fire in the desktop backend runs under multiplex semantics
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)
2026-09-15 11:03:39 +05:30
fangliquanflq
4a927e9a6e fix(gateway): revoke stale profile secret snapshots 2026-09-13 14:41:26 -07:00
fangliquanflq
d461b27dcd fix(gateway): clear stale profile secret snapshots 2026-09-13 14:41:26 -07:00
fangliquanflq
917cfbd740 fix(gateway): retry failed profile secret hydration 2026-09-13 14:41:26 -07:00
teknium1
93d0dba281 fix(profiles): HERMES_HOME-blind path sites resolve through hermes_constants
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.
2026-09-13 05:09:06 -07:00
teknium1
c849bc383a refactor(env): agent.secret_scope.load_env_file is the only .env tokenizer; six hand parsers collapse onto it
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.
2026-09-13 05:07:50 -07:00
Teknium
08830efd96 fix(secrets): secret-source re-pull no longer latches an empty snapshot or wipes sibling profiles
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
2026-09-10 18:13:16 -07:00
Teknium
580322ef1e fix(mcp): stdio MCP children get the routed profile's vault secrets, not the default's
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.
2026-09-10 18:12:39 -07:00
Bergmann89
2e24e06e55 fix(env): scope terminal config re-bridge to the true process profile
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.
2026-09-05 17:01:23 +05:30
Teknium
e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
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.
2026-09-03 09:44:26 -07:00
Teknium
20ef0d04fb refactor(hermes_cli): env_loader — hand-compact prose (AST-identical code), pack log/print call shapes 2026-09-03 00:01:12 -07:00
Teknium
f0b1d49d0f refactor(hermes_cli): join wrapped kanban call/arg lists; tighten env_loader guard comments (code AST-identical) 2026-09-02 21:55:16 -07:00
Teknium
6069479bef refactor(hermes_cli): compact env_loader comments/docstrings (AST-identical code), no behavior change 2026-09-02 20:36:46 -07:00
Teknium
5d4b97939e refactor(hclib): config — config/config_migrations/tools_config/toolset_* dispatch tables and dedupe 2026-09-02 14:42:18 -07:00
Teknium
0a6aa7cce1 fix(env_loader): log routed-scope dotenv skip once per home; port single-profile control test
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>
2026-09-02 06:19:03 -07:00
Lester Liang
1aa62ceb45 fix(security): isolate multiplex dotenv reloads 2026-09-02 06:19:03 -07:00
ducky_56789
a525bbed0e fix(update): avoid cryptography self-lock on Windows 2026-08-15 13:28:14 -07:00
fangliquanflq
97051703ae fix(updater): keep recovered retries native-safe 2026-08-15 03:54:07 -07:00
Halldrix
5618f5d5b3 fix(env_loader): use 'enabled is True' (explicit) instead of name whitelist
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
2026-08-15 01:55:22 -07:00
Halldrix
5a76c8a978 fix(update): lazy-import secrets backends + defer registry import — break Windows self-lock loop
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
2026-08-15 01:55:22 -07:00
Halldrix
abf4dfc1b8 fix(env_loader): defer secret_sources registry import until a source is enabled
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).
2026-08-15 01:55:22 -07:00
doncazper
85020f2238 fix(plugins): isolate ownership by profile 2026-08-12 19:13:32 -07:00
Paulo Nascimento
b76498ba07 fix(cli): strip UTF-8 BOM on latin-1 .env fallback path
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.
2026-08-08 12:32:23 -07:00
Paulo Nascimento
aa1fac980d fix(cli): read .env as utf-8-sig so a BOM doesn't drop the first key
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.
2026-08-08 12:32:23 -07:00
Jiahui-Gu
e471c7165e fix(env): make config.yaml authoritative for terminal.backend (#29186)
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>
2026-08-02 18:21:58 -07:00
Teknium
52308ff455 fix(env): seed .op.env bootstrap into cold-profile hydration
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.
2026-08-02 00:11:50 -07:00
Bao
6ab390a476 fix(gateway): hydrate cold profile secret sources 2026-08-02 00:11:50 -07:00
Teknium
fe5a718c4e fix(env): narrow startup env scrub to profile-managed ACP keys
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.
2026-08-01 16:42:51 -07:00
keepConcentration
61b2fa7937 fix(env): strip export prefix in dotenv key scan for cleanup (review fix) 2026-08-01 16:42:51 -07:00
keepConcentration
968b66338c fix(env): clear inherited Hermes keys missing from profile .env (ACP leak)
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'.
2026-08-01 16:42:51 -07:00
teknium1
fedd689d37 perf(config): one raw config.yaml parse per process instead of 3-4
Counted with an open()/read_text audit hook on a real 'hermes --version'
run: config.yaml was parsed 3x before load_config() even ran — once by
env_loader._load_secrets_config, once by main.py's early redact/ipv4
bridge (bespoke yaml.load), once by hermes_logging._read_logging_config.
Each raw parse is 1-3 ms with libyaml plus an open/stat — pure
duplication since all three want the same raw dict.

All three now route through read_raw_config()'s existing (mtime_ns,
size)-keyed shared cache:

- env_loader._load_secrets_config: uses the shared reader when reading
  the process HERMES_HOME (the cache key's home); other homes (profile
  seeding) keep the isolated direct parse. Parse-error isolation is
  preserved — the shared reader also swallows errors and returns {}.
- main.py early bridge: drops the bespoke yaml.load for read_raw_config
  (managed-scope overlay unchanged).
- hermes_logging._read_logging_config: prefers the shared reader,
  falls back to the direct fast_safe_load parse when hermes_cli.config
  isn't importable.

Measured: config.yaml opens per 'hermes --version' 3 -> 1.
348 targeted tests green (env_loader + secret sources + applied-homes +
bitwarden + hermes_logging + config).
2026-07-29 10:32:25 -07:00
Eugeniusz Gilewski
6fba781945 fix(config): preserve opaque .env values
The .env sanitizer inferred missing newlines from known KEY= substrings
inside existing values. Plain secrets containing those bytes could therefore
be split into synthetic assignments and rewritten to disk.

Treat each physical line as the only assignment boundary and keep bytes after
the first equals sign opaque for boundary discovery. Preserve safe formatting,
null-byte removal, BOM handling, and normal one-assignment-per-line parsing.

Cover direct loading, dotenv loading, sanitization, writers, and migration
with behavioral regressions.

Fixes #29155
2026-07-24 16:02:08 -07:00
izumi0uu
0583692c2d fix(secrets): scope BWS-injected provider keys
Snapshot values applied by external secret sources per resolved HERMES_HOME so a later profile cannot replace an earlier profile scope through shared os.environ.

Keep provider and credential-pool fallback reads on the active secret scope, and fail closed on unscoped multiplex reads.

Tests: scripts/run_tests.sh tests/test_env_loader_secret_sources.py tests/test_env_loader_op_bootstrap.py tests/agent/test_secret_scope.py tests/agent/test_credential_pool.py tests/tools/test_credential_pool_env_fallback.py tests/hermes_cli/test_xiaomi_provider.py tests/cron/test_run_one_job.py tests/hermes_cli/test_api_key_providers.py tests/gateway/test_multiplex_credential_isolation.py -q (395 passed)
2026-07-22 04:39:17 -07:00
Teknium
c7b0c0d35f fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056)
_apply_external_secret_sources() added the home to _APPLIED_HOMES before
loading config, so a malformed config.yaml, a missing secrets section, or
all-sources-disabled permanently disabled secret loading for the process
— even after the user fixed the config.  Long-lived processes (gateway)
never recovered without a restart.

Now the home is marked only after apply_all() actually ran with at least
one enabled source.  Fetch errors still mark the home (so import-time
load_hermes_dotenv() calls don't re-fetch and re-print the same failure
3-5x per startup); the cheap early-exit paths stay retryable.

Fixes #40597.
2026-07-22 03:20:25 -07:00
andrexibiza
fe5d0be6db fix(env): stop printing Bitwarden secret names 2026-07-22 03:19:58 -07:00
Teknium
d3b0e61429 feat(secrets): one-command token rotation + actionable startup errors for all secret sources (#68605)
* feat(secrets): one-command token rotation + actionable startup errors for all secret sources

When a Bitwarden machine-account token expired, users saw a raw Rust
error dump (invalid_client + Location: + backtrace hints) and the only
fix was manually editing .env or re-running the whole setup wizard.

- New `hermes secrets bitwarden token` / `hermes secrets onepassword
  token`: paste a new token (masked prompt or flag), the command probes
  the backend BEFORE persisting — a rejected token changes nothing; a
  good one is written to .env and the fetch caches are cleared.
- New optional SecretSource.remediation(kind, cfg) hook: startup
  warnings now print a '→ Run `hermes secrets <name> token`…' fix-it
  line after any fetch error, for bundled AND plugin sources (generic
  per-ErrorKind defaults in the ABC).
- bws stderr is summarized to its cause line (Location:/backtrace noise
  dropped) and invalid_client/invalid_grant/400 identity rejects are
  now classified AUTH_FAILED (was INTERNAL) with a plain-English
  explanation naming the token env var.
- op whoami probe accepts a candidate token so rotation validates the
  NEW credential, not the ambient one.

Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump.

* docs: fix MDX parse error in secret-source-plugin hook table

Escaped backticks around a <name> placeholder made MDX parse it as an
unclosed JSX tag, breaking the docs-site build.  Use a plain code span
instead.
2026-07-21 06:41:04 -07:00
Paulo Nascimento
90d3ba5be9 fix(cli): warn once per path for UTF-32 .env refuse-to-mangle
Hot-reload and multi-entry load_hermes_dotenv can hit the same UTF-32
file repeatedly; gate the refuse-to-mangle warning on a module-level
seen-set (house style: _WARNED_KEYS sibling) so logs are not spammed.
2026-07-18 19:01:10 -04:00
Paulo Nascimento
7d597cc5d4 fix(cli): sanitize UTF-16 .env without corrupting the first key
Notepad "Unicode" saves write UTF-16 with a BOM. The sanitizer decoded
those bytes as utf-8-sig with errors=replace, glued U+FFFD onto the first
key name, stripped NULs, and rewrote the mangled content permanently.

Sniff leading BOMs before any text decode (UTF-32 before UTF-16, because
UTF-32-LE's BOM starts with UTF-16-LE's FF FE). Decode UTF-16 correctly
and rewrite as clean UTF-8. Refuse UTF-32 (leave untouched + warning).
After errors=replace, do not persist a first line that starts with U+FFFD.

Does not touch _load_dotenv_with_fallback (#65124's surface).
2026-07-18 19:01:10 -04:00
Taylor H. Perkins
8a76de962f fix(secrets): make 1Password bootstrap token reliable outside systemd
The 1Password secret source resolves op:// references using
OP_SERVICE_ACCOUNT_TOKEN read from os.environ. Under systemd the gateway
gets that token via EnvironmentFile, but cron jobs, subprocesses, CLI
runs, macOS launchd, and Docker containers spawn fresh interpreters with
no inherited shell state — so they silently failed to resolve any
reference and fell back to empty strings.

Two patches close the gap, matching Bitwarden's reliability guarantees:

1. env_loader: auto-load ~/.hermes/.op.env after .env so the gitignored
   bootstrap token is available everywhere. override=False plus an
   explicit guard ensure it never clobbers a token already in env (e.g.
   from a systemd EnvironmentFile, which keeps precedence).

2. credential_pool: _get_env_prefer_dotenv() now prefers the resolved
   value in os.environ when .env still holds a raw op:// reference,
   instead of handing a URL to provider auth. Non-op:// values keep the
   existing .env-takes-precedence behaviour.

Also gitignore .op.env, document the three bootstrap-token options, and
add tests covering auto-load, no-override, and the resolved-vs-raw
precedence (plus regression guards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:58:07 -07:00
teknium1
2d16ec7fb7 feat(secrets): pluggable SecretSource interface + multi-source orchestrator
Introduces a first-class secret-source contract so password managers
(Bitwarden today, 1Password next, third-party vaults as plugins) plug
into one orchestrated startup path instead of each hardcoding into
env_loader.

- agent/secret_sources/base.py: SecretSource ABC (fetch-only contract:
  never raises, never prompts, sync with orchestrator-enforced timeout),
  shared ErrorKind taxonomy, FetchResult, run_secret_cli() minimal-env
  subprocess helper, API versioning for plugin compatibility.
- agent/secret_sources/registry.py: registration gating (name/scheme
  uniqueness, api_version, shape), apply_all() orchestrator owning
  precedence (mapped-beats-bulk, first-claim-wins, override_existing
  never crosses sources, protected bootstrap tokens), conflict warnings,
  per-var provenance, per-source wall-clock timeout.
- Bitwarden converted to a registered BitwardenSource (bulk shape);
  behavior unchanged, apply_bitwarden_secrets kept as legacy shim.
- env_loader._apply_external_secret_sources now drives the orchestrator;
  provenance labels resolve through registry (e.g. '(from 1Password)').
- PluginContext.register_secret_source() for external backends.
- secrets.sources optional ordering key in DEFAULT_CONFIG + example.
- tests/secret_sources/: 47 new tests incl. reusable conformance kit
  (SecretSourceConformance) that plugin authors run against their source.
2026-07-06 04:58:07 -07:00
Teknium
980622d0ec perf(startup): parse config + plugin manifests with libyaml CSafeLoader (#54486)
The startup config/manifest reads used PyYAML's pure-Python SafeLoader,
which is ~8x slower than the libyaml-backed CSafeLoader C extension.
config.yaml is parsed several times during launch (cli config, raw
config, early interface/redaction bridge, logging config) and every
plugin manifest is parsed once — all on the slow path.

Add utils.fast_safe_load (CSafeLoader-preferring, pure-Python fallback,
true drop-in for safe_load) and route the hot startup parse sites
through it: hermes_cli/config.py (config + manifest reads),
hermes_cli/plugins.py (manifest parse), env_loader, cli.load_cli_config,
hermes_logging, and the two pre-config early YAML bridges in main.py.

Behavior is identical (same restricted safe tag set); only speed changes.
safe_load calls on the startup path drop from ~79 to ~0, cutting the
YAML parse cost from ~0.9s to ~0.15s under profiling.

Adds tests/test_fast_safe_load.py asserting equivalence with safe_load
across input shapes, empty-doc falsiness, C-loader preference, and that
python/object tags are still rejected (safe, not full loader).
2026-06-28 15:38:39 -07:00
Ben
81a663abea feat(managed-scope): apply managed .env last with override
load_hermes_dotenv now loads the managed-scope .env after user/project .env
and external secret sources, with override=True, so managed env values beat
the user .env and any pre-existing shell export. Reuses the existing dotenv
fallback + credential-sanitization path. Fail-open: no managed dir/.env is a
no-op and any error is swallowed so managed scope never blocks startup.
2026-06-19 07:46:33 -07:00
Teknium
de76f4dbcf fix(secrets): only apply external secrets once per HERMES_HOME per process (#32271)
`load_hermes_dotenv()` is called at module-import time from cli.py,
hermes_cli/main.py, run_agent.py, trajectory_compressor.py, gateway/run.py,
tui_gateway/server.py, acp_adapter/entry.py, and a few others. Each call
triggered `_apply_external_secret_sources()`, which re-parsed config,
re-fetched from Bitwarden Secrets Manager (its own 300s cache mostly absorbed
this), re-ran the ASCII sanitization sweep, and reprinted

  Bitwarden Secrets Manager: applied N secret(s) (...)

to stderr. Users saw the status line 3-5x per CLI startup.

Guard the function with a process-level set of HERMES_HOME paths that have
already had external secrets applied. Subsequent calls for the same home_path
are no-ops. `reset_secret_source_cache()` lets tests (and any future
long-running consumer that wants to refresh after a config change) force a
re-pull.
2026-05-25 15:18:55 -07:00
Teknium
0219b0408a perf(cli): cut hermes startup 63% — flip head-to-head vs codex (#31968)
* perf(bitwarden): persist secret-fetch cache across CLI invocations

Every `hermes` invocation paid a ~380ms tax for `bws secret list` to
Bitwarden Secrets Manager because the existing cache was in-process only.
Back-to-back `hermes chat -q`, gateway-spawned agents, and cron-launched
runs all re-fetched.

Adds a disk-persisted L2 cache at `<hermes_home>/cache/bws_cache.json`
(mode 0600, never contains the access token — only the SHA-256
fingerprint prefix). Same TTL as the in-process cache. Read on miss,
write on bws success, ignored on key mismatch / corruption / expiry.

Measured on a startup profile:
  load_hermes_dotenv() cold: 372ms → warm (disk cache hit): 20ms

End-to-end `hermes --version` cold→warm: 666ms → ~295ms.

In a hermes-vs-codex benchmark across 11 single- and multi-turn tasks
(framework overhead = wall − llm − tool_exec, median over 3 trials):

  cohort               before    after    saved
  single-turn (median)  2.96s    2.31s   -0.65s
  multi-turn  (5-turn)  9.40s    8.95s   -0.45s (≈0.3s/turn)

Hermes now wins head-to-head on 6/11 tasks vs codex (was 4/11 before).
The remaining ~0.6s single-turn delta is mostly Python's own import
cost in hermes_cli.main, which is a separate optimization.

* perf(cli): lazy-load model catalog + dedupe config.yaml reads at startup

Two import-time wins on top of the bws disk-cache fix:

1. Lazy-load `hermes_cli.models._PROVIDER_MODELS` via PEP 562
   module-level `__getattr__`. The catalog is ~55ms of work that was
   eagerly imported on every CLI invocation (line 4557 `if not
   _is_termux_startup_environment(): from hermes_cli.models import
   _PROVIDER_MODELS`). Audit showed every internal call site already
   does its own function-local import; only test code reads
   `hermes_cli.main._PROVIDER_MODELS` as a module attribute, and
   __getattr__ keeps that working transparently. First access triggers
   the import once and caches the result on the module via
   `globals()[name] = ...`, so subsequent reads are dict lookups.

2. Dedupe the double config.yaml read in the top-of-module bootstrap.
   Previously: one raw yaml.safe_load for the `security.redact_secrets`
   bridge, then a separate full `load_config()` (with deep-merge) for
   `network.force_ipv4`. Both keys come from the same file. Merged
   into one raw yaml load.

Combined with the bws cache fix in the previous commit:

  hermes --version wall time:
    original (cold):           666 ms
    after bws fix (warm):      295 ms
    after lazy-load + dedupe:  228 ms   (-67 ms additional, -66% from original)

Tests:
  - tests/hermes_cli/test_api_key_providers.py: 173/173 pass
    (lazy __getattr__ correctly handles
     `from hermes_cli.main import _PROVIDER_MODELS`)
  - tests/test_ipv4_preference.py + tests/hermes_cli/test_redact_config_bridge.py +
    tests/agent/test_redact.py: 93/93 pass (dedupe preserves both bridges)
  - tests/test_bitwarden_secrets.py + env_loader tests: 49/49 pass
2026-05-25 03:06:39 -07:00
Hasan Ali
d7c5d5dee5 fix: avoid persisting borrowed credential secrets (#31416) 2026-05-25 00:32:08 -07:00