mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
fix(web_server): keep Desktop custom endpoint API keys out of config.yaml
The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so
the credential sat in plaintext in a file users routinely share and commit.
The input is masked, so nothing warned them.
Write the key to .env and reference it via key_env, the same indirection
built-in providers use and that runtime_provider already resolves. The read
side has to move with it: reporting has_api_key from api_key alone would
show "no API key" for every migrated endpoint, and activate copying only
api_key would drop the credential entirely. Delete now clears the .env slot
too, and an entry still carrying a pre-fix plaintext key is migrated on its
next save so existing users get cleaned up without re-entering anything —
unless the key is a hand-written ${VAR} template, which is already safe and
must not be duplicated into a second env var.
Fixes #69449
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
This commit is contained in:
parent
de6375ebc5
commit
bd2dcfe9ca
1 changed files with 71 additions and 10 deletions
|
|
@ -72,6 +72,7 @@ from hermes_cli.config import (
|
|||
save_config,
|
||||
save_env_value,
|
||||
remove_env_value,
|
||||
custom_endpoint_key_env,
|
||||
check_config_version,
|
||||
detect_install_method,
|
||||
format_docker_update_message,
|
||||
|
|
@ -7584,6 +7585,38 @@ def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]:
|
|||
return [model for model in models if model and not (model in seen or seen.add(model))]
|
||||
|
||||
|
||||
def _api_key_display(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||
"""Return ``(has_api_key, preview)`` for a provider or model config block.
|
||||
|
||||
Keys live in ``.env`` behind ``key_env``; only entries written before
|
||||
#69449 still carry a plaintext ``api_key``. Checking both keeps the panel
|
||||
honest either way — reading only ``api_key`` reported "no API key" for
|
||||
every endpoint whose key had been moved to ``.env``.
|
||||
"""
|
||||
plaintext = str(entry.get("api_key") or "").strip()
|
||||
if plaintext:
|
||||
return True, redact_key(plaintext)
|
||||
key_env = str(entry.get("key_env") or "").strip()
|
||||
if key_env:
|
||||
return True, f"${{{key_env}}}"
|
||||
return False, None
|
||||
|
||||
|
||||
def _config_api_key_is_env_ref(endpoint_id: str) -> bool:
|
||||
"""True when this endpoint's on-disk ``api_key`` is a ``${VAR}`` template.
|
||||
|
||||
``load_config()`` expands env refs, so a hand-written
|
||||
``api_key: ${MY_KEY}`` is indistinguishable from a literal secret by the
|
||||
time it reaches us. Such an entry is already keeping its secret out of
|
||||
config.yaml, so migrating it would only copy that secret into a second
|
||||
env var the user didn't ask for.
|
||||
"""
|
||||
providers = read_raw_config().get("providers")
|
||||
entry = providers.get(endpoint_id) if isinstance(providers, dict) else None
|
||||
raw_key = entry.get("api_key") if isinstance(entry, dict) else None
|
||||
return bool(isinstance(raw_key, str) and re.search(r"\$\{[^}]+\}", raw_key))
|
||||
|
||||
|
||||
def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
||||
current_provider = str(model_cfg.get("provider", "") or "")
|
||||
|
|
@ -7602,6 +7635,7 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
endpoint_id = str(provider_id)
|
||||
models = _models_from_custom_endpoint_entry(raw_entry)
|
||||
endpoint_model = str(raw_entry.get("model") or raw_entry.get("default_model") or (models[0] if models else ""))
|
||||
has_api_key, api_key_preview = _api_key_display(raw_entry)
|
||||
endpoints.append({
|
||||
"id": endpoint_id,
|
||||
"name": str(raw_entry.get("name") or endpoint_id),
|
||||
|
|
@ -7610,13 +7644,14 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"models": models,
|
||||
"context_length": raw_entry.get("context_length"),
|
||||
"discover_models": bool(raw_entry.get("discover_models", True)),
|
||||
"has_api_key": bool(str(raw_entry.get("api_key", "") or "").strip()),
|
||||
"api_key_preview": redact_key(str(raw_entry.get("api_key", "") or "")) if raw_entry.get("api_key") else None,
|
||||
"has_api_key": has_api_key,
|
||||
"api_key_preview": api_key_preview,
|
||||
"is_current": endpoint_id == current_provider,
|
||||
"source": "providers",
|
||||
})
|
||||
|
||||
if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints):
|
||||
has_api_key, api_key_preview = _api_key_display(model_cfg)
|
||||
endpoints.insert(0, {
|
||||
"id": "custom",
|
||||
"name": "Custom",
|
||||
|
|
@ -7625,8 +7660,8 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"models": [current_model] if current_model else [],
|
||||
"context_length": model_cfg.get("context_length"),
|
||||
"discover_models": True,
|
||||
"has_api_key": bool(str(model_cfg.get("api_key", "") or "").strip()),
|
||||
"api_key_preview": redact_key(str(model_cfg.get("api_key", "") or "")) if model_cfg.get("api_key") else None,
|
||||
"has_api_key": has_api_key,
|
||||
"api_key_preview": api_key_preview,
|
||||
"is_current": True,
|
||||
"source": "direct-config",
|
||||
})
|
||||
|
|
@ -7659,7 +7694,7 @@ def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) ->
|
|||
return
|
||||
if str(model_cfg.get("provider") or "").strip().lower() != provider_key:
|
||||
return
|
||||
for field in ("provider", "base_url", "api_key"):
|
||||
for field in ("provider", "base_url", "api_key", "key_env"):
|
||||
model_cfg.pop(field, None)
|
||||
cfg["model"] = model_cfg
|
||||
|
||||
|
|
@ -7719,8 +7754,29 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
|||
if body.context_length and body.context_length > 0:
|
||||
entry["context_length"] = int(body.context_length)
|
||||
entry["models"][model]["context_length"] = int(body.context_length)
|
||||
if body.api_key is not None and body.api_key.strip():
|
||||
entry["api_key"] = body.api_key.strip()
|
||||
|
||||
# API keys never belong in config.yaml (#69449). Write to .env and
|
||||
# reference it via ``key_env`` — the same indirection built-in providers
|
||||
# use and that runtime_provider.py already resolves at load time.
|
||||
env_var = custom_endpoint_key_env(endpoint_id)
|
||||
submitted_key = body.api_key.strip() if body.api_key is not None else None
|
||||
if submitted_key:
|
||||
save_env_value(env_var, submitted_key)
|
||||
entry["key_env"] = env_var
|
||||
entry.pop("api_key", None)
|
||||
elif submitted_key is not None:
|
||||
# Blank field means "clear the key", not "leave it alone".
|
||||
remove_env_value(env_var)
|
||||
entry.pop("key_env", None)
|
||||
entry.pop("api_key", None)
|
||||
elif str(entry.get("api_key") or "").strip() and not _config_api_key_is_env_ref(endpoint_id):
|
||||
# No new key submitted, but this entry still carries one an earlier
|
||||
# release wrote in plaintext. Migrate it on the next save so endpoints
|
||||
# configured before the fix get cleaned up too, without the user
|
||||
# having to re-enter the key.
|
||||
save_env_value(env_var, entry["api_key"].strip())
|
||||
entry["key_env"] = env_var
|
||||
entry.pop("api_key", None)
|
||||
|
||||
providers[endpoint_id] = entry
|
||||
cfg["providers"] = providers
|
||||
|
|
@ -7729,8 +7785,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
|||
cfg["model"] = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), endpoint_id, model, base_url
|
||||
)
|
||||
if entry.get("api_key") and isinstance(cfg["model"], dict):
|
||||
cfg["model"]["api_key"] = entry["api_key"]
|
||||
if entry.get("key_env") and isinstance(cfg["model"], dict):
|
||||
cfg["model"]["key_env"] = entry["key_env"]
|
||||
cfg["model"].pop("api_key", None)
|
||||
|
||||
return endpoint_id, entry
|
||||
|
||||
|
|
@ -7781,7 +7838,10 @@ def activate_custom_endpoint(endpoint_id: str):
|
|||
raise HTTPException(status_code=400, detail="custom endpoint is incomplete")
|
||||
|
||||
model_cfg = _apply_main_model_assignment(cfg.get("model", {}), provider_key, model, base_url)
|
||||
if entry.get("api_key"):
|
||||
if entry.get("key_env"):
|
||||
model_cfg["key_env"] = entry["key_env"]
|
||||
model_cfg.pop("api_key", None)
|
||||
elif entry.get("api_key"):
|
||||
model_cfg["api_key"] = entry["api_key"]
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
|
|
@ -7805,6 +7865,7 @@ def delete_custom_endpoint(endpoint_id: str):
|
|||
providers.pop(provider_key, None)
|
||||
cfg["providers"] = providers
|
||||
_detach_main_model_from_provider(cfg, provider_key)
|
||||
remove_env_value(custom_endpoint_key_env(provider_key))
|
||||
save_config(cfg)
|
||||
response = _custom_endpoint_response(cfg)
|
||||
response["ok"] = True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue