20 Commits
Author SHA1 Message Date
EloquentBrush0x 6c26727bb3 fix(gateway): extend observe+attribution to location and media handlers
_handle_location_message and _handle_media_message were skipped when the
observe-unmentioned-group-messages feature landed (a9db0e2c7). Both handlers
now:

1. Check _should_observe_unmentioned_group_message on the skipped path and
   call _observe_unmentioned_group_message so group chatter is stored as
   shared session context even when the bot is not addressed.

2. Call _apply_telegram_group_observe_attribution on the triggered path so
   the dispatched event uses the shared (user_id=None) group session instead
   of the per-user session, letting the model see previously observed context.
   For stickers the attribution is applied after _handle_sticker completes
   (which overwrites event.text with the vision description); for all other
   media types it is applied once after caption cleaning.

Four new tests cover the observe and attribution paths for both handlers.
2026-05-20 23:52:18 -07:00
EloquentBrush0x 8f92327891 fix(skills-hub): fix dedup in browse_skills() programmatic API
browse_skills() is the TUI gateway's API for the web UI skills browser
(tui_gateway/server.py:6574). It had the same dedup-by-name bug as
do_browse() and unified_search() fixed in the parent commit: r.name is
not unique for browse-sh skills (Airbnb, Booking.com, Zillow all publish
"search-listings"), so the dedup loop silently dropped all but the first
skill with each task name.

Switch to r.identifier, which is always globally unique.

Add a regression test asserting that two browse-sh skills with the same
name but different hostnames both appear in the browse_skills() result.
2026-05-20 15:04:01 -07:00
EloquentBrush0x fc7e04e9ed fix(skills-hub): deduplicate search results by identifier, not name
Browse.sh exposes skills by task name (e.g. "search-listings"), which is
shared across hundreds of sites. Deduplicating by name silently dropped
every browse-sh skill after the first one with a given task name — e.g.
only Airbnb's "search-listings" would survive, collapsing Booking.com,
Zillow, and every other site's variant into nothing.

Switch unified_search() and do_browse() to use r.identifier as the dedup
key. identifier is always globally unique (e.g.
"browse-sh/airbnb.com/search-listings-ddgioa"), so same-named skills from
different browse-sh hostnames are preserved as distinct results.

Update existing TestUnifiedSearchDedup tests to model the real scenario
(same identifier appearing from two sources) and add a regression test
that asserts browse-sh skills with the same name but different hostnames
are never collapsed.
2026-05-20 15:04:01 -07:00
EloquentBrush0x 5a3317693c fix(discord): define view classes after lazy discord.py install
When discord.py is not installed at import time, DISCORD_AVAILABLE=False
and the view class definitions at module bottom are skipped.
check_discord_requirements() performs a lazy install and sets
DISCORD_AVAILABLE=True but never re-ran the class definitions, causing
NameError on the first button interaction (exec approval, slash confirm, etc.).

Extract the five ui.View subclasses into _define_discord_view_classes() and
call it both at module load (when discord.py is pre-installed) and inside
check_discord_requirements() after a successful lazy install.
2026-05-19 09:28:22 -07:00
EloquentBrush0x b3e714e8b7 fix(xai-oauth): quarantine dead tokens on terminal refresh failure
resolve_xai_oauth_runtime_credentials() called _refresh_xai_oauth_tokens()
with no try/except. A terminal refresh failure (HTTP 400/401/403 —
invalid_grant, token revoked) propagated without clearing the dead
access_token / refresh_token from auth.json, causing every subsequent
session to retry the same doomed network request.

Add a try/except around the refresh call that mirrors the existing
credential_pool.py quarantine: when _is_terminal_xai_oauth_refresh_error
identifies a non-retryable failure, clear the dead token fields from
auth.json and write a last_auth_error diagnostic marker so future calls
fail fast with a clear relogin_required error instead of hitting the
network.

active_provider is preserved (set_active=False) so multi-provider users
whose chosen provider is not xai-oauth are unaffected.

