Skip to content

fix(#735): refuse api_server connect() when platforms.api_server.enabled is false - #3

Merged
zebadee2kk merged 2347 commits into
mainfrom
fix/735-api-server-enabled-guard
Jul 5, 2026
Merged

fix(#735): refuse api_server connect() when platforms.api_server.enabled is false#3
zebadee2kk merged 2347 commits into
mainfrom
fix/735-api-server-enabled-guard

Conversation

@zebadee2kk

Copy link
Copy Markdown
Owner

Summary

Defense-in-depth for the durable NousResearch#735 fix. The scoped port-lock (already merged) correctly prevents a losing gateway from stealing port 8642, but a profile with api_server.enabled: false was observed live reaching connect() and winning the lock race — leaving that profile, not main, as the actual api_server backend on 8642, while main logged a permanent (non-recoverable) "port already in use" error every retry-backoff cycle forever.

Root cause of the upstream call still isn't nailed down (the enabled flag correctly resolves to False when loaded directly via load_gateway_config(), and the main startup loop does gate on platform_config.enabled — so whatever reaches connect() anyway is a separate, not-yet-found bug). This adds an explicit self.config.enabled check at the very top of connect(), before any lock acquisition attempt, so regardless of that other bug, a disabled platform can never proceed.

Verified live

  • Before: ~/.local/state/hermes/gateway-locks/api-server-port-*.lock was held by a profile gateway PID (confirmed via matching /proc/[pid]/stat start_time — not a stale/reused-PID false positive, a genuine live-held lock), while main repeatedly failed to acquire it.
  • After: coordinated restart of main + all 6 live profiles (one at a time, verifying active running after each) — the lock is now held by main's own PID, confirmed matching ~/.hermes/gateway.lock. Zero repeated "already in use" errors observed over the following watch window.

Related: NousResearch#735

teknium1 and others added 30 commits July 2, 2026 01:58
Follow-up to the NousResearch#39227 salvage: config refreshes fire mid-session too
(gateway events, settings saves), so applying terminal.cwd
unconditionally would yank the workspace out from under an attached
session. Gate the override on activeSessionIdRef like the sibling
reasoning/tier settings, keep branch refresh on the live cwd, and add
coverage for the active-session path. Also lint-polish the new test
file (typed config mock, prettier formatting).
…bprocesses (NousResearch#56935)

CLAUDE_CODE_OAUTH_TOKEN is set and owned by the user's Claude Code
install (subscription OAuth), not a Hermes-managed inference
credential — Claude subscription auth is not a working Hermes provider
path. Blocklisting it broke agent-spawned claude CLIs: with no token in
the child env, claude fell through to the shared macOS Keychain /
~/.claude/.credentials.json store and, on auth failure, cleared it —
logging the user out of their interactive Claude sessions and the
desktop app.

Exempt it from _HERMES_PROVIDER_ENV_BLOCKLIST (it arrives via the
anthropic registry entry, so discard explicitly with rationale).
ANTHROPIC_API_KEY / ANTHROPIC_TOKEN and every other provider credential
remain stripped, and the GHSA-rhgp-j443-p4rf fail-closed passthrough
guard is unchanged for everything still on the blocklist.

Fixes NousResearch#55878
…en (NousResearch#56955)

delegation.max_concurrent_children is now the single cap for both a
batch's parallelism and concurrent background delegation units.

- _get_max_async_children() delegates to _get_max_concurrent_children();
  a leftover max_async_children key logs a one-time deprecation warning
- config v32→33 migration removes the stale key, folding a raised
  max_async_children into max_concurrent_children (max wins, no lost
  headroom)
- capacity error messages now point at max_concurrent_children
- pool-at-capacity sync fallback now attaches an explanatory note so
  the model/user know why the call blocked instead of dispatching async

Previously users who raised max_concurrent_children (e.g. to 15) still
hit the invisible default-3 async cap: the 4th background delegate_task
silently ran inline, blocking the turn with no signal.
…nabled helper

Salvage of NousResearch#2863 by @aydnOktay, reimplemented against current main using the
existing utils.env_var_enabled / TRUTHY_STRINGS helper instead of per-site
tuple edits. Covers the 7 gateway/config.py env-flag sites that still rejected
'on' (WHATSAPP_ENABLED, SIGNAL_IGNORE_STORIES, MATRIX_ENCRYPTION,
API_SERVER_ENABLED, WEBHOOK_ENABLED, MSGRAPH_WEBHOOK_ENABLED,
BLUEBUBBLES_SEND_READ_RECEIPTS) plus HERMES_DESKTOP gating in
read_terminal/close_terminal. The PR's approval.py HERMES_YOLO_MODE portion is
already on main via is_truthy_value.
…ousResearch#1955)

- config: ChannelOverride + PlatformConfig.channel_overrides

- run: _resolve_model_for_channel, _get_system_prompt_for_channel, channel provider runtime

- tests: channel overrides + config guard for bare runner; conftest asyncio fix; slack/whatsapp warning filters

Made-with: Cursor
…ousResearch#1955)

- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope
…ousResearch#1955)

- ChannelOverride + channel_overrides; session /model > channel > global
- Thread/parent lookup; YAML bridge for discord.channel_overrides
- Guard channel_overrides when config lacks platforms (test mocks)
- Add sampiyonyus@gmail.com to AUTHOR_MAP
Salvage of NousResearch#2794 by @CharmingGroot, ported to the relocated
plugins/platforms/email/adapter.py:

- Guard raw_email = msg_data[0][1] against IndexError/TypeError and
  non-bytes payloads. UIDs are added to _seen_uids before fetch, so an
  exception mid-batch permanently skipped every remaining message in
  the batch — now the bad message is logged and skipped instead.
- Message-ID domain generation falls back to 'localhost' when
  EMAIL_ADDRESS lacks '@' (now via a shared _message_id_domain() helper
  covering all 3 send paths; the PR fixed 2 of 3).
…mote frontends

Salvage of the surviving piece of NousResearch#2696 by @tarunravi. The PR's other two
changes (tool progress streaming, SSE None-sentinel fix) were independently
superseded on main by the structured hermes.tool.progress SSE events and the
rewritten queue-drain loop.

Remote OpenAI-compatible frontends can't read server-local file paths, so
MEDIA:<path> tags (browser screenshots, generated images) were dead text.
_resolve_media_to_data_urls() now inlines small (<=5MB) local images as
markdown data URLs across all four response surfaces: chat completions
(non-streaming), session chat, session chat stream final event, and the
Responses API. Non-image, missing, or oversized paths pass through
untouched.
…esearch#57000)

Three CLI reliability fixes:

1. Interrupt reliability: chat() only re-queued the user's interrupt
   message when the turn result carried interrupted=True. When the agent
   thread raced past its last interrupt check (or finished) before the
   interrupt landed, the message was silently dropped — and the stale
   _interrupt_requested flag left on the agent instantly aborted the
   NEXT turn. Un-acknowledged interrupt messages are now re-queued as
   the next turn and the stale flag is cleared (only when the agent
   thread actually exited). The clarify-race path also parks the message
   in _pending_input instead of dropping it.

2. Slow exit (5+ min): stdlib ThreadPoolExecutor workers are non-daemon
   and joined unconditionally by concurrent.futures' atexit hook — even
   after shutdown(wait=False). One wedged tool worker (abandoned after
   interrupt/timeout) held the process open forever. Promoted
   async_delegation's daemon executor to a shared tools/daemon_pool
   module and adopted it in tool_executor (concurrent tool batches),
   memory_manager (background sync), delegate_tool (child timeout wrapper
   + batch fan-out), and skills_hub (source fan-out). Added a 30s exit
   watchdog (HERMES_EXIT_WATCHDOG_S) armed at _run_cleanup start as a
   backstop for wedged cleanup steps.

3. Exit jank: after prompt_toolkit tears down the input/status bars the
   terminal sat silent for the whole cleanup window, looking hung. Print
   'Shutting down… (finalizing session)' immediately at exit start.

E2E: live PTY interrupt of a foreground 'sleep 120' terminal tool now
aborts in ~1s and the typed message runs as the next turn; wedged-worker
+ wedged-cleanup subprocess exits in 5.8s (watchdog) instead of hanging.
Adds the AUTHOR_MAP entry for CrazyBoyM (ai-lab@foxmail.com) so the
contributor-attribution CI check passes when PR NousResearch#55828's commits are
rebase-merged with authorship preserved.
…-map-crazyboym-55828

chore(release): map ai-lab@foxmail.com to CrazyBoyM
Lower the openai-codex stale-timeout floor from 25k to 10k estimated
tokens so Telegram/gateway sessions (~20k tools+instructions) are not
aborted at the generic 90s cutoff while Codex is still prefilling.
The helper docstring described the typical ~15-25k gateway payload but
read as if that were the trigger range; the floor actually engages above
10k tokens. Clarify the prose to match the gate.
Follow-up to NousResearch#56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.

Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.

Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.
… codex backend

Replace the plugin-local _IMAGE_MAGIC_MIME table + _sniff_image_mime
body with a delegation to agent.image_routing._sniff_mime_from_bytes,
the canonical magic-byte sniffer already used across the codebase, then
gate its result to the raster formats gpt-image-2's Responses
input_image actually accepts (png/jpeg/gif/webp).

The shared sniffer also recognizes SVG/TIFF/ICO; without the allowlist
those would pass local validation and be rejected server-side with an
opaque HTTP 400. Gating locally fails them cleanly as invalid_image_input.
Adds a regression test for SVG rejection.

Follow-up on top of @CrazyBoyM's NousResearch#55828.
…age payload

WhatsApp has migrated to Linked Identity Device (LID) format for user
IDs (e.g. 244645917392975@lid instead of 18505551234@s.whatsapp.net).

The bridge already resolves LIDs to phone numbers for its own allowlist
check via buildLidMap(), but the senderId field in the message payload
sent to the gateway still contained the raw LID. This caused the
gateway's WHATSAPP_ALLOWED_USERS check to reject all messages as
unauthorized, since the LID numbers don't match the phone numbers in
the allowlist.

Fix: resolve LID → phone in the senderId, senderName, and chatName
fields of the event payload before sending to the gateway, using the
existing lidToPhone mapping.
… is set

Salvage of the surviving hunk of NousResearch#3296 by @Mibayy. The PR's gateway
_handle_provider_command hunk targets code removed on main (/provider was
absorbed into /model + /status, which already read model.base_url); the
hermes status mislabel was the remaining live symptom:
_effective_provider_label() only checked the legacy OPENAI_BASE_URL env var,
so a custom endpoint configured canonically in config.yaml still displayed
as OpenRouter.
Salvage of NousResearch#3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR NousResearch#3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.
…ess (NousResearch#3243 salvage)

Salvaged from PR NousResearch#3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (NousResearch#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.
danilofalcao and others added 26 commits July 4, 2026 13:40
Revert "feat(egress): iron-proxy credential-injection firewall" (NousResearch#30179)
…ment

Salvage of NousResearch#35362, evolved to also close the vision sandbox-escape
(GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image
bytes host-side while every other tool reads through the terminal backend —
so one resolver fixes both the delivery gaps and the escape.

Delivery (from NousResearch#35362, re-authored against current main since the branch was
4140 commits stale and vision_tools.py had been rewritten on both sides):
- tools/image_source.py: one resolver for data:/http(s)/file/local/container
  image sources, returning raw bytes through a single magic-byte-sniff +
  50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source'
  for every source type (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#9077).
- tools/credential_files.py: from_agent_visible_cache_path, the container->host
  cache reverse-map (inverse of the existing forward twin).
- tools/vision_tools.py: both vision sites route through the resolver with
  task_id threaded from the handler; resolved bytes are materialized to a temp
  file so main's evolved encode/resize/embed-cap pipeline is reused verbatim
  (kept over the PR's older bytes-core resize to avoid touching browser_tool /
  conversation_compression callers).

Security (fills NousResearch#35362's deliberately-stubbed _within_allowed_roots seam):
- Under a non-local terminal backend the file tools are confined to the sandbox
  (SECURITY.md 2.2), but vision read host-side — a prompt-injected
  vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even
  redirects the model to vision_analyze for image paths. The resolver now
  enforces the same boundary: local backend reads any host path (chosen
  posture); non-local backend host-reads ONLY the media caches under
  HERMES_HOME (where the gateway/download media lives) and routes every other
  path to an in-sandbox base64 exec-read — which reads the CONTAINER's file,
  the same one 'cat' would, never the host's. Paths are resolve()-d so a
  symlink can't escape a cache; fail-closed when no sandbox env exists.
  This closes the escape AND delivers container-only images (NousResearch#32709) with the
  same mechanism.

Tests: unified resolver + confinement model (tests/tools/test_image_source.py,
incl. proof a non-cache host path under Docker yields container bytes not the
host secret); existing vision tests updated to the resolver boundary; Docker
integration test verified green against a real daemon (exec-read of a tmpfs
/workspace file, a root-owned mode-600 file, and the host-secret invariant).

Fixes GHSA-gpxw-6wxv-w3qq.
Co-authored-by: banditburai <promptsiren@gmail.com>
…d code, async I/O

- resolve_image_source: bare cwd-relative filenames ('pic.png') resolve
  again (main accepted them; the path-shape gate regressed them — review
  by egilewski). Unknown explicit schemes (ftp://, s3://) still rejected.
- Local backend: nonexistent path now raises a clean 'image file not
  found' instead of a misleading sandbox-fallback message.
- W4: remove the now-dead path-based _detect_image_mime_type (suffix-trust
  SVG acceptance) so future callers can't reintroduce it.
- W3: SVG sources rejected with a dedicated actionable message + test.
- Polish: host read_bytes / temp-file write_bytes offloaded via
  asyncio.to_thread (matches the container exec-read); unused
  ResolveContext.cfg/extra_roots fields dropped; duplicate policy check
  documented as intentional pre-flight short-circuit.
…dding

vision_analyze embedded SVG (and BMP/TIFF) tool-results into conversation
history with media_type image/svg+xml. Anthropic only accepts jpeg/png/
gif/webp, so the request fails with a non-retryable 400. Because the image
is baked into immutable history and re-sent every turn, the session is
permanently wedged on resume — retries re-send the same bad bytes.

Add _normalize_to_supported_image(): SVG is rasterized to PNG (best-effort
via cairosvg/svglib/rsvg-convert/inkscape), other non-supported raster
formats are re-encoded to PNG via Pillow, and if conversion is impossible
the tool returns an actionable error instead of a session-wedging payload.
Wired into both the native-vision fast path and the auxiliary-API path so
the whole bug class is covered, not just the one call site.

All 99 existing vision tests pass.
… rasterizer; AUTHOR_MAP for NousResearch#52688

- _normalize_to_supported_image: ensure cache/vision exists before writing
  the converted PNG (fresh HERMES_HOME had no dir -> FileNotFoundError).
- resolver: SVG passes through as image/svg+xml instead of erroring; the
  call sites rasterize to PNG via the salvaged normalize step (cairosvg /
  svglib / rsvg-convert / inkscape, best-effort with actionable error).
- normalization offloaded via asyncio.to_thread at both call sites.
- tests: resolver pass-through + rasterization + no-converter error paths.
- AUTHOR_MAP: jonathan@mintrx.com -> JAlmanzarMint (PR NousResearch#52688 salvage).
The salvaged NousResearch#52688 rasterizer shell-out predates the TUI subprocess
stdin= guard; a rasterizer that prompts on stdin could hang the tool
under prompt_toolkit. DEVNULL it.
The container exec-read piped the whole file through base64 with no size
guard — the 50MB cap was only enforced host-side AFTER the full payload
had already streamed into host memory. A prompt-injected read of a huge
container file (or /dev/zero) could balloon the gateway process.

head -c (cap+1) bounds the read inside the sandbox; the +1 byte lets the
host distinguish at-cap from over-cap and reject with SourceTooLarge.
Input redirect replaces 'base64 --' (no argv exposure at all for
leading-dash paths). Docker integration tests re-verified live.
…o usable entry

_try_anthropic() hard-failed (return None, None) when the anthropic
credential pool was present but had no selectable entry — e.g. the pooled
OAuth token expired and its refresh_token had gone stale, so
_select_pool_entry("anthropic") returned (True, None). This wedged every
auxiliary task routed to Anthropic (goal judge surfaced "no auxiliary
client configured") even when a perfectly valid ANTHROPIC_TOKEN /
credentials-file token was available. The main session stayed healthy
because it resolves the env token directly.

The openrouter path (_try_openrouter) and codex path already fall through
to their standalone credential on (True, None); anthropic was the only
provider that hard-failed. Make _try_anthropic fall through to
resolve_anthropic_token() on that branch so the three paths are symmetric:
a temporarily dead pool entry must not block auxiliary tasks when a valid
standalone credential exists.

Adds a regression test covering: (1) pool present + no entry + valid env
token -> client built from the env token, (2) pool present + no entry + no
resolvable token -> clean (None, None), (3) base_url defaults correctly
when falling through with pool_present=True.
…llback

When OpenRouter routes to an endpoint that does not support tool/function
calling, it returns HTTP 404 with the message 'No endpoints found that
support tool use. Try disabling "browser_back".'

The raw error body does not contain 'model not found' or any other
_MODEL_NOT_FOUND_PATTERNS entry, so it falls through to FailoverReason.unknown
with retryable=True. The retry loop wastes 3-5 attempts on the same
deterministic rejection, then surfaces a confusing generic error instead of
automatically failing over to a fallback model or provider.

Adding the OpenRouter phrase to _MODEL_NOT_FOUND_PATTERNS classifies it as
model_not_found (retryable=False, should_fallback=True), which triggers the
client-error fast-fallback path in conversation_loop.py: the agent switches
to a configured fallback model/provider before the user sees the error.

Existing buffered guidance in conversation_loop.py (the 'support tool use'
hint at line ~2967) remains intact and surfaces only if every fallback
exhausts.
- ChatCompletionsTransport.normalize_response: convert integer
  finish_reason (e.g. 24) to string for Poolside compatibility
- Chat completion helpers: handle integer tool_call.id during streaming
  by converting to string
- Add Poolside as first-class CANONICAL_PROVIDERS entry (visible in
  CLI/TUI/desktop provider pickers)
- package-lock.json changes in NousResearch#58451 were unrelated peer-flag churn
- CANONICAL_PROVIDERS 'poolside' entry from NousResearch#58374 has no ProviderConfig
  in hermes_cli/auth.py and no setup flow, so the picker entry would be
  dead; the wire-format coercions stand on their own
Per-session /model overrides supplied api_key and provider but omitted
credential_pool, so billing rotation never ran on HTTP 402. Wire the pool
on fast override, rehydrate, and apply paths; backfill from provider for
legacy persisted overrides. Regression tests in tests/gateway/.
… auto-reset

After a config change (e.g. switching model provider), the /new command
must clear the per-session _last_resolved_model cache so the next turn
resolves the model from the updated config instead of falling back to
the stale cached value.

Without this fix, if a transient config-cache miss occurs on the first
post-/new turn, the NousResearch#35314 recovery path serves the old model from the
cache — the user sees the old model being used even though they changed
config.yaml and explicitly ran /new.

Fix applies to both call sites that reset session model state:
- GatewaySlashCommandsMixin._handle_reset_command (slash_commands.py)
- GatewayRunner compression-exhausted auto-reset (run.py)

Fixes NousResearch#58403
Root cause of NousResearch#719 (zero Langfuse traces despite plugin enabled, SDK
installed, credentials present): the module-level import

    from langfuse import Langfuse, propagate_attributes

fails with ImportError on the installed langfuse SDK (2.60.10, a v2
release) because propagate_attributes is a v3-only symbol. The
blanket except Exception then set BOTH Langfuse and
propagate_attributes to None, permanently disabling the plugin
(_get_langfuse() short-circuits on `Langfuse is None`) regardless of
env credentials, in every process that loads it.

Fix:
- Import the langfuse module itself, then getattr() each symbol
  individually so a v3-only name being absent doesn't null out
  Langfuse itself.
- Feature-detect the v3 fluent API (start_as_current_observation,
  create_trace_id, start_observation) and fall back to the v2
  StatefulClient API (trace/span/generation/update/end) when absent.
- Detect whether the installed SDK's Langfuse(...) constructor takes
  base_url or host (v2 uses host).
- Guard end/update/set_trace_io calls with hasattr so the code works
  across both API shapes without raising.

Verified live: a `hermes chat` turn against the patched code produced
a real "Hermes turn" trace with a completed LLM-call generation in
Langfuse (previously the traces table only had one manually-created
plumbing-test trace). Full test_langfuse_plugin.py suite passes
(49/49), including a new test pinning the v2-shaped client path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAJsrMEDkKTsCLuEHCdycq
The OpenAI-compatible API server (default port 8642) previously guarded
its port with only a raw TCP connect check (_port_is_available), which is
racy. platforms.api_server.enabled:false is not honored by profile
gateways, so a profile gateway could race the bind and steal 8642 from
the main gateway.

Acquire the same scoped lock (gateway.status.acquire_scoped_lock, scope
'api-server-port', identity=port) that telegram/discord/signal/weixin use
so exactly one gateway owns the port; the loser fails fast with a
retryable fatal error instead of racing the bind. Release on disconnect
and on any post-acquire connect failure.
…NousResearch#765)

The Discord cron-delivery path had no retry, unlike the equivalent
Telegram path (_send_telegram_message_with_retry). A one-day-only
SSLCertVerificationError ("Hostname mismatch") to discord.com from a
transient CDN/edge event silently dropped that day's cron report with
no retry and no visible failure to the user.

Add _send_discord_message_with_retry / _discord_retry_delay mirroring
the Telegram helper: 3 attempts, exponential backoff (1s/2s/4s),
retrying on aiohttp.ClientConnectorCertificateError/ClientConnectorError,
ssl.SSLError, and (since _standalone_send swallows exceptions into an
{"error": ...} dict) the equivalent transient-looking error text. Wire
it into the Discord branch of _send_to_platform in place of the direct
standalone_sender_fn call.
…_server.enabled is false

Defense-in-depth for the durable NousResearch#735 fix. The scoped port-lock (already
merged) correctly prevents a losing gateway from stealing port 8642, but a
profile with api_server.enabled: false was still observed reaching
connect() and WINNING the lock race — leaving that profile, not main,
as the actual api_server backend on 8642, while main logged a permanent
(non-recoverable, since main will never regain the lock on its own)
'port already in use' error every retry-backoff cycle forever.

Add an explicit self.config.enabled check at the top of connect(), before
any lock acquisition attempt. Whatever upstream call path lets a disabled
platform reach connect() in the first place, this is the one place that
can see this adapter's own resolved config and refuse outright.
…a YAML-disabled profile

Root cause of the NousResearch#735 log-noise gap found via a live diagnostic print:
API_SERVER_KEY is a globally-loaded env var (every process reads
~/.hermes/.env, including profile gateways with api_server.enabled: false
in their own config.yaml). Its mere presence unconditionally set
enabled=True in the env-var overlay pass of load_gateway_config(),
silently re-enabling api_server on profiles that explicitly disabled it
in YAML -- letting a profile win the scoped port-lock race against main
instead of losing it as designed, and (separately) reach connect() at all
despite platforms.api_server.enabled: false.

Verified empirically (env -i with only HOME/PATH/HERMES_HOME/API_SERVER_KEY
set, replicating exactly what the live gateway process sees):
- Profile with api_server.enabled: false + API_SERVER_KEY present: now
  correctly stays enabled=False (extra.key still populates for any
  legitimate downstream use of the key).
- Main with no platforms.api_server YAML entry at all + API_SERVER_KEY
  present: still correctly enabled=True (unchanged, main's existing
  behavior preserved).
- A profile with api_server.enabled: false but an explicit
  API_SERVER_ENABLED=true env var: still force-enables (explicit env var
  override intentionally still wins over a YAML disable).
@zebadee2kk

Copy link
Copy Markdown
Owner Author

Found the actual root cause (the guard commit above is real defense-in-depth but wasn't the full story). Second commit on this branch fixes it properly.

Root cause: `API_SERVER_KEY` is a globally-loaded env var — every hermes process reads `~/.hermes/.env` regardless of profile. In `load_gateway_config()`'s env-var overlay pass, the mere presence of `API_SERVER_KEY` unconditionally set `enabled = True` for the api_server platform, with no check for whether the profile's own `config.yaml` had already explicitly set `enabled: false`. Confirmed via a live diagnostic print inside connect(): a profile with api_server.enabled: false in its YAML was constructing a PlatformConfig(enabled=True, ...) at runtime.

Fix: only force enabled = True when either (a) the platform has no YAML entry at all (env-var-only config, the original intended use case — this is how main has always worked, since it has no platforms.api_server block), or (b) the operator set the explicit API_SERVER_ENABLED=true env var, which should still win over a YAML disable same as any other intentional override. Merely having a key configured must not imply "enabled" for a platform the profile explicitly turned off.

Separately found and cleaned up (config, not code): 8 of 10 profiles had a stray API_SERVER_ENABLED=true line in their own profile-scoped .env, directly contradicting their own config.yaml's api_server.enabled: false — almost certainly a profile-creation template artifact from before per-profile api_server was disabled by policy. Removed from all 8 (backed up first).

Verified live, with a full coordinated restart (main + 6 live profiles, one at a time): main now correctly binds and listens on 8642 (ss -tlnp confirms), holds the scoped lock with its own matching PID, and no profile has reattempted it since. Also caught (and fixed) an unrelated transient during rollout: main briefly lost the api_server bind after a profile's stale lock registration outlived that profile's process — a plain restart of main resolved it; if this class of staleness recurs it may want a look at acquire_scoped_lock's liveness check, but wasn't reproducible enough to chase further today.

Unblocks merging this PR - the check-attribution job flags any commit
author email with no AUTHOR_MAP entry and no numeric-ID noreply format.
richard.ham@live.com is the fork owner's own address (used for local
carries on this fork, not a community PR salvage); DavidMetcalfe's legacy
noreply address predates the numeric-ID+username format the existing
regex fallback handles.
@zebadee2kk
zebadee2kk merged commit 7822d9a into main Jul 5, 2026
36 checks passed
@zebadee2kk
zebadee2kk deleted the fix/735-api-server-enabled-guard branch July 5, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.