The #22773 heuristic classified any telegram:<positive_chat_id>:<numeric_thread_id>
cron target as a Bot API channel Direct-Messages topic and routed it via
direct_messages_topic_id, which nulls message_thread_id. A normal forum-style
topic inside a private chat has the identical shape, so every such cron
delivery landed in General instead of the target thread (#52060). It also
means the only way to address a genuine channel DM topic was this same
ambiguous guess.
Disambiguate with the real runtime signal instead: probe the live adapter's
get_chat_info once and route via direct_messages_topic_id only when the chat
is actually a channel; everything else (private forum topic, forum supergroup,
group) and any probe failure fall back to message_thread_id — parity with
0.16.0 and with live reply routing. This fixes forum-topic delivery AND makes
genuine channel DM-topic cron delivery work correctly.
check-attribution requires every contributor author email to be in
AUTHOR_MAP; the salvaged commit is authored by
huanshan5195 <huanshan5195@users.noreply.github.com>.
PR #57601's original branch added a top-level reasoning_effort emit to the
LEGACY build_kwargs path (agent/transports/chat_completions.py), but
provider=custom resolves to CustomProfile (plugins/model-providers/custom/),
so chat_completion_helpers takes the profile path and returns early — the
added branch was unreachable dead code for every custom endpoint.
Move the fix to its real site, CustomProfile.build_api_kwargs_extras(), and
follow the DeepSeek/Zai profile precedent:
- disabled -> extra_body.think = False (unchanged)
- enabled + effort -> TOP-LEVEL reasoning_effort (the OpenAI-compatible
format GLM-5.2/ARK expect), passed through verbatim
incl. max/xhigh
- enabled + no effort -> omit, so the endpoint's server default applies
(avoids silently forcing 'medium' as the original
branch did)
Deliberately does NOT force think=True on enable — that flag is Ollama-only
and risks a 400 on GLM/vLLM endpoints that don't recognize it; thinking is
already server-default-on for these backends.
Verified end-to-end through the real profile dispatch (temp HERMES_HOME):
custom+high -> reasoning_effort=high; custom+max -> reasoning_effort=max;
custom+none -> think=False; custom+unset -> nothing; num_ctx composes.
Adds tests/plugins/model_providers/test_custom_profile.py (13 cases).
Addresses the custom-provider half of #55276.
Co-authored-by: huanshan5195 <huanshan5195@users.noreply.github.com>
Follow-up to salvaged #57601. Adding "max" to VALID_REASONING_EFFORTS
made parse_reasoning_effort("max") valid, so:
- test_unknown_levels_return_none no longer lists "max" (it is now valid;
auto-covered by test_each_valid_level which iterates the tuple).
- test_known_supported_levels_are_documented and the parse_reasoning_effort
docstring now include "max" so the doc-sync guard actually protects it.
- Add 'max' to VALID_REASONING_EFFORTS (GLM-5.2 native parameter)
- Emit top-level reasoning_effort string for custom providers
- Stop hardcoding 'medium' in legacy extra_body.reasoning, use actual effort
Custom providers (e.g. GLM-5.2 on Volcengine ARK) silently dropped
reasoning_effort — the value never reached the upstream API. Kimi,
TokenHub, and LM Studio all had dedicated branches for this, but
custom providers had none.
Follow-up to the salvaged SSH-tilde-cwd fix. The predicate
"backend == ssh and (cwd == ~ or cwd.startswith(~/))" was inlined at
each expanduser guard site, which is how the test simulator drifted from
production (it grew an SSH guard on a top-level-alias branch that has no
production counterpart).
- Add tools/terminal_tool._is_ssh_remote_tilde_cwd(backend, cwd) as the
single source of truth (case/whitespace-tolerant).
- Use it in _get_env_config and the gateway config bridge.
- Test simulator imports the real helper instead of re-implementing the
predicate; revert the phantom SSH guard on the top-level-alias branch
(production maps top-level cwd: to a plain env var, not TERMINAL_CWD via
an SSH-guarded path — that branch tested nothing real).
Self-review (hermes-pr-review Phase 2) flagged the mid-turn pre-API
compaction for reuse/duplication (W1/W2); fixing that surfaced a
regression against a deliberate existing feature, now also fixed.
The block now mirrors the turn-prologue preflight's guard chain exactly
(agent/turn_context.py) instead of a hand-rolled pressure limit:
1. should_defer_preflight_to_real_usage(rough) — defer when the rough
estimate is known-noisy vs a recent real provider prompt that fit
under threshold (schema overhead / post-compaction over-count, #36718).
2. get_active_compression_failure_cooldown() — skip during a same-session
compression-failure cooldown.
3. should_compress(rough) — reuses the canonical threshold_tokens (output
room already reserved by _compute_threshold_tokens) plus its summary-LLM
cooldown + anti-thrash guards (#11529).
Dropped the seven inline _reserve/_output_pressure locals (W2: they
re-derived _compute_threshold_tokens and omitted its 85% degenerate-window
fallback). compression_attempts stays as the hard per-turn backstop.
Without guard (1) the block fired a compaction the preflight deliberately
defers, breaking test_413_compression::test_preflight_defers_when_recent_
real_usage_fit (ValueError from the mocked _compress_context). Verified:
test_413_compression 26/26, codex 82/82, and anti-thrash engaged
(_ineffective_compression_count=2) still suppresses the block (0 calls).
Follow-up to the salvaged #58000 fix.
- Extract _raise_if_non_interactive(lead) so the shared 'hermes mcp login'
next-step wording lives in one place across both OAuth boundaries
(_redirect_handler, _wait_for_callback), rather than two copy-pasted
inline raises. Boundary-specific lead sentences preserved verbatim, so
existing message-match tests stay green.
- Add a positive-control test asserting the guard does NOT over-fire on the
interactive path (valid/refreshable tokens keep working), satisfying an
explicit regression-coverage line from #57836.
Add TestNonInteractiveFailFastAtCallbackBoundary: the callback boundary must
reject before binding a listener and without entering the poll loop, the guard
must hold even when a (stale) token file exists on disk, the redirect handler
must not print a URL or open a browser, and both boundaries must point users at
`hermes mcp login`.
Mark the existing timeout test and the SSH-hint redirect tests interactive so
they exercise their intended paths rather than short-circuiting on the new
non-interactive guard.
A cached-but-unusable OAuth token (expired/revoked, or a refresh the IdP
rejects) makes the MCP SDK fall through to the authorization-code flow even
though build_oauth_auth's guard only checks token-file existence. In a
non-interactive context (systemd gateway, cron, background MCP discovery)
_redirect_handler then printed an auth URL / launched a browser flow no
operator can complete, and _wait_for_callback bound a localhost listener and
blocked for the full 300s timeout — gating gateway adapter startup and, on
retry, colliding on the callback port (OSError: [Errno 98] Address already
in use).
Re-check interactivity at the redirect/callback boundary and raise an
actionable OAuthNonInteractiveError before printing a URL, opening a browser,
or binding a listener. The guard holds regardless of whether a token file
exists (the point the token-file guard cannot cover), and only triggers on
the authorization-code path, so valid/refreshable tokens keep working
non-interactively. Both build_oauth_auth and MCPOAuthManager reuse these
handlers, so the sibling construction path is covered too.
The salvaged max-output/pressure fix set conversation_history=None after
the new pre-API compaction. That is only correct for legacy session-
rotation. Under the default in-place compaction (compression.in_place:
True), archive_and_compact inserts the compacted rows into the session DB
directly without stamping them with the intrinsic persisted-marker, so a
subsequent flush with conversation_history=None re-appends them — doubling
the active context and retriggering compression (the early-persist
duplicate-row trap).
Use conversation_history_after_compression(agent, messages), matching the
two existing compaction sites (post-response should_compress and the
turn-prologue preflight), which returns None for rotation and
list(messages) for in-place so the compacted dicts are skipped by identity.
Adds a regression test with a real SessionDB + real archive_and_compact
that asserts the compacted summary row is persisted exactly once (fails
with 2 copies on the None variant).
Salvage of PR #57893 (envelope-layout prompt-cache marker fix, #57845)
uses the contributor's local git identity lavya@loom.local, which is not
GitHub-resolvable. Add the mapping so contributor_audit passes when the
salvage PR lands.
Follow-up to the salvaged #57845 fix. _can_carry_marker used
any(isinstance(part, dict)) but _apply_cache_marker only marks the LAST
content part, so a list whose last element is a non-dict passed the carrier
gate yet received no marker — wasting one of the four breakpoints. Tighten
the predicate to require content[-1] to be a dict (mirroring the apply
logic) and add a regression test. Flagged by a 3-agent review.
Gateway users can now search resumable sessions from messaging surfaces:
/sessions search <query> (alias: find) matches titles and session ids —
including every title/id in a row's forward compression chain, so a
compressed-away title still surfaces its live tip — plus a
punctuation-normalized variant so 'an94' matches 'AN-94'.
Implemented by generalizing the existing id_query chain-filter in
SessionDB.list_sessions_rich into a combined SQL-level filter (search
stays ORDER BY last-active + LIMIT at SQL level), threading a
search_query through the shared query_session_listing helper, and
teaching parse_session_listing_args to split off a search query.
Search results pass through the existing _resume_row_visible guard
unchanged: origin scoping, admin-only 'all', and the fail-closed
legacy-row posture from the July 1 hardening are preserved exactly.
Over-fetch (50) before the visibility cut so origin-invisible matches
can't starve the page.
Salvages the feature direction of PR #57595 by @GodsBoy with a minimal
implementation that keeps the resume authorization surface untouched.
The Capabilities/MCP/Hub/Skills UX has settled, so lift every
`// TODO(i18n): literal until the UX settles` hardcoded English string into
the typed i18n catalog and drop the comments.
- New keys under `common` (expand, tryHint), `settings.mcp` (capability
summary, status line, all-servers, auth flow, tool chip titles, log empty
label), and `skills` (provenance, sort/bulk labels, empty states, editor
actions). Full translations in en + zh; ja + zh-hant overrides added.
- Module-level pure fns that had no `t` in scope now take the mcp translations
(`capabilitySummary`/`statusLine`) or an `emptyLabel` prop (`McpLogs`); the
archive toast takes `t`.
- Shared `common.tryHint(term)` dedupes the "Try “…”" search hint across
skills/messaging/cron/artifacts.
No behavior or styling change — string lookups only. Zero TODO(i18n) remain.
Post-merge follow-ups + several review rounds + a hub-search rework, folded together.
Merge-scuff restores (a stale-base refactor had reverted two live-on-main fixes):
- gateway: SessionStore compression-tip healing + its regression test.
- desktop: messaging session/transcript polling in desktop-controller
(MESSAGING_POLL / ACTIVE_MESSAGING_SESSION_POLL, refreshMessagingSessions,
refreshActiveMessagingTranscript, the richer sameCronSignature) so inbound
platform traffic updates live again instead of freezing until manual refresh.
Profile-switch isolation (epoch/close/guard on every profile-scoped async):
- Hub store clears + in-flight runHubAction bails (and swallows the post-switch
404 instead of a phantom toast); hub preview/scan/search/sources profile-scoped.
- MCP: probe/auth epoch guards, dirty-draft reset, sidebar mutations blocked
until config resettles AND every persist re-checks the epoch post-await;
profilePending clears on config settle incl. error; logs re-key on profile.
- Model settings reload on switch and epoch-guard setModelAssignment /
saveMoaModels / API-key activation.
- Config draft resets + cancels its autosave on switch; skill editor/archive and
star-map node dialogs close on switch; openSkillEditor / star-map openEdit
discard stale fetches; tool-usage analytics loads are profile-guarded/keyed.
Correctness + UX:
- Unique per-skill action names for hub install AND uninstall; hub/catalog rows
flip only on a clean exit_code; catalog install polls the background bootstrap
to completion, reconciles the mcp.json draft (no dropped server), and fails
loudly on non-zero exit; MCP catalog query keyed by profile.
- /test reports needs-auth for anonymous auth:oauth servers; /auth snapshots +
restores tokens on a failed re-auth and clears the full 300s callback window.
- config-settings shows a retry on load failure; CodeEditor/JsonDocumentEditor
go read-only while saving so edits typed mid-save aren't dropped.
- Deep-link highlighter deletes its param only after a successful scroll.
- Restored the PageSearchShell trailing slot → Artifacts refresh button/spinner.
- /settings?tab=mcp redirect keeps server=.
Progressive hub search: fan out one query per backend-searchable source
(index-covered API sources stay unsearchable → no ~70-call GitHub re-hammer),
merge/dedupe by trust as each lands, per-source spinner overlaid on the dimmed
chip — results stream in without blocking on the slowest, no layout shift.
test(web): /api/skills list carries usage + provenance (CI contract).
A back-to-back run of 3+ adjacent tool calls now collapses into a
fixed-height window that pins the newest call to the bottom and fades
older ones up under a top gradient, so a long run no longer shoves the
reply off screen. Shorter runs are byte-identical to before, and the DOM
shape is the same in both modes (only classes flip) so crossing the
threshold mid-stream never remounts a row. Expanding any row breaks the
window out to full height via a `:has([data-tool-open])` rule.
from a live blocked-sites pass (no PII):
- posture shift: blind opt-out is the DEFAULT, not a fallback -- submit on every site with an
accessible removal channel even without first confirming a listing (own identifiers to the broker's
own official channel = still least-disclosure). guided flows double as the authoritative search.
- blocked-form rule: when a form is automation-hostile (hard captcha / cloudflare / datadome /
slide-to-verify), default to the broker's CITED rights-email rather than recording blocked.
- captcha policy clarified: never defeat behavioral/token/slider challenges; ok to read a static
distorted-text or plain-arithmetic captcha on the subject's own opt-out; stop if the whole
submission is rejected after a correct answer (fingerprinting the automation, not grading it).
- intelius/peopleconnect: delete-wipes-suppression is field-confirmed -- a deletion-complete email
means the suppression is gone and the subject re-lists cluster-wide; re-run suppression and verify
the Control step reads "suppressed". guided-mode session persists; DOB is an <input type=date>.
- new records: addresses.json (intelius front-end, cluster-covered) and socialcatfish.json
(cited rights-email lane + automation-hostile form).
- new references/site-playbooks.md: per-site game-plan matrix (8 blocked-tail sites), the meta-search
no-op skip-list (idcrawl/lullar/yasni/webmii/namesdir/itools/skipease), and the infopay /
peopleconnect backend clusters. OSINT-list triage taxonomy added to methods.md.
- state-machine.md: fixed doc drift + documented submitted->not_found illegal (resolves as
awaiting_processing), blocked->submitted via action_selected, operator_manual_check, --evidence & pitfall.
tests: standalone 99, PR 97 (+1 cluster-coverage regression); ruff + windows-footguns clean.
Route the app off its hand-rolled helpers onto lib/{text,time,format,json-format}
and the new primitives, plus assorted small tidy-ups:
- compactNumber for counts/tokens; normalize/capitalize/asText at the many
filter/label sites; shared Intl date/time formatters; row-hover + framed
editor adoption; scrollbar-gutter + padding parity on list surfaces.
- Messaging/Artifacts/Cron search hints + narrow-viewport tab dropdown;
floating-pet adopts useOnProfileSwitch; number formatting in statusbar,
command-center, agents.
- Electron: native overlay width + backend spawn tidy.
- Settings > Keys: credential fields read as plain subtext (all-unset) until
the group is focused or expanded, then take full input chrome with no
horizontal/vertical shift; inline Remove (trash) + Save mirror SearchField's
trailing-clear pattern instead of a floating hint that overlapped the card;
Esc still cancels. Drops the now-dead or/escToCancel i18n keys.
- Shared TabDropdown/ResponsiveTabs (components/ui): PageSearchShell and the
Command Center log file/level filters reuse the one narrow-width collapse.
- OverlayNav: data-driven pane nav — persistent rail on wide, a single dropdown
riding the titlebar strip on narrow; Settings and Command Center adopt it, and
the mobile dropdown carries the same section icons as the rail. Fixes narrow
vertical centering, redundant mobile section titles, gateway-status wrap, and
Panel master/detail stacking.
- OverlayIconButton is now the titlebar ghost button, matching the close X at
every size. Settings sub-view nav opens section + sub-view in one navigate so
API-keys/accounts actually open on narrow.
- Settings > Model: cube icon (was the {} namespace glyph) and a DOM-shaped
skeleton in place of the centered spinner.
- Command palette / session switcher clear the macOS traffic lights on small
screens.
- Prettier/eslint sweep across the touched files.
phase-2 work (sending webmail, clearing session-bound gates like peopleconnect guided-mode) needs
the operator's own logged-in browser, not a cloud browser. new `pdd.py cdp`:
- finds chrome/chromium/brave/edge (macos/linux/windows), launches it detached on a dedicated debug
profile ($HERMES_HOME/chrome-debug) with --remote-debugging-port, waits for the port, prints the
CDP endpoint (webSocketDebuggerUrl)
- `--check`: report whether a debug browser is already live (never double-launches)
- `--print`: emit the exact command for the operator to run themselves
- doctor, SKILL.md, and methods.md all point at it
- windows-safe detach (start_new_session on posix, DETACHED_PROCESS on windows); stdlib only
tests: standalone 98, PR 96 (+6 cdp); ruff + windows-footguns clean.
from a live run (NY subject, 43 brokers):
- fanout default 8->5 (8+ batches time out)
- setup/doctor read $HERMES_HOME/.env so creds hermes already loads are detected
- new `show <subject> <broker>`: reads back case state+evidence for cheap parent re-verify
- intelius: requires.dob + 5-step guided-mode gate; planner pre-warns when dob is missing
- rehold.json: property-record != PII (an address-only match is not_found, not removable)
- tps/fps: match_signal_notes tell the scanner to ignore SEO-templated titles
- methods.md: browser backends (scan vs execute + operator chrome over CDP), property/SEO callouts
- doctor: warn when browser email-mode pairs with a cloud scan backend (needs operator chrome/CDP)
- ledger: found->not_found retract (false-positive), blocked->human_task_queued
- autopilot: indirect-exposure web-form fallback; drop a stray f-string
tests: standalone 92 pass; ruff clean.
lint.yml inlined github.head_ref (the fork PR branch name, attacker-
controlled) into the diff-summary run: block. GitHub expands ${{ }} into
the script text before bash tokenizes it, so a branch like x$(id) runs on
the lint runner. The pull_request trigger keeps the token read-only, but
the sink still allows CI resource abuse and cache/artifact tampering, and
would become RCE-with-secrets under pull_request_target.
Route head_ref through an env var (env values are not subject to expression
injection) and reference "$HEAD_REF". Apply the same to the two docker.yml
sites that interpolate github.event.release.tag_name.
Fixes GHSA-jpw6-c7jr-c56v, GHSA-2843-hjmf-7x96.
Credit: @technotion, @youngstar-eth.
Addresses two non-blocking review notes on the Hermes Console PR:
- console_engine: the four _*_summaries helpers import a subcommand module
and build a throwaway argparse tree purely to extract help summaries. The
dashboard opens a fresh HermesConsoleEngine per /api/console connection, so
every reconnect re-imported + re-parsed the whole CLI surface. The surface
is process-static, so memoize with functools.lru_cache — callers only read
the returned map.
- web_server: console commands run in a worker thread via asyncio.to_thread.
On a 60s timeout asyncio.wait_for cancels the awaitable, but Python threads
aren't preemptible, so a stuck worker keeps running and would leak into the
shared default thread pool. Route console execution through a small
dedicated bounded ThreadPoolExecutor (max_workers=4) so a leaked worker is
capped and concurrent console execution is bounded regardless of reconnects.
Follow-up on top of @shannonsands' NS-574 Hermes Console.
Self-review follow-up. check_web_api_key() had a hand-rolled 'walk all
registered providers and probe each' fallback that duplicated the registry's
own availability-filtered resolvers (get_active_search_provider /
get_active_extract_provider, backed by _resolve()) — a second resolution path
that could diverge (the hand-rolled walk ignored capability, so a search-only
custom provider was handled inconsistently). Delegate to the registry's
resolvers so there is one authority for 'is a custom provider usable'.
Also: _get_backend()'s tail walk now probes provider.is_available() directly
instead of round-tripping through _is_backend_available(provider.name), which
redundantly re-did the registry get_provider() lookup on a provider object
already in hand. Both fallback loops guard is_available() against exceptions.
Documented that _LEGACY_WEB_BACKENDS intentionally includes 'xai' (probed via
has_xai_credentials, not a registered provider) while the registry's
_LEGACY_PREFERENCE excludes it, so the two built-in sets don't silently drift.
A plugin-registered WebSearchProvider with no built-in provider credentials
must light up web_search / web_extract and be discoverable by the backend
selectors. Covers check_web_api_key(), _get_backend(), _is_backend_available()
registry delegation, per-capability extract selection (#32698), and that the
web_search / web_extract tool registry entries are not filtered out.
Tests contributed by @m0n5t3r (PR #28652, issue #28651).
Plugin-registered web providers (registered via agent.web_search_registry)
were invisible to the tool-availability gate: _is_backend_available() was a
hardcoded env-var if-chain that returned False for any name outside the eight
built-in backends. Because check_web_api_key() is the check_fn for both
web_search and web_extract, a working custom provider with no built-in creds
left both tools filtered out of the toolset entirely.
Fix at the single chokepoint: _is_backend_available() now delegates non-legacy
backend names to the registered provider's is_available(), falling back to the
legacy built-in probes for known names and unregistered providers. Because
_get_backend(), _get_capability_backend(), and check_web_api_key() all resolve
availability through this one function, the fix cascades to every caller —
including the per-capability extract selection that produced a dead-end
'search-only' error (#32698). The two remaining hardcoded whitelist
early-returns (_get_backend, check_web_api_key) now also accept registered
names, and both walk registered providers as a final fallback so a custom
backend still resolves when no built-in has credentials.
Built-in backend priority is preserved unchanged: the registry is consulted
only for names outside _LEGACY_WEB_BACKENDS.
Fixes#28651Fixes#31873Fixes#32698
Asserts the behavior contract that run_one_job installs a profile secret
scope around run_job under multiplexing (so resolve_runtime_provider's
get_secret does not fail-close with UnscopedSecretError) and tears it
down afterward. Mutation-verified: fails on unmodified main with the
exact UnscopedSecretError, passes with the fix.
Once profile isolation is active (multiple gateway profiles or room->profile
multiplexing), get_secret() fails closed outside an installed scope. The cron
ticker fires jobs from a thread with no per-turn scope, so run_job() died in
resolve_runtime_provider() with UnscopedSecretError (e.g. for
OPENROUTER_BASE_URL / CUSTOM_BASE_URL) before model selection - every cron
job failed while interactive turns worked fine.
Wrap run_job() in set_secret_scope(build_profile_secret_scope(...)) with a
finally-reset, mirroring the proven per-turn pattern in gateway/run.py
(_profile_runtime_scope). Single-profile installs are unaffected (the scope
is just the profile's own .env).
tests/cron: 611 passed, 1 pre-existing unrelated failure
(TestRoutingIntents::test_all_token_case_insensitive fails identically on
unmodified main in a full-suite run and passes in isolation).
Fold the xAI video credential-read guard into the same shared
agent.file_safety.raise_if_read_blocked chokepoint this PR introduces for
the image providers, so the whole image+video bug class is covered by one
enforced boundary. Consolidates the parallel salvage of #57695 (xAI
image+video) into this PR; #57727 is now redundant and will be closed.
- video_gen/xai: guard _image_ref_to_xai_url and _video_ref_to_xai_url
(the video image + video byte-read chokepoints) via the shared helper.
- Regression tests: symlinked auth.json with .png/.mp4 names are blocked
across both video read paths (mutation-checked).
Follow-up to the per-provider guards. Three improvements from review:
1. Extract agent.file_safety.raise_if_read_blocked() as a single shared
chokepoint and route the OpenAI, OpenRouter, and (newly) xAI image
providers through it, replacing the 3x-duplicated inline try/except.
Fixes the whole bug class: xai/_xai_image_field read a model-supplied
local path via open() with no guard — the same vulnerability the PR
fixed for OpenAI/OpenRouter, in a sibling provider it missed.
2. Strengthen the regression tests from pass-on-any-ValueError to true
security invariants: spy open()/read_bytes() and assert the blocked
credential is NEVER read; add negative controls (legit local image
still loads; remote/data: URIs pass through unguarded) so a
block-everything regression can't pass.
3. Guard is best-effort by design (defense-in-depth, not a security
boundary) — documented on the shared helper.
- agent/file_safety.py: raise_if_read_blocked()
- plugins/image_gen/{openai,openrouter,xai}: route through helper
- tests: no-read spies + negative controls across all three providers