Merge branch 'main' into bb/gui
This commit is contained in:
+40
-12
@@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
return get_qwen_auth_status()
|
||||
if target == "google-gemini-cli":
|
||||
return get_gemini_oauth_auth_status()
|
||||
if target == "minimax-oauth":
|
||||
return get_minimax_oauth_auth_status()
|
||||
if target == "copilot-acp":
|
||||
return get_external_process_provider_status(target)
|
||||
# API-key providers
|
||||
@@ -4757,6 +4759,20 @@ def _minimax_request_user_code(
|
||||
return payload
|
||||
|
||||
|
||||
def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool:
|
||||
"""True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds)."""
|
||||
return int(expired_in) > (now_ms // 2)
|
||||
|
||||
|
||||
def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
|
||||
"""Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
|
||||
raw = int(expired_in)
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
|
||||
return raw / 1000.0
|
||||
return now.timestamp() + max(1, raw)
|
||||
|
||||
|
||||
def _minimax_poll_token(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
|
||||
@@ -4765,12 +4781,11 @@ def _minimax_poll_token(
|
||||
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
|
||||
import time as _time
|
||||
now_ms = int(_time.time() * 1000)
|
||||
if expired_in > now_ms // 2:
|
||||
# Looks like a unix-ms timestamp.
|
||||
deadline = expired_in / 1000.0
|
||||
raw = int(expired_in)
|
||||
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
|
||||
deadline = raw / 1000.0
|
||||
else:
|
||||
# Treat as duration in seconds from now.
|
||||
deadline = _time.time() + max(1, expired_in)
|
||||
deadline = _time.time() + max(1, raw)
|
||||
interval = max(2.0, (interval_ms or 2000) / 1000.0)
|
||||
|
||||
while _time.time() < deadline:
|
||||
@@ -4884,8 +4899,10 @@ def _minimax_oauth_login(
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at = now.timestamp() + expires_in_s
|
||||
expires_at_unix = _minimax_resolve_token_expiry_unix(
|
||||
int(token_data["expired_in"]), now=now,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_unix - now.timestamp()))
|
||||
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
@@ -4899,7 +4916,7 @@ def _minimax_oauth_login(
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"resource_url": token_data.get("resource_url"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
|
||||
@@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state(
|
||||
relogin_required=True,
|
||||
)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
expires_in_s = int(payload["expired_in"])
|
||||
expires_at_unix = _minimax_resolve_token_expiry_unix(
|
||||
int(payload["expired_in"]), now=now_dt,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp()))
|
||||
new_state = dict(state)
|
||||
new_state.update({
|
||||
"access_token": payload["access_token"],
|
||||
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
|
||||
"obtained_at": now_dt.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
|
||||
tz=timezone.utc).isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
})
|
||||
_minimax_save_auth_state(new_state)
|
||||
@@ -5252,6 +5271,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
get_curated_nous_model_ids, get_pricing_for_provider,
|
||||
check_nous_free_tier, partition_nous_models_by_tier,
|
||||
union_with_portal_free_recommendations,
|
||||
union_with_portal_paid_recommendations,
|
||||
)
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
|
||||
@@ -5260,19 +5280,27 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
if model_ids:
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
free_tier = check_nous_free_tier()
|
||||
_portal_for_recs = auth_state.get("portal_base_url", "")
|
||||
if free_tier:
|
||||
# The Portal's freeRecommendedModels endpoint is the
|
||||
# source of truth for what's free *right now*. Augment
|
||||
# the curated list with anything new the Portal flags
|
||||
# as free so users on older Hermes builds still see
|
||||
# newly-launched free models without a CLI release.
|
||||
_portal_for_recs = auth_state.get("portal_base_url", "")
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
model_ids, pricing, _portal_for_recs,
|
||||
)
|
||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||
model_ids, pricing, free_tier=True,
|
||||
)
|
||||
else:
|
||||
# Paid-tier mirror: pull paidRecommendedModels so newly
|
||||
# launched paid models surface in the picker even if
|
||||
# the in-repo curated list and docs-hosted manifest
|
||||
# haven't caught up yet.
|
||||
model_ids, pricing = union_with_portal_paid_recommendations(
|
||||
model_ids, pricing, _portal_for_recs,
|
||||
)
|
||||
_portal = auth_state.get("portal_base_url", "")
|
||||
if model_ids:
|
||||
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
||||
|
||||
@@ -375,10 +375,12 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
creds = auth_mod._minimax_oauth_login(
|
||||
open_browser=not getattr(args, "no_browser", False),
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
)
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
creds["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
@@ -388,8 +390,9 @@ def auth_add_command(args) -> None:
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:minimax_oauth",
|
||||
access_token=creds["api_key"],
|
||||
base_url=creds.get("base_url"),
|
||||
access_token=creds["access_token"],
|
||||
refresh_token=creds.get("refresh_token"),
|
||||
base_url=creds.get("inference_base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
|
||||
+12
-9
@@ -468,20 +468,23 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
|
||||
Telegram command names cannot contain hyphens, so they are replaced with
|
||||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
||||
canonical command. Commands that require arguments are skipped because
|
||||
selecting a Telegram BotCommand sends only ``/command`` and would execute
|
||||
an incomplete command.
|
||||
canonical command.
|
||||
|
||||
Plugin-registered slash commands are included so plugins get native
|
||||
autocomplete in Telegram without touching core code.
|
||||
Built-in commands that require arguments (e.g. /queue, /steer, /background)
|
||||
are **included** because their handlers return usage text when selected
|
||||
without a payload, making them discoverable via autocomplete.
|
||||
|
||||
Plugin-registered slash commands that require arguments are **excluded**
|
||||
because plugins may not provide a no-arg usage fallback.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
result: list[tuple[str, str]] = []
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
if _requires_argument(cmd.args_hint):
|
||||
continue
|
||||
# Built-in arg-taking commands are included — their handlers show
|
||||
# usage text when invoked without arguments, and hiding them from
|
||||
# the menu hurts discoverability (issue #24312).
|
||||
tg_name = _sanitize_telegram_name(cmd.name)
|
||||
if tg_name:
|
||||
result.append((tg_name, cmd.description))
|
||||
@@ -1359,9 +1362,9 @@ class SlashCommandCompleter(Completer):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=2,
|
||||
cwd=cwd,
|
||||
cwd=cwd, encoding="utf-8", errors="replace",
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
if proc.returncode == 0 and proc.stdout and proc.stdout.strip():
|
||||
raw = proc.stdout.strip().split("\n")
|
||||
# Store relative paths
|
||||
for p in raw[:5000]:
|
||||
|
||||
@@ -477,6 +477,12 @@ DEFAULT_CONFIG = {
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
"gateway_timeout_warning": 900,
|
||||
# Maximum time (seconds) the gateway will block an agent waiting for
|
||||
# a clarify-tool response from the user. Hit this and the agent
|
||||
# unblocks with "[user did not respond within Xm]" so it can adapt
|
||||
# rather than pinning the running-agent guard forever. CLI clarify
|
||||
# blocks indefinitely (input() is synchronous) and ignores this.
|
||||
"clarify_timeout": 600,
|
||||
# Periodic "still working" notification interval (seconds).
|
||||
# Sends a status message every N seconds so the user knows the
|
||||
# agent hasn't died during long tasks. 0 = disable notifications.
|
||||
@@ -628,6 +634,12 @@ DEFAULT_CONFIG = {
|
||||
# so the server maps it to a persistent Firefox profile automatically.
|
||||
# When false (default), each session gets a random userId (ephemeral).
|
||||
"managed_persistence": False,
|
||||
# Optional externally managed Camofox identity. Useful when another
|
||||
# app owns the visible browser and Hermes should operate in it.
|
||||
"user_id": "",
|
||||
"session_key": "",
|
||||
# Rehydrate tab_id from Camofox before creating a new tab.
|
||||
"adopt_existing_tab": False,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -917,6 +929,14 @@ DEFAULT_CONFIG = {
|
||||
"persistent_output": True,
|
||||
"persistent_output_max_lines": 200,
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
# File-mutation verifier footer. When true (default), the agent
|
||||
# appends a one-line advisory to its final response whenever a
|
||||
# write_file / patch call failed during the turn and was never
|
||||
# superseded by a successful write to the same path. This catches
|
||||
# the "batch of parallel patches, half fail, model claims success"
|
||||
# class of over-claim that otherwise forces users to run
|
||||
# `git status` to verify edits landed. Set false to suppress.
|
||||
"file_mutation_verifier": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
@@ -1338,6 +1358,21 @@ DEFAULT_CONFIG = {
|
||||
"domains": [],
|
||||
"shared_files": [],
|
||||
},
|
||||
# Acknowledged supply-chain security advisories. Each entry is the
|
||||
# ID of an advisory the user has read and acted on (uninstalled the
|
||||
# compromised package, rotated credentials). Acked advisories no
|
||||
# longer trigger the startup banner. Add via `hermes doctor --ack
|
||||
# <id>`; remove by editing the list directly. See
|
||||
# ``hermes_cli/security_advisories.py`` for the catalog.
|
||||
"acked_advisories": [],
|
||||
# Allow Hermes to lazy-install opt-in backend packages from PyPI
|
||||
# the first time the user enables a backend that needs them
|
||||
# (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as
|
||||
# their TTS provider). Set to false to require explicit
|
||||
# ``pip install`` for everything beyond the base set — appropriate
|
||||
# for restricted networks, audited environments, or air-gapped
|
||||
# systems where any runtime install is unacceptable.
|
||||
"allow_lazy_installs": True,
|
||||
},
|
||||
|
||||
"cron": {
|
||||
@@ -1476,6 +1511,53 @@ DEFAULT_CONFIG = {
|
||||
"backup_keep": 5,
|
||||
},
|
||||
|
||||
# Language Server Protocol — semantic diagnostics from real
|
||||
# language servers (pyright, gopls, rust-analyzer, etc.) wired
|
||||
# into the post-write lint check used by ``write_file`` and
|
||||
# ``patch``.
|
||||
#
|
||||
# LSP is gated on git-workspace detection: when the agent's
|
||||
# cwd (or the file being edited) is inside a git worktree, LSP
|
||||
# runs against that workspace. When neither is in a git repo,
|
||||
# LSP stays dormant and the in-process syntax check is the only
|
||||
# tier — handy for Telegram/Discord chats where the cwd is the
|
||||
# user's home directory.
|
||||
"lsp": {
|
||||
# Master toggle. Setting this to false disables the entire
|
||||
# subsystem — no servers spawn, no background event loop, no
|
||||
# cost.
|
||||
"enabled": True,
|
||||
|
||||
# Diagnostic-wait mode for the post-write check.
|
||||
# ``"document"`` waits up to ``wait_timeout`` seconds for the
|
||||
# current file's diagnostics; ``"full"`` additionally requests
|
||||
# workspace-wide diagnostics (slower).
|
||||
"wait_mode": "document",
|
||||
"wait_timeout": 5.0,
|
||||
|
||||
# How to handle missing server binaries.
|
||||
# ``"auto"`` — try to install via npm/go/pip into
|
||||
# ``<HERMES_HOME>/lsp/bin/`` on first use.
|
||||
# ``"manual"`` — only use binaries already on PATH.
|
||||
# ``"off"`` — alias for ``manual``.
|
||||
"install_strategy": "auto",
|
||||
|
||||
# Per-server overrides. Each key is a server_id from the
|
||||
# registry (``pyright``, ``typescript``, ``gopls``,
|
||||
# ``rust-analyzer``, etc.) and accepts:
|
||||
# disabled: true
|
||||
# — skip this server even when its extensions match
|
||||
# command: ["full/path/to/server", "--stdio"]
|
||||
# — pin a custom binary path; bypasses auto-install
|
||||
# env: {"KEY": "value"}
|
||||
# — extra env vars passed to the spawned process
|
||||
# initialization_options: {...}
|
||||
# — merged into the LSP ``initializationOptions``
|
||||
# Empty by default; the registry defaults work for typical
|
||||
# setups.
|
||||
"servers": {},
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 23,
|
||||
}
|
||||
|
||||
+86
-3
@@ -287,7 +287,8 @@ def _build_apikey_providers_list() -> list:
|
||||
(_pp.models_url or (_pp.base_url.rstrip("/") + "/models"))
|
||||
if _pp.base_url else None
|
||||
)
|
||||
_static.append((_label, _key_vars, _models_url, _base_var, True))
|
||||
_hc = getattr(_pp, "supports_health_check", True)
|
||||
_static.append((_label, _key_vars, _models_url, _base_var, _hc))
|
||||
except Exception:
|
||||
pass
|
||||
return _static
|
||||
@@ -296,19 +297,101 @@ def _build_apikey_providers_list() -> list:
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
ack_target = getattr(args, 'ack', None)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||
# checks (like cronjob management) should see the same context as `hermes`.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
|
||||
# Handle `hermes doctor --ack <id>` as a fast path. Persist the ack and
|
||||
# return without running the rest of the diagnostics — the user has
|
||||
# already seen the advisory and just wants to silence it.
|
||||
if ack_target:
|
||||
from hermes_cli.security_advisories import (
|
||||
ADVISORIES,
|
||||
ack_advisory,
|
||||
)
|
||||
valid_ids = {a.id for a in ADVISORIES}
|
||||
if ack_target not in valid_ids:
|
||||
print(color(
|
||||
f"Unknown advisory ID: {ack_target!r}. Known IDs: "
|
||||
f"{', '.join(sorted(valid_ids)) or '(none)'}",
|
||||
Colors.RED,
|
||||
))
|
||||
sys.exit(2)
|
||||
if ack_advisory(ack_target):
|
||||
print(color(
|
||||
f" ✓ Acknowledged advisory {ack_target}. "
|
||||
f"It will no longer trigger startup banners.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
else:
|
||||
print(color(
|
||||
f" ✗ Failed to persist ack for {ack_target}. "
|
||||
f"Check ~/.hermes/config.yaml is writable.",
|
||||
Colors.RED,
|
||||
))
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
issues = []
|
||||
manual_issues = [] # issues that can't be auto-fixed
|
||||
fixed_count = 0
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
# =========================================================================
|
||||
# Check: Security advisories (RUNS FIRST — these are the most urgent)
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD))
|
||||
try:
|
||||
from hermes_cli.security_advisories import (
|
||||
detect_compromised,
|
||||
filter_unacked,
|
||||
full_remediation_text,
|
||||
get_acked_ids,
|
||||
)
|
||||
all_hits = detect_compromised()
|
||||
fresh_hits = filter_unacked(all_hits)
|
||||
if fresh_hits:
|
||||
for hit in fresh_hits:
|
||||
check_fail(
|
||||
f"{hit.advisory.title}",
|
||||
f"({hit.package}=={hit.installed_version})",
|
||||
)
|
||||
# Print the full remediation block, indented under the
|
||||
# check_fail header so it reads as a single section.
|
||||
for line in full_remediation_text(hit):
|
||||
if line:
|
||||
print(f" {color(line, Colors.YELLOW)}")
|
||||
else:
|
||||
print()
|
||||
# Funnel into the action list so the summary block surfaces it
|
||||
# for users who scroll past the section.
|
||||
manual_issues.append(
|
||||
f"Resolve security advisory {hit.advisory.id}: "
|
||||
f"uninstall {hit.package}=={hit.installed_version} and "
|
||||
f"rotate credentials, then run "
|
||||
f"`hermes doctor --ack {hit.advisory.id}`."
|
||||
)
|
||||
# Acked-but-still-installed: show as informational so the user
|
||||
# knows the package is still on disk after the ack.
|
||||
acked_ids = get_acked_ids()
|
||||
for h in all_hits:
|
||||
if h.advisory.id in acked_ids:
|
||||
check_warn(
|
||||
f"{h.package}=={h.installed_version} still installed "
|
||||
f"(advisory {h.advisory.id} acknowledged)",
|
||||
)
|
||||
else:
|
||||
check_ok("No active security advisories")
|
||||
except Exception as e:
|
||||
# Never let a bug in the advisory check block the rest of doctor.
|
||||
check_warn(f"Security advisory check failed: {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Check: Python version
|
||||
|
||||
+16
-2
@@ -2164,7 +2164,7 @@ Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
@@ -2199,7 +2199,7 @@ Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
@@ -3658,6 +3658,15 @@ def _all_platforms() -> list[dict]:
|
||||
``hermes setup gateway`` without needing the gateway to be running.
|
||||
Built-ins keep their dict shape; plugin entries are adapted to the same
|
||||
shape with ``_registry_entry`` holding the source.
|
||||
|
||||
Platform-specific gating: some platforms can't be configured on
|
||||
every host. Currently:
|
||||
- Matrix is hidden on Windows. The [matrix] extra pulls
|
||||
``mautrix[encryption]`` -> ``python-olm``, which has no Windows
|
||||
wheel and needs ``make`` + libolm to build from sdist. There's
|
||||
no native Windows path that works, so we don't offer it in the
|
||||
picker. Users who want Matrix on Windows can run hermes under
|
||||
WSL.
|
||||
"""
|
||||
# Populate the registry so plugin platforms are visible. Idempotent.
|
||||
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
|
||||
@@ -3671,6 +3680,11 @@ def _all_platforms() -> list[dict]:
|
||||
logger.debug("plugin discovery failed during platform enumeration: %s", e)
|
||||
|
||||
platforms = [dict(p) for p in _PLATFORMS]
|
||||
|
||||
# Drop platforms that can't function on this host. See docstring.
|
||||
if sys.platform == "win32":
|
||||
platforms = [p for p in platforms if p.get("key") != "matrix"]
|
||||
|
||||
by_key = {p["key"]: p for p in platforms}
|
||||
|
||||
try:
|
||||
|
||||
+31
-1
@@ -2607,6 +2607,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
check_nous_free_tier,
|
||||
partition_nous_models_by_tier,
|
||||
union_with_portal_free_recommendations,
|
||||
union_with_portal_paid_recommendations,
|
||||
)
|
||||
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
@@ -2662,6 +2663,10 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# with the Portal's freeRecommendedModels list so newly-launched free
|
||||
# models show up even if this CLI build's hardcoded curated list and
|
||||
# docs-hosted manifest haven't caught up yet.
|
||||
#
|
||||
# For paid users: mirror the same idea with paidRecommendedModels so
|
||||
# newly-launched paid models surface in the picker too — independent
|
||||
# of CLI release cadence.
|
||||
unavailable_models: list[str] = []
|
||||
if free_tier:
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
@@ -2670,6 +2675,10 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||
model_ids, pricing, free_tier=True
|
||||
)
|
||||
else:
|
||||
model_ids, pricing = union_with_portal_paid_recommendations(
|
||||
model_ids, pricing, _nous_portal_url,
|
||||
)
|
||||
|
||||
if not model_ids and not unavailable_models:
|
||||
print("No models available for Nous Portal after filtering.")
|
||||
@@ -9412,7 +9421,7 @@ def main():
|
||||
gateway_parser = subparsers.add_parser(
|
||||
"gateway",
|
||||
help="Messaging gateway management",
|
||||
description="Manage the messaging gateway (Telegram, Discord, WhatsApp)",
|
||||
description="Manage the messaging gateway (Telegram, Discord, WhatsApp, Weixin, and more)",
|
||||
)
|
||||
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
|
||||
|
||||
@@ -9555,6 +9564,17 @@ def main():
|
||||
|
||||
gateway_parser.set_defaults(func=cmd_gateway)
|
||||
|
||||
# =========================================================================
|
||||
# lsp command
|
||||
# =========================================================================
|
||||
try:
|
||||
from agent.lsp.cli import register_subparser as _lsp_register
|
||||
_lsp_register(subparsers)
|
||||
except Exception as _lsp_err: # noqa: BLE001
|
||||
# LSP is optional infrastructure — never let a registration
|
||||
# failure break the CLI overall.
|
||||
logger.debug("LSP CLI registration failed: %s", _lsp_err)
|
||||
|
||||
# =========================================================================
|
||||
# setup command
|
||||
# =========================================================================
|
||||
@@ -10117,6 +10137,16 @@ def main():
|
||||
doctor_parser.add_argument(
|
||||
"--fix", action="store_true", help="Attempt to fix issues automatically"
|
||||
)
|
||||
doctor_parser.add_argument(
|
||||
"--ack",
|
||||
metavar="ADVISORY_ID",
|
||||
default=None,
|
||||
help=(
|
||||
"Acknowledge a security advisory by ID and exit. After ack, the "
|
||||
"advisory will no longer trigger startup banners. Run `hermes "
|
||||
"doctor` first to see active advisories and their IDs."
|
||||
),
|
||||
)
|
||||
doctor_parser.set_defaults(func=cmd_doctor)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -621,6 +621,71 @@ def union_with_portal_free_recommendations(
|
||||
return (augmented_ids, augmented_pricing)
|
||||
|
||||
|
||||
def union_with_portal_paid_recommendations(
|
||||
curated_ids: list[str],
|
||||
pricing: dict[str, dict[str, str]],
|
||||
portal_base_url: str = "",
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> tuple[list[str], dict[str, dict[str, str]]]:
|
||||
"""Augment curated list with the Portal's ``paidRecommendedModels``.
|
||||
|
||||
Mirror of :func:`union_with_portal_free_recommendations` for paid-tier
|
||||
users. The Portal's ``/api/nous/recommended-models`` endpoint advertises
|
||||
which paid models are blessed *right now* — independent of what the
|
||||
in-repo ``_PROVIDER_MODELS["nous"]`` list happens to contain or whether
|
||||
the docs-hosted catalog manifest has been rebuilt since the last release.
|
||||
|
||||
For paid-tier users this lets newly-launched paid models surface in the
|
||||
picker even if the user is running an older Hermes that doesn't ship
|
||||
them in its hardcoded curated list. This function returns an augmented
|
||||
``(model_ids, pricing)`` pair where:
|
||||
|
||||
* Portal paid recommendations missing from ``curated_ids`` are
|
||||
appended at the front (so the picker shows them first).
|
||||
* ``pricing`` is left untouched — we deliberately do NOT synthesize
|
||||
pricing entries for paid models. Live pricing is fetched separately
|
||||
via :func:`get_pricing_for_provider`; if the live endpoint hasn't
|
||||
published pricing yet, the picker shows a blank price column rather
|
||||
than fabricating numbers. (The free helper synthesizes ``$0`` so
|
||||
:func:`partition_nous_models_by_tier` keeps free models selectable;
|
||||
no equivalent gating applies on the paid side, so synthesis would
|
||||
only mislead the user.)
|
||||
|
||||
Failures (network, parse, missing field) are silent and degrade to
|
||||
returning the inputs unchanged — never block the picker on a
|
||||
Portal-side hiccup.
|
||||
"""
|
||||
try:
|
||||
payload = fetch_nous_recommended_models(
|
||||
portal_base_url, force_refresh=force_refresh
|
||||
)
|
||||
except Exception:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
paid_block = payload.get("paidRecommendedModels") if isinstance(payload, dict) else None
|
||||
if not isinstance(paid_block, list) or not paid_block:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
portal_paid_ids: list[str] = []
|
||||
for entry in paid_block:
|
||||
name = _extract_model_name(entry)
|
||||
if name:
|
||||
portal_paid_ids.append(name)
|
||||
if not portal_paid_ids:
|
||||
return (list(curated_ids), dict(pricing))
|
||||
|
||||
augmented_ids = list(curated_ids)
|
||||
seen = set(augmented_ids)
|
||||
# Prepend Portal paid recommendations that aren't already curated, so
|
||||
# the Portal-blessed picks surface first in the picker.
|
||||
new_ones = [mid for mid in portal_paid_ids if mid not in seen]
|
||||
if new_ones:
|
||||
augmented_ids = new_ones + augmented_ids
|
||||
|
||||
return (augmented_ids, dict(pricing))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTL cache for free-tier detection — avoids repeated API calls within a
|
||||
# session while still picking up upgrades quickly.
|
||||
|
||||
@@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry(
|
||||
elif provider == "google-gemini-cli":
|
||||
api_mode = "chat_completions"
|
||||
base_url = base_url or "cloudcode-pa://google"
|
||||
elif provider == "minimax-oauth":
|
||||
# MiniMax OAuth tokens are valid only against the Anthropic Messages
|
||||
# compatible endpoint. Do not honor stale model.api_mode values from a
|
||||
# prior OpenAI-compatible provider, or the client will hit
|
||||
# /chat/completions under /anthropic and receive a bare nginx 404.
|
||||
api_mode = "anthropic_messages"
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
base_url = base_url or (pconfig.inference_base_url if pconfig else "")
|
||||
elif provider == "anthropic":
|
||||
api_mode = "anthropic_messages"
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Security advisory checker for Hermes Agent.
|
||||
|
||||
Detects known-compromised Python packages installed in the active venv
|
||||
(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that
|
||||
poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to
|
||||
the user.
|
||||
|
||||
Design goals:
|
||||
|
||||
- **Cheap.** A single ``importlib.metadata.version()`` call per advisory
|
||||
package. Safe to run on every CLI startup.
|
||||
- **Loud when it matters, silent otherwise.** If no compromised package is
|
||||
installed, the user sees nothing.
|
||||
- **Acknowledgeable.** Once the user has read and acted on an advisory they
|
||||
can dismiss it via ``hermes doctor --ack <id>``; the ack is persisted to
|
||||
``config.security.acked_advisories`` and survives restart.
|
||||
- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``;
|
||||
adding a new compromised version is a one-line edit. No code changes
|
||||
needed when the next worm hits.
|
||||
|
||||
The check is invoked from three places:
|
||||
|
||||
1. ``hermes doctor`` (and ``hermes doctor --ack <id>``)
|
||||
2. CLI startup banner (one short line, then full guidance via
|
||||
``hermes doctor``)
|
||||
3. Gateway startup (logged to gateway.log; first interactive message gets
|
||||
a one-line operator banner)
|
||||
|
||||
This module is intentionally dependency-free beyond the stdlib so it can
|
||||
run in environments where the rest of Hermes failed to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Advisory catalog
|
||||
#
|
||||
# Each advisory is a community-facing security warning about one or more
|
||||
# specific package versions that are known to be compromised. To add a new
|
||||
# advisory:
|
||||
#
|
||||
# 1. Append a new ``Advisory`` to ``ADVISORIES`` below
|
||||
# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)``
|
||||
# — version strings must match what ``importlib.metadata.version()``
|
||||
# returns. Use an empty frozenset to flag *any installed version*
|
||||
# (rare; only when the maintainer namespace itself is compromised).
|
||||
# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste.
|
||||
#
|
||||
# Do NOT remove old advisories. Once an advisory ships, leave it in place so
|
||||
# users running an older release with the compromised package still get
|
||||
# warned. Mark superseded ones via ``superseded_by`` if needed.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Advisory:
|
||||
"""One security advisory entry.
|
||||
|
||||
Attributes:
|
||||
id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``).
|
||||
Lowercase-hyphen, never reused.
|
||||
title: one-line headline shown in banners.
|
||||
summary: 1-3 sentence description of what was compromised and how.
|
||||
url: reference URL (Socket advisory, GitHub advisory, PyPI page).
|
||||
compromised: tuple of ``(package_name, frozenset_of_versions)``
|
||||
pairs. Empty frozenset means "any version of this package is
|
||||
considered suspect" — use sparingly.
|
||||
remediation: ordered list of steps the user should take. First step
|
||||
should be the uninstall command; subsequent steps the credential
|
||||
audit / rotation guidance.
|
||||
published: ISO date string for sort order.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
compromised: tuple[tuple[str, frozenset[str]], ...]
|
||||
remediation: tuple[str, ...]
|
||||
published: str = ""
|
||||
severity: str = "high" # low / medium / high / critical
|
||||
|
||||
|
||||
ADVISORIES: tuple[Advisory, ...] = (
|
||||
Advisory(
|
||||
id="shai-hulud-2026-05",
|
||||
title="Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPI",
|
||||
summary=(
|
||||
"PyPI quarantined the mistralai package on 2026-05-12 after a "
|
||||
"malicious 2.4.6 release. The worm steals credentials from "
|
||||
"environment variables and credential files (~/.npmrc, ~/.pypirc, "
|
||||
"~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils "
|
||||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
("mistralai", frozenset({"2.4.6"})),
|
||||
),
|
||||
remediation=(
|
||||
"Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)",
|
||||
"Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, "
|
||||
"Nous, GitHub, AWS, Google, Mistral, etc.).",
|
||||
"Audit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, "
|
||||
"and any other credential files for tokens that may have been read.",
|
||||
"Check GitHub for unexpected new SSH keys, deploy keys, or webhook "
|
||||
"additions on repos you have admin on.",
|
||||
"After cleanup: hermes doctor --ack shai-hulud-2026-05 to dismiss "
|
||||
"this warning.",
|
||||
),
|
||||
published="2026-05-12",
|
||||
severity="critical",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Detection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdvisoryHit:
|
||||
"""One package-version match against an advisory."""
|
||||
|
||||
advisory: Advisory
|
||||
package: str
|
||||
installed_version: str
|
||||
|
||||
|
||||
def _installed_version(pkg_name: str) -> Optional[str]:
|
||||
"""Return the installed version of ``pkg_name``, or None if not installed.
|
||||
|
||||
Uses ``importlib.metadata`` so we don't depend on pip being importable
|
||||
inside the active venv (uv-created venvs may lack pip).
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive.
|
||||
return None
|
||||
try:
|
||||
return version(pkg_name)
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
# Some metadata corruption modes raise ValueError or OSError. Don't
|
||||
# let advisory checking crash the CLI startup path.
|
||||
logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def detect_compromised(
|
||||
advisories: Iterable[Advisory] = ADVISORIES,
|
||||
) -> list[AdvisoryHit]:
|
||||
"""Scan installed packages and return all advisory hits.
|
||||
|
||||
A "hit" means an advisory's listed package is installed AND the version
|
||||
is in the compromised set (or the compromised set is empty, meaning
|
||||
*any* version is suspect).
|
||||
"""
|
||||
hits: list[AdvisoryHit] = []
|
||||
for advisory in advisories:
|
||||
for pkg_name, bad_versions in advisory.compromised:
|
||||
installed = _installed_version(pkg_name)
|
||||
if installed is None:
|
||||
continue
|
||||
if not bad_versions or installed in bad_versions:
|
||||
hits.append(AdvisoryHit(
|
||||
advisory=advisory,
|
||||
package=pkg_name,
|
||||
installed_version=installed,
|
||||
))
|
||||
return hits
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Acknowledgement persistence
|
||||
#
|
||||
# Acks live under ``security.acked_advisories`` in config.yaml as a list of
|
||||
# advisory IDs. The list is the only state — no per-host data, no
|
||||
# timestamps, no fingerprints. Users sharing a config.yaml across machines
|
||||
# (rare but possible) get the same dismissal everywhere, which is the
|
||||
# correct behavior for a global advisory.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_acked_ids() -> set[str]:
|
||||
"""Return the set of advisory IDs the user has dismissed.
|
||||
|
||||
Returns an empty set if config can't be loaded (don't block startup
|
||||
just because config is broken — the advisory will keep firing until
|
||||
config is repaired, which is fine).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
logger.debug("Could not load config for advisory acks", exc_info=True)
|
||||
return set()
|
||||
sec = cfg.get("security") or {}
|
||||
raw = sec.get("acked_advisories") or []
|
||||
if not isinstance(raw, list):
|
||||
return set()
|
||||
return {str(x).strip() for x in raw if str(x).strip()}
|
||||
|
||||
|
||||
def ack_advisory(advisory_id: str) -> bool:
|
||||
"""Persist an ack for ``advisory_id``. Returns True on success.
|
||||
|
||||
Idempotent — acking an already-acked ID is a no-op.
|
||||
"""
|
||||
advisory_id = advisory_id.strip()
|
||||
if not advisory_id:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception:
|
||||
logger.warning("Could not import config module to persist ack")
|
||||
return False
|
||||
try:
|
||||
cfg = load_config()
|
||||
sec = cfg.setdefault("security", {})
|
||||
existing = sec.get("acked_advisories") or []
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
if advisory_id not in existing:
|
||||
existing.append(advisory_id)
|
||||
sec["acked_advisories"] = existing
|
||||
save_config(cfg)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to persist advisory ack for %s", advisory_id)
|
||||
return False
|
||||
|
||||
|
||||
def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]:
|
||||
"""Return only hits whose advisories the user has not dismissed."""
|
||||
if not hits:
|
||||
return []
|
||||
acked = get_acked_ids()
|
||||
return [h for h in hits if h.advisory.id not in acked]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rendering helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _term_supports_color() -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]:
|
||||
"""Return 1-3 short lines suitable for a startup banner.
|
||||
|
||||
Caller is responsible for color/styling. Always names the worst hit
|
||||
explicitly so the user knows what's wrong without running doctor.
|
||||
"""
|
||||
if not hits:
|
||||
return []
|
||||
primary = hits[0]
|
||||
lines = [
|
||||
f"SECURITY ADVISORY [{primary.advisory.id}]: {primary.advisory.title}",
|
||||
f" Detected: {primary.package}=={primary.installed_version}",
|
||||
" Run 'hermes doctor' for remediation steps.",
|
||||
]
|
||||
if len(hits) > 1:
|
||||
lines.insert(1, f" ({len(hits) - 1} additional advisor"
|
||||
f"{'ies' if len(hits) > 2 else 'y'} also active.)")
|
||||
return lines
|
||||
|
||||
|
||||
def full_remediation_text(hit: AdvisoryHit) -> list[str]:
|
||||
"""Return a multi-line block describing the advisory + remediation."""
|
||||
a = hit.advisory
|
||||
lines = [
|
||||
f"=== {a.title} ===",
|
||||
f"ID: {a.id} Severity: {a.severity} Published: {a.published}",
|
||||
f"Detected: {hit.package}=={hit.installed_version}",
|
||||
f"Reference: {a.url}",
|
||||
"",
|
||||
a.summary,
|
||||
"",
|
||||
"Remediation:",
|
||||
]
|
||||
for i, step in enumerate(a.remediation, 1):
|
||||
lines.append(f" {i}. {step}")
|
||||
return lines
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Startup-banner gating
|
||||
#
|
||||
# We do NOT want to hammer the user with the banner on every command. Once
|
||||
# they've seen it inside a 24h window we cache that fact in
|
||||
# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID:
|
||||
# ``<id> <iso8601_timestamp>``).
|
||||
#
|
||||
# Acked advisories never re-banner. Cached-but-not-acked advisories
|
||||
# re-banner after 24h so the user doesn't fully forget.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_BANNER_CACHE_FILE = "advisory_banner_seen"
|
||||
_BANNER_REPEAT_HOURS = 24
|
||||
|
||||
|
||||
def _banner_cache_path() -> Optional[Path]:
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
cache_dir = Path(get_hermes_home()) / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / _BANNER_CACHE_FILE
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_banner_cache() -> dict[str, float]:
|
||||
p = _banner_cache_path()
|
||||
if p is None or not p.exists():
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
try:
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
advisory_id, ts = parts
|
||||
try:
|
||||
out[advisory_id] = float(ts)
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
return {}
|
||||
return out
|
||||
|
||||
|
||||
def _write_banner_cache(seen: dict[str, float]) -> None:
|
||||
p = _banner_cache_path()
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
lines = [f"{aid} {ts}" for aid, ts in seen.items()]
|
||||
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
logger.debug("Could not write advisory banner cache", exc_info=True)
|
||||
|
||||
|
||||
def hits_due_for_banner(
|
||||
hits: list[AdvisoryHit],
|
||||
*,
|
||||
repeat_hours: int = _BANNER_REPEAT_HOURS,
|
||||
) -> list[AdvisoryHit]:
|
||||
"""Return only hits whose banner is due (not acked, not recently shown).
|
||||
|
||||
Side effect: stamps the banner cache for any hit that's about to be
|
||||
shown. Callers should subsequently render the result.
|
||||
"""
|
||||
import time
|
||||
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return []
|
||||
now = time.time()
|
||||
cache = _read_banner_cache()
|
||||
cutoff = now - (repeat_hours * 3600)
|
||||
|
||||
due: list[AdvisoryHit] = []
|
||||
for hit in fresh:
|
||||
last = cache.get(hit.advisory.id, 0.0)
|
||||
if last < cutoff:
|
||||
due.append(hit)
|
||||
cache[hit.advisory.id] = now
|
||||
if due:
|
||||
_write_banner_cache(cache)
|
||||
return due
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public entry points used by doctor / CLI / gateway
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]:
|
||||
"""Render the security-advisory section for ``hermes doctor``.
|
||||
|
||||
Returns ``(has_problems, lines)``. Caller is responsible for printing
|
||||
with whatever color scheme it uses.
|
||||
"""
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return False, ["No active security advisories. ✓"]
|
||||
|
||||
lines: list[str] = []
|
||||
for i, hit in enumerate(fresh):
|
||||
if i:
|
||||
lines.append("")
|
||||
lines.extend(full_remediation_text(hit))
|
||||
return True, lines
|
||||
|
||||
|
||||
def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]:
|
||||
"""Return a printable startup banner, or None if nothing is due.
|
||||
|
||||
Updates the banner cache as a side effect (so the next call within
|
||||
24h returns None for the same hit).
|
||||
"""
|
||||
due = hits_due_for_banner(hits)
|
||||
if not due:
|
||||
return None
|
||||
lines = short_banner_lines(due)
|
||||
if _term_supports_color():
|
||||
red = "\x1b[1;31m"
|
||||
reset = "\x1b[0m"
|
||||
return red + "\n".join(lines) + reset
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]:
|
||||
"""Return a one-line log message for gateway operators, or None."""
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return None
|
||||
if len(fresh) == 1:
|
||||
h = fresh[0]
|
||||
return (f"Security advisory [{h.advisory.id}] active: "
|
||||
f"{h.package}=={h.installed_version} matches {h.advisory.title}. "
|
||||
f"See {h.advisory.url}")
|
||||
return (f"{len(fresh)} security advisories active "
|
||||
f"(IDs: {', '.join(h.advisory.id for h in fresh)}). "
|
||||
f"Run `hermes doctor` on the gateway host for details.")
|
||||
@@ -205,15 +205,9 @@ TOOL_CATEGORIES = {
|
||||
],
|
||||
"tts_provider": "elevenlabs",
|
||||
},
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
"tts_provider": "mistral",
|
||||
},
|
||||
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
|
||||
# package is currently quarantined (malicious 2.4.6 release on
|
||||
# 2026-05-12). Restore this entry once PyPI un-quarantines.
|
||||
{
|
||||
"name": "Google Gemini TTS",
|
||||
"badge": "preview",
|
||||
|
||||
+53
-11
@@ -59,10 +59,22 @@ try:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
# First try lazy-installing the dashboard extras. Only the user actually
|
||||
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
|
||||
# them out of every other install path. After install, re-import.
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tool.dashboard", prompt=False)
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -280,7 +292,9 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
"options": ["local", "groq", "openai", "mistral", "xai", "elevenlabs"],
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "groq", "openai", "xai", "elevenlabs"],
|
||||
},
|
||||
"stt.elevenlabs.model_id": {
|
||||
"type": "select",
|
||||
@@ -2808,6 +2822,7 @@ def _minimax_poller(session_id: str) -> None:
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
_minimax_poll_token,
|
||||
_minimax_resolve_token_expiry_unix,
|
||||
_minimax_save_auth_state,
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
MINIMAX_OAUTH_SCOPE,
|
||||
@@ -2845,8 +2860,10 @@ def _minimax_poller(session_id: str) -> None:
|
||||
# dashboard path; cn-region operators can still use the CLI
|
||||
# flow which supports `--region cn`.
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at_ts = now.timestamp() + expires_in_s
|
||||
expires_at_ts = _minimax_resolve_token_expiry_unix(
|
||||
int(token_data["expired_in"]), now=now,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": sess.get("region", "global"),
|
||||
@@ -4802,6 +4819,9 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
|
||||
global _dashboard_plugins_cache
|
||||
if _dashboard_plugins_cache is None or force_rescan:
|
||||
_dashboard_plugins_cache = _discover_dashboard_plugins()
|
||||
elif _dashboard_plugins_cache:
|
||||
if any(not Path(p["_dir"]).is_dir() for p in _dashboard_plugins_cache):
|
||||
_dashboard_plugins_cache = _discover_dashboard_plugins()
|
||||
return _dashboard_plugins_cache
|
||||
|
||||
|
||||
@@ -5213,11 +5233,33 @@ def start_server(
|
||||
if open_browser:
|
||||
import webbrowser
|
||||
|
||||
def _open():
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
# On headless Linux (no DISPLAY or WAYLAND_DISPLAY) some registered
|
||||
# browsers are TUI programs (links, lynx, www-browser) that try to
|
||||
# take over the terminal. That can send SIGHUP to the server process
|
||||
# and cause an immediate exit even though uvicorn bound successfully.
|
||||
# Skip the auto-open attempt on headless systems and let the user
|
||||
# open the URL manually. macOS and Windows are always considered
|
||||
# display-capable.
|
||||
_has_display = (
|
||||
sys.platform != "linux"
|
||||
or bool(os.environ.get("DISPLAY"))
|
||||
or bool(os.environ.get("WAYLAND_DISPLAY"))
|
||||
)
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
if _has_display:
|
||||
def _open():
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
else:
|
||||
_log.debug(
|
||||
"Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
|
||||
"(headless Linux). Pass --no-open to suppress this detection."
|
||||
)
|
||||
|
||||
print(f" Hermes Web UI → http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
|
||||
Reference in New Issue
Block a user