Tests: two new cases in test_auth_xai_oauth_provider.py cover terminal
quarantine and transient pass-through.
2026-05-18 20:02:11 -07:00
EloquentBrush0x 5cbf86f1c8 fix(acp): resolve /tmp symlink before workspace auto-approve check on macOS
Path.resolve() follows the /tmp -> /private/tmp symlink on macOS, so
str(path).startswith("/tmp/") is always False for temp-dir paths.
The "Accept Edits" (workspace_session) mode silently refused to
auto-approve every /tmp write on macOS, breaking the documented
behaviour and making the existing test fail on this platform.

Fix: keep the raw expanded path (pre-resolve) for the /tmp prefix
check and continue using the resolved form only for the cwd
relative_to() call where symlink resolution is correct behaviour.
2026-05-18 19:36:27 -07:00
EloquentBrush0x 5766504c60 fix(gateway): align kanban artifact _IMAGE_EXTS with response dispatch
_deliver_kanban_artifacts used a broader _IMAGE_EXTS that included
.bmp, .tiff, and .svg. These three extensions are absent from the
equivalent set in _deliver_media_from_response (line 10661), which
intentionally routes them through send_document rather than
send_multiple_images (comment near line 10522 notes that Telegram
sendPhoto recompresses and rejects non-raster formats).

Routing .svg (XML text), .bmp, or .tiff through the photo API causes
send_multiple_images to raise on most platforms; the exception is caught
and logged as a warning, silently dropping the artifact. Aligning the
two sets ensures kanban deliverables with these extensions follow the
same send_document path as regular agent responses.

No behaviour change for .png/.jpg/.jpeg/.gif/.webp.
2026-05-18 19:33:53 -07:00
EloquentBrush0x d9331eecee fix(minimax-oauth): quarantine dead tokens on terminal refresh failure
resolve_minimax_oauth_runtime_credentials called _refresh_minimax_oauth_state
without a try/except, so a terminal failure (invalid_grant,
refresh_token_reused, invalid_refresh_token) raised AuthError but left
the dead refresh_token in auth.json. Every subsequent API call retried
the same token via a network round-trip, failing identically each time.

Fix: wrap the refresh call and, when exc.relogin_required is True and a
refresh_token is present, clear the dead OAuth fields (access_token,
refresh_token, expires_*) and write a last_auth_error quarantine marker
to auth.json before re-raising. The next call sees no access_token and
fails fast with 'not_logged_in' — no network retry — and the user is
prompted to re-authenticate.

