A `gateway.multiplex_profiles` gateway enumerated `profiles/` once at boot, so a profile
created afterwards (CLI, dashboard, Desktop, TUI) was never served until `hermes gateway
restart`; Desktop and the dashboard gave no reminder, so a new profile's bot simply never
connected.
The served set is now reconciled at runtime (`gateway/run_profile_reconcile.py`):
- `hermes_cli/profiles.py` create/delete ping the multiplexer over its control socket
(new `rescan-profiles` verb); a supervised watcher rescans every 30s as the safety net.
- A new profile gets its adapters under its own runtime scope from its config/.env
(`_start_one_profile_adapters`, same duplicate-credential guard as boot, now seeded
with the LIVE secondaries' claims), `served_profiles` in gateway_state.json is
updated, MCP discovery + log routing run for it. Other profiles' adapters are never
touched.
- A served profile whose config.yaml/.env changed is re-scanned so a token added after
create builds the adapter; already-live/queued platforms are skipped (no second poller).
- A deleted profile (tombstone) has its reconnects cancelled, adapters torn down,
pairing/busy bookkeeping and cached agents dropped, and this process's SQLite /
memory-store handles released so the deleter's rmtree succeeds.
- The in-process cron ticker takes a live enumerator so new profiles' jobs fire.
- PUT /api/messaging/platforms/<id>?profile=X returns `hot_served` when a live
multiplexer rebuilt X's adapters; Desktop/dashboard skip the restart banner then.
- `hermes profile create` confirms hot-serve; the restart reminder stays for a gateway
that did not pick the profile up (older build / signal failed).
The profiles guide promised "fresh sessions and memory" while
_CLONE_SUBDIR_FILES deliberately copies memories/MEMORY.md and USER.md
(curated identity, same tier as SOUL.md). State the real behaviour and how
to get a blank memory, so #10376's first half stops surprising people.
Refs #10376
Drop the categorical 'missing any step always means 200340' and
'200672/673 necessarily is an adapter bug' claims; the Interactive Card
toggle's role is unverified. Bring the Chinese guide in line (it still
pointed at the Event tab). Review follow-up.
Six issues (#10251, #10073, #13924, #38305, #25886, #8246) and four PRs
proposed json.dumps()-ing CallBackCard.data. The lark_oapi SDK types
`data` as Any and marshals the whole response with JSON.marshal(), so a
dict already reaches Feishu as the nested object the card-callback doc
shows in its response example; a string would nest a JSON *string* there
instead. Feishu documents 200340 as "the application has not configured
the card callback address or the configured request address is invalid"
— the click is rejected before delivery (nothing reaches Hermes), which
matches the reports (no gateway log, all four buttons identical).
The docs told users to subscribe to `card.action.trigger` under Event
Subscriptions; it lives under the separate Callback Configuration tab and
needs its own long-connection/URL mode and a published app version.
Spell out the four steps and map the sibling codes (200342/200343 =
unreachable URL; 200672/200673 = our payload).
Refs #10251
The check called the registry check_fn inside the updater's own process,
whose import caches predate the install just performed (and which may be
the outer Python entirely), so a healthy freshly installed SDK produced a
false "will fail to load" warning. Run the same registry check in the
target interpreter via the existing _venv_probe path used by the core
dependency verifier.
Found by independent review before merge.
When `.[all]` fails and the per-extra fallback also fails for e.g.
`feishu`, the update printed only "Skipped optional extras that still
failed" and finished green. The running gateway kept its already-imported
modules, so the loss surfaced hours later as "No adapter available for
feishu" on the next restart (#10651).
After the fallback, check every enabled+configured platform through its
registry `check_fn` (and MCP when `mcp_servers` is set) and print which
configured feature will fail to load, with its install hint. Unconfigured
extras stay a quiet skipped line.
Reworks PR #10733 (LeonSGP43) against the registry instead of a
hand-written platform->module table so plugin platforms are covered.
Fixes#10651
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Two regressions in the mirror support: (1) the canonical
https://openrouter.ai/api/v1 that `hermes setup` persists under
provider: openrouter was treated as a custom endpoint, dropping the
auth.json credential pool and returning an empty API key; (2) an
unrelated CUSTOM_BASE_URL (which outranks the config mirror) still
received OPENROUTER_API_KEY because key selection tested mirror
eligibility, not the endpoint actually selected. A config URL is a
mirror only when its host is not openrouter.ai, and the mirror key
branch fires only when base_url is the config URL.
Found by independent review before merge.
When config.yaml sets `model.provider: openrouter` together with a
`model.base_url` mirror/proxy, an explicit `--provider openrouter`
request ignored the mirror and sent traffic to the public OpenRouter
endpoint: the config base_url was only trusted for auto/custom, the
credential pool was still consulted (so a pooled key won over the
mirror), and even when the mirror URL was used its host failed the
openrouter.ai match so OPENROUTER_API_KEY was not selected for it.
Trust the config base_url for the explicit openrouter case, treat that
mirror as an OpenRouter context for key selection, and bypass the pool
like the other custom-endpoint cases already do.
Fixes#10622
send_document() accepted reply_to but never passed it down, so attachments
always threaded from the cached per-address context (or not at all) even
when the caller named the message to reply to. The plain-text path
(_send_email) already honored it; the attachment path now does too.
The metadata half of #10131 (send_image rejecting metadata=) was already
fixed on main by the adapter parity pass. Diagnosis from #10131 and the
explicit-reply_to-wins shape from PR #10321 (which targeted the
pre-plugin path).
Fixes#10131
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Every start (and every `ensure_hermes_home` from a sibling CLI invocation)
chmod'd the data directory to 0700, wiping group/other bits and the ACL
mask on a bind mount shared with other containers (hermes-webui, Nix
desktop + dashboard). _secure_file already skipped containers for this
reason; _secure_dir did not.
In a container the directory mode is now left to the operator unless
HERMES_HOME_MODE is set explicitly, which is still applied. cron/jobs.py
had its own 0700/0600 copies that bypassed the managed/container rules;
they now delegate to the shared helpers so cron/output stops re-locking
the mount as well.
Fixes#10757
A second Ctrl+C while the atexit hook joins the browser janitor thread
surfaced as "Exception ignored in atexit callback: _stop_browser_cleanup_thread"
with a KeyboardInterrupt traceback. The janitor is a daemon thread and the
interpreter is already exiting, so nothing is lost by swallowing the
interrupt — the terminal tool's sibling _stop_cleanup_thread already does.
Salvaged from #10765 (function has since moved to browser_tool_lifecycle.py).
Fixes#10764
Co-authored-by: LehaoLin <lehaolin98@outlook.com>
A tool batch that is cancelled or errors before any tool.started event
left the tool name in _tool_gen_announced, muting the next invocation's
'preparing <tool>…' line. Reset it in _reset_stream_state alongside the
other per-invocation state. Found by independent review before merge.
The tool-gen callback fires once per tool CALL, so a model issuing three
parallel terminal calls printed three identical status lines before the
spinner took over (#10478). Coalesce repeats of the same tool name until a
tool actually starts (tool.started), which marks the next generation batch.
The line itself stays: it is the only feedback while a large argument
payload (e.g. a 45 KB write_file) streams, before the spinner exists.
PR #10598 removed it entirely; this keeps the first line and drops only
the duplicates.
Live A/B (in-process HTTP mock streaming 3 parallel terminal calls into a
real HermesCLI with display.streaming on): before 3 lines, after 1.
Fixes#10478
A root-level `webhook:` block (the pre-`platforms:` spelling, still
supported by platform_section) is never copied into platforms_data, so
the removed _PORT_BRIDGE_KEYS table was its only route to `extra` and
the previous commit regressed it (port fell back to 8644). Bridge every
non-typed key of a root block in _bridged_keys with the same typed-key
exclusion and explicit-extra precedence as PlatformConfig.from_dict.
Found by independent review before merge.
`platforms.webhook.port: 9100` (and `routes`, `secret`, api_server `key`/
`cors_origins`, any adapter setting) was silently dropped unless nested
under `extra:` — PlatformConfig.from_dict only read a fixed set of typed
fields. Two partial bridges (a per-platform port/host/secret table in the
loader and an api_server-only block) covered a few keys and had to be
extended for every new one.
from_dict now promotes every non-typed top-level key into `extra`, with an
explicit `extra:` value winning on a clash and typed fields never leaking
into `extra` on a to_dict/from_dict roundtrip. Both hand-written bridges
are removed.
Same direction as PRs #10208/#10211/#10453 (rainow's #10206 diagnosis) and
#20506; those targeted the pre-loader layout.
Fixes#10206
The package-manager uninstall patterns were bare \b-anchored, so quoted
prose (`git commit -m "document npm uninstall usage"`) prompted while the
real `npm --prefix DIR uninstall x` slipped past because the option group
did not allow an operand. Use the file's _CMDPOS anchor like every other
command-name rule and let each global option take one operand.
Found by independent review before merge.
`npm uninstall -g`, pnpm/yarn remove, `pip uninstall` and `brew uninstall`
remove software outside the project yet matched no dangerous-command
pattern, so the agent ran them without asking (#10199). Add one
"package manager uninstall" rule per manager; installs and updates stay
unprompted.
Hand-ported from PR #64175 (the patterns moved from tools/approval.py to
tools/approval_detection.py after it was opened).
Previously, setting HERMES_MANAGED=false (or 0, no, off) would be
interpreted as a literal managed-system name, causing is_managed()
to incorrectly return True and block update/config commands.
- Add _MANAGED_FALSE_VALUES tuple for canonical false strings
- Check false values before true values in get_managed_system()
- Add parametrized regression tests for all false variants
Fixes#12864
Review finding: the salvaged rule assumed a lowercase-hex grammar that
AgentMail's docs do not establish (only the `am_` / `am_org_` prefix is
documented), so a non-hex key would have gone unmasked. Discriminate on
what actually separates keys from identifiers: an alphanumeric body with
no `_`/`-` and a 20-char floor. `am_example_identifier_123` still passes.
The pattern am_[A-Za-z0-9_-]{10,} was too broad and matched ordinary
identifiers like 'am_example_identifier' that happen to start with 'am_'.
Changed to am_[a-f0-9]{32,} which:
- Requires lowercase hex characters only (real AgentMail keys are hex)
- Requires 32+ characters after the prefix (reducing false positives)
FixesNousResearch/hermes-agent#10983
Review finding: the guard ran before `edit_message` was awaited; a restart
notice sent during that await followed by a failed edit produced a fresh
"Working" fallback bubble after the notice. Recheck before the fallback
send; the notifier ends instead.
The gateway's long-running notification task was sending "Still working...
messages even after a restart was requested, causing confusing UX where
users received a restart warning followed by normal heartbeat messages.
Added a check in _notify_long_running() to skip notifications when
gateway is draining or restart has been requested.
FixesNousResearch/hermes-agent#10990
Review finding: with multiline shortcuts on, a `\` + Enter arriving inside
a paste kept the literal backslash while a typed one removed it. The
continuation branch inserts a newline anyway, so it runs first and the
outcome no longer depends on timing.
Without bracketed paste (tmux strips it; IME/voice dictation emits keys
one at a time) each pasted newline reaches _tui_handle_enter as its own
Enter event, so a multi-line paste while the agent ran became N separate
steering messages. An Enter within 50 ms of the last buffer change is
text still arriving; insert the newline and let the buffer accumulate.
Humans type >50 ms apart, so a real submit is unaffected.
Same mechanism as #10997 (@iRonin) and #19498 (@Ralojeil1), which
targeted the removed classic-CLI handle_enter.
Refs #10994
Upstream sets process.title="openclaw-gateway" in the gateway run loop, so
the real daemon has comm "openclaw-gatewa" (15-char truncation) and no
`node … openclaw` argv for the script probe to match. Add the exact comm
probe; substring matching stays out.
The runtime already registers nothing for an explicit empty include list
(fb1ec36a4b), but every CLI reader still coerced `[]` to "no filter":
`hermes mcp list` printed "all", `hermes mcp configure` and `hermes tools`
pre-checked every tool (so confirming the picker silently re-enabled all of
them), and a catalog reinstall pre-checked the manifest defaults over the
user's zero-tool choice.
`_tool_filters` now returns the list whenever the key holds a list; only an
absent/non-list key is None. The pickers and list output branch on `is not
None`, matching `tools/mcp_tool_registration.py`.
Fixes#12865. Builds on #13096 (@dingn42) and #52874 (@Bartok9).
`isToolEnabled` used `include?.length`, so `tools.include: []` (the block-all
whitelist the runtime honours) rendered every tool as enabled, and toggling the
last include entry off deleted the key, silently flipping the server back to
"all tools". Keep the empty list.
Salvaged from #52874 (desktop portion) by @Bartok9. Part of #12865.
Review finding: `echo cd backend` injected backend/AGENTS.md, and the
rstrip(";") applied after shlex had removed quoting turned `cd 'backend;'`
into `backend`. Tokenize with punctuation_chars so operators are their own
tokens: a `cd` counts only at a segment start, `backend;ls` splits at the
operator, and a quoted `'backend;'` stays the literal name.
SubdirectoryHintTracker's generic token filter in
_extract_paths_from_command drops any token that does not contain /
or ., so relative directory names in commands like 'cd backend && ls'
were silently ignored. As a result, Hermes missed AGENTS.md /
CLAUDE.md / .cursorrules in the entered subdirectory whenever users
navigated with plain relative names -- the common case.
Add _extract_nav_command_targets, which scans the token stream for
'cd' / 'pushd' and treats the next non-flag token as a path candidate
resolved against working_dir. The generic token pass still runs so
all other shapes (absolute paths, files with extensions, etc.) keep
working. 'cd -' and bare 'cd' are intentionally skipped -- neither
points at a project subdirectory.
Three new tests cover 'cd backend && ls', 'pushd backend', and
'cd backend' appearing after an earlier chained sub-command.
Known limitation called out in review: multi-step chains like
'cd backend && cd src' still resolve each hop against working_dir
rather than simulating the shell's evolving cwd. That's a bigger
change (shell state tracking) and is out of scope for this fix;
the common single-hop case reported in the issue is now covered.
Fixes#11032
Review finding: with the child now created before the parent is ended,
child.started_at < parent.ended_at, so _BRANCH_CHILD_SQL's timestamp
fallback no longer classifies the fork as a branch child and the default
GET /api/sessions dropped it. Persist the explicit marker the CLI /branch
path already writes; test covers listing + the failed-fork parent survival.
Both paths ended the source session as "branched" before create_session
ran, so a failed create left the user on a session already marked ended
with no branch behind it. Create the child first; the parent is ended
only once the branch is real.
Salvage of #11048 (targeted the pre-split cli.py handler; ported to
hermes_cli/cli_commands_mixin.py and the api_server fork sibling);
authored by @vominh1919.
Refs #11030
Review finding: with the install's own venv activated (a re-run), `uv python
find <range>` returned venv/bin/python3, which setup_venv then deleted before
handing the dead path to `uv venv`; the stage still exited 0 and printed
"Virtual environment ready". Probe with --system so only base interpreters
qualify, and fail the stage when the venv was not created.
check_python only asked uv for 3.11 and, missing that, downloaded it —
a hard failure on hosts that cannot reach GitHub releases even when a
3.12/3.13 inside requires-python is already installed. Ask uv for the
supported range second and pin the venv onto that interpreter.
Same direction as #10824 (@gnanirahulnutakki), reduced to the shell
path; the PowerShell installer already has this fallback.
Refs #10778
The cap only fires on add/replace, so an externally written over-budget
file rode silently in the system prompt while every later add was refused
with no visible cause. Warn at load; entries stay loaded (never truncate
a user's memories).
Salvage of #10886 (original hunk targeted memory_tool.py before the
store split); authored by @easyvibecoding.
Refs #10877
test_loop_tick_witness_arms_over_tcp_on_windows swapped the module's `os`
binding for a proxy reporting name="nt" so a Linux interpreter would take
the non-POSIX branch. That is the "Don't fake the host OS" case: the test
only passes if the interpreter believes it is on another OS. Mark it
windows_only so the tests-os lane runs it on windows-latest for real;
scripts/ci/list_os_marked_tests.py picks the file up by marker name and
it is skipped (not errored) on Linux/macOS. The create=True tripwire from
the earlier salvage commit is what makes it runnable there.
The salvaged paragraph described one mechanism that is actually two.
Per developer.chrome.com/blog/remote-debugging-port, Chrome 136+ simply
stops respecting --remote-debugging-port/--remote-debugging-pipe against
the default data dir — no dialog, nothing listens. The "Allow remote
debugging?" prompt the contributor saw is the separate Chrome 144+ opt-in
approval flow (chrome://inspect/#remote-debugging), which asks per
incoming connection, not per launch. Say so, cite both sources, and keep
the fix (non-default --user-data-dir, or use_real_profile) unchanged.
Same site class as the e2e fixtures: tests-js/scripts/mock-server.ts writes
the config for `npm run dev:mock`. Entry-level providers.<name>.context_length
is honoured since #98387, so a 4096 window now fails agent init below
MINIMUM_CONTEXT_LENGTH exactly as it did for the Playwright fixtures.
The two get_hermes_dir symlink tests wrapped symlink_to() in a
try/except -> pytest.skip(). tests/conftest.py already has the
require_symlinks marker for exactly this (probes symlink support once,
skips at setup); using it keeps the test bodies straight-line and the
skip reason uniform with the sibling tests in this file.
Ladder rung 3 says a service-gated tool 'only appears when a prerequisite
is configured', while the SESSION-surface section below says check_fn must
not gate per-session surface. Both are correct but read as a contradiction
at the ladder; add the disambiguation inline with a pointer to the section.
asyncio.start_unix_server only exists where an AF_UNIX event loop does.
On native Windows the attribute is absent, so patch.object raised
AttributeError while arming the tripwire — the TCP-witness test could
only ever pass on POSIX, the platform it pretends not to be. create=True
arms the forbidden-call tripwire on every platform; mock removes the
created attribute on exit, so no cross-test leakage.
Before: AttributeError on native Windows. After: 4/4 pass on Windows
(verified) — and unchanged on POSIX, where the attribute exists.
Five tests were silently failing on the Windows CI job because they
either required a Linux/macOS runtime or created symlinks (which the
default Windows user cannot do without Developer Mode).
Mark them with the project's existing linux_only / require_symlinks
markers so CI on Windows skips them cleanly instead of reporting red:
* TestPosixNoOp, test_noop_on_posix, test_no_hermes_home_returns_native
-> @pytest.mark.linux_only (the asserts only make sense on POSIX
where '~/.hermes' is the natural HERMES_HOME fallback).
* test_atomic_replace_copy_fallback_preserves_symlink,
test_symlinked_target_survives_a_contended_rename
-> @pytest.mark.require_symlinks (matches the pattern already used
by sibling tests in the same file).
For the two symlink tests in test_hermes_constants.py that weren't
covered by require_symlinks, wrap the symlink_to() call in a
try/except(OSError, NotImplementedError) -> pytest.skip() so a host
without symlink privilege still skips instead of erroring out.
No production code touched. Verified locally on win32 Python 3.11.9:
88 passed, 25 skipped, 0 failed across the three files (down from
7 failed, 88 passed).
The `/browser connect` tip said Chrome 136+ "silently refuse[s] to open the
remote debugging port" on the default user-data-dir and that "there is no error
message". Chrome does surface it: on Chrome 152/macOS, launching the default
profile with --remote-debugging-port puts up an "Allow remote debugging?" dialog,
and the port opens once you press Allow. Port 9222 refusing connections is the
symptom while that consent is pending, not a permanent silent block.
Two details this cost time to rediscover, now written down:
- the consent is asked per launch, so it reappears on every browser restart
- the remote-debugging toggle in chrome://settings does not suppress it; that
toggle only makes the feature available
Also cross-references browser.use_real_profile for the case the tip leaves
unanswered — wanting existing logins in the agent's browser. A dedicated
user-data-dir avoids the dialog but starts signed out; the real-profile snapshot
gets both, since the snapshot copy is itself a non-default user-data-dir.
Verified locally: default profile + --remote-debugging-port=9222 shows the
dialog and leaves 9222 closed, while a --user-data-dir launch answers
/json/version in under a second with no prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_finish_turn` gated the history update on truthiness, so a turn that
legitimately returned an empty transcript left the previous history
in place and the client kept rendering stale state. Same falsey-vs-absent
class as the step-callback fix (#10845); gate on key presence.
Refs #10844