Commit graph

8674 commits

Author SHA1 Message Date
teknium1
e66a3e86ef chore(acp): bump registry manifest to 0.14.0 matching pyproject 2026-05-17 12:44:48 -07:00
teknium1
822e92edb3 fix(aux): default OpenRouter auxiliary to gemini-3-flash-preview 2026-05-17 12:44:48 -07:00
xxxigm
e3f7ff1123 test(xai-oauth): pin PKCE token-exchange wire format
14 focused tests on the extracted helper
``_xai_oauth_exchange_code_for_tokens`` cover:

Core contract:
* ``code_verifier`` is on the wire (RFC 7636 §4.5).
* ``code_challenge`` + ``code_challenge_method=S256`` are echoed
  (the #26990 defense-in-depth that makes xAI's token endpoint
  stop rejecting valid exchanges).
* ``grant_type=authorization_code``, ``code``, ``redirect_uri``,
  and ``client_id`` are all locked.
* Content-Type is ``application/x-www-form-urlencoded`` (xAI
  rejects ``application/json`` on this endpoint).
* The supplied ``token_endpoint`` URL is used verbatim — no
  hard-coded constant sneaks in via a future refactor.
* ``timeout_seconds`` is forwarded; floored at 20s.

Sanity guard:
* Empty ``code_verifier`` raises ``xai_pkce_verifier_missing``
  with a link to #26990 — and NOTHING is sent.  Leaking the auth
  code to a server that can't redeem it is the wrong failure mode.
* Empty ``code_challenge`` omits only the defensive echo; the
  standards-compliant ``code_verifier`` request still goes out so
  RFC-compliant servers keep working.

