fix(#3750): preserve LM Studio auth on reasoning probe (self-rebased #3837 + credential hardening) (#4202)

* fix(#3750): preserve LM Studio auth on reasoning probe

Rebased rodboev's #3837 onto current master (master reworked
resolve_model_reasoning_efforts via #3431 with a combined hermes_cli
import + _nested_route_reasoning_denied guard). Threaded the api_key
resolution + the _lmstudio_model_reasoning_options wrapper + the
HTTP-fallback probe into master's current structure; intent preserved
byte-identically (+164/-24, same as the original PR diff).

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(#3837): gate LM Studio probe credential to configured endpoint + no-redirect opener

Codex security review (CORE) on the rebased #3837: GET /api/reasoning accepts
a caller-supplied base_url, and the new probe attached the configured LM Studio
API key as a Bearer token; urllib also re-sends Authorization across redirects.
Together that could exfiltrate the stored credential to an attacker-controlled
host. (master only escalates here because master probed WITHOUT a key.)

Fixes:
1. Only forward the LM Studio credential when the probe target normalizes to
   the configured LM Studio base URL (reuses _normalize_base_url_for_match);
   a caller-supplied non-matching base_url is probed keyless.
2. _lmstudio_reasoning_probe_options_fallback uses a no-redirect opener
   (_NoRedirectHandler) so a 3xx can't forward the Authorization header onward.
3. Two regression tests: caller-supplied base_url drops the key; redirect is
   not followed (credential never reaches the redirect target).

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(#3837): route ALL credentialed LM Studio probes through the no-redirect probe

Codex re-gate caught that the first fix only hardened the built-in fallback;
in production hermes_cli IS importable, so the preferred path called
hermes_cli.models.lmstudio_model_reasoning_options(..., api_key=...) which uses
plain urlopen and still follows redirects with the credential.

Fix: _lmstudio_model_reasoning_options now short-circuits to the built-in
no-redirect probe whenever api_key is set; the hermes_cli path is used ONLY for
keyless probes (no credential to leak). Reworked tests accordingly:
- test_credentialed_probe_never_calls_hermes_cli: credentialed probe bypasses CLI
- test_keyless_probe_logs_signature_mismatch_before_fallback: keyless CLI degrade
- test_credentialed_probe_does_not_follow_redirects_end_to_end: E2E through
  resolve_model_reasoning_efforts with real hermes_cli; redirect target never hit

Verified both security tests fail without the guard and pass with it.

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* chore(release): stamp v0.51.420 (Release OG) in CHANGELOG

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-14 15:46:35 -07:00
1 parent 5fa92729c6
commit 6c6444396d
3 files changed
+834 -9

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased] ## [Unreleased]
## [v0.51.420] — 2026-06-14 — Release OG (LM Studio reasoning-probe auth, #3750/#3837)
### Fixed
- **LM Studio reasoning-effort probes now keep their auth and work without the bundled CLI.** When the active model is an LM Studio provider, the WebUI reasoning-effort probe now resolves an LM Studio API key (active model key → `providers.lmstudio.api_key` → `LM_API_KEY` → `LMSTUDIO_API_KEY`) and forwards it as a Bearer token, so authenticated LM Studio servers no longer 401 the probe and silently report "no reasoning support." If `hermes_cli` is unavailable (or its `lmstudio_model_reasoning_options` signature changes), the probe falls back to a built-in HTTP query of the LM Studio `/api/v1/models` capabilities endpoint instead of skipping reasoning detection entirely. The configured LM Studio credential is only ever sent to the configured LM Studio endpoint (a caller-supplied `base_url` that does not match is probed without the key), and the probe refuses HTTP redirects so the credential can never be forwarded to another host. (#3750, #3837)
## [v0.51.419] — 2026-06-14 — Release OF (settled-stream message-count consistency, #4192) ## [v0.51.419] — 2026-06-14 — Release OF (settled-stream message-count consistency, #4192)
### Fixed ### Fixed
+202 -9
View File
@@ -21,6 +21,8 @@ import sys
import threading import threading
import time import time
import traceback import traceback
import urllib.error
import urllib.request
import uuid import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -2623,6 +2625,184 @@ def _models_dev_reasoning_efforts(model_id: str, provider_id: str) -> list[str]
return None return None
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""urllib redirect handler that refuses to follow any redirect.
Used by the LM Studio reasoning probe so a 3xx from the probe URL can never
forward the ``Authorization`` header (the configured LM Studio key) to a
redirected, possibly attacker-controlled host. ``redirect_request``
returning ``None`` makes urllib raise the original 3xx as an ``HTTPError``,
which the probe swallows. (#3837 security review)
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _get_lmstudio_reasoning_probe_api_key() -> str | None:
"""Resolve the LM Studio key for reasoning probes with WebUI precedence."""
config_data = cfg
model_cfg = config_data.get("model") or {}
if isinstance(model_cfg, dict):
active_provider = str(model_cfg.get("provider") or "").strip().lower()
model_key = str(model_cfg.get("api_key") or "").strip()
if active_provider == "lmstudio" and model_key:
return model_key
providers_cfg = config_data.get("providers") or {}
if isinstance(providers_cfg, dict):
lmstudio_cfg = providers_cfg.get("lmstudio") or {}
if isinstance(lmstudio_cfg, dict):
config_key = str(lmstudio_cfg.get("api_key") or "").strip()
if config_key:
return config_key
env_key = str(os.getenv("LM_API_KEY") or "").strip()
if env_key:
return env_key
legacy_env_key = str(os.getenv("LMSTUDIO_API_KEY") or "").strip()
if legacy_env_key:
return legacy_env_key
return None
def _lmstudio_reasoning_probe_options_fallback(
model: str,
base_url: str | None,
*,
api_key: str | None = None,
timeout: float = 5.0,
) -> list[str]:
"""Query LM Studio reasoning options without relying on hermes_cli."""
server_root = str(base_url or "").strip().rstrip("/")
if server_root.endswith("/v1"):
server_root = server_root[:-3].rstrip("/")
if not server_root or not model:
return []
headers = {
"Accept": "application/json",
"User-Agent": "hermes-webui-reasoning-probe",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = urllib.request.Request(
server_root + "/api/v1/models",
headers=headers,
method="GET",
)
# SECURITY: never follow redirects on this probe. urllib re-sends request
# headers (including Authorization: Bearer <lmstudio key>) to the redirect
# target, so a 3xx from the probe URL could exfiltrate the configured
# LM Studio credential to an attacker-controlled host. A no-redirect opener
# turns any 3xx into an HTTPError we swallow below. (#3837 security review)
opener = urllib.request.build_opener(_NoRedirectHandler)
try:
with opener.open(request, timeout=timeout) as response: # nosec B310
payload = json.loads(response.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as exc:
logger.debug(
"LM Studio reasoning probe at %s failed with HTTP %s",
server_root,
exc.code,
)
return []
except Exception as exc:
logger.debug("LM Studio reasoning probe at %s failed: %s", server_root, exc)
return []
raw_models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
logger.debug(
"LM Studio reasoning probe at %s returned malformed payload",
server_root,
)
return []
for raw in raw_models:
if not isinstance(raw, dict):
continue
if raw.get("key") != model and raw.get("id") != model:
continue
caps = raw.get("capabilities")
reasoning = caps.get("reasoning") if isinstance(caps, dict) else None
opts = reasoning.get("allowed_options") if isinstance(reasoning, dict) else None
if isinstance(opts, list):
return [str(opt).strip().lower() for opt in opts if isinstance(opt, str)]
return []
return []
def _lmstudio_model_reasoning_options(
model: str,
base_url: str | None,
*,
api_key: str | None = None,
timeout: float = 5.0,
) -> list[str]:
"""Prefer hermes_cli, but keep WebUI reasoning probes working without it.
SECURITY: when an ``api_key`` is being sent, always use the built-in
no-redirect fallback probe rather than ``hermes_cli``. The bundled CLI probe
uses a plain ``urllib.request.urlopen`` that follows redirects and re-sends
the ``Authorization`` header to the redirect target, which could leak the
configured LM Studio credential to another host. We can only guarantee
redirect safety for code we control, so credentialed probes go through
``_lmstudio_reasoning_probe_options_fallback`` (no-redirect opener). Keyless
probes have no credential to leak, so they may use the richer CLI path.
(#3837 security review)
"""
if api_key:
return _lmstudio_reasoning_probe_options_fallback(
model,
base_url,
api_key=api_key,
timeout=timeout,
)
try:
from hermes_cli.models import (
lmstudio_model_reasoning_options as _cli_lmstudio_model_reasoning_options,
)
except Exception:
return _lmstudio_reasoning_probe_options_fallback(
model,
base_url,
api_key=api_key,
timeout=timeout,
)
try:
return _cli_lmstudio_model_reasoning_options(
model,
base_url,
api_key=api_key,
timeout=timeout,
)
except (TypeError, AttributeError):
logger.warning(
"hermes_cli.lmstudio_model_reasoning_options has an unexpected signature; "
"falling back to the built-in LM Studio reasoning probe",
exc_info=True,
)
return _lmstudio_reasoning_probe_options_fallback(
model,
base_url,
api_key=api_key,
timeout=timeout,
)
except Exception:
return _lmstudio_reasoning_probe_options_fallback(
model,
base_url,
api_key=api_key,
timeout=timeout,
)
def resolve_model_reasoning_efforts( def resolve_model_reasoning_efforts(
model_id: str | None = None, model_id: str | None = None,
provider_id: str | None = None, provider_id: str | None = None,
@@ -2650,23 +2830,36 @@ def resolve_model_reasoning_efforts(
if _nested_route_reasoning_denied(hinted_model): if _nested_route_reasoning_denied(hinted_model):
return [] return []
if provider in {"copilot", "github-copilot"}:
try: try:
from hermes_cli.models import ( from hermes_cli.models import github_model_reasoning_efforts
github_model_reasoning_efforts,
lmstudio_model_reasoning_options,
)
except Exception: except Exception:
if provider in {"copilot", "github-copilot"}:
return _heuristic_reasoning_efforts(hinted_model, provider) return _heuristic_reasoning_efforts(hinted_model, provider)
else:
if provider in {"copilot", "github-copilot"}:
return _filter_reasoning_efforts_for_provider( return _filter_reasoning_efforts_for_provider(
github_model_reasoning_efforts(hinted_model), hinted_model, provider github_model_reasoning_efforts(hinted_model), hinted_model, provider
) )
if provider == "lmstudio": if provider == "lmstudio":
probe_base = resolved_base_url or _get_provider_base_url(provider) configured_base = _get_provider_base_url(provider)
opts = lmstudio_model_reasoning_options(hinted_model, probe_base) probe_base = resolved_base_url or configured_base
# SECURITY: only forward the configured LM Studio credential when the
# probe target is the configured LM Studio endpoint. /api/reasoning
# accepts a caller-supplied base_url, so a request could otherwise point
# the probe at an arbitrary host and harvest the stored key. When the
# caller supplies a base_url that does not normalize to the configured
# one, probe it WITHOUT a key. (#3837 security review)
probe_key: str | None = None
if not resolved_base_url or (
configured_base
and _normalize_base_url_for_match(probe_base)
== _normalize_base_url_for_match(configured_base)
):
probe_key = _get_lmstudio_reasoning_probe_api_key()
opts = _lmstudio_model_reasoning_options(
hinted_model,
probe_base,
api_key=probe_key,
)
normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()] normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()]
if not normalized or set(normalized).issubset({"off"}): if not normalized or set(normalized).issubset({"off"}):
return [] return []
+626
View File
@@ -0,0 +1,626 @@
"""Regression tests for #3750 LM Studio reasoning probes dropping auth."""
from __future__ import annotations
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
import api.config as config
import api.onboarding as onboarding
import api.profiles as profiles
_API_KEY_ENV_VARS = (
"LM_API_KEY",
"LMSTUDIO_API_KEY",
)
class _LmStudioProbeServer:
def __init__(self):
self.requests: list[dict[str, str | None]] = []
self._server = HTTPServer(("127.0.0.1", 0), self._handler())
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
@property
def base_v1(self) -> str:
return f"http://127.0.0.1:{self._server.server_address[1]}/v1"
def close(self) -> None:
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
def _handler(self):
parent = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
auth = self.headers.get("Authorization")
parent.requests.append(
{
"path": self.path,
"authorization": auth,
}
)
if self.path == "/api/v1/models":
if auth:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{
"models": [
{
"key": "auth-model",
"capabilities": {
"reasoning": {
"allowed_options": ["low", "medium", "high"]
}
},
}
]
}
).encode()
)
return
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "unauthorized"}).encode())
return
if self.path == "/v1/models":
if auth:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"data": [{"id": "auth-model"}]}).encode())
return
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps({"error": {"message": "unauthorized"}}).encode()
)
return
self.send_response(404)
self.end_headers()
def log_message(self, fmt, *args):
return None
return Handler
@pytest.fixture(autouse=True)
def _isolate_config(monkeypatch, tmp_path):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
old_path = config._cfg_path
old_fp = config._cfg_fingerprint
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.setenv("BROWSER", "echo")
for var in _API_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
config.invalidate_models_cache()
yield
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config._cfg_path = old_path
config._cfg_fingerprint = old_fp
config.invalidate_models_cache()
@pytest.fixture
def lmstudio_probe_server():
server = _LmStudioProbeServer()
try:
yield server
finally:
server.close()
def _write_config(tmp_path, monkeypatch, text: str) -> None:
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(text, encoding="utf-8")
monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile)
config.reload_config()
config.invalidate_models_cache()
def test_reasoning_probe_uses_config_key_before_env_aliases(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
providers:
lmstudio:
api_key: config-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
monkeypatch.setenv("LM_API_KEY", "env-token")
monkeypatch.setenv("LMSTUDIO_API_KEY", "legacy-token")
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is True
assert status["supported_efforts"] == ["low", "medium", "high"]
assert lmstudio_probe_server.requests[0] == {
"path": "/api/v1/models",
"authorization": "Bearer config-token",
}
def test_reasoning_probe_honors_active_model_api_key(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
api_key: model-token
providers:
lmstudio:
api_key: provider-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is True
assert status["supported_efforts"] == ["low", "medium", "high"]
assert lmstudio_probe_server.requests[0] == {
"path": "/api/v1/models",
"authorization": "Bearer model-token",
}
def test_reasoning_probe_falls_back_without_hermes_cli(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
blocked_modules = [
name
for name in list(sys.modules)
if name == "hermes_cli" or name.startswith("hermes_cli.")
]
for name in blocked_modules:
monkeypatch.delitem(sys.modules, name, raising=False)
class _Blocker:
def find_module(self, name, path=None):
if name == "hermes_cli" or name.startswith("hermes_cli."):
return self
return None
def load_module(self, name):
raise ImportError(f"hermes_cli blocked for test: {name}")
blocker = _Blocker()
sys.meta_path.insert(0, blocker)
try:
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
providers:
lmstudio:
api_key: config-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is True
assert status["supported_efforts"] == ["low", "medium", "high"]
assert lmstudio_probe_server.requests[0] == {
"path": "/api/v1/models",
"authorization": "Bearer config-token",
}
finally:
try:
sys.meta_path.remove(blocker)
except ValueError:
pass
def test_reasoning_probe_stays_keyless_when_no_key_is_configured(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is False
assert status["supported_efforts"] == []
assert lmstudio_probe_server.requests[0] == {
"path": "/api/v1/models",
"authorization": None,
}
def test_onboarding_probe_remains_authorized_control(
lmstudio_probe_server,
):
result = onboarding.probe_provider_endpoint(
"lmstudio",
lmstudio_probe_server.base_v1,
api_key="control-token",
)
assert result["ok"] is True
assert lmstudio_probe_server.requests[0] == {
"path": "/v1/models",
"authorization": "Bearer control-token",
}
def test_credentialed_probe_never_calls_hermes_cli(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
"""When a credential is configured, the probe must use the built-in
no-redirect fallback and NEVER the bundled hermes_cli probe (which follows
redirects and could forward the key). (#3837 security review)
"""
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
providers:
lmstudio:
api_key: config-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
cli_calls: list[tuple] = []
class _CliModule:
@staticmethod
def lmstudio_model_reasoning_options(model, base_url, api_key=None, timeout=5.0):
cli_calls.append((model, base_url, api_key))
return ["low", "medium", "high"]
monkeypatch.setitem(sys.modules, "hermes_cli", type("HermesCli", (), {})())
monkeypatch.setitem(sys.modules, "hermes_cli.models", _CliModule)
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is True
assert status["supported_efforts"] == ["low", "medium", "high"]
# The credential reached the configured endpoint via the built-in probe …
assert lmstudio_probe_server.requests[0] == {
"path": "/api/v1/models",
"authorization": "Bearer config-token",
}
# … and the redirect-following hermes_cli probe was never invoked.
assert cli_calls == [], "credentialed probe must bypass hermes_cli"
def test_keyless_probe_logs_signature_mismatch_before_fallback(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
"""Keyless probes still prefer hermes_cli; an incompatible CLI signature
logs a warning and degrades to the built-in probe. (#3750)
"""
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
captured: list[tuple[str, bool]] = []
class _CliModule:
@staticmethod
def lmstudio_model_reasoning_options(model, base_url):
raise TypeError("unexpected keyword argument: api_key")
monkeypatch.setitem(sys.modules, "hermes_cli", type("HermesCli", (), {})())
monkeypatch.setitem(sys.modules, "hermes_cli.models", _CliModule)
monkeypatch.setattr(
config.logger,
"warning",
lambda msg, *args, **kwargs: captured.append((str(msg), bool(kwargs.get("exc_info")))),
)
# No key configured -> CLI path is attempted, raises TypeError, warning is
# logged, then the built-in (keyless) probe runs against the auth-requiring
# test server, which 401s -> []. The point is the warning + degrade path.
status = config.get_reasoning_status()
assert status["supports_reasoning_effort"] is False
assert status["supported_efforts"] == []
assert lmstudio_probe_server.requests[-1]["authorization"] is None
assert captured, "TypeError fallback must log a warning before probing directly"
assert "unexpected signature" in captured[0][0]
assert captured[0][1] is True
def test_reasoning_probe_drops_key_for_caller_supplied_base_url(
tmp_path,
monkeypatch,
lmstudio_probe_server,
):
"""A caller-supplied base_url that is NOT the configured LM Studio endpoint
must be probed WITHOUT the stored credential. /api/reasoning takes a
base_url query param, so attaching the key to an arbitrary host would leak
it. (#3837 security review)
"""
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {lmstudio_probe_server.base_v1}
providers:
lmstudio:
api_key: config-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
# Point the probe at the SAME server but via an unconfigured base_url. The
# configured base_url is the only one allowed to carry the key. The probe
# server returns 401 without auth, so reasoning is reported unsupported and,
# crucially, no Bearer token reaches the caller-supplied URL.
efforts = config.resolve_model_reasoning_efforts(
"auth-model",
provider_id="lmstudio",
base_url=lmstudio_probe_server.base_v1.replace("127.0.0.1", "localhost"),
)
assert efforts == []
assert lmstudio_probe_server.requests, "probe should still be attempted keyless"
assert lmstudio_probe_server.requests[0]["authorization"] is None
def test_reasoning_probe_does_not_follow_redirects_with_credential(
tmp_path,
monkeypatch,
):
"""A 3xx from the configured probe URL must NOT forward the Authorization
header to the redirect target. urllib re-sends request headers across
redirects, so the probe uses a no-redirect opener. (#3837 security review)
"""
captured: list[dict[str, str | None]] = []
class _Target(BaseHTTPRequestHandler):
def do_GET(self):
captured.append(
{"host": "target", "authorization": self.headers.get("Authorization")}
)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"models": []}).encode())
def log_message(self, fmt, *args):
return None
class _Redirector(BaseHTTPRequestHandler):
def do_GET(self):
captured.append(
{"host": "redirector", "authorization": self.headers.get("Authorization")}
)
self.send_response(302)
self.send_header(
"Location", f"http://127.0.0.1:{target.server_address[1]}/api/v1/models"
)
self.end_headers()
def log_message(self, fmt, *args):
return None
target = HTTPServer(("127.0.0.1", 0), _Target)
target_thread = threading.Thread(target=target.serve_forever, daemon=True)
target_thread.start()
redir = HTTPServer(("127.0.0.1", 0), _Redirector)
redir_thread = threading.Thread(target=redir.serve_forever, daemon=True)
redir_thread.start()
try:
result = config._lmstudio_reasoning_probe_options_fallback(
"auth-model",
f"http://127.0.0.1:{redir.server_address[1]}/v1",
api_key="secret-token",
)
finally:
redir.shutdown()
redir.server_close()
redir_thread.join(timeout=5)
target.shutdown()
target.server_close()
target_thread.join(timeout=5)
# The probe stops at the redirector (no-redirect opener) and never reaches
# the redirect target, so the credential is never forwarded onward.
assert result == []
hosts_hit = {c["host"] for c in captured}
assert "target" not in hosts_hit, "redirect must not be followed"
assert all(c["host"] == "redirector" for c in captured)
def test_credentialed_probe_does_not_follow_redirects_end_to_end(
tmp_path,
monkeypatch,
):
"""End-to-end through resolve_model_reasoning_efforts() with the REAL
hermes_cli importable: when the CONFIGURED LM Studio endpoint returns a 302,
the credentialed probe must not forward the key to the redirect target.
This is the production path (hermes_cli present) — credentialed probes route
through the built-in no-redirect probe, so the redirect is refused.
(#3837 security review)
"""
captured: list[dict[str, str | None]] = []
class _Target(BaseHTTPRequestHandler):
def do_GET(self):
captured.append(
{"host": "target", "authorization": self.headers.get("Authorization")}
)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{
"models": [
{
"key": "auth-model",
"capabilities": {
"reasoning": {"allowed_options": ["low", "high"]}
},
}
]
}
).encode()
)
def log_message(self, fmt, *args):
return None
class _Redirector(BaseHTTPRequestHandler):
def do_GET(self):
captured.append(
{"host": "redirector", "authorization": self.headers.get("Authorization")}
)
self.send_response(302)
self.send_header(
"Location", f"http://127.0.0.1:{target.server_address[1]}/api/v1/models"
)
self.end_headers()
def log_message(self, fmt, *args):
return None
target = HTTPServer(("127.0.0.1", 0), _Target)
target_thread = threading.Thread(target=target.serve_forever, daemon=True)
target_thread.start()
redir = HTTPServer(("127.0.0.1", 0), _Redirector)
redir_thread = threading.Thread(target=redir.serve_forever, daemon=True)
redir_thread.start()
# Sanity: this test only proves the redirect guard if hermes_cli is
# importable (the production path). If it isn't, the keyless/credentialed
# split still routes credentialed probes through the no-redirect fallback,
# so the assertion holds either way.
redir_base = f"http://127.0.0.1:{redir.server_address[1]}/v1"
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: auth-model
base_url: {redir_base}
providers:
lmstudio:
api_key: secret-token
agent:
reasoning_effort: medium
display:
show_reasoning: true
""",
)
try:
efforts = config.resolve_model_reasoning_efforts(
"auth-model",
provider_id="lmstudio",
base_url=redir_base,
)
finally:
redir.shutdown()
redir.server_close()
redir_thread.join(timeout=5)
target.shutdown()
target.server_close()
target_thread.join(timeout=5)
assert efforts == []
hosts_hit = {c["host"] for c in captured}
assert "target" not in hosts_hit, "credentialed redirect must not be followed"
# The credential only ever touched the configured (redirector) endpoint.
assert all(c["host"] == "redirector" for c in captured)