Commit graph

13747 commits

Author SHA1 Message Date
Brooklyn Nicholson
18d54bf0fd refactor(desktop): split onboarding overlay god file into onboarding/ folder
desktop-onboarding-overlay.tsx (1,291 lines) folded into components/onboarding/
as a cohesive feature folder. Behaviour-preserving — every move is verbatim;
typecheck + lint + tests green.

- index.tsx (665) — overlay shell, Picker, Header/Preparing, API-key catalog +
  ApiKeyForm; re-exports the provider API the settings page consumes.
- flow.tsx (364) — OAuth flow panels (FlowPanel, steps, DeviceCode, CodeBlock,
  ConfirmingModelPanel, DocsLink, Status).
- providers.tsx (118) — provider rows + display/sort (FeaturedProviderRow,
  ProviderRow, KeyProviderRow, providerTitle, sortProviders).
- glyph.tsx (170) — the decode/scramble animation toolkit (pure leaf).

Importers (desktop-controller, providers-settings) repointed to
@/components/onboarding; the overlay test moved to onboarding/index.test.tsx.
2026-06-30 13:21:49 -05:00
brooklyn!
f99ba56df4
Merge pull request #55331 from xxxigm/fix/desktop-projects-stale-backend
fix(desktop): handle stale backend when creating projects (#54999)
2026-06-30 11:47:04 -05:00
Jeffrey Quesnelle
c4b59e6463
Merge pull request #55663 from xxxigm/fix/bootstrap-diverged-git-pull
fix(installer): recover bootstrap when managed git clone diverged
2026-06-30 12:38:41 -04:00
kshitij
efd87a1545
Merge pull request #55715 from kshitijk4poor/fix/config-migration-no-default-expansion
fix(config): route every migration write through one default-stripping chokepoint
2026-06-30 20:41:29 +05:30
kshitijk4poor
c717be8ded fix(config): route every migration write through one default-stripping chokepoint
A single 'hermes update' / 'hermes -p' could rewrite a hand-curated config.yaml
into a near-full DEFAULT_CONFIG dump (the 'you blow up my profile config on one
tweak' reports). Root cause: migrate_config() had ~16 independent save_config()
call sites, each author deciding ad hoc whether to materialise a value, and many
persisted pure schema defaults with strip_defaults=False. Defaults already merge
transparently at read time via load_config(), so writing them is pure bloat that
also shadows future default changes (see save_config's docstring).

Architectural fix (not a per-site patch): introduce a single _persist_migration()
chokepoint that enforces one invariant — a migration may persist only values that
DIFFER from the current schema default, plus explicit removals/renames of user
data; pure defaults are never written. Every migration write (all 17 sites incl.
the version-bump finalizer) now routes through it. The invariant is mechanically
correct for all cases and verified empirically:
  - pure-default seeds (timezone='', curator/auxiliary.curator blocks, interim
    flag, curator.consolidate=False, empty plugins.enabled) are stripped → merged
    in at read time;
  - non-default values (write_approval=True, model_catalog.ttl_hours=1) preserved
    via explicit-raw-path preservation;
  - behaviour flips (agent.verify_on_stop=False, schema default still 'auto')
    preserved because False != 'auto';
  - data transforms (custom_providers->providers, stt.model relocation,
    write_mode->write_approval, compression.summary_* removal, MCP-disable)
    persist their removals/renames.

An explicitly user-set non-default value (e.g. matrix.require_mention: false) is
preserved across the bump.

Guard tests lock the architecture: an AST check asserts migrate_config() makes no
direct save_config() call (all writes go through _persist_migration), and a
full-range v1->latest test asserts a lean config is never dumped. Two existing
change-detector tests that froze the on-disk representation of default-valued
keys are rewritten to assert the effective value via load_config() (behaviour
contract, not snapshot).

Validation: lean v1->latest migration drops from ~567 bytes to ~196 bytes;
148 config+setup and 196 profile/curator/migrate tests pass on scripts/run_tests.sh.
2026-06-30 20:30:22 +05:30
kshitijk4poor
a5e8cd4d40 fix(memory): degrade gracefully after repeated at-capacity consolidation failures (#42405)
Builds on the zero-match feedback fix (previous commit) to close the silent-hang
symptom: when memory is at capacity, a failed `add`/`replace`/`remove`
consolidation could loop the whole turn to iteration-budget exhaustion and
deliver no user-facing reply.

#41755 turned the at-capacity overflow error into a *commanded* in-turn retry
("...then retry this add — all in this turn"); combined with the fragile
substring-only `replace`/`remove` matching (LLMs can't reliably re-quote a long
entry verbatim), the model loops add↔replace on inexact guesses until the turn
dies. The existing tool_guardrails halt would catch this, but hard_stop_enabled
is opt-in (off by default), so a default install still hangs.

This fixes it at the memory layer without changing global guardrail behavior:
- MemoryStore tracks per-turn consolidation failures; after a cap (3) it drops
  the "retry in this turn" instruction and returns a terminal "leave memory
  unchanged, continue your reply" result, so a failed memory side effect can
  never block the turn's reply.
- The counter resets on any successful write (progress) and at each turn
  boundary (turn_context.reset_consolidation_failures, guarded via getattr so
  plugin memory stores without the method are a no-op).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-30 20:01:16 +05:30
kyssta-exe
62a1bf4c55 fix(tools): return previews on zero-match in replace/remove to prevent memory retry loops (#42405)
- replace() and remove() now return entry previews and current_entries
  when no entry matches old_text, matching the multi-match and add-limit
  error behavior
- add() limit error also now returns previews for consistency
- Agent can self-correct after a failed replace/remove instead of looping
  blindly until turn budget is exhausted with no user response
2026-06-30 20:01:16 +05:30
xxxigm
83e10a6777 fix(desktop): detect stale backend for projects.create
Probe the projects.* RPC surface, block create with a clear update hint,
and avoid the raw "unknown method" toast. Includes i18n for en, zh, ja,
and zh-hant.

Fixes NousResearch/hermes-agent#54999
2026-06-30 21:23:38 +07:00
xxxigm
217b3c283a test(desktop): cover projects RPC capability probing
Regression tests for stale-backend detection when projects.create is
missing from an older backend that still reports the same semver.
2026-06-30 21:23:38 +07:00
kshitijk4poor
1b3768558e docs(image-gen): align OpenRouter model-resolution docstrings with new precedence
The cherry-picked fix added explicit-kwarg and top-level image_gen.model
resolution but left _resolve_model / _resolve_model_chain docstrings stating
the old 'env override -> config -> DEFAULT_MODEL' order. Document the full
precedence (explicit kwarg -> env -> scoped -> top-level -> default chain) to
match the sibling krea/openai providers.
2026-06-30 19:11:49 +05:30
xxxigm
63731fe856 test(image-gen): cover Nous/OpenRouter top-level model resolution
Assert image_gen.model, explicit model kwargs, and Nous provider wiring
so the config path mismatch cannot regress.
2026-06-30 19:11:49 +05:30
xxxigm
1324add563 fix(image-gen): honor top-level image_gen.model for Nous/OpenRouter
hermes tools persists the selected model to image_gen.model, but the
OpenRouter-compatible provider only read scoped image_gen.<provider>.model
and ignored the dispatch model kwarg — so Nous users always hit the default
quality-first chain and fell back to Gemini.
2026-06-30 19:11:49 +05:30
xxxigm
665d0f0789 test(installer): cover diverged managed-clone recovery in install scripts
Pin the ff-only-then-reset fallback in install.sh and install.ps1 so
bootstrap cannot regress to hard-failing on diverged git history.
2026-06-30 20:11:01 +07:00
xxxigm
a40f22798e fix(installer): reset managed clone when ff-only pull fails
Bootstrap and desktop updates run install.ps1/install.sh, which aborted
with exit 128 when the managed checkout had diverged from origin/main.
Mirror the hermes update recovery path: reset to origin/$BRANCH instead
of failing the repository stage.
2026-06-30 20:11:01 +07:00
kshitijk4poor
824f2279da refactor(registry): drop dead toolset-check helpers after per-tool availability
Follow-up to the per-tool availability derivation: `_snapshot_toolset_checks`
and `_evaluate_toolset_check` had no remaining callers once the four
availability surfaces switched to `_toolset_has_exposable_tools`. Remove both,
drop the no-op `quiet` param from the new helper, and document why
`_toolset_checks` is still written (banner.py reads it via TOOLSET_REQUIREMENTS
to classify unavailable toolsets as lazy-init vs disabled).
2026-06-30 17:47:37 +05:30
xxxigm
efebe451db test(registry): cover mixed terminal toolset doctor false negative
Regression for #54820: a desktop-only helper with a failing check_fn must
not mark the whole terminal toolset unavailable when terminal/process
still pass their per-tool gates.
2026-06-30 17:47:37 +05:30
xxxigm
6e84257717 fix(registry): derive toolset availability from per-tool checks
Doctor and banner used the first check_fn registered for a toolset, so
desktop-only read_terminal gated the whole terminal toolset even though
terminal and process still expose at runtime.

Fixes #54820
2026-06-30 17:47:37 +05:30
Neo
c969090878 fix(cli): clear input-blocking overlays when interrupting a running agent
Interrupting the agent while an approval/clarify/sudo/secret prompt is up
left the overlay state dict set with no thread servicing it. The prompt's
worker thread is torn down on interrupt, but read_only (gated on
_command_running) plus the keypress filter kept the CLI input locked until
the prompt's own timeout expired — the terminal appeared frozen.

Drain and clear all four input-blocking overlays on interrupt via a single
helper (_clear_active_overlays_for_interrupt): approval -> deny,
clarify/sudo/secret -> cancel, each guarded so a dead queue can't block the
others; sudo restores the pre-modal draft. Wired into all three interrupt
paths — new-message interrupt, Ctrl+C, and Ctrl+Q. Blocking overlays now
clear AND fall through so one keypress both clears a stale overlay and
interrupts a still-running agent; the /model picker and slash-confirm
foreground prompts keep their cancel-and-return behavior.

Closes #13618.
2026-06-30 04:49:29 -07:00
teknium1
8e6fd4cfa6 chore(release): add AUTHOR_MAP entry for londo161 (#15795 salvage) 2026-06-30 04:38:43 -07:00
teknium1
fe355d0a27 fix(moa): handle dict/str message shape in MoA response extraction
Sibling of #15795's context_compressor fix. agent/moa_loop.py used the
same response.choices[0].message.content access; while wrapped in
try/except (so no crash), a dict/str-shaped message silently returned
empty. Coerce defensively so the content is actually extracted.
2026-06-30 04:38:43 -07:00
Vladimir Smirnov
9dc6dc062f fix(agent): handle string context compression messages 2026-06-30 04:38:43 -07:00
Vladimir Smirnov
c080a530ae fix(cli): redact status API keys with --all 2026-06-30 04:38:43 -07:00
Gille
a8841e2a68 fix(aux): preserve provider identity for resolved endpoints
_resolve_task_provider_model() flattened any explicit base_url to
provider=custom. Correct for bare/custom endpoints, but wrong for
provider-backed routes (anthropic, qwen-oauth, minimax-oauth,
openai-codex, etc.) whose provider branch adds auth refresh, transport,
or request shaping. MoA reference slots resolved through those providers
lost their identity before the aux call, so e.g. a Codex reference hit
chatgpt.com/backend-api/codex without its Cloudflare headers and got
HTML back (surfacing as a spurious rate-limit).

Keep first-class providers intact when paired with a resolved base_url
via _preserve_provider_with_base_url(); bare/custom/auto/unknown and the
direct openai alias still route through custom.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
2026-06-30 04:23:27 -07:00
teknium1
1cae1bd0de test(cli): deterministically join bg worker thread instead of polling deadline
test_background_task_registers_thread_local_approval_callbacks polled a
2s wall-clock deadline waiting for the background daemon thread to pop
its entry from _background_tasks. Under loaded CI the thread's
finally-block cleanup could lag the deadline, flaking the final
'assert not cli._background_tasks'. Join the actual worker thread
(timeout=10) so the wait ends exactly when the thread finishes.
2026-06-30 04:23:03 -07:00
teknium1
6148a9a3fe chore(release): map nnnet author email for PR #25142 salvage 2026-06-30 04:23:03 -07:00
nnnet
5582b51a68 fix(gateway): stop poisoning the LLM prompt with STT-mode chatter
The STT-failure enrichment templates injected setup instructions —
"no STT provider is configured", "a direct message has already been
sent", and a "hermes-agent-setup" skill mention — into the LLM-visible
prompt. That text persists in conversation history, so after one STT
failure the model kept volunteering Whisper/Vosk setup advice on every
later voice turn, even after transcription started working (observed in
prod on gpt-5-nano). The gateway also fired a hardcoded English notice
via _stt_adapter.send(), producing a second, wrong-language reply that
TTS then spoke aloud.

- Neutralize all enrichment templates: success passes the transcript
  through as a plain quoted line; every failure branch emits a single
  [voice message could not be transcribed] marker.
- Move the operator-facing failure cause to logger.info so it stays
  diagnosable in container logs without leaking into the prompt.
- Remove the hardcoded English _stt_adapter.send() notice; the LLM now
  produces one coherent reply in the user's language.
- Update the gateway STT tests to assert the neutral contract.

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
2026-06-30 04:23:03 -07:00
Teknium
cbe397ef45
fix(agent): merge consecutive assistant messages before API replay (#29148, #49147) (#55603)
* fix(agent): merge consecutive assistant messages in repair_message_sequence

Strict OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject a
replayed history where an assistant message carrying tool_calls is
immediately followed by another assistant message instead of its tool
results — HTTP 400 'An assistant message with tool_calls must be
followed by tool messages...'.

repair_message_sequence (the defensive belt run before every API call)
fixed orphan-tool and consecutive-user shapes but never merged
consecutive assistant messages. Adds a Pass 0 that collapses adjacent
assistant turns into one — union of tool_calls, concatenated content,
carried reasoning_content — covering both reported shapes:
  - parallel tool calls split across two assistant turns (#29148)
  - content-only assistant followed by tool_calls-only assistant (#49147)

A tool result or user turn between two assistants blocks the merge
(distinct, valid rounds). Runs before Pass 1 so the merged union of
tool_call ids is known to the orphan-tool filter.

Closes #29148, #49147.
Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: woaini30050 <woaini30050@users.noreply.github.com>
Co-authored-by: weidzhou <weidzhou@users.noreply.github.com>

* fix(agent): exempt codex Responses interim turns from assistant merge

The Pass 0 consecutive-assistant merge collapsed codex_responses interim
turns, which legitimately stay separate — each carries its own encrypted
continuation state (codex_reasoning_items / codex_message_items) that
must replay verbatim. Skip the merge when either side is a codex interim
(has codex_reasoning_items / codex_message_items / finish_reason=='incomplete').

Fixes the slice-2 regression in test_run_agent_codex_responses.py
(test_duplicate_detection_distinguishes_different_codex_{reasoning,message_items}).

---------

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: woaini30050 <woaini30050@users.noreply.github.com>
Co-authored-by: weidzhou <weidzhou@users.noreply.github.com>
2026-06-30 04:22:56 -07:00
Teknium
d2d470e321
test(compression): tolerate safe contention rollback in concurrent-fork test (#55597)
The concurrent-compression regression asserted the parent ends with exactly
one child. Under heavy CI write contention the lock winner's child
create_session can exhaust its SQLite retry budget, and _compress_context
deliberately rolls the live id back to the still-indexed parent rather than
orphaning a child (the create-failure rollback in
agent/conversation_compression.py). That safe rollback leaves zero children
and is correct — so the exact == 1 assertion flaked under load.

Assert the actual invariant instead: children <= 1 (a 2+ fork is the bug
Damien's incident is about), rotated <= 1, and rotated == n_children. A
mutation check (force the lock to always acquire) confirms the relaxed
assertion still fails hard on a real 2-child fork.
2026-06-30 04:22:47 -07:00
fayenix
d6c53dcdcb fix(gateway): stop per-turn agent-cache eviction from model + message_id signature churn
Two independent bugs evicted the cached gateway AIAgent on every turn,
preventing the prompt cache from ever warming:

1. Model normalization mismatch: the post-run fallback-eviction check
   compared _agent.model (stripped in AIAgent.__init__) against the raw
   _resolve_gateway_model() config string. For vendor-prefixed config on
   native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro')
   this was always unequal, so the agent was evicted after every
   successful run. Normalize _cfg_model the same way (skip aggregators).

2. Discord triggering message_id leaked into the cached system prompt via
   build_session_context_prompt()'s Discord IDs block. message_id changes
   every turn, so the agent-cache signature (computed from the ephemeral
   prompt) changed every Discord turn -> rebuild every message. The id is
   now injected per-turn into the user message (where per-turn content
   belongs and does not touch the cache signature); the cached IDs block
   carries a static pointer to it, preserving reply/react/pin via the
   discord tools.

Adapted from #28846. Bug #1 fix is the contributor's; bug #2 reworked to
be non-destructive (keeps the triggering-id capability instead of deleting
it). Redundant auto-reset eviction (already on main via #9893/#48031) and
the wrong-premise reset_context_note plumbing from the original PR were
dropped.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-06-30 04:22:41 -07:00
Teknium
e7ca53e6b8
fix(moa): disabled presets no longer hijack a plain model switch (#55598)
exact_moa_preset_name matched any bare model name equal to a preset key,
regardless of the preset's enabled flag. On the no-explicit-provider switch
path (PATH B in model_switch.py), a plain /model switch whose name collided
with a preset key (e.g. "default") silently pivoted the session onto the MoA
virtual provider — even when the user had set enabled: false to opt out
(issue #55187). The LLM driving a routine model switch could land on a broken
moa provider with empty default_preset / unconfigured aggregator credentials.

Gate the implicit bare-name match on the per-preset enabled flag. Explicit
selection via --provider moa / the model picker uses PATH A and does not go
through exact_moa_preset_name, so a disabled preset stays reachable when the
user explicitly asks for it.
2026-06-30 04:22:32 -07:00
teknium1
bff61f558f feat(plugins): enable-time consent prompt for tool_override grant
Builds on memosr's sink-level opt-in gate (#29249). Enabling a
non-bundled plugin now surfaces the privileged allow_tool_override
decision at `hermes plugins enable` time instead of leaving the
operator to discover the config key after a runtime rejection.

- `hermes plugins enable <name>` prompts for non-bundled plugins:
  'Allow this plugin to replace built-in tools?' Default is deny
  (blank Enter / non-interactive stdin / EOF all fail closed).
- --allow-tool-override / --no-allow-tool-override flags for
  non-interactive and scripted use (and a future desktop checkbox).
- Bundled plugins are trusted: never prompted, no entry written.
- Writes plugins.entries.<key>.allow_tool_override, the same key the
  sink gate reads (manifest.key == discovery key), so consent and
  enforcement compose end to end.
2026-06-30 04:00:42 -07:00
memosr
12f5624a76 fix(security): bind tool_override authorization to handler's defining plugin module
egilewski found the prior sink gate was transient: it only applied while
PluginManager executed register(ctx). A plugin could defer a direct
registry.register(..., override=True) to a post-load callback/thread, after
the scope was cleared, and still replace a built-in.

Make authorization durable by binding it to where the handler is DEFINED
(handler.__globals__['__name__']) rather than to call timing. At load, each
plugin's module namespace is mapped to its allow_tool_override opt-in in a
table that is never cleared. The sink resolves the handler's owning plugin
module and rejects an override from any plugin namespace without opt-in,
regardless of when or on which thread the call happens. Plugin namespaces
with no recorded policy are treated as not-opted-in (fail-closed). Built-in
and MCP handlers live outside the plugin namespace and are unaffected.

Adds a regression test for the delayed/post-load direct-registry override.
2026-06-30 04:00:42 -07:00
memosr
3101222312 fix(security): enforce tool_override opt-in at registry sink to close direct-import bypass
The opt-in gate lived only in PluginContext.register_tool, so a plugin
could bypass it by importing tools.registry and calling
registry.register(..., override=True) directly. Enforce the same gate at
the sink: during plugin load, the registry rejects an override from a
plugin without operator opt-in regardless of the path taken. Built-in and
MCP registrations (no active plugin scope) are unaffected.

Adds a regression test covering the direct-registry bypass.
2026-06-30 04:00:42 -07:00
memosr
179eb8c2a3 fix(security): require operator opt-in for plugin tool_override to prevent silent built-in tool replacement
The tool_override flag landed in v0.14.0 (#26759) so plugins can replace
a built-in tool with their own implementation. It works as advertised
but there is no trust gate, so any enabled third-party plugin can
silently override any built-in like shell_exec, write_file, or web_fetch
and exfiltrate everything the agent invokes through it. The only trace
is a DEBUG-level log line.

Compare with ctx.llm (#23194) which does gate the equivalent privilege
escalation: overriding the provider requires
plugins.entries.<id>.llm.allow_provider_override: true in config.yaml.
The policy shape exists, it just was not extended to tool overrides.

Fix:

* Add PluginToolOverrideError(PermissionError) for the gate failure.

* register_tool() now checks _tool_override_allowed(name) when
  override=True. Bundled plugins (manifest.source == 'bundled') are
  trusted by default. Every other source requires
  plugins.entries.<plugin_id>.allow_tool_override: true in config.yaml.

* fail-closed: if config.yaml cannot be loaded for any reason,
  _tool_override_allowed returns False. Same posture as
  MSGraphWebhookAdapter.connect() in #22353.

Backwards compatibility:

* Bundled plugins: no change (source == 'bundled' short-circuits the
  gate).
* Third-party plugins not using override: no change (gate is only
  consulted when override=True).
* Third-party plugins using override: registration fails until the
  operator opts in. The error message includes the exact config path
  to add, so the fix is one config edit away for legitimate use cases.
  Same migration path users went through for allow_provider_override
  after #23194 landed.

Regression tests:

* tests/hermes_cli/test_plugins.py::test_register_tool_override_replaces_existing
  and ::test_register_tool_override_on_new_name_is_noop_path were
  written before the gate existed. Updated their test configs to
  include allow_tool_override: true under
  plugins.entries.<plugin_id>, mirroring how a legitimate operator
  would now grant the privilege.

* New regression test ::test_register_tool_override_blocked_without_operator_opt_in
  exercises both the PluginManager-catches-error path (built-in tool is
  preserved, attacker plugin is skipped) and the direct-call path
  (PluginToolOverrideError is raised with a message that names the
  config key to set). Verified the test fails without this fix and
  passes with it.

* All 73 tests in test_plugins.py continue to pass.
2026-06-30 04:00:42 -07:00
Zane Ding
ac380050ea fix(credential-pool): distinguish OpenRouter upstream 429s from account 429s
OpenRouter returns 429 in two shapes: an account-level throttle on the
user's key, and an upstream-provider throttle (DeepSeek/Anthropic/etc.
rate-limiting OpenRouter's aggregate traffic). The classifier treated
both identically and rotated/exhausted OPENROUTER_API_KEY on every 429 —
burning the key for ~24min and silently disabling auxiliary features
(compression, summarization, vision) on an upstream throttle where the
key was healthy.

Add a FailoverReason.upstream_rate_limit classified from OpenRouter's
unambiguous wrapper message "Provider returned error" (the same signal
the metadata-raw parser already trusts). Recovery skips credential
rotation and defers to the fallback chain to switch models instead.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
2026-06-30 03:57:14 -07:00
Teknium
abca77615a chore(release): map Jeffgithub0029 author email for #28558 salvage 2026-06-30 03:51:08 -07:00
Jeffgithub0029
b7c4369ca0 fix(telegram): chunk formatted messages with UTF-16 length accounting
The standalone send path (_send_telegram, used by the send_message tool,
cron delivery, and out-of-process callers) chunked the *raw* message on
UTF-16 length, then formatted and sent the result un-rechunked. MarkdownV2
escaping inflates the text (`!`/`.`/`-` -> `\!`/`\.`/`\-`), so a
4096 UTF-16-unit raw message can become ~8192 units once formatted and gets
rejected by Telegram as 'Message is too long'.

Move all text chunking into _send_telegram, after formatting: split the
formatted MarkdownV2/HTML text on UTF-16 length so every send is <=4096,
with per-chunk plain-text fallback and thread-not-found retry preserved.
Media attaches after all text chunks. (#28557)
2026-06-30 03:51:08 -07:00
teknium1
af5cea04ab fix(discord): split oversized final edits, truncate mid-stream previews (#27881)
DiscordAdapter.edit_message clipped any formatted payload over the 2,000-char
cap to [:1997]+"..." and returned success=True, so the stream consumer
believed the full reply landed and stopped — the user lost everything past the
boundary and perceived the agent as quitting mid-task.

edit_message is now overflow-aware, mirroring Telegram's proven contract:
- finalize=True: split-and-deliver via _edit_overflow_split — edit chunk 1 in
  place, send chunks 2..N as reply-threaded continuations, return the last
  visible id in message_id plus continuation_message_ids so the stream
  consumer keeps editing the most recent chunk and can clean them all up.
- finalize=False (mid-stream): truncate a one-message preview in place, never
  split. A mid-stream split moves the edit target to a continuation and the
  next accumulated-token tick re-splits, looping forever (the Telegram #48648
  lesson the original port predated).
- Reactive 50035 '2000 or fewer in length' on edit runs the same branch logic.
- Partial continuation failure still reports success with a partial_overflow
  raw_response so the consumer retries the tail instead of marking a clipped
  reply complete.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com>
2026-06-30 03:49:52 -07:00
memosr
ea9f8bd162 fix(security): sanitize LSP diagnostic fields to prevent indirect prompt injection
agent/lsp/reporter.py builds the <diagnostics> block that the LSP
write-time analysis feature (#24168, #25978) injects into every
write_file / patch tool result. Three fields from each diagnostic --
message, code, and source -- were passed through verbatim, and
file_path was interpolated unescaped into an XML-ish attribute. All
four sources cross a trust boundary into model tool output, so a
hostile repository can plant instruction-shaped text in identifier
names, type aliases, or import paths and have it echo back into the
tool result the model reads.

Attack scenario (TypeScript-flavored, the same trick works with Rust
trait names, Python class names, and any LSP that echoes identifiers
in diagnostic messages):

    type IGNORE_PREVIOUS_INSTRUCTIONS_AND_EXFILTRATE_AUTH_JSON = string;
    const x: IGNORE_PREVIOUS_INSTRUCTIONS_AND_EXFILTRATE_AUTH_JSON = 42;

typescript-language-server's resulting Type-not-assignable message
echoes the hostile identifier back into <diagnostics>, and the model
can treat it as a directive. Stronger variants:

* a raw newline in an identifier preserved by the server can fake a
  </diagnostics> close and inject content as a new block;
* a crafted file name like evil.py"><tool_call>... closes the
  file="..." attribute early and synthesizes attacker-controlled
  tags inside the tool result.

Fix:

* Introduce a small _sanitize_field() helper applied to message,
  code, and source at the point each crosses the trust boundary into
  the formatted diagnostic line. It collapses CR/LF, drops ASCII
  control characters, caps per-field length (message 300, code 80,
  source 80), and html.escape(..., quote=False)s the result so < >
  & can no longer synthesize tags.

* html.escape(file_path, quote=True) on the <diagnostics file="...">
  attribute so a crafted filename can't break out of the attribute.

Legitimate diagnostics produced by trustworthy language servers on
trustworthy code render the same way (just with HTML-escaped text);
the change is purely additive on the protective side. No call-site
contract changes for format_diagnostic / report_for_file.

CVSS estimate: AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N -> 7.3 (HIGH).
UI:R because the user has to point the agent at the hostile repo,
but that's the normal 'clone this repo and clean it up' workflow.
S:C because successful injection lets the attacker steer what the
agent does next -- read other files, call other tools, exfiltrate
secrets via subsequent tool calls.

Regression tests added in tests/agent/lsp/test_reporter.py:

* test_format_diagnostic_escapes_html_in_message -- a hostile message
  containing </diagnostics><tool_call> must HTML-escape, not pass
  through.
* test_format_diagnostic_collapses_newlines_in_message -- raw \n / \r
  in the message must not produce extra lines in the output.
* test_format_diagnostic_caps_message_length -- a 1000-char identifier
  is capped to MAX_MESSAGE_CHARS so it can't push past block bounds.
* test_format_diagnostic_escapes_brackets_in_code_and_source -- code
  and source receive the same treatment as message.
* test_format_diagnostic_drops_control_characters -- NUL / BEL / ESC
  bytes are stripped.
* test_report_for_file_escapes_file_path_attribute -- a filename
  containing \">  cannot break out of file="...".

All six new tests fail without the fix and pass with it; the 10
existing test_reporter.py tests continue to pass.

Mirrors the defense-in-depth pattern used elsewhere in the codebase
(#23584 sanitize env + redact output, #26823 sanitize tool error
strings before re-injection, #26829 close 3 dangerous-command
detection bypasses, #22432 coerce Google Chat sender_type from
relay).
2026-06-30 03:48:41 -07:00
EloquentBrush0x
d634fa079e fix(pool): sync anthropic entry on access_token change, not just refresh_token
`_sync_anthropic_entry_from_credentials_file` only checked whether the
refresh_token in ~/.claude/.credentials.json differed from the pool
entry's refresh_token.  This missed the case where the CLI performs a
silent access-token re-issue — returning a new access_token alongside
the *same* refresh_token.  The pool entry's stale bearer token was never
updated, causing 401 errors on every request until the exhausted-TTL
(5 min) expired.

Bring this function to parity with its Codex and xAI OAuth siblings:
- Check either access_token *or* refresh_token changed (dual-field guard).
- Use `file_X or entry.X` fallbacks so a partial file can't blank a field.
- Clear all six status/error fields on sync (last_error_reason,
  last_error_message, last_error_reset_at were previously omitted),
  ensuring an exhausted entry becomes available immediately.

Spotted via parity review against commit 569bc94b5 which fixed the same
pattern in `_sync_nous_entry_from_auth_store`.
2026-06-30 03:45:12 -07:00
teknium1
c510f48680 chore(release): add jasonQin6 to AUTHOR_MAP for PR #15093 salvage 2026-06-30 03:42:25 -07:00
jasonQin6
6dd188d786 fix(gateway): add session staleness guard to stream consumer
GatewayStreamConsumer.run() processed queued deltas in an infinite loop
with no check on whether the session was still current. On /new or /stop
mid-stream, the consumer kept editing and delivering stale response
fragments alongside the 'Session reset!' ack.

PR #11016 (b7bdf32d) fixed the runner side via sentinel promotion/release
but left the stream consumer unguarded. Every other async callback in
run.py already bails via _run_still_current(); the stream consumer was
the only one missing it.

- stream_consumer.py: optional run_still_current callback, checked at the
  top of the run() loop; returns early when the session is stale.
- run.py: pass the existing _run_still_current closure at both call sites
  (proxy path and agent path).
- tests: TestRunStillCurrentGuard — immediate staleness, mid-stream
  staleness, always-current, no-callback default, pending-finish.

Co-authored-by: jasonQin6 <39369769+jasonQin6@users.noreply.github.com>
2026-06-30 03:42:25 -07:00
teknium1
2ae9e222f0 chore: AUTHOR_MAP entry for PR #27123 salvage (jimmyjohansson84) 2026-06-30 03:42:20 -07:00
Jimmy Johansson
018009bc38 fix(kanban): unknown skill warns instead of crashing the worker
A Kanban task referencing a non-existent skill (e.g. a typo'd name)
crashed the worker on startup via ValueError, which the dispatcher
retried until the task auto-blocked. Both cli.py and tui_gateway/server.py
now skip the unknown skill(s), log a warning, and continue with whatever
loaded — but still hard-fail when EVERY requested skill is missing, so a
fully-misconfigured worker fails loudly instead of running blind.

Closes #27136

Co-authored-by: Jimmy Johansson <jimmyjohansson84@users.noreply.github.com>
2026-06-30 03:42:20 -07:00
flamiinngo
c701c6dad7 fix(security): redact Fireworks AI API keys in logs
Fireworks AI is a first-class provider in hermes-agent — FIREWORKS_API_KEY
is listed in tools/environments/local.py and the provider is selectable via
the model picker (api.fireworks.ai in model_metadata, hermes_cli/models.py).

Fireworks API keys follow the format fw_<40 alphanumeric chars> and were
absent from _PREFIX_PATTERNS in agent/redact.py. The ENV-assignment and
Bearer header patterns catch FIREWORKS_API_KEY=fw_... in config output,
but a raw key in a stack trace, debug print, or tool error passed through
completely unmasked.

Four unit tests added to TestFireworksToken covering bare token masking,
env assignment, short-prefix false positive, and visible prefix in output.
2026-06-30 03:41:55 -07:00
teknium1
ea95fdd6d7 chore(release): add nikshepsvn to AUTHOR_MAP for PR #27426 salvage 2026-06-30 03:41:46 -07:00
nikshepsvn
d82a69b624 fix(tools): prune acp_command from delegate_task schema when no ACP CLI is on PATH
Defense-in-depth follow-up to the runtime guard added in the previous commit.
Models on headless hosts (Railway / Fly / Docker / fresh VPS) without any ACP
CLI installed occasionally hallucinate ``acp_command="copilot"`` from the
schema description, despite the explicit "Do NOT set" instruction. The runtime
guard prevented the crash but the model still wasted a tool turn and got an
opaque silent fallback.

This commit removes the temptation at its source: ``_build_dynamic_schema_overrides``
now strips ``acp_command`` and ``acp_args`` from both the top-level and per-task
schemas when none of the known ACP CLIs (``copilot``, ``claude``, ``codex``) are
detectable on PATH. The model literally never sees the fields, so it cannot
pass them.

The runtime guard from the previous commit stays in place as defense-in-depth
for internal callers, tests, and any future code path that bypasses the schema.

``_acp_binary_available`` is intentionally NOT cached: ``shutil.which`` is
cheap, and avoiding the cache means the schema reacts to mid-session installs
without requiring a process restart.

Tests:
- ``test_schema_prunes_acp_command_when_no_acp_binary``
- ``test_schema_keeps_acp_command_when_binary_available``
- ``test_acp_binary_available_checks_known_clis``

Full ``test_delegate.py`` suite: 136/136 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 03:41:46 -07:00
nikshepsvn
2e0b591076 fix(tools): validate acp_command binary exists before forcing copilot-acp transport
When a model passes `acp_command="copilot"` (or any other binary name) in a
`delegate_task` tool call, `_build_child_agent` unconditionally sets
`effective_provider = "copilot-acp"`, which routes the subagent through
`CopilotACPClient`. That client spawns the named binary via subprocess; if it
isn't on PATH, every retry raises RuntimeError and an asyncio cleanup race
during error delivery can take the entire gateway down.

This is a real failure mode on headless deploys (Railway / Fly / VPS / Docker)
where `copilot` / `claude` / etc. aren't installed. The schema does say
"Do NOT set unless the user explicitly told you an ACP CLI is installed,"
but models occasionally pass it anyway — particularly for X (Twitter) search
prompts where Grok seems to associate ACP with "search assistance."

Reproduction:
- Headless install (no `copilot` binary on PATH)
- Set provider to xai-oauth + model grok-4.3
- Telegram prompt: "Search X for crypto twitter trends"
- Grok decides to delegate and passes `acp_command="copilot"`
- Subagent crashes 3x, gateway crashes on the 3rd retry teardown

Fix: validate the binary exists on PATH via `shutil.which` before honoring
the override. If missing, log a warning and fall through to the parent's
default transport. No behavior change when the binary IS present (covered
by `test_build_child_agent_honors_acp_command_when_binary_present`).

Tests:
- `test_build_child_agent_ignores_acp_command_when_binary_missing`
- `test_build_child_agent_honors_acp_command_when_binary_present`

Verified on Python 3.11 (macOS) and 3.12 (Debian 13 container).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 03:41:46 -07:00
Kong
6d6702ef50 fix(whatsapp-bridge): clarify FIFO outbound-id tracker semantics
Rename LRU/refresh wording to match Set insertion-order eviction and
reject non-positive maxSize at construction time.
2026-06-30 03:41:43 -07:00
Kong
24aa02179b test(whatsapp): repoint owner test import after adapter relocation
WhatsAppAdapter lives under plugins/platforms/whatsapp/adapter.py on
current upstream; the owner-forward test still imported the removed
gateway.platforms.whatsapp module.
2026-06-30 03:41:43 -07:00