2026-03-15 06:46:28 -07:00
|
|
|
|
"""Helpers for loading Hermes .env files consistently across entrypoints."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
2026-04-20 22:14:03 -07:00
|
|
|
|
import sys
|
2026-03-15 06:46:28 -07:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from dotenv import load_dotenv
|
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
|
|
|
|
from utils import atomic_replace, fast_safe_load
|
2026-03-15 06:46:28 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 17:17:15 -07:00
|
|
|
|
# Env var name suffixes that indicate credential values. These are the
|
|
|
|
|
|
# only env vars whose values we sanitize on load — we must not silently
|
|
|
|
|
|
# alter arbitrary user env vars, but credentials are known to require
|
|
|
|
|
|
# pure ASCII (they become HTTP header values).
|
|
|
|
|
|
_CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY")
|
|
|
|
|
|
|
2026-04-20 22:14:03 -07:00
|
|
|
|
# Names we've already warned about during this process, so repeated
|
|
|
|
|
|
# load_hermes_dotenv() calls (user env + project env, gateway hot-reload,
|
|
|
|
|
|
# tests) don't spam the same warning multiple times.
|
|
|
|
|
|
_WARNED_KEYS: set[str] = set()
|
|
|
|
|
|
|
feat(secrets): label detected credentials with their source (Bitwarden) (#30364)
When Bitwarden Secrets Manager supplies a provider key, 'hermes model'
and the setup wizard show 'credentials ✓' with no hint of where the
key came from — identical to the .env case. Users assume the integration
isn't wired up and re-enter the key (or hit Enter and cancel).
env_loader now tracks which env vars were injected by an external secret
source and exposes get_secret_source() / format_secret_source_suffix() so
the provider flows can render 'Anthropic credentials: sk-ant-... ✓
(from Bitwarden)' instead of an unlabeled checkmark.
Wired into _prompt_api_key (kimi, z.ai, minimax, opencode, ...), the
Anthropic provider flow, the Bedrock flow, and the GitHub Copilot token
display.
Future secret sources (Vault, 1Password, etc.) drop in by setting their
own label in _SECRET_SOURCES; format_secret_source_suffix() has a generic
fallback so no call sites need updating.
2026-05-22 03:32:58 -07:00
|
|
|
|
# Map of env-var name → source label ("bitwarden", etc.) for credentials
|
|
|
|
|
|
# that were injected by an external secret source during load_hermes_dotenv().
|
|
|
|
|
|
# Used by setup / `hermes model` flows to label detected credentials so
|
|
|
|
|
|
# users understand WHERE a key came from when their .env doesn't contain it
|
|
|
|
|
|
# directly (otherwise the "credentials detected ✓" line looks identical to
|
|
|
|
|
|
# the .env case and they don't know Bitwarden is wired up).
|
|
|
|
|
|
_SECRET_SOURCES: dict[str, str] = {}
|
|
|
|
|
|
|
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
|
|
|
|
# HERMES_HOME paths we've already pulled external secrets for during this
|
|
|
|
|
|
# process. ``load_hermes_dotenv()`` is called at module-import time from
|
|
|
|
|
|
# several hot modules (cli.py, hermes_cli/main.py, run_agent.py,
|
|
|
|
|
|
# trajectory_compressor.py, gateway/run.py, ...), so without this guard the
|
|
|
|
|
|
# Bitwarden status line gets printed 3-5x per startup. Bitwarden's own
|
|
|
|
|
|
# in-process cache prevents redundant network calls, but the print, the
|
|
|
|
|
|
# config re-parse, and the ASCII sanitization sweep still ran every time.
|
|
|
|
|
|
_APPLIED_HOMES: set[str] = set()
|
|
|
|
|
|
|
feat(secrets): label detected credentials with their source (Bitwarden) (#30364)
When Bitwarden Secrets Manager supplies a provider key, 'hermes model'
and the setup wizard show 'credentials ✓' with no hint of where the
key came from — identical to the .env case. Users assume the integration
isn't wired up and re-enter the key (or hit Enter and cancel).
env_loader now tracks which env vars were injected by an external secret
source and exposes get_secret_source() / format_secret_source_suffix() so
the provider flows can render 'Anthropic credentials: sk-ant-... ✓
(from Bitwarden)' instead of an unlabeled checkmark.
Wired into _prompt_api_key (kimi, z.ai, minimax, opencode, ...), the
Anthropic provider flow, the Bedrock flow, and the GitHub Copilot token
display.
Future secret sources (Vault, 1Password, etc.) drop in by setting their
own label in _SECRET_SOURCES; format_secret_source_suffix() has a generic
fallback so no call sites need updating.
2026-05-22 03:32:58 -07:00
|
|
|
|
|
|
|
|
|
|
def get_secret_source(env_var: str) -> str | None:
|
|
|
|
|
|
"""Return the label of the secret source that supplied ``env_var``, if any.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager
|
|
|
|
|
|
during the current process's ``load_hermes_dotenv()`` call. Returns
|
|
|
|
|
|
``None`` for keys that came from ``.env``, the shell environment, or
|
2026-05-25 03:32:08 -04:00
|
|
|
|
aren't tracked. The returned label is metadata only: credential-pool
|
|
|
|
|
|
persistence may store it to explain the origin of a borrowed secret, but
|
|
|
|
|
|
must never treat it as authorization to persist the raw value.
|
feat(secrets): label detected credentials with their source (Bitwarden) (#30364)
When Bitwarden Secrets Manager supplies a provider key, 'hermes model'
and the setup wizard show 'credentials ✓' with no hint of where the
key came from — identical to the .env case. Users assume the integration
isn't wired up and re-enter the key (or hit Enter and cancel).
env_loader now tracks which env vars were injected by an external secret
source and exposes get_secret_source() / format_secret_source_suffix() so
the provider flows can render 'Anthropic credentials: sk-ant-... ✓
(from Bitwarden)' instead of an unlabeled checkmark.
Wired into _prompt_api_key (kimi, z.ai, minimax, opencode, ...), the
Anthropic provider flow, the Bedrock flow, and the GitHub Copilot token
display.
Future secret sources (Vault, 1Password, etc.) drop in by setting their
own label in _SECRET_SOURCES; format_secret_source_suffix() has a generic
fallback so no call sites need updating.
2026-05-22 03:32:58 -07:00
|
|
|
|
"""
|
|
|
|
|
|
return _SECRET_SOURCES.get(env_var)
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
def reset_secret_source_cache() -> None:
|
|
|
|
|
|
"""Forget which HERMES_HOME paths have already had external secrets applied.
|
|
|
|
|
|
|
|
|
|
|
|
The first call to ``_apply_external_secret_sources(home_path)`` in a
|
|
|
|
|
|
process pulls from Bitwarden (or other configured backend), records the
|
|
|
|
|
|
applied keys in ``_SECRET_SOURCES``, and remembers ``home_path`` so
|
|
|
|
|
|
subsequent calls in the same process are no-ops. Call this to force the
|
|
|
|
|
|
next call to re-pull — useful for tests, and for long-running processes
|
|
|
|
|
|
that want to refresh after a config change.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_APPLIED_HOMES.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(secrets): label detected credentials with their source (Bitwarden) (#30364)
When Bitwarden Secrets Manager supplies a provider key, 'hermes model'
and the setup wizard show 'credentials ✓' with no hint of where the
key came from — identical to the .env case. Users assume the integration
isn't wired up and re-enter the key (or hit Enter and cancel).
env_loader now tracks which env vars were injected by an external secret
source and exposes get_secret_source() / format_secret_source_suffix() so
the provider flows can render 'Anthropic credentials: sk-ant-... ✓
(from Bitwarden)' instead of an unlabeled checkmark.
Wired into _prompt_api_key (kimi, z.ai, minimax, opencode, ...), the
Anthropic provider flow, the Bedrock flow, and the GitHub Copilot token
display.
Future secret sources (Vault, 1Password, etc.) drop in by setting their
own label in _SECRET_SOURCES; format_secret_source_suffix() has a generic
fallback so no call sites need updating.
2026-05-22 03:32:58 -07:00
|
|
|
|
def format_secret_source_suffix(env_var: str) -> str:
|
|
|
|
|
|
"""Return a human-readable suffix like ``" (from Bitwarden)"`` or ``""``.
|
|
|
|
|
|
|
|
|
|
|
|
Use this when printing a detected credential so the user can see where
|
|
|
|
|
|
it came from. Empty string when the credential came from ``.env`` or
|
|
|
|
|
|
the shell — those are the implicit / "default" cases users already
|
|
|
|
|
|
understand.
|
|
|
|
|
|
"""
|
|
|
|
|
|
source = get_secret_source(env_var)
|
|
|
|
|
|
if not source:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if source == "bitwarden":
|
|
|
|
|
|
return " (from Bitwarden)"
|
|
|
|
|
|
# Generic fallback — future-proofing for additional secret sources
|
|
|
|
|
|
# (e.g. 1Password, HashiCorp Vault) without having to update every
|
|
|
|
|
|
# call site.
|
|
|
|
|
|
return f" (from {source})"
|
|
|
|
|
|
|
2026-04-20 22:14:03 -07:00
|
|
|
|
|
|
|
|
|
|
def _format_offending_chars(value: str, limit: int = 3) -> str:
|
|
|
|
|
|
"""Return a compact 'U+XXXX ('c'), ...' summary of non-ASCII codepoints."""
|
|
|
|
|
|
seen: list[str] = []
|
|
|
|
|
|
for ch in value:
|
|
|
|
|
|
if ord(ch) > 127:
|
|
|
|
|
|
label = f"U+{ord(ch):04X}"
|
|
|
|
|
|
if ch.isprintable():
|
|
|
|
|
|
label += f" ({ch!r})"
|
|
|
|
|
|
if label not in seen:
|
|
|
|
|
|
seen.append(label)
|
|
|
|
|
|
if len(seen) >= limit:
|
|
|
|
|
|
break
|
|
|
|
|
|
return ", ".join(seen)
|
|
|
|
|
|
|
2026-04-14 17:17:15 -07:00
|
|
|
|
|
|
|
|
|
|
def _sanitize_loaded_credentials() -> None:
|
|
|
|
|
|
"""Strip non-ASCII characters from credential env vars in os.environ.
|
|
|
|
|
|
|
|
|
|
|
|
Called after dotenv loads so the rest of the codebase never sees
|
|
|
|
|
|
non-ASCII API keys. Only touches env vars whose names end with
|
|
|
|
|
|
known credential suffixes (``_API_KEY``, ``_TOKEN``, etc.).
|
2026-04-20 22:14:03 -07:00
|
|
|
|
|
|
|
|
|
|
Emits a one-line warning to stderr when characters are stripped.
|
|
|
|
|
|
Silent stripping would mask copy-paste corruption (Unicode lookalike
|
|
|
|
|
|
glyphs from PDFs / rich-text editors, ZWSP from web pages) as opaque
|
|
|
|
|
|
provider-side "invalid API key" errors (see #6843).
|
2026-04-14 17:17:15 -07:00
|
|
|
|
"""
|
|
|
|
|
|
for key, value in list(os.environ.items()):
|
|
|
|
|
|
if not any(key.endswith(suffix) for suffix in _CREDENTIAL_SUFFIXES):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
value.encode("ascii")
|
2026-04-20 22:14:03 -07:00
|
|
|
|
continue
|
2026-04-14 17:17:15 -07:00
|
|
|
|
except UnicodeEncodeError:
|
2026-04-20 22:14:03 -07:00
|
|
|
|
pass
|
|
|
|
|
|
cleaned = value.encode("ascii", errors="ignore").decode("ascii")
|
|
|
|
|
|
os.environ[key] = cleaned
|
|
|
|
|
|
if key in _WARNED_KEYS:
|
|
|
|
|
|
continue
|
|
|
|
|
|
_WARNED_KEYS.add(key)
|
|
|
|
|
|
stripped = len(value) - len(cleaned)
|
|
|
|
|
|
detail = _format_offending_chars(value) or "non-printable"
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" Warning: {key} contained {stripped} non-ASCII character"
|
|
|
|
|
|
f"{'s' if stripped != 1 else ''} ({detail}) — stripped so the "
|
|
|
|
|
|
f"key can be sent as an HTTP header.",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
" This usually means the key was copy-pasted from a PDF, "
|
|
|
|
|
|
"rich-text editor, or web page that substituted lookalike\n"
|
|
|
|
|
|
" Unicode glyphs for ASCII letters. If authentication fails "
|
|
|
|
|
|
"(e.g. \"API key not valid\"), re-copy the key from the\n"
|
|
|
|
|
|
" provider's dashboard and run `hermes setup` (or edit the "
|
|
|
|
|
|
".env file in a plain-text editor).",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
2026-04-14 17:17:15 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
|
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
|
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
|
2026-04-14 17:17:15 -07:00
|
|
|
|
# Strip non-ASCII characters from credential env vars that were just
|
|
|
|
|
|
# loaded. API keys must be pure ASCII since they're sent as HTTP
|
|
|
|
|
|
# header values (httpx encodes headers as ASCII). Non-ASCII chars
|
|
|
|
|
|
# typically come from copy-pasting keys from PDFs or rich-text editors
|
|
|
|
|
|
# that substitute Unicode lookalike glyphs (e.g. ʋ U+028B for v).
|
|
|
|
|
|
_sanitize_loaded_credentials()
|
2026-03-15 06:46:28 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 18:41:12 +08:00
|
|
|
|
def _sanitize_env_file_if_needed(path: Path) -> None:
|
|
|
|
|
|
"""Pre-sanitize a .env file before python-dotenv reads it.
|
|
|
|
|
|
|
|
|
|
|
|
python-dotenv does not handle corrupted lines where multiple
|
|
|
|
|
|
KEY=VALUE pairs are concatenated on a single line (missing newline).
|
|
|
|
|
|
This produces mangled values — e.g. a bot token duplicated 8×
|
|
|
|
|
|
(see #8908).
|
|
|
|
|
|
|
2026-05-20 23:06:58 +08:00
|
|
|
|
Also strips embedded null bytes which crash ``os.environ[k] = v``
|
|
|
|
|
|
with ``ValueError: embedded null byte`` — typically introduced by
|
|
|
|
|
|
copy-pasting API keys from terminals or rich-text editors.
|
|
|
|
|
|
|
2026-04-13 18:41:12 +08:00
|
|
|
|
We delegate to ``hermes_cli.config._sanitize_env_lines`` which
|
|
|
|
|
|
already knows all valid Hermes env-var names and can split
|
|
|
|
|
|
concatenated lines correctly.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import _sanitize_env_lines
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return # early bootstrap — config module not available yet
|
|
|
|
|
|
|
feat(cross-platform): psutil for PID/process management + Windows footgun checker
## Why
Hermes supports Linux, macOS, and native Windows, but the codebase grew up
POSIX-first and has accumulated patterns that silently break (or worse,
silently kill!) on Windows:
- `os.kill(pid, 0)` as a liveness probe — on Windows this maps to
CTRL_C_EVENT and broadcasts Ctrl+C to the target's entire console
process group (bpo-14484, open since 2012).
- `os.killpg` — doesn't exist on Windows at all (AttributeError).
- `os.setsid` / `os.getuid` / `os.geteuid` — same.
- `signal.SIGKILL` / `signal.SIGHUP` / `signal.SIGUSR1` — module-attr
errors at runtime on Windows.
- `open(path)` / `open(path, "r")` without explicit encoding= — inherits
the platform default, which is cp1252/mbcs on Windows (UTF-8 on POSIX),
causing mojibake round-tripping between hosts.
- `wmic` — removed from Windows 10 21H1+.
This commit does three things:
1. Makes `psutil` a core dependency and migrates critical callsites to it.
2. Adds a grep-based CI gate (`scripts/check-windows-footguns.py`) that
blocks new instances of any of the above patterns.
3. Fixes every existing instance in the codebase so the baseline is clean.
## What changed
### 1. psutil as a core dependency (pyproject.toml)
Added `psutil>=5.9.0,<8` to core deps. psutil is the canonical
cross-platform answer for "is this PID alive" and "kill this process
tree" — its `pid_exists()` uses `OpenProcess + GetExitCodeProcess` on
Windows (NOT a signal call), and its `Process.children(recursive=True)`
+ `.kill()` combo replaces `os.killpg()` portably.
### 2. `gateway/status.py::_pid_exists`
Rewrote to call `psutil.pid_exists()` first, falling back to the
hand-rolled ctypes `OpenProcess + WaitForSingleObject` dance on Windows
(and `os.kill(pid, 0)` on POSIX) only if psutil is somehow missing —
e.g. during the scaffold phase of a fresh install before pip finishes.
### 3. `os.killpg` migration to psutil (7 callsites, 5 files)
- `tools/code_execution_tool.py`
- `tools/process_registry.py`
- `tools/tts_tool.py`
- `tools/environments/local.py` (3 sites kept as-is, suppressed with
`# windows-footgun: ok` — the pgid semantics psutil can't replicate,
and the calls are already Windows-guarded at the outer branch)
- `gateway/platforms/whatsapp.py`
### 4. `scripts/check-windows-footguns.py` (NEW, 500 lines)
Grep-based checker with 11 rules covering every Windows cross-platform
footgun we've hit so far:
1. `os.kill(pid, 0)` — the silent killer
2. `os.setsid` without guard
3. `os.killpg` (recommends psutil)
4. `os.getuid` / `os.geteuid` / `os.getgid`
5. `os.fork`
6. `signal.SIGKILL`
7. `signal.SIGHUP/SIGUSR1/SIGUSR2/SIGALRM/SIGCHLD/SIGPIPE/SIGQUIT`
8. `subprocess` shebang script invocation
9. `wmic` without `shutil.which` guard
10. Hardcoded `~/Desktop` (OneDrive trap)
11. `asyncio.add_signal_handler` without try/except
12. `open()` without `encoding=` on text mode
Features:
- Triple-quoted-docstring aware (won't flag prose inside docstrings)
- Trailing-comment aware (won't flag mentions in `# os.kill(pid, 0)` comments)
- Guard-hint aware (skips lines with `hasattr(os, ...)`,
`shutil.which(...)`, `if platform.system() != 'Windows'`, etc.)
- Inline suppression with `# windows-footgun: ok — <reason>`
- `--list` to print all rules with fixes
- `--all` / `--diff <ref>` / staged-files (default) modes
- Scans 380 files in under 2 seconds
### 5. CI integration
A GitHub Actions workflow that runs the checker on every PR and push is
staged at `/tmp/hermes-stash/windows-footguns.yml` — not included in this
commit because the GH token on the push machine lacks `workflow` scope.
A maintainer with `workflow` permissions should add it as
`.github/workflows/windows-footguns.yml` in a follow-up. Content:
```yaml
name: Windows footgun check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.11"}
- run: python scripts/check-windows-footguns.py --all
```
### 6. CONTRIBUTING.md — "Cross-Platform Compatibility" expansion
Expanded from 5 to 16 rules, each with message, example, and fix.
Recommends psutil as the preferred API for PID / process-tree operations.
### 7. Baseline cleanup (91 → 0 findings)
- 14 `open()` sites → added `encoding='utf-8'` (internal logs/caches) or
`encoding='utf-8-sig'` (user-editable files that Notepad may BOM)
- 23 POSIX-only callsites in systemd helpers, pty_bridge, and plugin
tool subprocess management → annotated with
`# windows-footgun: ok — <reason>`
- 7 `os.killpg` sites → migrated to psutil (see §3 above)
## Verification
```
$ python scripts/check-windows-footguns.py --all
✓ No Windows footguns found (380 file(s) scanned).
$ python -c "from gateway.status import _pid_exists; import os
> print('self:', _pid_exists(os.getpid())); print('bogus:', _pid_exists(999999))"
self: True
bogus: False
```
Proof-of-repro that `os.kill(pid, 0)` was actually killing processes
before this fix — see commit `1cbe39914` and bpo-14484. This commit
removes the last hand-rolled ctypes path from the hot liveness-check
path and defers to the best-maintained cross-platform answer.
2026-05-08 12:57:33 -07:00
|
|
|
|
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
|
2026-04-13 18:41:12 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with open(path, **read_kw) as f:
|
|
|
|
|
|
original = f.readlines()
|
2026-05-20 23:06:58 +08:00
|
|
|
|
# Strip null bytes before _sanitize_env_lines so they never
|
|
|
|
|
|
# reach python-dotenv (which passes them to os.environ and
|
|
|
|
|
|
# crashes with ValueError).
|
|
|
|
|
|
stripped = [line.replace("\x00", "") for line in original]
|
|
|
|
|
|
sanitized = _sanitize_env_lines(stripped)
|
2026-04-13 18:41:12 +08:00
|
|
|
|
if sanitized != original:
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
fd, tmp = tempfile.mkstemp(
|
|
|
|
|
|
dir=str(path.parent), suffix=".tmp", prefix=".env_"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
|
|
|
|
f.writelines(sanitized)
|
|
|
|
|
|
f.flush()
|
|
|
|
|
|
os.fsync(f.fileno())
|
refactor: consolidate symlink-safe atomic replace into shared helper
Extract the islink/realpath guard from the 16743 fix into a single
atomic_replace() helper in utils.py, then migrate every os.replace()
call site in the codebase to use it.
The original PR #16777 correctly identified and fixed the bug, but
only patched 9 of ~24 call sites. The same bug class (managed
deployments that symlink state files silently losing the link on
every write) still existed at auth.json, sessions file, gateway
config, env_loader, webhook subscriptions, debug store, model
catalog, pairing, google OAuth, nous rate guard, and more.
Rather than add another 10+ copies of the same three-line guard,
consolidate into atomic_replace(tmp, target) which:
- resolves symlinks via os.path.realpath before os.replace
- returns the resolved real path so callers can re-apply permissions
- is a drop-in replacement for os.replace at the use sites
Changes:
- utils.py: new atomic_replace() helper + atomic_json_write /
atomic_yaml_write now call it instead of inlining the guard
- 16 files: all os.replace() call sites migrated to atomic_replace()
- agent/{google_oauth, nous_rate_guard, shell_hooks}.py
- cron/jobs.py
- gateway/{pairing, session, platforms/telegram}.py
- hermes_cli/{auth, config, debug, env_loader, model_catalog, webhook}.py
- tools/{memory_tool, skill_manager_tool, skills_sync}.py
Tests: tests/test_atomic_replace_symlinks.py pins the invariant for
atomic_replace + atomic_json_write + atomic_yaml_write, covers plain
files, first-time creates, broken symlinks, and permission preservation.
Refs #16743
Builds on #16777 by @vominh1919.
2026-04-28 04:51:38 -07:00
|
|
|
|
atomic_replace(tmp, path)
|
2026-04-13 18:41:12 +08:00
|
|
|
|
except BaseException:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(tmp)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # best-effort — don't block gateway startup
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
def load_hermes_dotenv(
|
|
|
|
|
|
*,
|
|
|
|
|
|
hermes_home: str | os.PathLike | None = None,
|
|
|
|
|
|
project_env: str | os.PathLike | None = None,
|
|
|
|
|
|
) -> list[Path]:
|
|
|
|
|
|
"""Load Hermes environment files with user config taking precedence.
|
|
|
|
|
|
|
|
|
|
|
|
Behavior:
|
|
|
|
|
|
- `~/.hermes/.env` overrides stale shell-exported values when present.
|
|
|
|
|
|
- project `.env` acts as a dev fallback and only fills missing values when
|
|
|
|
|
|
the user env exists.
|
|
|
|
|
|
- if no user env exists, the project `.env` also overrides stale shell vars.
|
|
|
|
|
|
"""
|
|
|
|
|
|
loaded: list[Path] = []
|
|
|
|
|
|
|
|
|
|
|
|
home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
|
|
|
|
|
user_env = home_path / ".env"
|
|
|
|
|
|
project_env_path = Path(project_env) if project_env else None
|
|
|
|
|
|
|
2026-04-13 18:41:12 +08:00
|
|
|
|
# Fix corrupted .env files before python-dotenv parses them (#8908).
|
|
|
|
|
|
if user_env.exists():
|
|
|
|
|
|
_sanitize_env_file_if_needed(user_env)
|
2026-04-22 10:10:46 +03:00
|
|
|
|
if project_env_path and project_env_path.exists():
|
|
|
|
|
|
_sanitize_env_file_if_needed(project_env_path)
|
2026-04-13 18:41:12 +08:00
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
if user_env.exists():
|
|
|
|
|
|
_load_dotenv_with_fallback(user_env, override=True)
|
|
|
|
|
|
loaded.append(user_env)
|
|
|
|
|
|
|
|
|
|
|
|
if project_env_path and project_env_path.exists():
|
|
|
|
|
|
_load_dotenv_with_fallback(project_env_path, override=not loaded)
|
|
|
|
|
|
loaded.append(project_env_path)
|
|
|
|
|
|
|
2026-05-21 14:10:34 -07:00
|
|
|
|
_apply_external_secret_sources(home_path)
|
2026-06-18 14:08:51 +10:00
|
|
|
|
_apply_managed_env()
|
2026-05-21 14:10:34 -07:00
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
return loaded
|
2026-05-21 14:10:34 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 14:08:51 +10:00
|
|
|
|
def _apply_managed_env() -> None:
|
|
|
|
|
|
"""Apply the managed-scope .env last, with override, so it beats user/shell.
|
|
|
|
|
|
|
|
|
|
|
|
Managed scope is machine-global (independent of HERMES_HOME / profile). v1
|
|
|
|
|
|
enforcement is "applied last with override=True" — at the end of startup load
|
|
|
|
|
|
``os.environ`` holds the managed value for every managed key, beating both the
|
|
|
|
|
|
user ``.env`` and any pre-existing shell export. This deliberately inverts the
|
|
|
|
|
|
usual env-over-config precedence for the pinned keys (see
|
|
|
|
|
|
``docs/design/managed-scope.md`` §4.1).
|
|
|
|
|
|
|
|
|
|
|
|
This does NOT prevent the agent from later mutating ``os.environ`` in-process
|
|
|
|
|
|
or ``export``-ing in a subprocess shell; that hard boundary is a documented
|
|
|
|
|
|
v2 item (design §8.1). v1 relies on filesystem permissions only.
|
|
|
|
|
|
|
|
|
|
|
|
Fail-open: a missing managed dir or .env is the common case and a no-op; any
|
|
|
|
|
|
error here is swallowed so managed scope can never block startup.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli import managed_scope
|
|
|
|
|
|
|
|
|
|
|
|
managed_dir = managed_scope.get_managed_dir()
|
|
|
|
|
|
except Exception: # noqa: BLE001 — managed scope must never block startup
|
|
|
|
|
|
return
|
|
|
|
|
|
if managed_dir is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
managed_env = managed_dir / ".env"
|
|
|
|
|
|
if not managed_env.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
_sanitize_env_file_if_needed(managed_env)
|
|
|
|
|
|
_load_dotenv_with_fallback(managed_env, override=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 14:10:34 -07:00
|
|
|
|
def _apply_external_secret_sources(home_path: Path) -> None:
|
|
|
|
|
|
"""Pull secrets from external sources (currently Bitwarden) into env.
|
|
|
|
|
|
|
|
|
|
|
|
Runs AFTER dotenv loads so .env values are visible (we use them to
|
|
|
|
|
|
locate the access token) but BEFORE the rest of Hermes reads
|
|
|
|
|
|
``os.environ`` for credentials. Any failure here is logged and
|
|
|
|
|
|
swallowed — external secret sources must never block startup.
|
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
|
|
|
|
|
|
|
|
|
|
Idempotent within a process: subsequent calls for the same
|
|
|
|
|
|
``home_path`` are no-ops. ``load_hermes_dotenv()`` runs at import
|
|
|
|
|
|
time from several hot modules (cli.py, hermes_cli/main.py,
|
|
|
|
|
|
run_agent.py, trajectory_compressor.py, ...), so without this guard
|
|
|
|
|
|
the Bitwarden status line would print 3-5x per CLI startup. Use
|
|
|
|
|
|
``reset_secret_source_cache()`` if you need to force a re-pull
|
|
|
|
|
|
(tests, future ``hermes secrets bitwarden sync`` from a long-running
|
|
|
|
|
|
process).
|
2026-05-21 14:10:34 -07:00
|
|
|
|
"""
|
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
|
|
|
|
home_key = str(Path(home_path).resolve())
|
|
|
|
|
|
if home_key in _APPLIED_HOMES:
|
|
|
|
|
|
return
|
|
|
|
|
|
_APPLIED_HOMES.add(home_key)
|
|
|
|
|
|
|
2026-05-21 14:10:34 -07:00
|
|
|
|
try:
|
|
|
|
|
|
cfg = _load_secrets_config(home_path)
|
|
|
|
|
|
except Exception: # noqa: BLE001 — config errors must not block startup
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
bw_cfg = (cfg or {}).get("bitwarden") or {}
|
|
|
|
|
|
if not bw_cfg.get("enabled"):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from agent.secret_sources.bitwarden import apply_bitwarden_secrets
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
result = apply_bitwarden_secrets(
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
access_token_env=bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"),
|
|
|
|
|
|
project_id=bw_cfg.get("project_id", ""),
|
|
|
|
|
|
override_existing=bool(bw_cfg.get("override_existing", False)),
|
|
|
|
|
|
cache_ttl_seconds=float(bw_cfg.get("cache_ttl_seconds", 300)),
|
|
|
|
|
|
auto_install=bool(bw_cfg.get("auto_install", True)),
|
2026-05-24 02:19:57 -07:00
|
|
|
|
server_url=str(bw_cfg.get("server_url", "") or "").strip(),
|
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
|
|
|
|
home_path=home_path,
|
2026-05-21 14:10:34 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if result.applied:
|
|
|
|
|
|
# Re-run the ASCII sanitization pass: BSM values are user-supplied
|
|
|
|
|
|
# and might have the same copy-paste corruption as a manually
|
|
|
|
|
|
# edited .env (see #6843).
|
|
|
|
|
|
_sanitize_loaded_credentials()
|
feat(secrets): label detected credentials with their source (Bitwarden) (#30364)
When Bitwarden Secrets Manager supplies a provider key, 'hermes model'
and the setup wizard show 'credentials ✓' with no hint of where the
key came from — identical to the .env case. Users assume the integration
isn't wired up and re-enter the key (or hit Enter and cancel).
env_loader now tracks which env vars were injected by an external secret
source and exposes get_secret_source() / format_secret_source_suffix() so
the provider flows can render 'Anthropic credentials: sk-ant-... ✓
(from Bitwarden)' instead of an unlabeled checkmark.
Wired into _prompt_api_key (kimi, z.ai, minimax, opencode, ...), the
Anthropic provider flow, the Bedrock flow, and the GitHub Copilot token
display.
Future secret sources (Vault, 1Password, etc.) drop in by setting their
own label in _SECRET_SOURCES; format_secret_source_suffix() has a generic
fallback so no call sites need updating.
2026-05-22 03:32:58 -07:00
|
|
|
|
# Remember where these came from so the setup / `hermes model`
|
|
|
|
|
|
# flows can label detected credentials with "(from Bitwarden)" —
|
|
|
|
|
|
# otherwise users see "credentials ✓" with no hint that the value
|
|
|
|
|
|
# came from BSM rather than .env.
|
|
|
|
|
|
for name in result.applied:
|
|
|
|
|
|
_SECRET_SOURCES[name] = "bitwarden"
|
2026-05-21 14:10:34 -07:00
|
|
|
|
print(
|
|
|
|
|
|
f" Bitwarden Secrets Manager: applied {len(result.applied)} "
|
|
|
|
|
|
f"secret{'s' if len(result.applied) != 1 else ''} "
|
|
|
|
|
|
f"({', '.join(sorted(result.applied))})",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result.error:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" Bitwarden Secrets Manager: {result.error}",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
for warn in result.warnings:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" Bitwarden Secrets Manager: {warn}",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_secrets_config(home_path: Path) -> dict:
|
|
|
|
|
|
"""Read just the ``secrets:`` section out of config.yaml.
|
|
|
|
|
|
|
|
|
|
|
|
Imported lazily and isolated from the main config loader so a
|
|
|
|
|
|
malformed config can't take down dotenv loading entirely.
|
|
|
|
|
|
"""
|
|
|
|
|
|
config_path = home_path / "config.yaml"
|
|
|
|
|
|
if not config_path.exists():
|
|
|
|
|
|
return {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
import yaml # type: ignore
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
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
|
|
|
|
data = fast_safe_load(f) or {}
|
2026-05-21 14:10:34 -07:00
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
return {}
|
|
|
|
|
|
return data.get("secrets") or {}
|