Error surfacing:
* Non-200 responses include both ``HTTP <status>`` and the body
  verbatim — disambiguates 400 (PKCE / bad request) from 403
  (tier denied, see #26847).
* Transport errors are wrapped as ``AuthError`` with the
  ``xai_token_exchange_failed`` code, so the surrounding
  ``format_auth_error`` UI mapping still fires.
* Non-dict JSON payloads raise ``xai_token_exchange_invalid``.
* 200 happy path returns the parsed payload dict verbatim.

End-to-end wire-format guard:
* A real ``httpx.Client`` with a stub transport captures the bytes
  on the wire and asserts every PKCE field round-trips through
  ``urlencode``.  Catches a future refactor that swaps
  ``data=`` for ``json=`` (which xAI would silently reject).
2026-05-17 12:35:01 -07:00
xxxigm
cb53c40e45 fix(xai-oauth): echo code_challenge in token POST so PKCE exchange succeeds
xAI's OAuth implementation at ``auth.x.ai`` validates the PKCE
``code_challenge`` at the **token** endpoint, not just at the
authorize step.  When Hermes sends the standards-compliant token
POST with ``code_verifier`` alone — exactly what RFC 7636 §4.5
prescribes — xAI rejects the exchange with ``code_challenge is
required`` and the user is stuck with no working OAuth login.

The fix:

* Extract the token POST into ``_xai_oauth_exchange_code_for_tokens``
  so the wire format is unit-testable in isolation.
* Send the original ``code_challenge`` and ``code_challenge_method``
  in the form body alongside ``code_verifier``.  Strict RFC-compliant
  servers ignore the extras at the token endpoint, and xAI's
  permissive implementation accepts the exchange.  This is the
  standard "defensive echo" workaround used by every OAuth client
  that targets a server with this quirk.
* Refuse to fire the POST when ``code_verifier`` is empty — leaking
  the authorization code to a server that can't redeem it is worse
  than failing locally with an actionable error.  The new error
  code is ``xai_pkce_verifier_missing`` and the message points at
  this issue for context.
* Surface the HTTP status code prominently in the 4xx error message
  (``xAI token exchange failed (HTTP 400). Response: …``) so users
  and maintainers can tell a 400 (bad request / PKCE problem) from
  a 403 (tier denied, see #26847) at a glance instead of parsing
  the JSON body by eye.

Closes #26990
2026-05-17 12:35:01 -07:00
aqilaziz
bc7c608d54 fix(gateway): ignore inaccessible service path dirs 2026-05-17 11:55:25 -07:00
aqilaziz
1a82b7a1ff fix(tests): stabilize xai env and provider parity 2026-05-17 11:55:25 -07:00
worlldz
73df329214 fix(doctor): flag missing credentials for active openrouter provider 2026-05-17 11:53:04 -07:00
teknium1
a2cc30544c chore(release): map vaddisrinivas for #26394 salvage 2026-05-17 11:51:46 -07:00
vaddisrinivas
7847a58b3a fix(docker): preload messaging gateway deps 2026-05-17 11:51:46 -07:00
Hoang V. Pham
4a7cd2e16d fix(codex): allow kanban worker board writes 2026-05-17 11:50:43 -07:00
teknium1
ee7cd10281 chore(release): map hehehe0803 email for #26212 salvage 2026-05-17 11:50:43 -07:00
soynchux
280c63ce91 fix(mcp): prevent parallel-safe prefix collisions 2026-05-17 11:41:26 -07:00
Mind-Dragon
874dad5cc1 test(delegation): add regression test for runtime missing 'provider' key
Addresses reviewer feedback: when resolve_runtime_provider returns a dict
without the 'provider' key, the result must be None regardless of
configured_provider. This guards against malformed runtime responses.

Test: test_runtime_missing_provider_key_returns_none
2026-05-17 11:40:05 -07:00
Mind-Dragon
84667cbc21 fix(delegation): preserve configured_provider name when runtime returns 'custom'
Named custom providers (e.g. crof.ai) resolve to provider='custom' at the
runtime level, causing subagents to lose their intended provider identity.
On retry/fallback, resolve_provider_client('custom', model=...) searches all
providers advertising that model and picks non-deterministically, routing to
Z.AI or Bailian instead of the configured target.

The fix preserves configured_provider when runtime['provider'] == 'custom',
restoring the original provider name so routing stays correct through retries.
Adds a named constant _RUNTIME_PROVIDER_CUSTOM instead of a magic string.

Adds three regression tests:
- test_named_custom_provider_preserves_provider_name: the #26954 case
- test_standard_provider_not_overwritten_by_configured_name: openrouter/nous
  must still return their own identity, not the configured name
- test_custom_provider_with_empty_configured_provider_falls_back_to_runtime:
  empty provider triggers the early-return None path as before
2026-05-17 11:40:05 -07:00
brooklyn!
08a66b2ae3
Merge pull request #27489 from NousResearch/bb/tui-composer-cursor-drift-v2
fix(tui): align composer cursorLayout with wrap-ansi to kill multiline cursor drift
2026-05-17 13:39:39 -05:00
teknium1
3f01e9493c chore(release): AUTHOR_MAP entries for batch salvage group 6 contributors
Final LHF run group. Adds release-note attribution mappings for:
- @bird (PR #25219)
- @davidcampbelldc (PR #26834)

(zccyman, wesleysimplicio already mapped from prior groups.)
2026-05-17 11:39:37 -07:00
wesleysimplicio
74031e1e2a fix(dashboard): respect HERMES_BASE_PATH in WebSocket URLs (#25547)
When the dashboard is reverse-proxied under a path prefix
(`X-Forwarded-Prefix: /dashboard`), the SPA already routes its
`/api/...` REST traffic through `HERMES_BASE_PATH` via
`web/src/lib/api.ts`. Three WebSocket URLs constructed elsewhere
were still hardcoded to root `/api/...` and so opened
`wss://host/api/...` instead of `wss://host/dashboard/api/...`,
forcing operators to forward selected root API/WS paths through the
reverse proxy as a workaround (see issue #25547).

Add `HERMES_BASE_PATH` between `host` and `/api/...` in the
three constructed WebSocket URLs:

- `web/src/pages/ChatPage.tsx` — PTY WebSocket
- `web/src/components/ChatSidebar.tsx` — events subscriber
- `web/src/lib/gatewayClient.ts` — JSON-RPC gateway WebSocket

When the dashboard is served at root, `HERMES_BASE_PATH === """
and the URLs are bit-for-bit identical to before. Under a prefix,
the WebSocket connections now go through the same proxy path the
REST calls already use.

Note: bundled dashboard plugins (kanban, hermes-achievements) embed
`"/api/plugins/..."` in their compiled `dist/index.js` and
remain out of scope here — those need source-side fixes per plugin.

Fixes #25547.
2026-05-17 11:39:37 -07:00
davidcampbelldc
714b3b2bd8 fix(web_server): pass proxy_headers=False to uvicorn.run so the dashboard's loopback gate sees the real connection peer
`_ws_client_is_allowed()` enforces a loopback-only client check on every
dashboard WebSocket upgrade (`/api/ws`, `/api/events`, `/api/pty`,
`/api/pub`):

    def _ws_client_is_allowed(ws):
        if _is_public_bind():
            return True
        client_host = ws.client.host if ws.client else ""
        if not client_host:
            return True
        return client_host in _LOOPBACK_HOSTS

The intent is: when bound to 127.0.0.1, only accept WS upgrades from
loopback peers. Public bind (--insecure) trades that for token-only.

However, `uvicorn.run(app, host=host, port=port, log_level="warning")`
omits `proxy_headers`. In modern uvicorn (>= 0.20) `proxy_headers`
defaults to True and `forwarded_allow_ips` defaults to "127.0.0.1".
With those defaults, any reverse proxy connecting from loopback (nginx,
in-cluster proxy, Cloudflare Tunnel sidecar in HTTP mode, K8s
ingress-nginx) causes uvicorn to rewrite `ws.client.host` from the
request's `X-Forwarded-For` header. So the gate sees the original
client's IP (a public address) instead of the loopback peer, returns
False, and closes every browser WS with code=4403 (surfaces as HTTP
403 to the proxy).

Passing `proxy_headers=False` keeps the loopback gate's view of
`ws.client.host` at the immediate transport peer (the proxy on
127.0.0.1), which is exactly what the gate is designed to check.

The bug is invisible in dev (no proxy → no XFF → ws.client.host stays
loopback). It surfaces in proxied production: dashboard chat tab opens,
events feed banner shows "disconnected — tool calls may not appear",
all WS endpoints return 403. Reproduces with:

    curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \
         -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \
         -H "X-Forwarded-For: 1.2.3.4" \
         "http://127.0.0.1:9119/api/ws?token=\$TOKEN"
    # Before: HTTP/1.1 403 Forbidden
    # After:  HTTP/1.1 101 Switching Protocols

Without the XFF header, both behave the same (101) — confirming the
single-variable trigger.

Discovered while diagnosing why the Hermes dashboard at
mandy.loadmagic.ai (behind nginx + Cloudflare Tunnel + CF Access)
refused all browser WS upgrades despite Access app config matching a
known-working sibling deployment (Simone, which doesn't have nginx in
the path).
2026-05-17 11:39:37 -07:00
bird
4afd479f51 fix(gateway): use service restart path in Docker/Podman containers
The /restart command used a detached subprocess approach to restart
the gateway. In Docker, when the gateway process exits, tini (PID 1)
also exits, causing Docker to stop the container and kill the detached
helper before it can restart the gateway. This made /restart effectively
a /shutdown in containerized deployments.

Detect Docker (/.dockerenv) and Podman (/run/.containerenv) containers
and use the service restart path (exit code 75) instead, letting the
container restart policy handle the actual restart.

Note: requires restart policy that restarts on non-zero exit (e.g.
unless-stopped or on-failure).
2026-05-17 11:39:37 -07:00
teknium1
55d6a1636b fix(agent): honor provider timeout config in streaming API calls
Closes #25249 (and supersedes PR #25260) in spirit.

Two bugs in the streaming chat-completions path caused provider timeout
configuration to be silently ignored:

1. Hardcoded connect/pool timeout. The httpx.Timeout for streaming
   calls used hardcoded connect=30.0 and pool=30.0 regardless of the
   user's providers.<id>.request_timeout_seconds config. If the custom
   provider (e.g. Ollama) was unreachable, the call always waited
   exactly 30s before failing, ignoring any configured timeout.

   Fix: use min(_base_timeout, 60.0) for connect and pool when a
   provider timeout is configured, falling back to 30.0 otherwise.
   The 60s cap addresses review feedback (TCP handshake shouldn't
   wait the inference timeout — connect/pool cover the connection
   layer, not model latency).

2. Streaming stale-stream detector ignored provider config. The
   stale detector read only HERMES_STREAM_STALE_TIMEOUT (env default
   180s). The providers.<id>.stale_timeout_seconds key (correctly
   used in the non-streaming path) was never consulted.

   Fix: check get_provider_stale_timeout(provider, model) first,
   then fall back to the env var. Aligns the streaming path with
   the non-streaming path's priority chain (config > env > default).

Salvage shape diverged from PR #25260: the function moved to
agent/chat_completion_helpers.py and the contributor's two commits
(initial fix + 60s-cap review follow-up) are squashed into one final
commit applied at the new location.

Original diagnosis, fix shape, AND the 60s-cap review response from
@zccyman in PR #25260; credited via Co-authored-by.

Co-authored-by: zccyman <16263913+zccyman@users.noreply.github.com>
2026-05-17 11:39:37 -07:00
QuenVix
2f28b60a47 fix(send_message): preserve Slack and Matrix thread targets resolved from channel directory 2026-05-17 11:38:55 -07:00
QuenVix
d5a0815c3d fix(transports): use monotonic deadlines in codex app-server turn loop 2026-05-17 11:37:45 -07:00
teknium1
37286a5bcd chore(release): map QuenVix, Mind-Dragon, soynchux emails for Tier 4 salvage 2026-05-17 11:37:45 -07:00
EloquentBrush0x
d0f551b44e fix(doctor): show xAI OAuth login state in hermes doctor Auth Providers section
`hermes doctor` displayed OAuth status for Nous, Codex, Gemini, and MiniMax
but silently omitted xAI OAuth, even though `get_xai_oauth_auth_status()`
exists and the same information is already surfaced in `hermes status`.

Add xAI OAuth as a *separate* try/except block so an import failure cannot
silence the already-printed provider rows above it — consistent with the
per-provider isolation introduced in the doctor fallback fix.

Tests:
- 9 new tests in TestDoctorXaiOAuthStatus covering: logged-in ok, not-logged-in
  warn, error line present/absent, import failure isolation, runtime exception
  and None-return safety.
- 9 existing run_doctor helpers updated to mock get_xai_oauth_auth_status for
  deterministic output.
2026-05-17 11:35:57 -07:00
EloquentBrush0x
016893f5e4 feat(status): show xAI OAuth login state in hermes status
hermes status listed Nous Portal, OpenAI Codex, Qwen OAuth, and MiniMax
OAuth in the Auth Providers section but omitted xAI OAuth entirely.
Users who authenticated via `hermes auth add xai-oauth` had no way to
verify their session state from the status output.

Add xAI OAuth display using the same field shape as OpenAI Codex:
auth_store (Auth file:), last_refresh (Refreshed:), and error when
not logged in. The import is isolated in its own try/except so an
import failure cannot affect the already-printed rows above it.

Tests cover:
- logged in: check mark, auth_store, last_refresh, error suppressed
- not logged in: login command hint, error shown, error absent = no line
- resilience: import failure, status function raises, returns None
- isolation: xAI import failure does not break Nous/MiniMax display
2026-05-17 11:35:57 -07:00
EloquentBrush0x
e10bb9dffa fix(doctor): isolate per-provider OAuth imports to prevent fallback regression
Shared try/except import block meant that if any one status function was
missing, all providers lost their OAuth fallback suppression. Split into
per-provider try/except so each branch is independently safe.

Add end-to-end test for xAI: bad XAI_API_KEY with healthy OAuth does not
surface a blocking issue in run_doctor output. Add tests for None return,
import failure isolation (xAI missing does not break Gemini), and move
test_returns_false_for_unknown_provider out of the xAI-specific class.
2026-05-17 11:35:57 -07:00
EloquentBrush0x
e89d78ff09 fix(doctor): suppress stale XAI_API_KEY issue when xAI OAuth is healthy
_has_healthy_oauth_fallback_for_apikey_provider() covers Gemini and
MiniMax (added by #26853) but omits xAI. The xAI provider profile
(plugins/model-providers/xai/__init__.py) has auth_type="api_key" and
env_vars=("XAI_API_KEY",), so it enters the generic API-key
connectivity loop. When XAI_API_KEY fails a 401 probe but xAI OAuth
is healthy, the failure is promoted to the blocking summary even though
xAI works fine via OAuth — the same false-positive #26853 fixed for
Gemini and MiniMax.

Fix: import get_xai_oauth_auth_status alongside the existing two
helpers and add the "xai" branch. get_xai_oauth_auth_status() already
exists in hermes_cli/auth.py and returns {"logged_in": True} when a
valid OAuth token is present.

Symmetric with the Gemini and MiniMax branches introduced in #26853.
No behavior change for providers without an OAuth path.
2026-05-17 11:35:57 -07:00
Brooklyn Nicholson
caac54796b chore: revert unrelated package-lock + nix hash churn to keep PR diff minimal 2026-05-17 13:33:10 -05:00
Brooklyn Nicholson
711f46e4bd review(tui): update stale comment refs to renamed visualLines helper 2026-05-17 12:32:29 -05:00
Brooklyn Nicholson
220736f417 chore(nix): refresh ui-tui npmDeps hash after wrap-ansi direct-dep drop 2026-05-17 11:54:48 -05:00
Brooklyn Nicholson
8c78f533dd review(tui): route cursorLayout through @hermes/ink wrapAnsi shim (Bun runtime parity)
Copilot caught an important runtime parity gap on PR #27489: the fix
imported the npm `wrap-ansi` package directly, but Ink's `<Text
wrap="wrap">` uses a runtime-selecting shim
(`ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts`) that prefers
`Bun.wrapAnsi` when running under Bun and falls back to the npm package
elsewhere. So under Bun, Ink would render via `Bun.wrapAnsi` while
`cursorLayout` would compute breaks via the npm package — any
disagreement reintroduces the exact cursor-drift symptom the PR is
meant to eliminate.

Fix:

- Export `wrapAnsi` from `@hermes/ink` (`packages/hermes-ink/src/entry-exports.ts`
  and `packages/hermes-ink/index.d.ts`) so the shim is the public surface.
- Switch `ui-tui/src/lib/inputMetrics.ts` from `import wrapAnsi from
  'wrap-ansi'` to `import { wrapAnsi } from '@hermes/ink'`. Both
  renderer (Ink) and cursor layout now traverse the same shim, so
  they share the runtime-selected implementation by construction.
- Same swap in `textInputWrap.test.ts` and `cursorDriftRegression.test.ts`
  — tests now assert parity through the shim, which means under Bun
  they actually exercise Bun's implementation instead of asserting a
  tautology against the npm package.
- Drop the direct `"wrap-ansi": "^9.0.0"` from `ui-tui/package.json`.
  `@hermes/ink` (which IS a declared dep) pulls wrap-ansi in
  transitively — that's not a phantom dep because the import path
  goes through `@hermes/ink`'s public exports, not through a
  hoisting accident.

Verified: 791/791 vitest tests pass. `@hermes/ink` rebuilt
(`dist/entry-exports.js` includes `wrapAnsi` export). TUI bundle
rebuilt clean.
2026-05-17 11:52:21 -05:00
Brooklyn Nicholson
55f13be65d chore(nix): refresh ui-tui npmDeps hash for wrap-ansi dep addition 2026-05-17 11:38:33 -05:00
Brooklyn Nicholson
1c0e59e557 review(tui): address Copilot feedback on cursorLayout wrap-ansi rewrite
Three small follow-ups from the Copilot review on #27489:

1. Declare `wrap-ansi` as a direct dependency of `ui-tui`. It was a
   phantom dep that resolved via npm hoisting from `@hermes/ink`'s
   transitive graph — fine on hoisted installs, but breaks under pnpm
   or `npm install --no-install-strategy=hoisted` style isolated
   installs. Now listed as `"wrap-ansi": "^9.0.0"` matching the
   @hermes/ink version. Lockfile regenerated.

2. Implement the defensive resync the comment promised. Previously the
   comment claimed the loop would "fall back to advancing by one to
   stay in lockstep" on wrap-ansi desync, but the code unconditionally
   advanced `originalIdx` with no actual check — so any future
   wrap-ansi option change or styled-input caller could silently slide
   `originalIdx` past the end of `value` and emit garbage line ranges.
   Now actually compares `value[originalIdx] === ch`, re-syncs via
   `indexOf` on mismatch, and bails out (returning whatever was built
   so far) if the desync is unrecoverable. Production paths still hit
   the equality fast-path on every char.

3. Drop the `visualLines` wrapper. It was a one-line indirection over
   `visualLinesFromWrappedOutput`. Renamed the implementation to
   `visualLines` and removed the wrapper — same name, no extra layer.

No behavior change beyond the defensive realign; all 791 vitest tests
still pass.
2026-05-17 11:34:06 -05:00
Brooklyn Nicholson
3b4dd68326 fix(tui): align composer cursorLayout with wrap-ansi to kill multiline cursor drift
The composer's `cursorLayout` (in `ui-tui/src/lib/inputMetrics.ts`) used a
hand-rolled word-wrap algorithm to decide where `useDeclaredCursor`
should park the hardware cursor. But Ink's `<Text wrap="wrap">` renders
the same text via `wrap-ansi`. The two algorithms disagreed on common
real-world inputs — `"branch investigate"` at cols=20, `"hello world"`
at cols=8, exact-fill strings like `"abcdefgh"` at cols=8 — so the
hardware cursor parked several cells past where Ink actually rendered
the last character. Users saw a multi-cell blank gap between their
last-typed letter and the cursor block, especially on narrow terminals
(the Cursor IDE built-in terminal was the worst offender).

Three previous PRs (#26717, #25860, #22197) chased fast-echo
displayCursor/cursorDeclaration drift and in-band-vs-native cursor
heuristics. None of them touched the underlying wrap-algorithm
mismatch, which is why the bug kept resurfacing.

Fix: source cursorLayout's line breaks from wrap-ansi directly. Walk
its emitted string char-by-char, tracking original-string offsets, push
a VisualLine at each '\n'. Also drop the buggy `column >= w` overflow
rule in cursorLayout — that's what pushed exact-fill text onto a
phantom next row.

canFastBackspaceShape now detects the wrap boundary in BOTH coordinate
conventions (column === 0 OR column >= columns), since exact-fill now
reports as (0, columns) instead of the previous (1, 0). The physical
state is identical — the terminal auto-wraps at column N either way —
but the layout function reports the position more honestly.

Tests:
- ui-tui/src/__tests__/textInputWrap.test.ts: 3 tests that pinned the
  BUGGY behavior were updated to assert wrap-ansi parity (the real
  invariant). Added a typing-prefix invariant: cursorLayout must agree
  with wrap-ansi at every character of a long input.
- ui-tui/src/__tests__/cursorDriftRegression.test.ts: new file. Walks
  the user-reported bug message char-by-char at 7 widths and asserts
  agreement with wrap-ansi at every prefix.

Verification:
- 791/791 vitest tests pass.
- 84/84 tui-gateway pytest tests pass via scripts/run_tests.sh.
- PTY repro (typing into a real `hermes --tui` PTY at cols=50/55/60):
  cursor lands exactly 1 cell past the last typed char in every case
  the bug previously drifted.
2026-05-17 11:10:06 -05:00
teknium1
f36c89cd57 fix(plugins/browser): carry forward requests.RequestException wrapping
PR #25580 was authored before #2746 landed on main, so its plugin
versions of browser_use/browserbase/firecrawl ship without the
requests.RequestException → RuntimeError wrapping that 13c72fb4 added
to the legacy tools/browser_providers/ files for #2746. Cherry-picking
the PR + git rm'ing the legacy files (the migration's intent) would
silently revert that network-error fix.

Port the same try/except pattern into the three plugin create_session()
methods. Browser Use managed-mode keeps its raw-exception propagation
(idempotency-key retry semantics).

Co-authored-by: nidhi-singh02 <nidhi2894@gmail.com>
2026-05-17 04:04:15 -07:00
kshitijk4poor
c74ff2c8ef fix(browser): self-review pass — dead-import, log levels, future-proofing
Addresses findings from two self-review passes pre-merge.

First pass (3-agent parallel review):

1. plugins/browser/browser_use/provider.py: drop the
   ``_ = managed_nous_tools_enabled`` dead-import-hider in
   _get_config_or_none(). The import was actively misleading — the
   helper IS used in _get_config() (separate method, separate import),
   not here. The "keep static analysis happy" comment was wrong about
   what the helper does in this scope.

2. agent/browser_provider.py: drop ``pragma: no cover`` from
   is_configured() / provider_name() backward-compat aliases. They ARE
   covered by ``TestLegacyAbcAliases`` — the pragma would have masked
   future regressions.

3. tools/browser_tool.py: refactor _is_legacy_provider_registry_overridden()
   to compare against a module-frozen _DEFAULT_PROVIDER_REGISTRY snapshot
   instead of hardcoded set of 3 keys. Future maintainers adding a 4th
   built-in provider now just extend _PROVIDER_REGISTRY; the override
   detection adapts automatically. Previously the hardcoded
   ``set(...) != {"browserbase", "browser-use", "firecrawl"}`` would flip
   True forever on any 4-key registry, silently routing every install
   onto the legacy fixture path.

4. tools/browser_tool.py: when explicit ``browser.cloud_provider`` is set
   but the registry has no matching plugin (typo, uninstalled plugin,
   discovery failure), emit a WARNING with actionable text instead of
   silently falling through to auto-detect. Legacy code surfaced a typed
   credentials error via direct class instantiation; this log restores
   the signal in the post-migration path.

5. agent/browser_registry.py: trim the triple-redundant _LEGACY_PREFERENCE
   documentation. Module docstring + 13-line block-comment + 5-line
   inline comment was repeating the same point. Kept the docstring and
   trimmed the block-comment to 5 lines.

6. agent/browser_registry.py: upgrade is_available()-raised logging from
   DEBUG to WARNING with exc_info=True. A provider's availability check
   throwing is unusual enough that users debugging "no cloud provider"
   need the traceback in logs.

7. tests/plugins/browser/check_parity_vs_main.py: drop dead top-level
   imports (os, shutil, tempfile — only referenced inside the
   SUBPROCESS_SCRIPT string literal that runs in a child process).

Second pass (architecture + claim-verification review):

8. tools/browser_tool.py: rewrite the inline comment in _get_cloud_provider
   auto-detect branch. Prior text claimed it "routes through the plugin
   registry's legacy preference walk so third-party plugins still get a
   chance to be selected when they're explicitly configured" — false on
   both counts. The branch uses module-level legacy class aliases
   (BrowserUseProvider / BrowserbaseProvider) directly; third-party
   plugins are intentionally reachable only via explicit
   ``browser.cloud_provider``. Corrected comment now matches behaviour
   and cross-references _LEGACY_PREFERENCE for the firecrawl gate
   rationale.

9. tools/browser_tool.py + tests/tools/test_managed_browserbase_and_modal.py:
   drop the unused ``get_active_browser_provider as
   _registry_get_active_browser_provider`` alias from the
   ``from agent.browser_registry import ...`` block. It was never
   referenced; matching test-stub line in the agent.browser_registry
   SimpleNamespace also dropped. ``get_provider`` is still imported (used
   by the explicit-config dispatch path at line 535).

10. plugins/browser/firecrawl/provider.py: align emergency_cleanup()
    with the early-guard pattern used in browserbase + browser_use
    plugins. Previously firecrawl tried the DELETE and relied on
    ``_headers()`` raising ValueError to trip a "missing credentials"
    warning; same final outcome but a different control flow that read
    like a bug to a maintainer skimming the three modules. Now: if
    is_available() is False, log+return early — identical shape to the
    other two providers.

Verification: 54/54 unit tests + 13/13 parity scenarios still pass.
2026-05-17 04:04:15 -07:00
kshitijk4poor
1bb6f03724 fix(browser): ensure plugin discovery before registry lookup; parity harness
Two changes that go together:

1. tools/browser_tool.py — add _ensure_browser_plugins_loaded() and call
   it from _get_cloud_provider() before consulting the registry. Normally
   model_tools triggers discover_plugins() as an import side-effect, but
   _get_cloud_provider() can be reached from contexts that haven't gone
   through model_tools (standalone scripts, certain unit-test paths, the
   new parity-sweep harness). Without the defensive call, the registry is
   empty and _registry_get_browser_provider() returns None — silently
   downgrading users to local mode when they explicitly configured a
   cloud provider with no credentials yet. The behavior-parity sweep
   below caught this as 4 scenario regressions (explicit-X-no-creds for
   all 3 providers, and explicit-firecrawl-with-creds).

2. tests/plugins/browser/check_parity_vs_main.py — subprocess harness
   that pins one Python invocation to origin/main and one to this PR's
   worktree via sys.path.insert(), runs _get_cloud_provider() across a
   13-scenario config matrix, and diffs the reduced shape tuple
   (is_local, provider_name, is_available). Provider_name pulls from
   provider.provider_name() which is the legacy CloudBrowserProvider
   API and remains as a backward-compat alias on the new BrowserProvider
   ABC, so the comparison is apples-to-apples regardless of class
   identity.

Final result: PARITY OK across 13 scenarios. The four observable
config/credential matrices that exercise the dispatcher all match
origin/main bit-for-bit:

  - no-config + no-env → local
  - explicit local + any env → local
  - explicit BB / BU / FC + no creds → provider returned with
    is_available()==False (so dispatcher surfaces typed credentials
    error; matches main exactly)
  - explicit BB / BU / FC + creds → provider returned with
    is_available()==True
  - no-config + BU creds → Browser Use
  - no-config + BB creds → Browserbase
  - no-config + both → Browser Use (legacy walk first hit)
  - no-config + FC only → local (firecrawl NOT in legacy walk)
  - no-config + FC + BB → Browserbase (legacy walk skips firecrawl)

Per the dev skill's "behavior-parity for refactor PRs" rule — without
this subprocess sweep, 31/31 unit tests pass while the production code
path is silently broken for users who type `browser.cloud_provider:
browserbase` and run a single browser command without prior model_tools
import. Caught + fixed before push.
2026-05-17 04:04:15 -07:00
kshitijk4poor
fec0a0da98 test(plugins/browser): coverage for the 3-plugin migration
Mirrors tests/plugins/web/test_web_search_provider_plugins.py from PR #25182.
31 tests across 5 classes:

  TestBundledPluginsRegister (8 tests)
    - Three plugins register (browserbase, browser-use, firecrawl)
    - Each plugin's name + display_name accessible
    - get_setup_schema() returns picker-shaped dict with post_setup hook
    - All three lifecycle methods (create_session, close_session,
      emergency_cleanup) overridden on every plugin

  TestIsAvailable (4 tests)
    - browserbase needs BOTH BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID
    - browserbase: api_key alone or project_id alone insufficient
    - browser-use satisfied by BROWSER_USE_API_KEY
    - firecrawl satisfied by FIRECRAWL_API_KEY

  TestRegistryResolution (8 tests) — most valuable, locks down
                                     pre-migration semantics:
    - _resolve(None) with no creds returns None (local mode)
    - _resolve('local') short-circuits to None
    - _resolve('browserbase') returns provider even when unavailable
      (so dispatcher surfaces typed credentials error)
    - _resolve('firecrawl') same: explicit-config wins
    - _resolve('unknown') falls through to auto-detect
    - Legacy walk picks browser-use over browserbase
    - browserbase-only configuration: browserbase wins
    - **Regression**: firecrawl is NEVER auto-selected even when
      single-eligible (preserves pre-migration gate; FIRECRAWL_API_KEY
      shared with web firecrawl must not silently route to paid cloud
      browser)

  TestLegacyAbcAliases (6 tests)
    - is_configured() delegates to is_available() for all three plugins
    - provider_name() returns display_name for all three plugins

  TestPickerIntegration (3 tests)
    - _plugin_browser_providers() exposes all three plugins as rows
    - Each row carries post_setup='agent_browser'
    - browser_plugin_name marker matches browser_provider

All tests use real imports — no mocking of provider classes — so the
suite catches drift in the ABC, registry, picker injection, and plugin
glue layer simultaneously.

31/31 passing.
2026-05-17 04:04:15 -07:00
kshitijk4poor
250caebeb1 refactor(browser): delete tools/browser_providers/ directory; migrate tests
The four files in tools/browser_providers/ (base.py, browserbase.py,
browser_use.py, firecrawl.py) have been migrated into
plugins/browser/<vendor>/provider.py over the previous commits. No
in-tree code references them anymore — the legacy class names
(BrowserbaseProvider / BrowserUseProvider / FirecrawlProvider) are
re-exported from tools.browser_tool as aliases to the plugin classes,
so existing test patches keep working.

Updates tests/tools/test_managed_browserbase_and_modal.py:
  - Adds _load_plugin_module() helper next to _load_tool_module().
  - Reroutes five _load_tool_module('tools.browser_providers.X', ...)
    calls to _load_plugin_module('plugins.browser.X.provider', ...).
  - Renames BrowserbaseProvider/BrowserUseProvider -> the new plugin
    class names (BrowserbaseBrowserProvider / BrowserUseBrowserProvider).
  - Updates is_configured() -> is_available() on the one assertion that
    cared about the rename (the others stay on is_configured() via the
    BrowserProvider ABC's backward-compat alias).

Net diff: -630 / +39 lines (tests + dead-code deletion). Verified
23/23 tests in test_browser_cloud_*.py + test_managed_browserbase_and_modal.py
still pass.

Closes the file-tree mismatch portion of #25214. Remaining work:
new plugin-level test coverage under tests/plugins/browser/, behaviour
parity subprocess sweep vs origin/main, and full tests/tools/ regression
sweep before opening the PR.
2026-05-17 04:04:15 -07:00
kshitijk4poor
1b9c539c6e feat(tools): mirror image_gen plugin-injection in Browser Automation picker
Drops the three hardcoded browser-provider rows (Browserbase, Browser Use,
Firecrawl) from TOOL_CATEGORIES['browser']['providers'] and replaces them
with runtime injection from agent.browser_registry — mirroring the
_plugin_web_search_providers() pattern PR #25182 established for the
Web Search and Extract category.

Adds _plugin_browser_providers() helper in hermes_cli/tools_config.py
that walks list_providers() and builds a TOOL_CATEGORIES-shape dict per
provider via get_setup_schema(). The new visible_providers() hook calls
it for cat['name'] == 'Browser Automation'.

The three remaining hardcoded rows are non-provider UX setup-flow rows:
  - 'Nous Subscription (Browser Use cloud)' — managed Browser Use billed
    via Nous subscription; uses the browser-use plugin as the underlying
    backend but has distinct setup UX (requires_nous_auth gates it).
  - 'Local Browser' — headless Chromium, no CloudBrowserProvider.
  - 'Camofox' — anti-detection local Firefox; _is_camofox_mode()
    short-circuits the cloud-provider dispatch path entirely.

Verified the picker output matches pre-migration order/content:
  Local Browser, Camofox, Browser Use, Browserbase, Firecrawl
(with 'Nous Subscription' surfaced only when the user is Nous-authed,
unchanged from main).
2026-05-17 04:04:15 -07:00
kshitijk4poor
40fde853fa refactor(browser): dispatch _get_cloud_provider through agent.browser_registry
Switches tools.browser_tool's cloud-provider lookup from the hardcoded
_PROVIDER_REGISTRY class-instantiation pattern to the
agent.browser_registry singleton registry that plugins self-populate.

Changes:

- tools/browser_tool.py top imports: pull BrowserProvider from
  agent.browser_provider (re-exported as CloudBrowserProvider for legacy
  callers) and the three provider classes from plugins/browser/<vendor>/.
  Legacy class names (BrowserbaseProvider, BrowserUseProvider, FirecrawlProvider)
  remain on tools.browser_tool as re-export shims so existing test patches
  (monkeypatch.setattr(browser_tool, 'BrowserUseProvider', ...)) keep working.

- _get_cloud_provider() now consults agent.browser_registry.get_provider()
  for explicit-config lookups. The auto-detect fallback still uses
  BrowserUseProvider() / BrowserbaseProvider() at the module level so the
  cache-policy test fixtures (which patch those names) keep driving the
  function. Test-time _PROVIDER_REGISTRY overrides are detected by class
  identity and routed through the legacy factory-call path.

- agent/browser_provider.py: BrowserProvider grows is_configured() and
  provider_name() as thin backward-compat aliases for the legacy
  CloudBrowserProvider API. Subclasses MUST implement is_available() and
  name; the aliases delegate. This keeps ~6 caller sites in browser_tool.py
  working without churning them.

- tests/tools/test_managed_browserbase_and_modal.py: _install_fake_tools_package
  grows stubs for agent.browser_provider / agent.browser_registry /
  plugins.browser.<vendor>.provider so the test's spec-loader path
  (sys.modules-reset + reload-tool-from-disk) can satisfy tools.browser_tool's
  top-level imports.

Verified: all 23 existing tests in test_browser_cloud_*.py +
test_managed_browserbase_and_modal.py still pass post-cutover.

The legacy tools/browser_providers/ directory is NOT yet deleted; several
tests still _load_tool_module() those files via spec_from_file_location.
The deletion + test-path updates land in a later commit.
2026-05-17 04:04:15 -07:00
kshitijk4poor
a15cdfb050 feat(browser): browser-use + firecrawl plugins; drop single-eligible shortcut
Migrates the remaining two cloud browser providers to plugins:

  plugins/browser/browser_use/    — dual auth (direct BROWSER_USE_API_KEY
                                    or managed Nous gateway), idempotency-
                                    key handling for retried managed-mode
                                    creates, x-external-call-id capture.
  plugins/browser/firecrawl/      — direct FIRECRAWL_API_KEY only;
                                    distinct from plugins/web/firecrawl/
                                    (same key, different endpoint).

Also drops the 'single-eligible shortcut' rule from
agent.browser_registry._resolve(). Was a copy-paste from
web_search_registry that would have introduced a real behavior change:
a user with only FIRECRAWL_API_KEY set (for web-extract) would silently
get routed to a paid Firecrawl cloud browser on a fresh install — not
matching origin/main, which only auto-detected between Browser Use and
Browserbase. Third-party browser plugins are subject to the same gate:
they require explicit `browser.cloud_provider` to take effect.

Verified end-to-end via plugin discovery:
  - 3 plugins register (browser-use, browserbase, firecrawl)
  - _resolve(None) with no creds: None (local mode)
  - _resolve(None) with only FIRECRAWL_API_KEY: None (matches main)
  - _resolve('firecrawl'): firecrawl (explicit wins)
  - _resolve(None) with BU+firecrawl: browser-use (legacy walk first hit)
  - _resolve(None) with all three: browser-use (legacy walk order)
2026-05-17 04:04:15 -07:00
kshitijk4poor
b8138ac405 feat(browser): browserbase plugin (spike — first migration)
Migrates tools/browser_providers/browserbase.py → plugins/browser/browserbase/.
Direct credentials only (BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID); same
session-creation, 402-handling, and feature-flag logic as the legacy
implementation. Renames is_configured() → is_available() to match the new
BrowserProvider ABC.

The legacy module tools/browser_providers/browserbase.py is NOT yet deleted
and tools/browser_tool.py still references the in-tree class. The dispatcher
cutover happens in a later commit so the plugin migration and the dispatcher
switch land as separate reviewable units.

Verified via plugin-discovery E2E:
  - browserbase registers as 'browserbase'
  - is_available() correctly tracks BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID
  - _resolve('browserbase') returns the provider even when unavailable
    (so dispatcher surfaces a typed credentials error)
  - _resolve(None) returns the provider when it's the single eligible one
2026-05-17 04:04:15 -07:00
kshitijk4poor
c6e6909e5a feat(browser): add BrowserProvider ABC mirroring web_search_provider template
Foundation commit for the browser-provider plugin migration (#25214).
Mirrors the architecture established by PR #25182 (web providers):

- agent/browser_provider.py — BrowserProvider ABC. Preserves the legacy
  CloudBrowserProvider lifecycle contract bit-for-bit (create_session,
  close_session, emergency_cleanup, session metadata shape) so the
  dispatcher in tools/browser_tool.py becomes a pure registry lookup.
  Renames is_configured() → is_available() for parity with WebSearchProvider.

- agent/browser_registry.py — selection registry with the same
  three-rule resolution as web_search_registry:
    1. Explicit config wins (returns even if is_available() == False so
       the dispatcher surfaces a precise credentials error)
    2. Single-eligible shortcut
    3. Legacy preference walk: browser-use → browserbase, filtered by
       availability. Firecrawl is intentionally NOT in the legacy walk
       (matches pre-migration behaviour — Firecrawl was only reachable
       via explicit browser.cloud_provider: firecrawl).

- hermes_cli/plugins.py — adds ctx.register_browser_provider() facade,
  one-liner mirror of register_web_search_provider().

No plugins registered yet; no dispatcher cutover yet. The next commits
move browserbase/browser-use/firecrawl into plugins/browser/<vendor>/
and switch tools/browser_tool.py over to the registry.
2026-05-17 04:04:15 -07:00
teknium1
150b577da5 chore(release): AUTHOR_MAP entries for batch salvage group 5 contributors
Adds release-note attribution mappings for the contributors from group 5:
- @haran2001 (PR #27070, #27068)
- @ms-alan (PR #26443)
- @godlin-gh (PR #26118)
- @wesleysimplicio (PR #25777, ext-email form)
- @Carry00 (PR #26851)
- @alaamohanad169-ship-it (PR #26036)
- @hawknewton (PR #26294)

(YanzhongSu PR #25879 and flamiinngo PR #27231 already mapped.)
2026-05-17 02:31:18 -07:00
hawknewton
c02606a385 chore(deps): lazy-install boto3/botocore for bedrock adapter
agent/bedrock_adapter.py now calls lazy_deps to install boto3 and
botocore on first import, mirroring how other optional provider
adapters defer their heavy AWS dependencies until actually used.

Keeps the base install slim for users who don't run on Bedrock.
2026-05-17 02:31:18 -07:00
Spider-Verse
1856bd9cc8 fix(telegram): re-trigger typing indicator after sending messages
Telegram clears the typing state when a new message is delivered.
When the agent sends intermediate progress messages (like 'Checking:'),
the '...typing' bubble disappears immediately and doesn't return until
the next keepalive tick (up to 2s later). This makes Hermes appear
unresponsive during multi-tool operations.

Fix: call send_typing() immediately after successful message delivery
to restart the typing indicator without waiting for the next keepalive tick.

Fixes #25836
2026-05-17 02:31:18 -07:00
carryzuo00
c9298bba06 fix(doctor): SSH check ignores TERMINAL_SSH_USER, TERMINAL_SSH_PORT, TERMINAL_SSH_KEY
The SSH connectivity check in `run_doctor` only passed the host to ssh,
using the current OS user and default port 22. When the target requires a
different user (TERMINAL_SSH_USER), non-standard port (TERMINAL_SSH_PORT),
or a specific identity file (TERMINAL_SSH_KEY), the check always failed
with "Permission denied" — even though the agent itself connects fine.

Fix: read all four TERMINAL_SSH_* env vars and build the ssh command with
-p, -i, and user@host as appropriate, matching how the terminal tool
actually establishes the connection.
2026-05-17 02:31:18 -07:00
flamiinngo
dbeaaa47f2 refactor(security): extract _block_message helper to unify block logic in _parse_response
Both the `action=block` and `decision=block` branches in _parse_response
shared identical field-priority and type-validation logic. Extract it into
a single _block_message(primary, secondary) helper so the two branches are
one line each and the type guard lives in exactly one place.

No functional change: existing tests (TestParseResponse, 14 tests) all
pass unchanged, confirming identical behaviour.
2026-05-17 02:31:18 -07:00
flamiinngo
63805965e7 fix(security): restore type safety and extract constant in shell hook block handler
Address code review feedback on _parse_response:

1. Restore isinstance(raw, str) guard so non-string message/reason values
   (e.g. integers, lists) from a malformed hook response fall back to the
   default rather than being forwarded as-is. This keeps the contract that
   message in the returned dict is always a string.

2. Extract the repeated literal 'Blocked by shell hook.' into a module-level
   constant _DEFAULT_BLOCK_MESSAGE to avoid duplication and make it easy to
   change in one place.

Four new unit tests added to tests/agent/test_shell_hooks.py covering:
- action block with no message (uses default)
- decision block with no reason (uses default)
- action block with empty string message (uses default)
- action block with non-string message, e.g. integer (uses default)
2026-05-17 02:31:18 -07:00