install.ps1 had three related problems that compounded into `hermes dashboard`
failing to boot on Windows with 'No module named fastapi':
1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11,
which is what `irm | iex` runs under) reads files without a BOM as
cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1
mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1,
PS 7, and the in-memory `irm | iex` path all read the file identically.
2. `uv pip install -e .[all]` had a single-tier silent fallback to bare
`.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any
transient extras install failure (network hiccup, wheel build issue,
etc.) would drop every optional extra including [web], and the installer
would still print 'Main package installed'. Replaced with a four-tier
fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that
prints output at every step and a targeted [web] verify+repair at the
end so `hermes dashboard` specifically is never silently broken.
3. tinker-atropos was installed unconditionally after the main install.
tinker-atropos/pyproject.toml pulls atroposlib and tinker from
git+https://github.com/... which can fail on locked-down networks,
flaky DNS, or rate-limited github.com and would half-install the venv.
install.sh already skipped it by default with a one-liner for users
who actually do RL training — install.ps1 now matches that behavior.
Parse-checked clean under Windows PowerShell 5.1.26100.8115
(5318 tokens, 0 parse errors).
scripts/install.sh runs 'npx playwright install --with-deps chromium'
on every Linux distro after the npm-install step, which is why browser
tools Just Work on Linux. scripts/install.ps1 never did the equivalent
step, so on native Windows installs check_browser_requirements() in
tools/browser_tool.py would return False (no Chromium under
%LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently
filtered out of the agent's tool schema — no error, no log entry, user
just wondered why the tools didn't exist.
Two-part fix:
1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run
'npx playwright install chromium'. Resolves npx via the same
execution-policy-aware logic already used for npm (prefer npx.cmd
next to npmExe, fall back to Get-Command). Surfaces a warning +
manual-recovery hint when the install fails, matching install.sh
behaviour for distros.
2. hermes_cli/doctor.py: after the agent-browser check, lazily import
tools.browser_tool and reuse the exact same _chromium_installed()
predicate check_browser_requirements() uses, so the doctor signal
cannot drift from the runtime gate. Skip the check when Camofox /
CDP override / a cloud provider / Lightpanda is configured (those
bypass local Chromium). On missing Chromium, the hint is
platform-correct: '--with-deps' on POSIX, plain 'install chromium'
on win32.
Verified on Windows 10:
- 'npx playwright install chromium' completes successfully, drops
Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright
- check_browser_requirements() flips from False -> True
- 'hermes doctor' now prints either '✓ Playwright Chromium (browser
engine)' or '⚠ Playwright Chromium not installed' + fix command
- tests/hermes_cli/test_doctor.py: 38/38 pass
- tests/tools/test_browser_chromium_check.py: 16/16 pass
Adds a dedicated '## Windows-Specific Quirks' section to the hermes-agent
skill so Windows pitfalls have one discoverable place to evolve. Inaugural
entries cover:
- Input / keybindings — Alt+Enter intercepted by Windows Terminal,
Ctrl+Enter as the Windows newline keystroke, mintty/git-bash behavior,
pointer to scripts/keystroke_diagnostic.py for investigation.
- Config / files — UTF-8 BOM HTTP-400 trap.
- execute_code / sandbox — WinError 10106 SYSTEMROOT root cause +
_WINDOWS_ESSENTIAL_ENV_VARS fix location.
- Testing / contributing — scripts/run_tests.sh POSIX-venv limitation and
the system-Python workaround, POSIX-only test skip-guard patterns.
- Path / filesystem — line-ending warnings (cosmetic), forward-slash
portability.
Collapses the old scattered Windows bullets under 'Platform-specific
issues' into a single pointer at the new dedicated section so there's
only one place to maintain this content.
Also adds the scripts/keystroke_diagnostic.py the skill now references —
a small prompt_toolkit Application that prints the Keys.* identifier and
raw escape bytes for every keystroke. Used to establish the Ctrl+Enter
= c-j fact on Windows Terminal; generally useful for anyone adding a
platform-aware keybinding.
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
Two fixes from teknium1's next install run:
1. **npm install: "npm.ps1 cannot be loaded because running scripts is
disabled on this system."** Get-Command's default PATHEXT ordering
picked up ``npm.ps1`` (the PowerShell shim) ahead of ``npm.cmd`` (the
batch shim). Most Windows users have PowerShell's execution policy
set to Restricted or RemoteSigned, which blocks unsigned ``.ps1``
files. ``npm.cmd`` has no such restriction and works universally.
Install-NodeDeps now detects when Get-Command returned npm.ps1, looks
for a sibling npm.cmd in the same directory, and prefers it. Prints
an info line so the user sees why. Emits a warning + hint if only
npm.ps1 is available.
2. **"Launch hermes chat now? Y" crashes with "%1 is not a valid Win32
application" on Windows installs.** The setup wizard calls
``relaunch(["chat"])``; ``resolve_hermes_bin()`` returned
``sys.argv[0]`` which was ``...\\hermes_cli\\main.py`` (because hermes
was launched via ``python -m hermes_cli.main`` during setup).
On Windows, ``os.access(script.py, os.X_OK)`` returns True because
PATHEXT lists ``.py`` when the Python launcher is registered — but
``subprocess.run([script.py, ...])`` can't actually execute a ``.py``
directly. CreateProcessW needs a real PE file.
Fixed ``resolve_hermes_bin`` to reject ``.py``/``.pyc`` argv0 values
on Windows specifically. Falls through to ``shutil.which("hermes")``
(hermes.exe in the venv Scripts dir) or, as a final fallback, lets
build_relaunch_argv build ``[sys.executable, "-m", "hermes_cli.main"]``
which is bulletproof. POSIX behaviour unchanged — ``.py`` argv0 with
a shebang + chmod+x is still a valid exec target there.
3 new tests cover the Windows paths: .py argv0 + hermes.exe on PATH →
returns hermes.exe; .py argv0 + no PATH → returns None (caller uses
python -m); POSIX + executable .py → still accepted.
26 relaunch tests pass, no POSIX regressions.
Three bugs from teknium1's successful install + diagnostic chat on Windows:
1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32
application".** Start-Process bypasses cmd.exe and PATHEXT to call
CreateProcessW directly, which refuses .cmd batch shims. Switched
Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe
install --silent *> $log``) which DOES honour PATHEXT. Extracted a
``_Run-NpmInstall`` helper so the browser + TUI paths share the same
logic. Captures $LASTEXITCODE correctly, still surfaces the real
stderr on failure with a log-file pointer for the full output.
2. **patch tool returns false-negative on Windows due to CRLF round-trip.**
Root cause was upstream of patch: ``subprocess.Popen(..., text=True,
stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows
through the stdin pipe. ``_pipe_stdin()`` was writing the patch's
new_content string through a text-mode pipe, bash then wrote those
CRLF bytes to disk, and patch's post-write verify compared the
on-disk CRLF bytes against the original LF-only string — fail.
Fixed in two places for defense in depth:
- ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with
explicit UTF-8 encoding, bypassing Python's newline translation on
every platform. No behaviour change on POSIX (bytes are identical)
but stops the CRLF injection on Windows.
- ``patch_replace``'s post-write verify normalizes CRLF→LF on both
sides before comparing, so even if some future backend still
translates newlines the patch tool won't report a bogus failure.
3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content
-Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed
in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees
the BOM (U+FEFF invisible char) and refuses to load the file, so
SOUL.md's persona instructions never get applied.
Fixed by writing the file via ``[System.IO.File]::WriteAllText``
with an explicit ``UTF8Encoding($false)`` — BOM-free on every
PowerShell version.
All POSIX behaviour verified unchanged: 198 tests pass across
test_file_operations, test_local_env_cwd_recovery, test_code_execution,
test_windows_native_support, test_windows_compat.
User hit 'fatal: not in a git directory' on re-install because:
1. They ran Remove-Item -Force $env:LOCALAPPDATA\hermes -ErrorAction
SilentlyContinue WHILE cd'd inside the install dir. Windows
silently refuses to delete a directory any shell is currently cd'd
inside and leaves the skeleton intact, but the -ErrorAction
SilentlyContinue swallowed every partial-delete failure so they
thought the wipe succeeded.
2. The installer then walked into Install-Repository, saw $InstallDir
still exists with a partial .git stub, my repo-validity probe
returned success (the probe's git rev-parse may have exit-code-zeroed
in a way I didn't expect), and the real git fetch died with three
'fatal: not a git repository' errors.
Two fixes belt-and-braces:
- Main() now cds to $env:USERPROFILE at start if the current shell
is inside $InstallDir. Harmless when the user ran from elsewhere;
critical when they didn't. This alone fixes the user's case.
- Install-Repository's 'is this a valid repo' probe now runs BOTH
git rev-parse --is-inside-work-tree AND git status, resets
$LASTEXITCODE before each to avoid picking up a stale 0, and
requires BOTH to succeed. Also requires rev-parse's output to
match 'true' (not just exit 0) to rule out exit-0-with-empty-output
edge cases.
teknium1 hit "fatal: not in a git directory" on re-install when the previous
install left a $InstallDir\.git stub that Test-Path matched but git didn't
recognize (three "fatal: not a git repository" lines, then the script
exited before touching anything).
Two bugs:
1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git
whether it's a directory, file, symlink, submodule gitfile, OR a
broken stub from a failed previous Remove-Item. Replaced with a
real repo probe: Push-Location + git rev-parse --is-inside-work-tree
+ $LASTEXITCODE check. If git itself can't see a repo, we treat
the directory as not-a-repo and fall through to fresh clone.
2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull
all emitted fatals but the script kept going. Now each command
checks $LASTEXITCODE and throws with an explicit message.
Also: when the directory exists but isn't a valid repo, the new code
wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh
clone, instead of dying with the old "Directory exists but is not a git
repository" error. If the wipe itself fails (file locked, hermes still
running), we throw with a user-readable "close any programs using files
in <dir>" hint.
Refactored the function to use a $didUpdate flag instead of my earlier
draft's early `return` — that was skipping the submodule init block at
the bottom of the function. Both the update and fresh-clone paths now
fall through to the submodule init step, which is correct (git pull
doesn't auto-update submodules).
PowerShell structural check: 21 functions defined, braces balanced.
Three real bugs from teknium1's first Windows install run:
1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows
distribution — it ships git.exe but deliberately strips bash and the POSIX
coreutils. Installer logged "Could not locate bash.exe" and Hermes would
fail to run any shell command. Switched to PortableGit — the full Git for
Windows minus the installer UI. PortableGit ships bash.exe at
<root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64
variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls
back to MinGit-32-bit with a warning (PortableGit is 64-bit only).
PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We
invoke it with `-o<target> -y` to extract silently — no 7z install needed,
it's self-contained.
Updated tools/environments/local.py::_find_bash candidate order to prefer
the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout
(<root>\usr\bin\bash.exe) as a fallback so existing installs keep working.
2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch
hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on
Windows can only swap to real Win32 .exe files — chokes with OSError(8)
on .cmd batch shims and Python console-script wrappers. Added a
win32 branch in hermes_cli/relaunch.py::relaunch() that uses
subprocess.run + sys.exit — functionally identical (user sees "hermes
exited, then new hermes started") with one extra PID in play. POSIX
path is UNCHANGED — still uses os.execvp for in-place replacement.
Catches OSError in the Windows branch and surfaces a "open a new
terminal so PATH picks up, then re-run hermes" hint instead of a
cryptic traceback.
3. **npm install failures silent on Windows.** The install.ps1 was invoking
`npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's
try/catch does NOT trigger on non-zero process exit codes — only on
unhandled .NET exceptions — so npm failing printed a generic "npm
install failed" with zero information about WHY. The silent pipe ate
the stderr.
Rewrote Install-NodeDeps to:
- Resolve npm.cmd via Get-Command (respects PATHEXT) instead of
relying on bare `npm` name resolution.
- Use Start-Process with -PassThru to capture the actual exit code.
- Redirect stderr to a temp log and surface the first ~800 chars of
the real npm error when install fails, plus the log path for the
full text.
- Fail loudly with the right exit code instead of a misleading success.
- Bail cleanly with a helpful message when npm isn't on PATH at all.
4. **"True" printing to console after Node check.** `Test-Node` returns $true;
installer called it as a bare statement (no assignment, no cast). PowerShell
prints bare return values. Wrapped the call in `[void](Test-Node)`.
## Tests
- Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the
Windows branch: subprocess is called (not execvp), child exit code
propagates, OSError surfaces a helpful message. All 23 tests pass
(20 existing + 3 new).
- 77 Windows-compat tests still pass, POSIX behaviour unchanged.
User hit a real failure case: their system Git was in a half-installed state
(can neither uninstall nor reinstall) and winget refused to work around it.
We were one step away from shipping an installer that would have left users
with exactly the problem he already had.
What other agents do (reality check):
- Claude Code: requires pre-installed Git; breaks if user doesn't have it.
- OpenCode, Codex: don't need bash at all — PowerShell-first design.
- Cline: uses whatever shell VSCode is configured with; installs nothing.
None of them solve the "broken system Git" problem. We need to own our Git.
Changes:
- scripts/install.ps1::Install-Git: dropped winget path entirely. Now:
(1) use existing git if present; (2) download portable MinGit from the
official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git.
No winget, no admin, no Windows installer registry, no system impact.
- Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash
+ POSIX coreutils (which, env, grep, …) resolve in fresh shells.
- tools/environments/local.py::_find_bash: reorder so Hermes' portable
MinGit install is checked BEFORE falling through to shutil.which("bash")
or system install locations. This way a broken system Git can't
hijack the bash lookup.
- README + installation docs reworded to reflect the new story: "portable
Git Bash, isolated from any system install, recoverable via rm -rf if it
ever breaks."
Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
and re-run the installer — no system impact, no uninstall drama, no winget
to fight with.
Follow-up to #15328's vision-unsupported retry branch in run_agent.py.
_strip_images_from_messages() previously deleted any message whose content
was entirely images. That's fine for synthetic user messages injected for
attachment delivery, but it breaks providers for tool-role messages — the
paired tool_call_id on the preceding assistant message ends up unmatched,
which OpenAI-compatible APIs reject with HTTP 400.
Fix: tool-role messages whose content becomes empty are replaced with a
plaintext placeholder that preserves the tool_call_id linkage. Only
non-tool messages are dropped. Added 10 tests covering the role-alternation
invariants + image-type coverage.
Image-rejection detector: expanded phrase list (image content not
supported / multimodal input / vision input / model does not support
image) and gated on 4xx status so transient 5xx errors never get
misinterpreted as 'server said no to images'. Detection is documented as
best-effort English phrase matching.
AUTHOR_MAP: mapped 3820588+ddupont808@users.noreply.github.com to
ddupont808 so release notes attribute the salvage correctly.
The new _is_gateway_approval_context() widened the gateway classification
to any call with HERMES_SESSION_PLATFORM bound via contextvars. But
cron/scheduler.py binds that same contextvar for delivery routing on
cron jobs that originate from a gateway platform (telegram/discord/etc.),
so those jobs were getting routed through submit_pending with no
listener — blocking indefinitely instead of honoring approvals.cron_mode.
Short-circuit on HERMES_CRON_SESSION before any gateway check. Cron is
always governed by cron_mode config, regardless of where the job was
scheduled from.
Adds regression coverage in TestCronWithGatewayOrigin and records the
contributor email mapping for scripts/release.py.
Small follow-ups on top of #19643:
- check_auth() takes quiet kwarg to suppress its AUTHENTICATED print
when called from check_auth_live(), so the final status line reflects
the live-call outcome only.
- Drop redundant _ensure_deps() call in check_auth_live() (check_auth()
already calls it).
- Add AUTHOR_MAP entry for ygd58 so release attribution script works.
Weak judge models (e.g. deepseek-v4-flash) return empty strings or prose
when asked for the strict {done, reason} JSON verdict. The old code
failed-open to continue on every such turn, burning the entire turn
budget with log lines like
judge returned empty response
judge reply was not JSON: "Let me analyze whether the goal..."
and /goal clear could not stop it mid-loop without /stop.
After N=3 consecutive *parse* failures (transport/API errors don't
count — those are transient), the loop auto-pauses and prints:
⏸ Goal paused — the judge model (3 turns) isn't returning the
required JSON verdict. Route the judge to a stricter model in
~/.hermes/config.yaml:
auxiliary:
goal_judge:
provider: openrouter
model: google/gemini-3-flash-preview
Then /goal resume to continue.
The counter resets on any usable reply (both "done"/"continue" and
API errors) and persists across GoalManager reloads so cross-session
resumes carry the correct state.
Also fixes test_goal_verdict_send.py sharing a hardcoded session_id
across tests — the shared id only worked because the previous
_post_turn_goal_continuation was a never-awaited coroutine. Now that
PR #19160 made it properly awaited, the xdist test-leakage bug
surfaced. Each test gets a unique session_id via uuid suffix.
When the installer is run via , uv resolves config file
paths against the process owner's (root) home directory rather than the
effective user's, causing a Permission denied error when trying to read
/root/uv.toml.
Setting UV_NO_CONFIG=1 prevents uv from discovering any config files
(uv.toml, pyproject.toml) during installation, which is the correct
behavior for a bootstrap script that manages its own environment.
Fixes#21269
The existing mapping pointed to the wrong GitHub user (blakejohnson, id
866695, IBM) — the email actually belongs to voteblake (id 5585957),
confirmed via search/commits?author-email. Mis-credited since 323ca7084.
Both implement WebSearchProvider via tools/web_providers/ — matching the
existing SearXNG pattern (PR #5c906d702). Search-only; pair with any
extract provider via web.extract_backend.
- tools/web_providers/brave_free.py — Brave Search API (free tier, 2k
queries/mo). Uses BRAVE_SEARCH_API_KEY as X-Subscription-Token.
- tools/web_providers/ddgs.py — DuckDuckGo via the ddgs Python package.
No API key; gated on package importability.
- tools/web_tools.py: both backends added to _get_backend() config list
and auto-detect chain (trails paid providers), _is_backend_available,
web_search_tool dispatch, web_extract_tool + web_crawl_tool search-only
refusals, check_web_api_key, and the __main__ diagnostic. Introduces
_ddgs_package_importable() helper so tests can monkeypatch a single
symbol for the ddgs availability check.
- hermes_cli/tools_config.py: picker entries for both providers; ddgs
gets a post_setup handler that runs `pip install ddgs`.
- hermes_cli/config.py: BRAVE_SEARCH_API_KEY in OPTIONAL_ENV_VARS.
- scripts/release.py: AUTHOR_MAP entry for @Abd0r.
- tests: 14 new tests (brave-free) + 15 new tests (ddgs) covering
provider unit behavior, backend wiring, and search-only refusals.
Salvages the brave-free + ddgs portion of PR #19796. Not included: the
in-line helpers in web_tools.py (replaced with provider modules to match
the shipped architecture), the lynx-based extract path (these backends
should refuse extract with a clear error — users pair with a real
extract provider), and scripts/start-llama-server.sh (unrelated).
Co-authored-by: Abd0r <223003280+Abd0r@users.noreply.github.com>
PR #21238 introduced top-level `allOf: [{if/then/required}]` blocks in the
built-in memory tool's parameters schema as conditional-required hints.
Two problems:
1. OpenAI's Codex backend (chatgpt.com/backend-api/codex, gpt-5.x) rejects
top-level `allOf`/`anyOf`/`oneOf`/`enum`/`not` outright with a
non-retryable 400 — affected every user on openai-codex/gpt-5.x.
2. The `if/then` hints were silently ignored by every other provider
(Chat Completions doesn't honour them on function schemas), so they
never actually enforced anything anywhere.
The runtime handler in `memory_tool()` already validates the per-action
required fields and returns actionable error messages, so removing the
block changes nothing behaviourally.
Paired with the defense-in-depth sanitizer in the previous commit, this
closes the bug both at the source (schema no longer emits the forbidden
form) and at the wire boundary (sanitizer strips it if anything else
re-introduces it).
- Rewrites `tests/tools/test_memory_tool_schema.py` to guard against
regressing the forbidden-combinator shape instead of asserting it.
- Adds AUTHOR_MAP entry for @hrkzogw (author of the sanitizer fix).
Self-chat mode (default) previously replied to ANY incoming DM with a
Python-side pairing-code message. Two compounding defaults:
1. allowlist.js::matchesAllowedUser returned true for an empty
allowlist — so WHATSAPP_ALLOWED_USERS unset → everyone passes the JS
bridge gate → messages reach Python gateway → _is_user_authorized
returns False but _get_unauthorized_dm_behavior falls back to
'pair' → stranger gets a pairing code reply.
2. bridge.js had no mode check on !fromMe messages, so self-chat mode
(where the operator only wants to talk to themselves) forwarded
everything anyway.
Fix:
- allowlist.js: empty allowlist now returns false. Operators who want
an open bot must set WHATSAPP_ALLOWED_USERS=* explicitly (the
existing wildcard behaviour, consistent with SIGNAL_GROUP_ALLOWED_USERS).
- bridge.js: self-chat mode hard-rejects all !fromMe messages at the
bridge, before they ever reach the Python gateway. Bot mode still
enforces the allowlist.
- Startup log message updated to reflect the new per-mode behaviour
(was '⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be
processed', which was both inaccurate post-fix and a bad default
signal pre-fix).
- allowlist.test.mjs: new regression test pinning the empty-rejects
contract, + null/undefined defensive cases.
Behaviour delta for existing users:
- self-chat mode, no allowlist: strangers got pairing codes, now
silently dropped. Strictly better.
- bot mode, no allowlist: strangers got pairing codes via the
Python-side pairing flow, now silently dropped at the JS bridge.
Operators who genuinely want an open bot set
WHATSAPP_ALLOWED_USERS=*.
Lists the skills sitting in ~/.hermes/skills/.archive/ so users have
something to pass to `hermes curator restore`. `curator status` already
shows counts; this fills the name-discovery gap.
Archive layout is flat (`archive_skill` writes to `.archive/<skill>/`),
so the directory name IS the skill name — no frontmatter parsing
needed. Timestamped collision directories (`<skill>-<ts>`) are listed
literally; user can still pass them to `restore`.
Reshape of @EvilDrag0n's #20651, simplified: drop the frontmatter
rglob + preamble/trailer output + duplicate subcommand registration.
Co-authored-by: EvilDrag0n <lxl694522264@gmail.com>
The MCP SDK discovers OAuth server metadata (token_endpoint, etc.) on
demand and keeps it in memory only. Without disk persistence, a restart
with valid cached refresh tokens forces the SDK to fall back to the
guessed '{server_url}/token' path — which returns 404 on most real
providers (Notion, Atlassian, GitHub remote MCP, etc.) and triggers a
full browser re-authorization even though the refresh token is fine.
Add a .meta.json file next to the existing tokens/client_info files:
HERMES_HOME/mcp-tokens/<server>.json -- tokens (existing)
HERMES_HOME/mcp-tokens/<server>.client.json -- client info (existing)
HERMES_HOME/mcp-tokens/<server>.meta.json -- oauth metadata (new)
Changes:
- HermesTokenStorage.save_oauth_metadata / load_oauth_metadata / _meta_path
— disk layer for the discovered OAuthMetadata.
- HermesTokenStorage.remove() now also clears .meta.json so
'hermes mcp remove <name>' and the manager's remove() path clean up fully.
- HermesMCPOAuthProvider._initialize cold-restores from disk before the
existing pre-flight discovery runs. If disk has metadata we skip the
discovery HTTP round-trips entirely.
- HermesMCPOAuthProvider._prefetch_oauth_metadata now persists ASM as
soon as it's discovered, so even the first pre-flight run seeds disk.
- HermesMCPOAuthProvider._persist_oauth_metadata_if_changed() is called
at the end of async_auth_flow so metadata discovered via the SDK's
lazy 401-branch (not pre-flight) is also saved for next time.
Tests cover the storage roundtrip (save/load/missing/corrupt/remove) and
the manager provider path (cold-load restore, skip-when-in-memory,
persist-on-discover, noop-when-unchanged, end-to-end async_auth_flow).
Co-authored-by: nocturnum91 <50326054+nocturnum91@users.noreply.github.com>