fix(#735): refuse api_server connect() when platforms.api_server.enabled is false - #3
Conversation
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.
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).
|
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 Fix: only force Separately found and cleaned up (config, not code): 8 of 10 profiles had a stray Verified live, with a full coordinated restart (main + 6 live profiles, one at a time): main now correctly binds and listens on 8642 ( |
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.
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: falsewas observed live reachingconnect()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
enabledflag correctly resolves toFalsewhen loaded directly viaload_gateway_config(), and the main startup loop does gate onplatform_config.enabled— so whatever reachesconnect()anyway is a separate, not-yet-found bug). This adds an explicitself.config.enabledcheck at the very top ofconnect(), before any lock acquisition attempt, so regardless of that other bug, a disabled platform can never proceed.Verified live
~/.local/state/hermes/gateway-locks/api-server-port-*.lockwas held by a profile gateway PID (confirmed via matching/proc/[pid]/statstart_time — not a stale/reused-PID false positive, a genuine live-held lock), while main repeatedly failed to acquire it.active runningafter 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