Skip to content

release: v0.50.159 — provider key management from Settings (PR #867 by @bergeouss)#876

Merged
nesquena-hermes merged 3 commits intomasterfrom
release/pr-867
Apr 23, 2026
Merged

release: v0.50.159 — provider key management from Settings (PR #867 by @bergeouss)#876
nesquena-hermes merged 3 commits intomasterfrom
release/pr-867

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

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 .env files. Closes #586.

Security fixes applied during review:

  • POST endpoints require auth or local-network client (B1)
  • _provider_has_key() no longer false-positives when model.api_key is set (B2)
  • .env files created at 0600, not umask 0644 (R1)
  • _ENV_LOCK covers full load/modify/write cycle, not just os.environ mutations (R2)
  • XSS: esc(e.message) in error handler
  • i18n: 19 provider keys added to all 5 non-English locales

Testing

  • 1914 tests passing (5 pre-existing ordering-sensitive flakies, unchanged from baseline)
  • QA harness: 20/20 structural/security checks pass
  • Browser API sanity: 11/11 checks pass
  • /api/providers endpoint verified: returns only key presence booleans, never raw key values
  • Opus security review: confirmed no eval/exec/subprocess in new code, no key leakage, CSRF covered

Closes #586.

Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_LOCK now covers the full load/modify/write cycle in _write_env_file, not just os.environ mutations — prevents TOCTOU race between two concurrent POST /api/providers calls 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 lock

Meaning 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 — asserts stat.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 via inspect.getsource(_write_env_file) that scans for env_path.write_text, env_path.chmod, os.open, os.fdopen, and env_path.parent.mkdir call sites and asserts their indentation is deeper than the with _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 checkget_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 validationset_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 with onclick="switchSettingsSection('providers')"
  • panels.js:1131,1145switchSettingsSection recognises 'providers' and calls loadProvidersPanel() on activation
  • panels.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.py pass (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_profiles
    • test_sprint46::test_session_compress_roundtrip
    • test_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_id normalization at set_provider_key:273 does .strip().lower() — good. But get_providers() emits IDs as the keys of _PROVIDER_ENV_VAR / _PROVIDER_DISPLAY, which are already canonical. If a future caller passes provider=Anthropic, normalization handles it. Defensive. ✓
  • ollama and ollama-cloud both map to OLLAMA_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_file reads the entire .env on every call. Fine — these files are small and writes are rare.
  • The 15-line diff bump in _write_env_file from my patch isn't strictly necessary for correctness — a with open(..., 'w') as f: + trailing chmod would work identically under the moved lock. I kept the os.open variant 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).

bergeouss and others added 3 commits April 23, 2026 01:08
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
…end — v0.50.157 (PR #868 by @frap129)

Adds openssh-client to apt-get install block so Docker users running the SSH terminal backend can connect to remote agents. Closes #868.
- 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
@nesquena-hermes nesquena-hermes merged commit 04b0006 into master Apr 23, 2026
0 of 3 checks passed
@nesquena-hermes nesquena-hermes deleted the release/pr-867 branch April 23, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow provider key update

4 participants