Commit graph

6915 commits

Author SHA1 Message Date
Christopher-Schulze
8dcbc910bf fix(tui): guard slash_worker sys.path against local package shadowing
The slash-command worker is spawned as `-m tui_gateway.slash_worker` and
inherits the user's CWD. A local package in that CWD (e.g. a project shipping
its own `utils/`, `proxy/`, or `ui/`) shadows the installed hermes module, so
`import cli` crashes the worker with:

    ImportError: cannot import name 'atomic_replace' from 'utils'

The child then exits 1 in a crash loop. #15989 added this sys.path guard to the
sibling entrypoint tui_gateway/entry.py but not to this worker, which is spawned
as a separate process and so starts with CWD back on sys.path.

Apply the same guard (insert HERMES_PYTHON_SRC_ROOT, strip ''/'.') before the
first non-stdlib import. Add a regression test that imports the worker from a
CWD containing colliding packages.

Fixes #51286
2026-07-01 05:29:22 -07:00
Steve Lawton
c73e74386b feat(vertex): add Google Vertex AI provider for Gemini (OAuth2)
Adds Vertex AI as a first-class provider for Gemini models via Vertex's
OpenAI-compatible endpoint. Vertex authenticates with short-lived OAuth2
access tokens (service-account JSON or ADC), not a static API key — the
missing piece behind the recurring requests (#13484, #12639, #56259).

- agent/vertex_adapter.py: OAuth2 token minting + refresh-on-expiry
  (5-min margin), ADC->service-account fallback, global vs regional
  endpoint URLs. Config precedence: env var > config.yaml > default.
- plugins/model-providers/vertex/: provider profile (auth_type=vertex),
  reuses Gemini's extra_body.google.thinking_config translation.
- runtime_provider: vertex short-circuit BEFORE the credential pool so a
  credentials-file path is never mistaken for a static API key; mints a
  fresh token + computes base_url per resolve.
- run_agent + conversation_loop: _try_refresh_vertex_client_credentials()
  re-mints the token and rebuilds the client on a mid-session 401, so a
  long-lived gateway agent survives token expiry (~1h).
- auxiliary_client: vertex auth_type branch for side-LLM tasks.
- config.yaml: vertex.project_id / vertex.region (non-secret, bridged to
  env); credential path stays in .env (VERTEX_CREDENTIALS_PATH).
- setup wizard + model picker: dedicated _model_flow_vertex; curated
  google/gemini-* model list; --provider choices.
- pricing/metadata: Vertex prices off the gemini docs snapshot; endpoint
  host auto-maps to the vertex provider (no probe spam).
- lazy_deps + pyproject [vertex] extra: google-auth, opt-in only.
- docs: guides/google-vertex.md + providers page; tests for adapter +
  runtime resolution.

Salvages and modernizes #8427 by @slawt onto current main: rewired from
the legacy PROVIDER_REGISTRY path to the provider-profile architecture,
moved non-secret config out of .env into config.yaml, and added the
per-turn 401 token-refresh the original lacked.
2026-07-01 05:25:33 -07:00
dsad
a4af257a6d fix(browser): extend private-network guard to browser_console 2026-07-01 05:23:17 -07:00
AJ
65a6a36093 fix(patch): preserve file Unicode when unicode_normalized strategy matches
The patch tool's strategy 7 (unicode_normalized) matches ASCII old_string
against a file containing real Unicode (em-dashes, smart quotes, ellipsis,
non-breaking spaces). Writing new_string verbatim silently replaced the
file's Unicode with the LLM's ASCII equivalents.

_preserve_unicode_in_replacement() diffs old_string->new_string and applies
only the actual edits to the file's original Unicode text, preserving
unchanged characters.

Salvaged from #50540 by @aj-nt. Only the Unicode-preservation half is
carried over; the write_file line-number-strip half was dropped (the
existing _looks_like_read_file_line_numbered_content reject guard already
covers its target case, and the strip's looser threshold risks silently
mutating legitimate pipe-delimited content).
2026-07-01 17:48:32 +05:30
Z
40dbfa0e3c fix(gateway): revive gateway on /restart under Restart=on-failure units
The in-chat /restart command was leaving the gateway dead on systemd
deployments using Restart=on-failure (the default for many
operator-managed and tutorial-style unit files). The gateway drained,
exited cleanly (code 0), and was never revived — the only recovery was
a host reboot.

Root cause was a multi-layer assumption mismatch:

1. gateway/run.py:_stop_impl assumed all systemd units use
   Restart=always, so the Linux/systemd branch returned exit code 0
   and relied on a `systemd-run` transient helper to restart the unit
   immediately. Units with Restart=on-failure never see a clean exit
   as a trigger, so nothing revived the process.

2. gateway/run.py:_launch_systemd_restart_shortcut hardcoded
   `--user` scope, so it could not even locate the unit PID on
   system-level deployments (the common case for
   /etc/systemd/system/hermes-gateway.service). It silently returned
   without launching the helper.

3. Even after the scope detection was fixed, the helper could not
   actually start: non-root gateway units (User=ubunutu) hit a Polkit
   denial on `systemd-run --system` ("Interactive authentication
   required"), and `--user` requires a D-Bus user session that is
   typically absent on headless servers.

The fix is two-fold:

* `_stop_impl` now always exits with GATEWAY_SERVICE_RESTART_EXIT_CODE
  (75 / EX_TEMPFAIL) on service-managed restarts, regardless of
  platform. Combined with RestartForceExitStatus=75 in the unit file,
  systemd treats the planned restart as a controlled failure and
  revives the gateway via Restart=on-failure, with RestartSec as the
  only delay. The planned-restart helper is still attempted (for
  RestartSec=0 setups that want sub-second restarts) but is no longer
  load-bearing.

* `_launch_systemd_restart_shortcut` now probes both system and user
  scopes via MainPID equality and uses whichever scope actually owns
  the gateway process. It bails out safely if neither matches.

StartLimitBurst in the unit file still bounds accidental restart
loops, and the macOS launchd path is unchanged.

Verified end-to-end on Ubuntu 24.04 with hermes-gateway as a
/etc/systemd/system/... service running under User=ubunutu. The
unit uses Restart=on-failure, RestartSec=30, RestartForceExitStatus=75,
StartLimitIntervalSec=600, StartLimitBurst=5. /restart from Feishu now
drains cleanly, exits 75, and the gateway is back online ~30s later
without manual intervention.

Tests: tests/gateway/test_gateway_shutdown.py renamed the affected
case to test_gateway_stop_systemd_service_restart_uses_tempfail and
now asserts exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE.
14/14 tests in this module pass.
2026-07-01 05:13:03 -07:00
claudlos
0a75616514 security(browser): enforce cloud-metadata floor on all backends; CDP is non-local
browser_navigate's always-blocked cloud-metadata floor (169.254.169.254,
metadata.google.internal, ECS/Azure/GCP IMDS) was gated on
`not _is_local_backend()`, contradicting both the adjacent comment and the
is_always_blocked_url docstring ("denied regardless of backend"). A default
local headless Chromium on a cloud VM — or an off-host CDP browser — could
navigate to IMDS and read instance credentials into the model context. Make the
floor unconditional on the initial-nav and post-redirect paths.

Also: _is_local_backend() ignored a CDP override while _is_local_mode() honors
it, so an off-host CDP browser was treated as "local" and skipped the broader
private/internal SSRF check too. Treat a CDP override as non-local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:09:35 -07:00
teknium1
d739194926 test(auth): mock new source-aware Nous state read boundary
resolve_nous_runtime_credentials / resolve_nous_access_token now read via
_load_provider_state_with_source (and write via _save_provider_state_to_source).
TestEnvOverrideWins mocked only the old _load_provider_state, so the real
(empty) state was read → AuthError. Mock the new boundary too, returning
(state, None) so the write-through helper treats it as the active store.
2026-07-01 05:06:00 -07:00
HODLCLONE
19fb1adf44 test: avoid OpenRouter dependency in Nous fallback coverage 2026-07-01 05:06:00 -07:00
HODLCLONE
6ed2f5d76f fix: make Nous Portal access token resolution resilient
- Track auth store source path on Nous state reads and write rotated
  OAuth refresh tokens back to the same store, preventing stale-token
  replays when Hermes falls back to a global/root auth.json.
- Skip Nous fallback entries locally when no access/refresh token is
  present, suppressing repeated failed resolution attempts within a
  session.
- Sync session model metadata after fallback switches so the gateway
  DB reflects the backend that actually served the latest turn.
2026-07-01 05:06:00 -07:00
teknium1
cfbc7ed1f9 fix(browser): narrow credential-query denylist to unambiguous names
Follow-up on the salvaged #49830 hardening. The contributor's sensitive
query-param set included bare English words (code, key, auth, session,
sig) that double as ordinary page facets — ?code= on promo/challenge
pages, ?key= as a search facet, ?session= on blogs — so web_extract and
cloud browser_navigate would refuse a large slice of normal browsing.

Narrow the set to unambiguously credential-named params (access_token,
authorization, client_secret, password, token, x-amz-signature, ...).
Prefix-based vendor-key redaction (is_safe_url) still catches recognizable
key shapes; this set is the belt-and-suspenders for opaque secrets carried
under an explicit credential-named parameter.

Also fixes two intra-PR-staleness test breakages surfaced by salvaging onto
current main:
- web_extract_tool() no longer accepts use_llm_processing= (signature
  changed since the PR was authored) — dropped the invalid kwarg.
- agent.redact now fully masks keyed 'token=<secret>' to 'token=***'
  instead of partial 'sk-...'; the console-redaction test now asserts the
  real invariant (secret body gone) rather than the exact mask format.

Added a regression test that generic English-word query params are NOT
blocked by the credential guard.
2026-07-01 05:04:41 -07:00
yongyong
937e56be92 fix(browser): block bracketed sensitive eval primitives 2026-07-01 05:04:41 -07:00
yongjin
a0beb52a50 fix(browser): harden browser tool safety boundaries
Add policy gates and output redaction for browser/CDP surfaces, strengthen session ownership tracking, and block credential-like query parameters before third-party browser/web backends receive URLs.

Inspired by the agbrowse review: keep local browser magic-link flows possible while preventing cloud reader/browser escalation from receiving opaque token, code, signature, or key query parameters.
2026-07-01 05:04:41 -07:00
kyssta-exe
7eb9716ad7 fix(agent): apply persist override to the DB row only, never the live list (#48677)
The persist user-message override was applied in place to the live messages
list. On the early crash-resilience persist (which runs BEFORE api_messages is
built), that stripped observed group-chat context off the live user message and
silently dropped it when observe_unmentioned_group_messages was enabled.

Fix at the single chokepoint: _flush_messages_to_session_db resolves the
override (idx/content/timestamp) locally and applies it ONLY to the row written
to the DB — the live dict is never mutated, so EVERY persist caller (early
persist, mid tool-loop flush, /resume, /branch) is protected uniformly. This
supersedes the earlier shallow-copy approach, which broke the intrinsic
_DB_PERSISTED_MARKER idempotency (copies never propagated the marker back to
the live dicts → duplicate rows) and closes the sibling class tracked in #56303.

Trailing empty-response scaffolding is still dropped from the live list in
_persist_session (unchanged behavior).

Salvaged from #48817; chokepoint reworked to coexist with the marker-based
dedup (#50372).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-07-01 17:28:04 +05:30
teknium1
34de127200 fix(auth): widen portal_base_url allowlist guard to runtime credential path
The salvaged PR guarded only resolve_nous_access_token; the primary
resolve_nous_runtime_credentials path also POSTs the refresh token to
portal_base_url on refresh with no allowlist check. Mirror the guard
there so a poisoned host can't receive the bearer, and drop the stray
duplicated allowlist comment. Adds a sibling-site regression test.
2026-07-01 04:57:40 -07:00
szzhoujiarui
f3c5327e67 fix(auth): validate portal_base_url and migrate stale api.nousresearch.com (#44710) 2026-07-01 04:57:40 -07:00
teknium1
3b41df6d46 test(gateway): regression for multi-profile node symlink leak; AUTHOR_MAP
Add tmp_path symlink regression tests for both generate_systemd_unit and
generate_launchd_plist (~/.local/bin/node -> profile node install must not
leak the profile target into the generated unit PATH). Register
jearnest11's AUTHOR_MAP entry for the salvage cherry-pick.
2026-07-01 04:57:21 -07:00
ygd58
50aaa426c1 fix(gateway): pairing store cannot bypass configured allowlist
A user who tapped Always on an approval button gets a pairing-store entry.
_is_user_authorized() checked the pairing store BEFORE the allowlist and
returned True unconditionally, so a paired-but-not-allowed user permanently
bypassed TELEGRAM_ALLOWED_USERS (or equivalent) even after being removed from
the allowlist (#23778).

Record pairing membership but only honor it in the no-allowlist branch. When
an allowlist IS configured, the paired user must appear in the canonical
allowed_ids set (the same set that resolves WhatsApp aliases, SimpleX names,
group allowlists, and the '*' wildcard), so pairing grants no extra access.

Cherry-picked/rebased from #47736 (#23805) by ygd58; membership check rewritten
to reuse the existing allowlist logic. Adds regression tests.
2026-07-01 04:56:25 -07:00
rrevenanttt
03bbd37dd7 fix(mcp): stop EventBridge silently dropping sessions.json-only changes
The MCP serve event bridge polls two files to decide whether there is new
conversation activity to surface to MCP clients: the gateway sessions.json
index and state.db. Its skip-when-unchanged guard was self-defeating — it
refreshed self._sessions_json_mtime with the current value *before*
comparing against it, so the sessions.json term was always true and the
guard collapsed to a state.db-only check.

The impact is silent message loss on the event stream. The gateway commonly
persists a message to state.db on one tick and registers the owning
conversation in sessions.json a moment later. On that later tick only
sessions.json has changed, so the broken guard takes the early return and
never processes the freshly-registered chat. Its messages are withheld from
every connected MCP client (events_poll / events_wait) until state.db
happens to change again — which, for an otherwise-idle conversation, may be
never. A polling bridge that quietly swallows new conversations is exactly
the failure mode this watcher exists to prevent.

The fix is minimal and low-risk: capture the previously-seen sessions.json
mtime before the cache refresh and compare against that, so the guard skips
only when NEITHER file changed since the last poll. The hot-path mtime
optimization is fully preserved (a genuinely idle tick still short-circuits),
and all existing EventBridge polling tests continue to pass unchanged.

## What does this PR do?

Fixes a logic error in `EventBridge._poll_once` (`mcp_serve.py`) where the
"nothing changed, skip this poll" guard compared `sj_mtime` against
`self._sessions_json_mtime` *after* that attribute had already been
overwritten with `sj_mtime`. The comparison was therefore always true,
reducing the intended "skip only if both files are unchanged" check to a
state.db-only check and discarding any tick in which only sessions.json
changed. The guard now compares against the mtime observed on the previous
poll, restoring the intended behavior.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ]  New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ]  Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

## Changes Made

- `mcp_serve.py`: in `EventBridge._poll_once`, snapshot
  `prev_sessions_json_mtime = self._sessions_json_mtime` before refreshing the
  cached index, and use it in the skip guard
  (`sj_mtime == prev_sessions_json_mtime`) so a sessions.json-only change no
  longer triggers the early return. Added a comment explaining the seam.
- `tests/test_mcp_serve.py`: added
  `TestEventBridgePollE2E::test_poll_picks_up_new_conversation_when_only_sessions_json_changed`,
  a regression test that reproduces the boundary state (state.db unchanged,
  sessions.json newly updated) and asserts the new conversation's message is
  emitted.

## How to Test

1. Reproduce the failure on the old code: with the guard comparing against
   `self._sessions_json_mtime`, the new test fails — the freshly-registered
   conversation yields `0` events instead of `1`.
2. Apply the fix and run `pytest tests/test_mcp_serve.py -q` — all 46 tests
   pass (40 skipped require the optional `mcp` SDK), including the three
   pre-existing `TestEventBridgePollE2E` polling tests and the new regression
   guard.
3. `ruff check mcp_serve.py tests/test_mcp_serve.py` and
   `python scripts/check-windows-footguns.py mcp_serve.py` both report clean.

## Checklist

### Code

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/test_mcp_serve.py -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-07-01 04:55:50 -07:00
ud
c126a99fc1 fix(subdirectory_hints): catch RuntimeError from Path.expanduser()
`pathlib.Path('~user').expanduser()` raises RuntimeError when the
tilde-expansion can't resolve the user (e.g. `~500-700` where the LLM
meant "approximately 500-700" rather than a path). The hint walker's
existing `except (OSError, ValueError):` clauses do not catch
RuntimeError, so it escapes through the tool dispatcher and surfaces
in the conversation loop as a misleading

    Error during OpenAI-compatible API call #N:
    Could not determine home directory.

Reproduced across three unrelated models (openai/gpt-5-mini,
openai/gpt-5.1-codex, deepseek/deepseek-v4-flash) on terminal-tool
commands containing literal tildes in non-path contexts — common in
LLM output ("~500 agencies", "~45,000 CVEs", "~80/hr blended rate").

Reproduction (one-liner):
    >>> from pathlib import Path
    >>> Path("~500-700").expanduser()
    RuntimeError: Could not determine home directory.

Fix: extend the three `except` clauses in
agent/subdirectory_hints.py to also catch RuntimeError:

  line 138 (_add_path_candidate's outer catch around the Path().expanduser() call)
  lines 198+202 (_load_hints_for_directory's nested catches around hint_path.relative_to(Path.home()))

Tests: tests/agent/test_subdirectory_hints_tilde.py adds three cases
covering: tilde-as-approximately in heredoc commands, ~unknown_user paths,
and a regression guard that legitimate ~/path expansion still works.
2026-07-01 04:55:15 -07:00
JabberELF
18a9467fca fix(tui): prevent killpg suicide during MCP shutdown
Root cause: gateway spawns LSP servers (jdtls/pyright/yaml-ls) and
slash_worker without start_new_session=True, so they inherit the
gateway process group (= TUI parent PID). When mcp_tool
_snapshot_child_pids() races with these spawns during stdio MCP
server startup, non-MCP children leak into _stdio_pgids with the
TUI parent PGID. shutdown_mcp_servers() then killpg(tui_parent_pid,
SIGTERM), killing the TUI itself.

Evidence: tui_gateway_crash.log shows recurring SIGTERM stacks:
  shutdown_mcp_servers -> _kill_orphaned_mcp_children ->
  _send_signal -> killpg(pgid, sig) -> SIGTERM received

Fix (3 layers):
1. agent/lsp/client.py: add start_new_session=True to LSP server
   spawn so each LSP server gets its own process group/session.
2. tui_gateway/server.py: same fix for slash_worker spawn, the
   symmetric root-cause patch so no gateway direct child shares
   the TUI parent pgid.
3. tools/mcp_tool.py: add _filter_mcp_children() defense-in-depth
   that drops non-MCP children (slash_worker, jdtls/eclipse LSP)
   from the PID delta before they can poison _stdio_pgids.
2026-07-01 04:54:46 -07:00
teknium1
04eed932eb test(gateway): cover auto-resume auth skip + fail-closed
Two tests for the auto-resume authorization gate: an unauthorized session
owner is skipped without claiming a _running_agents slot or persisting one,
and a raising auth check fails closed (session skipped, not resumed).
2026-07-01 04:53:58 -07:00
srojk34
74e59b8b68 fix(security): close abbreviated-flag bypasses in git/sudo approval patterns
git's and sudo's option parsers resolve unambiguous long-flag prefixes, so
`git reset --har`, `git branch --delete --force`, and `sudo --stdi`/`--ask`
execute identically to their full-flag forms while evading the exact-string
DANGEROUS_PATTERNS regexes that gate them. Verified live against real git
and sudo binaries. Widen the patterns to accept unambiguous abbreviations,
scoped narrowly enough to avoid colliding with sibling flags (--help,
--soft/--mixed/--merge/--keep, --shell/--set-home).
2026-07-01 17:17:01 +05:30
kshitijk4poor
723ccda275 fix(acp): also preserve archived rows on model-switch / restore saves
Follow-up widening the archived-history fix to the sibling save paths the
original PR did not cover. Model switches (_cmd_model, set_session_model) and
_restore mint a fresh AIAgent with _session_db_created=False, so the
agent-owns-persistence guard evaluates False and the blind full-history
replace_messages() fired — DELETEing the durable active=0/compacted=1 rows on
any compressed ACP session (same data-loss class the PR fixes, different
trigger).

- hermes_state.replace_messages: add active_only=True to delete/reinsert only
  the live (active=1) rows, leaving soft-archived rows untouched (idea adopted
  from the competing PR #50306 by @mrparker0980, credited).
- hermes_state.has_archived_messages: cheap existence probe for active=0 rows.
- acp_adapter._persist: when the agent doesn't own persistence but the session
  already has archived rows on disk, replace active-only; otherwise the
  destructive full replace stays (fresh create/fork has nothing to lose).
- Regression test: model-switch save on a compacted session keeps the archived
  turn discoverable via get_messages(include_inactive=True) + search_messages.
2026-07-01 17:16:51 +05:30
sasquatch9818
897240462a fix(acp): stop _persist from deleting compression-archived history
ACP's SessionManager._persist() called db.replace_messages() on every
save. That delete-then-reinsert is destructive by design. The agent
backing each ACP session already persists to the same SessionDB itself:
it flushes turns incrementally via append_message and, on context
compression, preserves pre-compaction turns non-destructively through
archive_and_compact() as searchable active=0/compacted=1 rows.

So the per-save replace_messages() was a redundant double-write that
deleted exactly those archived rows (and their FTS entries). Worse,
after a compression-driven id rotation the agent's live head no longer
equals the ACP session id, so the replace overwrote the ended parent
transcript while new turns flowed to the new id — split-brain corruption
of one conversation. Any ACP conversation (VS Code / Zed / JetBrains)
long enough to compress lost history.

Now _persist skips the destructive replace when the agent owns
persistence to this DB (its _session_db is this db and its row exists),
relying on the agent's own incremental + archival flush. It still falls
back to the atomic replace when the agent is not self-persisting — test
agent factories, and fresh create/fork sessions whose copied history the
agent has not flushed yet — so the #13675 rollback guarantee holds.

## What does this PR do?

Fixes silent history loss in ACP editor sessions. ACP _persist no longer
destroys the compression-archived transcript the agent already wrote.
Long enough conversations compress; that compression archives old turns
non-destructively; ACP then hard-deleted them on the next save. After an
id rotation it also clobbered the ended parent and split the
conversation across two ids. This change defers to the agent's own
persistence when it owns the DB and only uses the destructive replace
when nothing else is writing the transcript.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ]  New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ]  Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

## Changes Made

- `acp_adapter/session.py`: in `SessionManager._persist`, guard the
  `db.replace_messages()` call. Skip it when the agent owns persistence
  to this DB (`agent._session_db is db` and `agent._session_db_created`);
  otherwise keep the destructive atomic replace as the fallback.
- `tests/acp/test_session.py`: add a regression test proving archived
  (active=0/compacted=1) rows survive a save when the agent self-persists
  and stay FTS-searchable; add a test confirming the replace path still
  runs for agents that do not own DB persistence.

## How to Test

1. Run `pytest tests/acp/test_session.py -q` — 43 pass.
2. `test_save_session_preserves_agent_archived_history`: archive a turn
   via `archive_and_compact`, save, and confirm it survives and is found
   by `search_messages` (fails before this fix — replace_messages deleted
   it).
3. `test_save_session_still_replaces_when_agent_not_self_persisting`:
   confirm history still overwrites cleanly for non-self-persisting
   agents.

## Checklist

### Code

- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-07-01 17:16:51 +05:30
kshitijk4poor
b4342a83bb fix(approval): close bare powershell Remove-Item bypass + add ri alias (review)
Rework follow-up on the Windows destructive-shell detection. The PowerShell
pattern required an explicit -Command/-c before the verb, but PowerShell runs
the verb as the DEFAULT POSITIONAL arg — so `powershell Remove-Item -Recurse
-Force C:\x` (no -Command) slipped through, the exact case the PR body claims
to close. Also missing the canonical `ri` alias.

Anchor the verb to the command position (after the shell name + any leading
-Flag switches + optional -Command/-c) so bare invocations are caught while a
benign path arg containing 'del'/'rm' (e.g. -File c:\del-logs\run.ps1) is not.
Add ri to the verb list. Mutation-verified regression tests for the bare
invocation, ri alias, and the benign-path negative.
2026-07-01 17:16:08 +05:30
dsad
4b92a8cd31 fix(approval): detect Windows destructive shell commands 2026-07-01 17:16:08 +05:30
Zeheng Huang
4c2c54c78c fix(matrix): await inbound sync handlers
Register the Matrix room-message, reaction, and invite handlers with
mautrix's wait_sync=True. mautrix's handle_sync() only returns the tasks
for handlers registered as sync-awaited; non-waited handlers are
fire-and-forget via background_task.create() and are NOT returned. Since
_dispatch_sync() awaits only the returned tasks (await asyncio.gather),
the inbound handlers previously had no completion point, so Tuwunel/
mautrix homeservers connected and completed initial sync but dispatched
zero inbound messages.

Fixes #46142.

Co-authored-by: Zeheng Huang <153708448+hunjaiboy@users.noreply.github.com>
2026-07-01 04:42:33 -07:00
kshitijk4poor
dc1ea005d9 fix+test(codex): self-persist projected turns; keep agent_persisted=True
Follow-up correcting the salvaged fix's persistence approach to avoid a
duplicate user-message write (verified via E2E — the #860/#42039 bug class
the original diff aimed to avoid).

Root cause: in gateway mode the AIAgent is built WITH a session_db, so the
inbound user turn is already flushed at turn start (turn_context.
_persist_session). The original fix returned agent_persisted=False, making the
gateway re-write the whole new-message slice via append_to_transcript ->
append_message (a raw INSERT with no dedup), duplicating the already-flushed
user turn.

Corrected approach (single writer): run_codex_app_server_turn now flushes its
OWN projected assistant/tool messages via _flush_messages_to_session_db (which
dedups the already-persisted user turn through _DB_PERSISTED_MARKER) and
returns agent_persisted=True so the gateway skips its write. Net result:
session_search/distill see the full codex conversation, each message persisted
exactly once.

Adds regression coverage asserting exactly-once persistence on a real
SessionDB, agent_persisted=True, FTS visibility, and standard-runtime skip-db
behaviour preserved.

Co-authored-by: Lubos Buracinsky <lubos@komfi.health>
2026-07-01 17:08:59 +05:30
srojk34
a76aa6198c fix(cli): flush un-persisted messages before /resume and /branch end the old session
compress_context() and /new already flush un-persisted messages before
calling end_session() (fixed in #47202), but /resume and /branch still
call end_session() directly. When a turn is interrupted mid-flight and
the user immediately runs /resume or /branch, messages generated during
that turn have not yet been written to state.db and are silently lost on
session rotation.

Add the same best-effort _flush_messages_to_session_db() call before
end_session() in both _handle_resume_command and _handle_branch_command,
mirroring the pattern established in cli.py:new_session().

Regression tests verify the flush is called when an agent is present.
2026-07-01 17:08:55 +05:30
Dutch Dim
154c382d65 fix(gateway): recover from truncated responses 2026-07-01 17:08:50 +05:30
kshitijk4poor
9cf47fef54 fix(auxiliary_client): demote the 2 sibling routing fall-throughs too (review)
Phase 2c review flagged that only 2 of the 4 structurally-identical
resolve_provider_client routing dead-ends were demoted. Complete the bug-class:
also demote+dedup the external-process ('not directly supported') and OAuth
('not directly supported, try auto') fall-throughs, keyed by provider name, so
none of the four dead-ends spam WARNING on a retry loop.

Add direct tests for the unhandled-auth_type and OAuth dedup paths via a
monkeypatched PROVIDER_REGISTRY (the review noted these were unverified).
Mutation-checked: reverting either sibling demotion fails its test.
2026-07-01 17:00:30 +05:30
kshitijk4poor
c0d3ceb17e fix(auxiliary_client): dedup resolve_provider_client fall-through warnings
The two fall-through branches in resolve_provider_client (unknown provider,
unhandled auth_type) logged at WARNING on every retry of a misconfigured
provider, spamming logs during retry loops. Demote both to logger.debug with
per-process dedup: the first occurrence still surfaces (a provider-name typo or
PROVIDER_REGISTRY/auth_type-drift bug is worth seeing once), while identical
repeats are suppressed for the process lifetime.

Salvaged from #56283 (extracting only the stated auxiliary_client fix; the
original PR also bundled ~2800 lines of unrelated changes across 10 other
files, which are dropped).
2026-07-01 17:00:30 +05:30
kshitijk4poor
fb7a38ad21 fix(macos): compose launchd reload retry with _launchctl_bootstrap + drain-aware window
Reworks @valenteff's #53277 fix per review (Teknium's 3 findings):
- Route refresh_launchd_plist_if_needed's bootstrap through the existing
  _launchctl_bootstrap() EIO-recovery helper (canonical since #56256),
  wrapped in a wall-clock retry loop, instead of an ad-hoc 5x2s loop.
- Window sized to agent.restart_drain_timeout (default 180s), not a fixed
  ~10s: the failure happens while the old gateway is still draining (finding 1).
- Retry on subprocess.TimeoutExpired too, not just CalledProcessError — a
  bootstrap timeout after bootout otherwise escapes and leaves the service
  unloaded (finding 2).
- Confirm success with launchctl list, not a bare bootstrap exit 0 (finding 3);
  mirror verify+drain-window in the detached-helper bash path.
- Shared helpers _launchd_reload_log_path / _append_launchd_reload_log /
  _launchctl_label_registered / _retry_launchctl_bootstrap_until_registered.

3 new tests cover retry-until-listed, TimeoutExpired-retried, deadline-exhaust.
E2E: real reload log + mocked launchctl — retries CalledProcessError+TimeoutExpired,
verifies via launchctl list, logs failures.
2026-07-01 16:56:14 +05:30
shawchanshek
3b739b990b fix(title_generator): strip think blocks from LLM output before extracting title
Think-enabled models (MiniMax M2.7, DeepSeek, etc.) emit inline
<think>...</think> reasoning even for simple prompts like title
generation, and the raw XML was leaking into session titles. Route the
title-model response through the canonical strip_think_blocks scrubber
before cleanup so every tag variant — closed pairs, unterminated blocks,
orphan closes, mixed case — is handled, not just a single literal
<think> pair.

- 2 regression tests: closed <think> pair stripped, unterminated block
  at start yields no title.

Salvaged from PR #44126 by @shawchanshek.
2026-07-01 04:18:48 -07:00
kshitijk4poor
314cf43d50 test(matrix): assert real device_id in query_keys, not just guard-skip
Hardens the salvaged #53997 tests per review: the positive-resolution and
reconnect-recovery tests now assert query_keys is awaited with the REAL
resolved device id ({mxid: [<id>]}) and never [None] — the [null] body the
homeserver rejects (the actual bug), plus await_count==2 to prove
verification genuinely re-runs after resolution rather than just the flag
looking right.
2026-07-01 16:46:40 +05:30
Gary Walker
09dbe76955 fix(matrix): reset _device_id_unverified at start of connect()
Per review feedback on #53997 from @teknium1: the flag was set True
on failed device_id resolution but never reset, so a same-adapter
reconnect that successfully resolves a real device_id would keep
skipping server-side key verification indefinitely.

Reset now happens at the top of connect(), before resolution runs,
so every connect() attempt starts clean. A repeat failure re-sets
the flag (unchanged behavior); a recovery correctly clears it.

Adds TestDeviceIdRecoveryOnReconnect to cover the transition.
2026-07-01 16:46:40 +05:30
Gary Walker
9048457eab fix(matrix): device_id fallback prevents E2EE init failure on fresh bot accounts
- Resolve device_id via query_keys({mxid: []}) when whoami() returns None
- Guard _verify_device_keys_on_server and _reverify_keys_after_upload
  against None/unverified device_id to prevent 'device_keys values must
  be a list of strings' serialization failure
- Disconnect existing client before reconnect to prevent dual OlmMachine
  instances on the same crypto store

Re-targeted from #39779 (legacy gateway/platforms/matrix.py) onto the
migrated plugins/platforms/matrix/adapter.py path following the
2026-06-20 adapter migration. Logic unchanged from original fix.

242 tests passing (233 upstream + 9 new).
2026-07-01 16:46:40 +05:30
SahilRakhaiya05
2d8d08cae6 fix(api-server): require auth for /health/detailed and fail closed on weak keys
/health/detailed leaked runtime state (gateway state, connected
platforms, active-agent counts, PID, exit reason) with no auth. Gate it
behind the same Bearer auth as other API routes; plain /health stays
open for liveness probes.

Also refuse to start on a placeholder/too-short (<16 char) API_SERVER_KEY
regardless of bind address — a guessable key on a terminal-capable
endpoint is RCE-adjacent even on loopback, since any local process can
reach it. The required-key check was already unconditional; this extends
the strength floor to loopback binds too. Startup guards are hoisted
above app/background-task creation so a rejected start leaves no partial
state.

Salvaged from #44073 (external-surface hardening), split into a focused
PR per maintainer request.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-07-01 04:14:33 -07:00
shandian64
5126902f1d fix(title): honor configured auxiliary timeout 2026-07-01 16:41:43 +05:30
SahilRakhaiya05
5178b3f056 fix(code-exec): bind execute_code tool socket to a per-session RPC token
The execute_code sandbox exposed its tool-call RPC (AF_UNIX socket and
remote file-poll transports) without any caller check, so any local
process that could reach the socket / rpc dir could dispatch
terminal-capable tool calls through the parent. Mint a per-session
HERMES_RPC_TOKEN, pass it to the sandboxed child, and require a
timing-safe match on every request in both _rpc_server_loop and
_rpc_poll_loop. Empty/missing/wrong token fails closed.

Salvaged from #44073 (per-session RPC token). Added timing-safe
secrets.compare_digest comparison and fail-closed regression tests.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-07-01 04:08:37 -07:00
Teknium
5de65624d1
fix(moa): capture streamed aggregator output into full-turn traces (#56312)
MoA full-turn traces (moa.save_traces) recorded the aggregator's acting
output only on the non-streaming path, where it's captured inline at
call time. On the streaming path — which every hermes chat --query run
and every live gateway/CLI turn takes — the aggregator's raw token
stream is handed to the live consumer, so the trace left output=null and
only pointed at the session-db assistant row. An offline audit of a
benchmark run (HermesBench drives --query) then couldn't see what the
aggregator produced without hand-joining to state.db.

Capture the resolved streamed acting text at trace-flush time (the agent
already holds it in _current_streamed_assistant_text) and fold it into
the trace, so the record is self-contained in both modes. New
output_location value inline_from_stream marks a streamed turn whose text
was captured this way; a genuinely empty acting turn (pure tool call)
still points at the session db, matching state.db exactly.

Touches only the trace side-channel — no change to the acting path,
message history, role alternation, or prompt cache.

- agent/moa_loop.py: consume_and_save_trace(..., aggregator_output_fallback)
  on both the facade and the MoAClient wrapper; prefer inline capture,
  fall back to the resolved streamed text.
- agent/moa_trace.py: embed the fallback; add inline_from_stream location.
- agent/conversation_loop.py: pass _current_streamed_assistant_text at flush.
- tests: 5 cases across streaming / non-streaming / empty-fallback / no-double-write.
2026-07-01 04:07:46 -07:00
Teknium
81595cd588 fix(dashboard): run plugin gate after auth + enable example fixture
Follow-up on the salvaged #47491 commits:

- Register _plugin_api_runtime_gate BEFORE the auth middlewares so it
  executes AFTER them, and add an explicit auth check: unauthenticated
  requests to /api/plugins/<name>/ fall through to auth's 401 instead of
  this gate's 404. Prevents the gate from becoming a plugin-name oracle
  (an unauthenticated caller could otherwise fingerprint installed/enabled
  plugins by status code). Keeps test_non_kanban_plugin_route_requires_auth
  green.
- Enable the 'example' user plugin in the _install_example_plugin test
  fixture so the auth / static-asset-allowlist tests still reach the real
  serving paths now that user plugins are gated on plugins.enabled.
- Mark the runtime-gate unit-test scopes as authenticated so they exercise
  the enabled/disabled policy under the new auth-first ordering.
2026-07-01 04:05:15 -07:00
manusjs
b2e0086f1b fix(dashboard): enforce plugin disabled gate at request time and for bundled assets
Address two residual bypasses identified in review:

1. Add _plugin_api_runtime_gate middleware that checks plugins.enabled/
   plugins.disabled on every request to /api/plugins/{name}/... routes.
   Previously, disabling a plugin at runtime had no effect on its already-
   mounted API routes until a restart.

2. Extend serve_plugin_asset to check plugins.disabled for bundled plugins.
   Previously, only user plugins were gated — a bundled plugin in
   plugins.disabled would still serve assets from the unauthenticated
   /dashboard-plugins/{name}/... endpoint.

Both fixes ensure the enabled/disabled policy is evaluated live at request
time, not just at startup.

Adds regression tests covering:
- Middleware blocks disabled user plugin API routes (404)
- Middleware blocks user plugin removed from enabled set (404)
- Middleware passes enabled user plugin API routes
- Middleware blocks disabled bundled plugin API routes (404)
- Bundled plugin assets return 404 when disabled
- Bundled plugin assets served normally when not disabled
- User plugin asset gating still works correctly
2026-07-01 04:05:15 -07:00
Tao Chen
d3c8667462 fix(slack): authorize bot/workflow senders before the no-user-id guard
Slack Workflow Builder posts (and other app/bot messages) arrive as
subtype=bot_message with user=None. _is_user_authorized rejected them at
the `if not user_id: return False` guard, which runs *before* the #4466
{PLATFORM}_ALLOW_BOTS bypass — so @mentioning the bot from a Slack
workflow silently did nothing, even with SLACK_ALLOW_BOTS (or
SLACK_ALLOW_ALL_USERS) set. The chat-scoped allowlist for Telegram/QQ
already runs before that guard for the same reason (channel broadcasts
with no from_user); Slack was both missing from the bot-bypass map and
had the bypass running too late.

- gateway/authz_mixin: move the {PLATFORM}_ALLOW_BOTS bypass ahead of the
  no-user-id guard and add Platform.SLACK -> SLACK_ALLOW_BOTS.
- plugins/platforms/slack/adapter: set is_bot=True on inbound
  bot_message events so the gateway can identify workflow/app senders
  (they carry no user_id to match against the allowlist).

Tested: new tests/gateway/test_slack_bot_auth_bypass.py plus the existing
Discord/Feishu bot-auth and gateway authz/gating suites all pass.
2026-07-01 16:32:32 +05:30
teknium1
27347b2239 fix(gateway): align resume safety-net note with canonical recovery wording
Follow-up on the salvaged resume_pending fix: the empty-turn safety net
now emits the same reason-aware recovery note as the _is_resume_pending
branch (reason phrase + 'session restored' guidance + no-re-execute
instruction) instead of a second, differently-worded note. Also adds the
AUTHOR_MAP entry for the salvaged commit.
2026-07-01 03:57:44 -07:00
Adam Chiaravalle
c2db3ed7d8 fix(gateway): recover resume_pending sessions instead of sending a blank turn
A session interrupted by a gateway restart is flagged resume_pending and
auto-continued on startup via _schedule_resume_pending_sessions(), which
dispatches an empty-text internal MessageEvent. The recovery system note
that should fill that empty turn is gated, in _run_agent(), on
_interruption_is_fresh — the age of the LAST PERSISTED TRANSCRIPT ROW.

For an active thread returned to after >1h of silence, that transcript
clock is stale even though the interruption (last_resume_marked_at) is
seconds old. The gate evaluates False, the note is not prepended, and the
model receives a genuinely blank user turn — replying with confused
'that message came through blank' noise.

Fix (two parts, both default-on, behavior unchanged for healthy turns):

1. resume_pending freshness now also considers last_resume_marked_at (the
   restart watchdog's own stamp). The branch fires when EITHER the
   transcript clock OR the resume mark is fresh, so the startup scheduler's
   freshness decision and the per-turn injection agree.

2. Empty-turn safety net: if the user turn is still blank after all
   injections AND the session is resume_pending, backfill a recovery note
   so a blank turn can never reach the model. Scoped to resume_pending so
   ordinary empty turns (e.g. uncaptioned image) are untouched.

Adds 3 regression tests; the two core ones fail on the pre-fix logic.
2026-07-01 03:57:44 -07:00
teknium1
d1d1d81900 fix(gateway): repair sibling tests + harden _adapter_for_source after fail-closed flip
Follow-up to the salvaged fail-closed defaults. The own-policy default flip
(open -> pairing) and the email dispatch-level deny broke sibling tests
across the suite that relied on the old fail-open behavior:

- test_email.py: dispatch-mechanics tests now opt into EMAIL_ALLOW_ALL_USERS
  (they test formatting/attachments/threading, not authz); the two auth
  contract tests are rewritten to assert the new fail-closed behavior
  (no allowlist + no allow-all => sender dropped at the adapter).
- test_whatsapp_cloud.py / test_whatsapp_formatting.py / test_whatsapp_from_owner.py:
  autouse fixture opts into WHATSAPP_ALLOW_ALL_USERS so dm_policy: open
  dispatch-mechanics tests still flow (open now requires an explicit
  allow-all opt-in, SECURITY.md 2.6).
- _adapter_for_source: use getattr for source.platform/profile so bare
  SimpleNamespace test fixtures without .profile don't crash the busy/queue
  ingress path (AGENTS.md pitfall #17).

Full tests/gateway/ + yuanbao pipeline: 8555 passed, 0 failed.
2026-07-01 03:56:28 -07:00
SahilRakhaiya05
bb304b4914 fix(gateway): fail-closed external-surface defaults + profile-aware multiplex authz
Aligns runtime behaviour with SECURITY.md 2.6: externally reachable
messaging adapters must fail closed unless access is explicitly
configured. Closes the confirmed multiplex authorization bypass a
secondary profile's open dm/group policy no longer inherits the default
profile's allowlist trust.

- Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default
  dm_policy/group_policy to pairing/allowlist instead of open; open now
  requires an explicit GATEWAY_ALLOW_ALL_USERS or per-platform allow-all.
- Startup guard (_own_policy_open_startup_violation) refuses to boot when
  an enabled adapter is open without the allow-all opt-in; the guard now
  runs for every secondary profile in multiplex mode too.
- Profile-aware own-policy authorization: _authorization_adapter /
  _adapter_for_source resolve the live adapter via SessionSource.profile,
  so _is_user_authorized and the ingress/pairing/busy/queue paths read the
  originating profile's adapter policy, not the default profile's.
- Fail-closed intake for Email, Feishu P2P, and Discord (blank-principal
  denial, empty-allowlist deny, missing-interaction.user deny).

Salvaged from #44073 (external-surface hardening), split into a focused
gateway-authz PR per maintainer request. Follow-up fix by Hermes Agent:
the Discord slash-auth channel bypass now matches DISCORD_ALLOWED_CHANNELS
by the same name-inclusive keys (id + name + #name + parent) the on_message
scope gate uses, so a name-form channel allowlist authorizes slash
interactions consistently (was id-only, breaking #name matching).

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-07-01 03:56:28 -07:00
srojk34
8e94e8f882 fix(discord): tag unverified channel-context senders like Slack threads
Discord's _fetch_channel_context backfills recent channel/thread activity
(from any member who can post there, not just the allowlisted user) into
the agent's context with no sender-trust distinction. Slack's equivalent
_fetch_thread_context was fixed to prefix non-allowlisted senders with
[unverified] and add LLM guidance not to act on their content, mitigating
indirect prompt injection from third parties in shared channels/threads.
Port the same mechanism to Discord using the already-wired
_is_sender_authorized/set_authorization_check plumbing.
2026-07-01 16:25:16 +05:30
kshitijk4poor
23518a5e02 test(review): add integration guards for the two isolation wirings (review)
Phase 2c mutation-check found the salvaged tests covered only the pure helpers
(_is_background_review_harness_message / _strip_background_review_harness) — the
two integration WIRINGS had zero coverage: removing the _persist_disabled guard
in _flush_messages_to_session_db, or the _strip call in
get_messages_as_conversation, left all 13 tests green.

Add:
- TestPersistDisabledHardStop: a _persist_disabled agent's flush writes nothing
  to a live SessionDB (guards the run_agent hard-stop).
- TestGetMessagesAsConversationStripsHarness: a session with stray harness rows
  resumes clean end-to-end through get_messages_as_conversation (guards the
  hermes_state load-time wiring).
Mutation-checked: each new test fails when its wiring is reverted.
2026-07-01 16:21:39 +05:30