Mirrors the existing quarantine pattern for Nous (_quarantine_nous_oauth_state),
xAI-OAuth (#28116), and Codex-OAuth (#28118). Persist failure is
best-effort (logged at DEBUG, error still re-raised).

Salvaged from #28003 by @EloquentBrush0x — contributor's branch was
severely stale (would have reverted ~5000 LOC across azure/kanban/i18n
subsystems); fix re-applied surgically with their pattern preserved and
added two regression tests (terminal-quarantines + transient-does-not-quarantine).
2026-05-18 10:34:03 -07:00
EloquentBrush0x b570e0fdd0 fix(codex-oauth): quarantine terminal refresh errors so dead tokens are not replayed across sessions
When a Codex OAuth refresh token is permanently invalidated (HTTP 400/401/403,
token revoked or reused), _mark_exhausted was called but auth.json was left with
the dead credentials. On the next session, _seed_from_singletons re-read
auth.json and re-seeded the pool with the same revoked token, triggering the
same terminal failure in a loop.

Add _is_terminal_codex_oauth_refresh_error to auth.py and a matching quarantine
block in _refresh_entry: when a terminal error is detected and auth.json holds
no newer tokens, clear access_token/refresh_token from auth.json and remove all
device_code-sourced pool entries from memory. Mirrors the Nous quarantine added
in c90556262 and the xAI quarantine in #28116.

Also add a pre-refresh sync from auth.json before calling refresh_codex_oauth_pure,
matching the xAI and Nous patterns, to avoid refresh_token_reused races when
multiple Hermes processes share the same auth.json singleton.

Salvaged from #27911 by @EloquentBrush0x — contributor's branch was severely
stale (would have reverted ~5000 LOC across azure/kanban/i18n subsystems);
fix re-applied surgically on current main with their predicate and tests preserved.
2026-05-18 10:31:40 -07:00
EloquentBrush0x 5e40f83cb7 fix(xai-oauth): quarantine terminal refresh errors so dead tokens are not replayed across sessions
When refresh_xai_oauth_pure raises a terminal error (HTTP 400/401/403,
i.e. revoked or reused refresh token), _refresh_entry's existing race-
recovery path re-syncs from auth.json and returns if another process has
already rotated the tokens.  If auth.json still holds the same stale
token pair, the function fell through to _mark_exhausted — leaving the
dead credentials in auth.json.  On the next Hermes startup _seed_from_singletons
re-seeded the pool from those stale tokens, causing the same failure loop
on every session.

Fix: after the auth.json re-sync check in the xAI-oauth error handler,
detect terminal errors with the new _is_terminal_xai_oauth_refresh_error
helper and apply a quarantine:
- Clear access_token and refresh_token from providers["xai-oauth"]["tokens"]
  in auth.json so they are not re-seeded.
- Write a last_auth_error entry for hermes doctor / auth status diagnostics.
- Remove all loopback_pkce entries from the in-memory pool so the current
  session stops retrying with the dead credentials.

Mirrors the identical quarantine already in place for Nous OAuth
(c90556262).

Closes the parity gap introduced when c90556262 added Nous-only terminal
error handling without a corresponding xAI-oauth path.
2026-05-18 10:28:09 -07:00
EloquentBrush0x 1fabd6e100 fix(error_classifier): classify xAI Grok entitlement SSE errors as auth
When xAI returns a subscription/entitlement error through an SSE
``type=error`` frame, ``_StreamErrorEvent`` is raised with
``status_code=None``.  This caused ``_classify_by_status`` (step 2 of
``classify_api_error``) to be skipped entirely, and the Grok-specific
phrases ("do not have an active Grok subscription", "out of available
resources") appeared in none of the message-pattern lists.  The error
fell through to ``FailoverReason.unknown (retryable=True)``, burning
``max_retries`` on every affected X Premium+ / SuperGrok user before
the agent stopped — and ``_is_entitlement_failure`` was never called
because it only fires under ``FailoverReason.auth``.

The HTTP 403 path already handled this correctly (``_classify_by_status``
returns ``auth/non-retryable`` for 403).  Add an explicit pattern block
at step 1 (highest priority, before the ``status_code`` guard) so both
code paths route to ``FailoverReason.auth, retryable=False,
should_fallback=True`` — matching the 403 path exactly.

Add three regression tests in ``Fix D`` section of
``test_codex_xai_oauth_recovery.py``:
- primary "do not have an active Grok subscription" phrase
- "out of available resources" + "grok" variant
- unrelated ``_StreamErrorEvent`` must not be reclassified
2026-05-18 10:24:13 -07:00
EloquentBrush0x 2fae8fba9c fix(aux): strip pattern/format keywords from tool schemas on xAI Responses path
xAI's /responses endpoint rejects tool schemas that contain pattern or
format JSON Schema keywords with HTTP 400. chat_completion_helpers.py
already strips these for the main-agent xAI/xai-oauth path (lines
294-302), but _CodexCompletionsAdapter.create() — used for every xAI
OAuth auxiliary call (kanban decomposer, profile describer, etc.) —
passed raw tool schemas without sanitization.

MCP tools that carry pattern/format keywords (common for string fields)
silently caused every auxiliary call over xAI OAuth to fail with an
HTTP 400, while the main agent worked fine. Parity fix: call
strip_pattern_and_format() on the tool list before converting to
Responses API format, matching the main-agent guarantee.
2026-05-18 09:43:44 -07:00
EloquentBrush0x 502d03d5a3 fix(kanban): detect cycles in decompose_triage_task sibling-link pre-validation
decompose_triage_task inlines SQL INSERTs for atomicity and intentionally
bypasses link_tasks() — which calls _would_cycle() per edge.  If the LLM
emits a cyclic parent graph (e.g. A.parents=[1], B.parents=[0]) the DB
write succeeds but every involved child deadlocks in 'todo' forever:
recompute_ready() requires all parents to be done, which is impossible
when A waits for B and B waits for A.

Add a Kahn topological sort over the sibling parent indices in the
pre-validation block, before any DB writes.  Mirrors the cycle-safety
guarantee that link_tasks() provides for manually linked tasks.
2026-05-18 09:40:44 -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
EloquentBrush0x ad00777f04 fix(mcp-oauth): print SSH tunnel hint in _redirect_handler
When Hermes runs on a remote host over SSH, MCP OAuth loopback flows
silently fail: the OAuth provider redirects the user's browser to
http://127.0.0.1:<port>/callback, which reaches the callback server
on the *remote* machine — not the local machine where the browser is
running.

_redirect_handler already detected SSH (via _can_open_browser) and
printed "Headless environment detected — open the URL manually." but
gave no guidance on how to actually reach the callback server. Users
got silent timeouts or "Could not establish connection" errors.

This is the same bug fixed for xAI-oauth and Spotify in #26592, which
added _print_loopback_ssh_hint() in hermes_cli/auth.py. mcp_oauth.py
uses the identical loopback callback pattern (http://127.0.0.1:<port>/callback
via _configure_callback_port / _wait_for_callback) but was missing the hint.

Fix: when SSH_CLIENT or SSH_TTY is set and _oauth_port is available,
print the ssh -N -L port-forward command and the OAuth-over-SSH guide
URL to stderr, consistent with the rest of _redirect_handler's output.

Tests: 4 new cases in TestRedirectHandlerSshHint covering SSH_CLIENT,
SSH_TTY, local session (no hint), and missing _oauth_port (no hint).
2026-05-17 02:29:37 -07:00
EloquentBrush0x a9ba636d53 fix(tools): run post_setup in _reconfigure_provider() for env-var providers
_configure_provider() calls _run_post_setup() after collecting env vars
(line 2286). _reconfigure_provider() did not — providers with both
env_vars and post_setup (Browserbase, Browser Use, Firecrawl, Camofox)
skipped the installation step on reconfiguration.

Fix: mirror the _configure_provider() call. post_setup hooks are
idempotent (check before installing), so no behaviour change for users
who already have the dependencies installed.
2026-05-17 02:21:06 -07:00
EloquentBrush0x c1ae18ee81 fix(gateway): add trust_env=True to aiohttp sessions in SMS, Slack, Teams, Google Chat adapters
aiohttp.ClientSession defaults to trust_env=False, which silently ignores
HTTP_PROXY, HTTPS_PROXY, and ALL_PROXY environment variables. Users behind
a corporate or network proxy cannot reach external APIs on any of these
platforms — all outbound requests fail with connection errors.

Symmetric with wecom.py (line 276), weixin.py (lines 1055/1268/1274), and
matrix.py (no-proxy path) which already set this flag. Complements the
open LINE fix (#26635) with the remaining gateway and plugin adapters.

Changed:
- gateway/platforms/sms.py: persistent Twilio session (connect) + fallback
  session (send) — both hit https://api.twilio.com
- gateway/platforms/slack.py: ephemeral response_url POST session —
  hits https://hooks.slack.com/... callback URLs
- plugins/platforms/teams/adapter.py: standalone send session —
  hits login.microsoftonline.com (token) + Bot Framework service URL
- plugins/platforms/google_chat/adapter.py: standalone send session —
  hits https://chat.googleapis.com/v1/...

WhatsApp sessions are excluded: they connect to http://127.0.0.1:{port}
(local bridge) and must not be routed through a system proxy.
2026-05-16 23:11:43 -07:00