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:
Brooklyn Nicholson 2026-07-24 21:12:46 -05:00
parent de6375ebc5
commit bd2dcfe9ca

View file

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