mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
Merge remote-tracking branch 'origin/main' into pr48275-rebase
# Conflicts: # cron/scheduler.py
This commit is contained in:
commit
a58287afcb
162 changed files with 8521 additions and 634 deletions
|
|
@ -1554,6 +1554,7 @@ async def upload_managed_file_stream(
|
|||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
renamed = False
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "wb") as out:
|
||||
while True:
|
||||
|
|
@ -1565,16 +1566,21 @@ async def upload_managed_file_stream(
|
|||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
out.write(chunk)
|
||||
os.replace(tmp_path, target)
|
||||
renamed = True
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except PermissionError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
finally:
|
||||
# Clean up the temp file on every non-success exit, including
|
||||
# BaseException paths the `except` clauses above don't catch — most
|
||||
# importantly asyncio.CancelledError when a browser aborts a large
|
||||
# upload mid-stream (the exact NS-501 scenario). os.replace clears
|
||||
# tmp_path on success, so only unlink when the rename didn't happen.
|
||||
if not renamed:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
await file.close()
|
||||
|
||||
return {
|
||||
|
|
@ -2316,6 +2322,43 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str:
|
|||
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
|
||||
|
||||
|
||||
# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the
|
||||
# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx.
|
||||
_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}")
|
||||
|
||||
|
||||
def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
|
||||
"""Reject platform credentials that are clearly in the wrong field."""
|
||||
if platform_id != "slack" or not value:
|
||||
return
|
||||
|
||||
if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.",
|
||||
)
|
||||
if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
|
||||
)
|
||||
if key == "SLACK_ALLOWED_USERS":
|
||||
# Mirror the gateway's parse (gateway/platforms/slack.py): split on comma,
|
||||
# strip, and drop empty entries so a trailing/interior comma isn't rejected
|
||||
# here when the runtime would accept it. "*" is the allow-all wildcard.
|
||||
user_ids = [part.strip() for part in value.split(",") if part.strip()]
|
||||
invalid = [
|
||||
user_id
|
||||
for user_id in user_ids
|
||||
if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id)
|
||||
]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.",
|
||||
)
|
||||
|
||||
|
||||
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
|
||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||
|
||||
|
|
@ -3925,28 +3968,135 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
|
|||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
def _catalog_provider_env_metadata() -> dict:
|
||||
"""Map provider env vars → desktop card metadata, derived from the catalog.
|
||||
|
||||
Returns ``{env_var: {provider, provider_label, description, url, is_password,
|
||||
advanced}}`` for every API-key provider in the unified ``provider_catalog()``
|
||||
(i.e. the ``hermes model`` universe). This is what lets the desktop Keys tab
|
||||
render a card for a provider even when its env var was never hand-added to
|
||||
``OPTIONAL_ENV_VARS`` — closing the drift where CLI-configurable providers
|
||||
(openai-api, kilocode, novita, tencent-tokenhub, copilot, …) were missing
|
||||
from the GUI.
|
||||
|
||||
Hand ``OPTIONAL_ENV_VARS`` prose is layered ON TOP of this in the endpoint;
|
||||
this only supplies membership + grouping + sensible fallbacks.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.provider_catalog import provider_catalog
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Env vars already declared with a NON-provider category (e.g. the shared
|
||||
# GITHUB_TOKEN, which is a Skills-Hub "tool" credential) must not be
|
||||
# promoted into a provider card. Copilot lists GITHUB_TOKEN among its auth
|
||||
# aliases, but its provider card uses the provider-owned COPILOT_GITHUB_TOKEN.
|
||||
try:
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS as _OPT
|
||||
except Exception:
|
||||
_OPT = {}
|
||||
_non_provider_keys = {
|
||||
k for k, v in _OPT.items()
|
||||
if (v or {}).get("category") and (v or {}).get("category") != "provider"
|
||||
}
|
||||
|
||||
meta: dict = {}
|
||||
for d in provider_catalog():
|
||||
if d.tab != "keys":
|
||||
continue
|
||||
# API-key vars: the first is the primary (password) field; any aliases
|
||||
# are kept as additional password fields so users can clear them too.
|
||||
for env_var in d.api_key_env_vars:
|
||||
if env_var in _non_provider_keys:
|
||||
continue # don't hijack a shared tool/messaging credential
|
||||
meta.setdefault(
|
||||
env_var,
|
||||
{
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": d.description,
|
||||
"url": d.signup_url or None,
|
||||
"is_password": True,
|
||||
"advanced": False,
|
||||
"category": "provider",
|
||||
},
|
||||
)
|
||||
# Base-URL override is an advanced, non-secret field for the same card.
|
||||
if d.base_url_env_var:
|
||||
meta.setdefault(
|
||||
d.base_url_env_var,
|
||||
{
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": f"{d.label} base URL override",
|
||||
"url": None,
|
||||
"is_password": False,
|
||||
"advanced": True,
|
||||
"category": "provider",
|
||||
},
|
||||
)
|
||||
|
||||
# AWS-SDK providers (Bedrock) authenticate via the AWS credential chain
|
||||
# rather than a pasted API key, so they have no api_key_env_vars. Tag
|
||||
# their AWS_* settings to the provider card so they still appear on the
|
||||
# Keys tab (otherwise Bedrock — a `hermes model` provider — would be
|
||||
# invisible in the desktop app).
|
||||
if d.auth_type == "aws_sdk":
|
||||
for aws_var in ("AWS_REGION", "AWS_PROFILE"):
|
||||
existing = meta.get(aws_var, {})
|
||||
meta[aws_var] = {
|
||||
"provider": d.slug,
|
||||
"provider_label": d.label,
|
||||
"description": existing.get("description") or f"{d.label} ({aws_var})",
|
||||
"url": existing.get("url"),
|
||||
"is_password": False,
|
||||
"advanced": existing.get("advanced", True),
|
||||
"category": "provider",
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
env_on_disk = load_env()
|
||||
channel_keys = _channel_managed_env_keys()
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
catalog_meta = _catalog_provider_env_metadata()
|
||||
|
||||
def _row(var_name: str, info: dict) -> dict:
|
||||
value = env_on_disk.get(var_name)
|
||||
result[var_name] = {
|
||||
cat_meta = catalog_meta.get(var_name) or {}
|
||||
# Hand OPTIONAL_ENV_VARS prose wins where present; the catalog fills any
|
||||
# gaps (description/url) and always supplies provider grouping hints.
|
||||
return {
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
"description": info.get("description", ""),
|
||||
"url": info.get("url"),
|
||||
"category": info.get("category", ""),
|
||||
"is_password": info.get("password", False),
|
||||
"description": info.get("description") or cat_meta.get("description", ""),
|
||||
"url": info.get("url") if info.get("url") is not None else cat_meta.get("url"),
|
||||
"category": info.get("category") or cat_meta.get("category", ""),
|
||||
"is_password": info.get("password", cat_meta.get("is_password", False)),
|
||||
"tools": info.get("tools", []),
|
||||
"advanced": info.get("advanced", False),
|
||||
"advanced": info.get("advanced", cat_meta.get("advanced", False)),
|
||||
# True when this var is a messaging-platform credential owned by a
|
||||
# Channels page card. The Keys/Env page uses this to hide it and
|
||||
# avoid duplicating the (richer) Channels configuration UI.
|
||||
"channel_managed": var_name in channel_keys,
|
||||
# Provider grouping hints derived from the unified provider catalog
|
||||
# so the desktop Keys tab groups by the SAME provider identity the
|
||||
# CLI `hermes model` picker uses (not desktop-only prefix guesses).
|
||||
"provider": cat_meta.get("provider", ""),
|
||||
"provider_label": cat_meta.get("provider_label", ""),
|
||||
}
|
||||
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
result[var_name] = _row(var_name, info)
|
||||
# Synthesize rows for catalog provider env vars that have no hand entry in
|
||||
# OPTIONAL_ENV_VARS — these are the providers that were CLI-configurable but
|
||||
# invisible in the desktop app until now.
|
||||
for var_name in catalog_meta:
|
||||
if var_name not in result:
|
||||
result[var_name] = _row(var_name, {})
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -4146,9 +4296,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
|
|||
},
|
||||
"slack": {
|
||||
"name": "Slack",
|
||||
"description": "Use Hermes from Slack via Socket Mode.",
|
||||
"description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.",
|
||||
"docs_url": "https://api.slack.com/apps",
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"),
|
||||
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
},
|
||||
"mattermost": {
|
||||
|
|
@ -4633,6 +4783,7 @@ def _messaging_env_info(key: str) -> dict[str, Any]:
|
|||
return {
|
||||
"description": info.get("description", ""),
|
||||
"prompt": info.get("prompt", key),
|
||||
"help": info.get("help", ""),
|
||||
"url": info.get("url"),
|
||||
"is_password": info.get("password", False),
|
||||
"advanced": info.get("advanced", False),
|
||||
|
|
@ -5212,6 +5363,7 @@ async def update_messaging_platform(
|
|||
)
|
||||
trimmed = value.strip()
|
||||
if trimmed:
|
||||
_validate_messaging_env_value(platform_id, key, trimmed)
|
||||
save_env_value(key, trimmed)
|
||||
|
||||
if body.enabled is not None:
|
||||
|
|
@ -5413,13 +5565,53 @@ def _claude_code_only_status() -> Dict[str, Any]:
|
|||
return {"logged_in": False, "source": None}
|
||||
|
||||
|
||||
# Provider catalog. The order matters — it's how we render the UI list.
|
||||
# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
|
||||
# fallback while Phase 2 (in-browser flows) isn't built yet.
|
||||
# ``flow`` describes the OAuth shape so the future modal can pick the
|
||||
# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` =
|
||||
# show code + verification URL + poll, ``external`` = read-only (delegated
|
||||
# to a third-party CLI like Claude Code or Qwen).
|
||||
def _gemini_cli_status() -> Dict[str, Any]:
|
||||
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
|
||||
try:
|
||||
from hermes_cli import auth as hauth
|
||||
raw = hauth.get_gemini_oauth_auth_status()
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "google_oauth",
|
||||
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
|
||||
|
||||
def _copilot_acp_status() -> Dict[str, Any]:
|
||||
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
|
||||
|
||||
There is no cheap programmatic credential probe for the ACP subprocess, so
|
||||
this is a read-only "managed by the Copilot CLI" card (like claude-code):
|
||||
Hermes never claims a login state it can't verify.
|
||||
"""
|
||||
return {
|
||||
"logged_in": False,
|
||||
"source": "copilot_cli",
|
||||
"source_label": "Managed by the GitHub Copilot CLI",
|
||||
"token_preview": None,
|
||||
"expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
}
|
||||
|
||||
|
||||
# Explicit, hand-tuned OAuth/account provider cards. These carry the bits that
|
||||
# can't be derived from the unified provider catalog: the OAuth ``flow`` shape,
|
||||
# the per-provider ``status_fn``, the ``cli_command`` fallback, and curated
|
||||
# display order. They are the OVERRIDE BASE for ``_build_oauth_catalog()``,
|
||||
# which unions them with every accounts-tab provider in ``provider_catalog()``
|
||||
# so newly-added OAuth/external providers appear automatically (no hand edit).
|
||||
# This tuple also still includes two entries that are NOT catalog providers but
|
||||
# must show on the Accounts tab: the api-key Anthropic PKCE card and the
|
||||
# synthetic ``claude-code`` subscription row.
|
||||
# ``flow`` describes the OAuth shape so the modal can pick the right UI:
|
||||
# ``pkce`` = open URL + paste callback code, ``device_code`` = show code +
|
||||
# verification URL + poll, ``external`` = read-only (delegated to a third-party
|
||||
# CLI like Claude Code or Qwen), ``loopback`` = 127.0.0.1 callback listener.
|
||||
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "nous",
|
||||
|
|
@ -5469,6 +5661,22 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
|||
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
|
||||
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "google-gemini-cli",
|
||||
"name": "Google Gemini (OAuth + Code Assist)",
|
||||
"flow": "external",
|
||||
"cli_command": "hermes auth add google-gemini-cli",
|
||||
"docs_url": "https://ai.google.dev/gemini-api/docs",
|
||||
"status_fn": _gemini_cli_status,
|
||||
},
|
||||
{
|
||||
"id": "copilot-acp",
|
||||
"name": "GitHub Copilot (ACP)",
|
||||
"flow": "external",
|
||||
"cli_command": "copilot /login",
|
||||
"docs_url": "https://docs.github.com/en/copilot",
|
||||
"status_fn": _copilot_acp_status,
|
||||
},
|
||||
# ── Anthropic / Claude entries sit at the bottom: the API-key path
|
||||
# first, then the subscription OAuth path (which only works with extra
|
||||
# usage credits on top of a Claude Max plan — see disclaimer in name).
|
||||
|
|
@ -5555,6 +5763,31 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
|||
"has_refresh_token": True,
|
||||
"last_refresh": raw.get("last_refresh"),
|
||||
}
|
||||
# No hand-written branch for this provider id: fall through to the
|
||||
# canonical slug-driven dispatcher so accounts-tab providers derived
|
||||
# from the unified catalog (which carry status_fn=None) still reflect
|
||||
# real login state instead of rendering permanently logged-out. This
|
||||
# closes the membership-auto-extends-but-status-doesn't gap: add an
|
||||
# OAuth/account provider plugin and its card shows the right state.
|
||||
raw = hauth.get_auth_status(provider_id)
|
||||
if isinstance(raw, dict) and "logged_in" in raw:
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or raw.get("provider") or provider_id,
|
||||
"source_label": (
|
||||
raw.get("source_label")
|
||||
or raw.get("auth_store")
|
||||
or raw.get("auth_store_path")
|
||||
or raw.get("base_url")
|
||||
or raw.get("name")
|
||||
or ""
|
||||
),
|
||||
"token_preview": _truncate_token(
|
||||
raw.get("access_token") or raw.get("api_key")
|
||||
),
|
||||
"expires_at": raw.get("expires_at") or raw.get("access_expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
|
|
@ -5598,6 +5831,56 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
|
|||
return None
|
||||
|
||||
|
||||
def _build_oauth_catalog() -> list[Dict[str, Any]]:
|
||||
"""Build the Accounts-tab provider list.
|
||||
|
||||
MEMBERSHIP is the union of:
|
||||
1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry
|
||||
bespoke flow / status_fn / cli_command (including the api-key Anthropic
|
||||
PKCE card and the synthetic claude-code subscription row, which are not
|
||||
catalog providers), and
|
||||
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
|
||||
``hermes model`` universe) — so any OAuth/external provider added as a
|
||||
plugin appears automatically, with sensible defaults, even if no
|
||||
explicit card was written for it.
|
||||
|
||||
The explicit catalog wins on metadata; the unified catalog guarantees we
|
||||
never silently drop a provider the CLI picker offers. Order: explicit cards
|
||||
first (their curated order), then any catalog-only providers appended in
|
||||
``hermes model`` order.
|
||||
"""
|
||||
rows: list[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
|
||||
for entry in _OAUTH_PROVIDER_CATALOG:
|
||||
if entry["id"] in seen:
|
||||
continue
|
||||
seen.add(entry["id"])
|
||||
rows.append(dict(entry))
|
||||
|
||||
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
|
||||
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
|
||||
try:
|
||||
from hermes_cli.provider_catalog import provider_catalog
|
||||
for d in provider_catalog():
|
||||
if d.tab != "accounts" or d.slug in seen:
|
||||
continue
|
||||
seen.add(d.slug)
|
||||
rows.append({
|
||||
"id": d.slug,
|
||||
"name": d.label,
|
||||
"flow": "external",
|
||||
"cli_command": f"hermes auth add {d.slug}",
|
||||
"docs_url": d.signup_url or "",
|
||||
"status_fn": None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth")
|
||||
async def list_oauth_providers(profile: Optional[str] = None):
|
||||
"""Enumerate every OAuth-capable LLM provider with current status.
|
||||
|
|
@ -5617,10 +5900,14 @@ async def list_oauth_providers(profile: Optional[str] = None):
|
|||
token_preview last N chars of the token, never the full token
|
||||
expires_at ISO timestamp string or null
|
||||
has_refresh_token bool
|
||||
|
||||
Membership is derived from the unified provider_catalog() so this stays in
|
||||
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
|
||||
flow/status/cli metadata.
|
||||
"""
|
||||
with _profile_scope(profile):
|
||||
providers = []
|
||||
for p in _OAUTH_PROVIDER_CATALOG:
|
||||
for p in _build_oauth_catalog():
|
||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
||||
providers.append({
|
||||
|
|
@ -5647,7 +5934,7 @@ async def disconnect_oauth_provider(
|
|||
_require_token(request)
|
||||
|
||||
with _profile_scope(profile):
|
||||
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
||||
catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()}
|
||||
provider = catalog_by_id.get(provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -10914,6 +11201,7 @@ def _resolve_chat_argv(
|
|||
# the dashboard PTY path.
|
||||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||
env.setdefault("HERMES_TUI_INLINE", "1")
|
||||
env["HERMES_TUI_DASHBOARD"] = "1"
|
||||
|
||||
if profile_dir is not None:
|
||||
env["HERMES_HOME"] = str(profile_dir)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue