Merge remote-tracking branch 'origin/main' into pr48275-rebase

# Conflicts:
#	cron/scheduler.py
This commit is contained in:
teknium1 2026-06-19 07:40:29 -07:00
commit a58287afcb
No known key found for this signature in database
162 changed files with 8521 additions and 634 deletions

View file

@ -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)