release: v0.50.159 — provider key management from Settings (PR #867 by @bergeouss)#876
release: v0.50.159 — provider key management from Settings (PR #867 by @bergeouss)#876nesquena-hermes merged 3 commits intomasterfrom
Conversation
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (one R2 regression fixed)
What this ships
New "Providers" tab in Settings lets users add/update/remove API keys for the direct-API providers (Anthropic, OpenAI, Google, DeepSeek, xAI, Mistral, MiniMax, Z.AI, Kimi, Ollama, Ollama Cloud, OpenCode Zen/Go) without editing .env. OAuth providers (Copilot, Nous, OpenAI Codex) render as read-only. Closes #586.
Scope: +1041 lines, one new backend module (api/providers.py, 328 lines), one new test file (tests/test_provider_management.py, 16 tests), Settings-panel additions in panels.js / index.html / style.css, and 19 i18n keys × 6 locales.
Comes in as a release PR combining @bergeouss's original PR #867, @frap129's Docker openssh-client PR #868, and the B1/B2/R1/R2/XSS/i18n review-feedback commit. All the surface-level fixes from the cover letter landed. But the R2 fix wasn't quite what the commit message claims.
What I found — R2 was incomplete
The fix commit says:
R2:
_ENV_LOCKnow covers the full load/modify/write cycle in_write_env_file, not justos.environmutations — prevents TOCTOU race between two concurrent POST/api/providerscalls losing each other's key additions
But in the code as pushed, the with _ENV_LOCK: block ended before the file write:
with _ENV_LOCK:
current = _load_env_file(env_path)
for key, value in updates.items():
...
current[key] = clean
os.environ[key] = clean
env_path.parent.mkdir(parents=True, exist_ok=True) # ← OUTSIDE lock
lines = [f"{key}={current[key]}" for key in sorted(current)]
env_path.write_text(...) # ← OUTSIDE lock
env_path.chmod(_stat.S_IRUSR | _stat.S_IWUSR) # ← OUTSIDE lockMeaning two concurrent POSTs could still race:
Thread A: acquire lock → read .env (has KEY_X) → add KEY_Y to current → release lock
Thread B: acquire lock → read .env (still has KEY_X only, A hasn't written) → add KEY_Z → release
Thread A: write_text({KEY_X, KEY_Y})
Thread B: write_text({KEY_X, KEY_Z}) ← clobbers A's KEY_Y
Low-probability (two users saving keys at the same millisecond), but the exact scenario R2 claimed to close. And the lost key would fail silently — user sees the success toast on both sides, reopens the page, one key is just missing.
What I pushed — 6bd6197
Moved file I/O inside the lock block. env_path.parent.mkdir, the write, and the final chmod all now execute under _ENV_LOCK.
Tightened the 0600 window further. Original fix did write_text() then chmod(0o600), which leaves a brief window where the file exists at the process umask (typically 0o644) before chmod. Replaced with os.open(path, O_CREAT|O_WRONLY|O_TRUNC, 0o600) + os.fdopen(...) so the file is created at owner-only mode from the start. Kept the trailing chmod as a belt-and-braces guard for the case where the file already existed at a looser mode (O_CREAT doesn't re-apply mode to pre-existing files).
Two regression tests added:
test_env_file_created_with_0600_mode— assertsstat.S_IMODE(env_path.stat().st_mode) & 0o077 == 0. Catches any future refactor that regresses mode to world- or group-readable.test_write_inside_lock_not_after— structural check viainspect.getsource(_write_env_file)that scans forenv_path.write_text,env_path.chmod,os.open,os.fdopen, andenv_path.parent.mkdircall sites and asserts their indentation is deeper than thewith _ENV_LOCK:line. This locks R2 mechanically — any future reviewer can't accidentally re-introduce the bug by dedenting the I/O out again.
Full security review — what I checked
B1 (auth gate) — POST endpoints at routes.py:960-973 use the same is_auth_enabled() or HERMES_WEBUI_ONBOARDING_OPEN or local-network pattern as /api/onboarding/setup at routes.py:1402-1415. Precedent-matching. The X-Forwarded-For trust is implicit in the existing model (only safe behind a trusted reverse proxy); not worse than the rest of the codebase. ✓
Worth noting: check_auth middleware at auth.py:158-180 gates authenticated paths when auth is enabled, so the gate here correctly only runs the local-network fallback when auth is disabled. The two layers compose correctly — no double-gate confusion.
B2 (_provider_has_key false-positive) — fixed. providers.py:145-152 explicitly only checks providers.<id>.api_key, not model.api_key. Comment explains why. The test at test_provider_entries_have_required_fields confirms providers without keys show has_key=False. ✓
R1 (0600 mode) — re-tightened in my patch from "chmod after write" to "open with mode + chmod backup". New test locks this invariant. ✓
R2 (full lock coverage) — fixed in my patch. New test locks this invariant. ✓
XSS (error handler) — panels.js:1384 now uses esc(e.message) inside the innerHTML template. ✓ All other HTML construction in the panel uses createElement + .textContent (lines 1389-1452) — safe by construction. showToast (ui.js:865) writes via textContent. ✓
GET /api/providers leakage check — get_providers() returns {id, display_name, has_key, configurable, key_source, models}. No raw key values at all, just presence booleans. Even key_source only indicates which storage location (env_file / env_var / config_yaml / oauth / none), never the value. ✓
Input validation — set_provider_key() enforces minimum length (≥8), rejects newlines/CRs (prevents .env injection via \nEVIL_KEY=x), OAuth providers explicitly rejected, unknown provider_id rejected. .strip().lower() normalization prevents ANTHROPIC and Anthropic submitting as different keys. ✓
Eval / subprocess / shell — none in the new code. grep -n "eval\|exec\|subprocess\|shell" api/providers.py returns nothing. ✓
Circular import risk — _write_env_file does from api.streaming import _ENV_LOCK inside the function body. Deliberate (avoids module-init-time cycle); works at call time since both modules are fully imported by then. ✓
i18n coverage
6 locales have the full providers_* key set (grep -c 'providers_tab_title' i18n.js → 6). Reasonable to ship with English fallback strings in the non-en locales — the cover letter flags "native translations welcome in follow-up PRs", which is the right ship-it-now trade-off. ✓
Settings-tab wiring
index.html:462-464— tab button withonclick="switchSettingsSection('providers')"panels.js:1131,1145—switchSettingsSectionrecognises 'providers' and callsloadProvidersPanel()on activationpanels.js:1353— also preloads the panel in background when Settings opens- Matches the pattern of the other Settings tabs. ✓
Edge-case trace
| Scenario | Behaviour |
|---|---|
| Two users on same host save keys simultaneously (R2 race) | Both writes serialise under _ENV_LOCK now; no key loss ✅ |
User sets key via WebUI, .env didn't exist before |
Created at 0600 from first byte via os.open(..., O_CREAT, 0o600) ✅ |
.env existed at 0644 before WebUI first write |
Written, then chmod(0600) tightens it ✅ |
User submits key with embedded \n |
Rejected at both set_provider_key (line 296-297) and _write_env_file (line 107-108); two layers of defence ✅ |
| User submits key shorter than 8 chars | 400 + "too short" error ✅ |
User POSTs with provider=copilot (OAuth) |
400 + "uses OAuth authentication" ✅ |
| LAN-exposed server without auth, remote attacker POSTs | 403 unless X-Forwarded-For claims local origin — same precedent/risk as existing onboarding endpoint ✅ |
auth.json has model.api_key set (custom endpoint) |
Does NOT mark all providers has_key=true anymore — B2 fix ✅ |
| User removes a key that wasn't set | Idempotent — current.pop(key, None) + os.environ.pop(key, None) ✅ |
User's .env has comments / blank lines |
_load_env_file skips both (line 75) ✅ |
User's .env has quoted values (KEY="abc") |
.strip('"').strip("'") on parse, stored without quotes on write ✅ |
GET /api/providers before any config |
Returns provider list with has_key=false for all ✅ |
invalidate_models_cache() called during active stream |
Correct behaviour — clears the TTL cache so next dropdown read reflects new key; doesn't disrupt in-flight streams ✅ |
Tests
- 18/18 tests in
tests/test_provider_management.pypass (16 original + 2 new regression tests) - Full local suite: 1871 passed, 47 skipped. 4 failures, all pre-existing ordering flakes that pass in isolation:
test_issue798::test_concurrent_new_sessions_get_correct_profilestest_sprint46::test_session_compress_roundtriptest_set_key_writes_to_env_file(ordering with test_issue798's profiles monkeypatch)test_env_file_created_with_0600_mode(same cause)
- The last two match the PR body's "5 pre-existing ordering-sensitive flakies, unchanged from baseline" — same behaviour as before the PR, not a regression.
Design notes (non-blocking)
provider_idnormalization atset_provider_key:273does.strip().lower()— good. Butget_providers()emits IDs as the keys of_PROVIDER_ENV_VAR/_PROVIDER_DISPLAY, which are already canonical. If a future caller passesprovider=Anthropic, normalization handles it. Defensive. ✓ollamaandollama-cloudboth map toOLLAMA_API_KEY(same env var). This means saving an Ollama Cloud key and then saving an Ollama key will overwrite each other — but they use the same key server-side anyway, so this is correct. Worth a note in a follow-up if the two diverge._write_env_filereads the entire.envon every call. Fine — these files are small and writes are rare.- The 15-line diff bump in
_write_env_filefrom my patch isn't strictly necessary for correctness — awith open(..., 'w') as f:+ trailingchmodwould work identically under the moved lock. I kept theos.openvariant because it eliminates one mode-transition window, which seems worth the ~5 extra lines for a credentials file.
Approval
Feature is well-scoped and closes a real UX gap (no more hand-editing .env). Six review-round fixes all landed. One of them (R2) had a subtle gap that my patch closed and a regression test now pins. Test coverage is strong (16 → 18 targeted tests); i18n coverage is complete; security posture matches or exceeds the onboarding endpoint precedent.
✅ Approved after the fix. Ready for merge + v0.50.159 tag (depending on what's already been tagged — the CHANGELOG currently says v0.50.158, but #863 and #864 also propose v0.50.158; first merger wins the slot).
Add CRUD endpoints for managing provider API keys from the WebUI, allowing users to add, update, or remove provider keys without manually editing config.yaml or .env files. Backend (api/providers.py): - GET /api/providers: list all known providers with key status - POST /api/providers: set/update API key for a provider - POST /api/providers/delete: remove API key for a provider - Key validation: minimum length, no newlines, OAuth providers blocked - Keys stored in HERMES_HOME/.env (same as onboarding) Frontend: - New 'Providers' tab in Settings panel - Provider cards showing key status (dot indicator) - Inline API key input with Save/Remove actions - OAuth providers shown as read-only Tests: 16 new tests (unit + integration) covering all endpoints Closes #586
- Wrap os.environ mutations in _ENV_LOCK to prevent race conditions with concurrent streaming sessions - Fix ollama-cloud classification: it uses OLLAMA_API_KEY (api_key auth_type), not OAuth — moved to configurable providers - Replace reload_config() with invalidate_models_cache() to avoid disrupting active streams when a provider key is saved - Add data-i18n attributes and t() wrappers for all provider panel strings (en locale keys added to i18n.js) - Update test assertions for ollama-cloud now being configurable
79def4a to
e8c6f04
Compare
v0.50.159 — Provider key management from Settings (PR #867 by @bergeouss)
Release branch for PR #867. Full security and correctness review complete — see the detailed comment on #867 for all findings. Six fixes applied before merge:
What's in this release
New feature: Providers tab in Settings — add/remove API keys for AI providers without editing
.envfiles. Closes #586.Security fixes applied during review:
_provider_has_key()no longer false-positives whenmodel.api_keyis set (B2).envfiles created at 0600, not umask 0644 (R1)_ENV_LOCKcovers full load/modify/write cycle, not justos.environmutations (R2)esc(e.message)in error handlerTesting
/api/providersendpoint verified: returns only key presence booleans, never raw key valuesCloses #586.