Coverage for src/local_deep_research/settings/manager.py: 95%
738 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1import functools
2import json
3import math
4import os
5import string
6import threading
7import time
8from pathlib import Path
9from typing import Any, Callable, Dict, List, Optional, Union
11from loguru import logger
12from sqlalchemy import and_, func, or_
13from sqlalchemy.exc import SQLAlchemyError
14from sqlalchemy.orm import Session
16from .. import defaults
17from ..__version__ import __version__ as package_version
18from ..database.models import Setting, SettingType
19from ..utilities.sql_utils import escape_like
20from ..utilities.type_utils import to_bool, to_positive_int
21from ..web.models.settings import (
22 AppSetting,
23 BaseSetting,
24 ChatSetting,
25 LLMSetting,
26 ReportSetting,
27 SearchSetting,
28)
29from .base import ISettingsManager
30from .env_registry import registry as env_registry
31from .exceptions import SettingsValueError
34# Existing saved values remain in place, but accounts cannot redirect library
35# reads or writes through their settings after this release.
36OPERATOR_ONLY_SETTINGS = frozenset({"research_library.storage_path"})
38# SQLite's LIKE ignores case for ASCII letters only. get_setting() finds child
39# rows with LIKE, so "RESEARCH_LIBRARY.STORAGE_PATH.x" is a child of
40# "research_library.storage_path" there.
41_ASCII_CASE_FOLD = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
44def is_operator_only_setting(key: str) -> bool:
45 """Whether only trusted defaults or an LDR_* environment override may set key.
47 Matches each operator-only key and its dotted child keys, ignoring ASCII
48 case the same way SQLite's LIKE does in the child-row lookup.
49 """
50 folded = key.translate(_ASCII_CASE_FOLD)
51 for root in OPERATOR_ONLY_SETTINGS:
52 folded_root = root.translate(_ASCII_CASE_FOLD)
53 if folded == folded_root or folded.startswith(f"{folded_root}."):
54 return True
55 return False
58def parse_boolean(value: Any) -> bool:
59 """
60 Convert various representations to boolean using HTML checkbox semantics.
62 This function handles form values, JSON booleans, and environment variables,
63 ensuring consistent behavior across client and server.
65 **HTML Checkbox Semantics** (INTENTIONAL DESIGN):
66 - **Any value present (except explicit false) = checked = True**
67 - This matches standard HTML form behavior where checkbox presence indicates checked state
68 - In HTML forms, checkboxes send a value when checked, nothing when unchecked
70 **Examples**:
71 parse_boolean("on") # True - standard HTML checkbox value
72 parse_boolean("true") # True - explicit true
73 parse_boolean("1") # True - numeric true
74 parse_boolean("enabled") # True - any non-empty string
75 parse_boolean("disabled") # True - INTENTIONAL: any string = checkbox was checked!
76 parse_boolean("custom") # True - custom checkbox value
78 parse_boolean("false") # False - explicit false
79 parse_boolean("off") # False - explicit false
80 parse_boolean("0") # False - explicit false
81 parse_boolean("") # False - empty string = unchecked
82 parse_boolean(None) # False - missing = unchecked
84 **Why "disabled" returns True**:
85 This is NOT a bug! If a checkbox sends the value "disabled", it means the checkbox
86 was checked (present in form data). The actual string content doesn't matter for
87 HTML checkboxes - only presence vs absence matters.
89 Args:
90 value: Value to convert to boolean. Accepts strings, booleans, or None.
92 Returns:
93 bool: True for truthy values (any non-empty string except explicit false);
94 False for falsy values ('off', 'false', '0', '', 'no', False, None)
96 Note:
97 This function implements HTML form semantics, NOT generic boolean parsing.
98 See tests/settings/test_boolean_parsing.py for comprehensive test coverage.
99 """
100 # Constants for boolean value parsing
101 FALSY_VALUES = ("off", "false", "0", "", "no")
103 # Handle already-boolean values
104 if isinstance(value, bool):
105 return value
107 # Handle None (missing values)
108 if value is None:
109 return False
111 # Handle string values
112 if isinstance(value, str):
113 value_lower = value.lower().strip()
114 # Explicitly falsy values (empty string, false-like values)
115 if value_lower in FALSY_VALUES:
116 return False
117 # Any other non-empty string = True (HTML checkbox semantics)
118 return True
120 # For other types (numbers, lists, etc.), use Python's bool conversion
121 return bool(value)
124def _parse_number(x):
125 """Parse number, returning int if it's a whole number, otherwise float."""
126 if isinstance(x, bool):
127 # Fixed validation text; no configured value is included.
128 # bearer:disable python_lang_exception
129 raise ValueError("Expected a number")
130 f = float(x)
131 if not math.isfinite(f):
132 # Fixed validation text; no configured value is included.
133 # bearer:disable python_lang_exception
134 raise ValueError("Expected a finite number")
135 if f.is_integer():
136 return int(f)
137 return f
140def _parse_json_value(x):
141 """Parse JSON ui_element values.
143 DB values (via SQLAlchemy JSON column) arrive as Python objects already.
144 Form POST and env var overrides arrive as raw strings and need parsing.
145 For example, a textarea containing ``["general"]`` arrives as the string
146 ``'[\\r\\n "general"\\r\\n]'`` which must be decoded into a list.
147 """
148 if isinstance(x, str):
149 stripped = x.strip()
150 if stripped:
151 try:
152 return json.loads(stripped)
153 except (json.JSONDecodeError, ValueError, RecursionError):
154 # The JSON database column may already contain a decoded
155 # scalar string. Preserve that value; environment input is
156 # known to be encoded and is parsed strictly by the reader.
157 return x
158 return x
161def _parse_multiselect(x):
162 """Parse multiselect value, handling both lists and strings.
164 DB values (via SQLAlchemy JSON column) arrive as Python lists already.
165 Env var overrides arrive as strings and need parsing — either as JSON
166 arrays (e.g. '["markdown","latex"]') or comma-separated values
167 (e.g. 'markdown,latex').
168 """
169 if isinstance(x, list):
170 return x
171 if isinstance(x, str):
172 stripped = x.strip()
173 if stripped.startswith(("[", "{")):
174 parsed = json.loads(stripped)
175 if not isinstance(parsed, list):
176 # Fixed validation text; the parsed object is not included.
177 # bearer:disable python_lang_exception
178 raise ValueError("Expected a list")
179 return parsed
180 # Comma-separated values are an explicitly supported input format.
181 return [item.strip() for item in stripped.split(",") if item.strip()]
182 if x is None:
183 return None
184 # Fixed validation text; the rejected carrier is not included.
185 # bearer:disable python_lang_exception
186 raise ValueError("Expected a list or comma-separated values")
189def _filter_setting_columns(data: dict) -> dict:
190 """Filter a dict to only keys that are valid Setting model columns.
192 Prevents crashes when default_settings.json contains keys not present
193 as columns on the Setting model (e.g. future flags).
194 """
195 valid_columns = {c.name for c in Setting.__table__.columns}
196 return {k: v for k, v in data.items() if k in valid_columns}
199# Select settings whose options lists are populated dynamically (at code or
200# runtime) rather than from the static defaults JSON. Their static options
201# are only a sample of what is legal, so options validation must be skipped
202# for them. Re-exported by `web/services/settings_service.py` (single
203# source of truth) and imported by tests that assert identity across
204# import orders.
205DYNAMIC_SETTINGS = ["llm.provider", "llm.model", "search.tool"]
208# Re-exported by `api/settings_utils.py` (single source of truth) and
209# imported by tests that assert identity across import orders.
210def validate_imported_setting_value(
211 key: str, value: Any, default_meta: Dict[str, Any]
212) -> Optional[str]:
213 """Validate a file-supplied setting value against the current-defaults
214 schema for its key.
216 Mirrors the runtime checks `web.services.settings_service
217 .validate_setting` applies on the settings save path (type coercion via
218 ``get_typed_setting_value``, options / min / max constraints), but reads
219 constraints from the current defaults metadata instead of a stored
220 ``Setting`` row. Used by ``import_settings`` so a pre-upgrade export
221 cannot resurrect values that are invalid under the current schema
222 (#5589).
224 Trusted-vs-untrusted rationale (the call-site comments in both
225 managers' ``import_settings`` point here): file-supplied values are
226 new input, so ``import_settings`` runs this check on them (keys with
227 no defaults metadata, and in-memory entries without a ``value``
228 field, are stored unchecked). Retained database or in-memory values
229 — kept through ``overwrite=False`` or
230 ``preserve_environment_locked=True`` to preserve existing user data
231 — are not new input and deliberately skip this check. Re-validating
232 them would not discard them: on a reject, both managers keep the
233 key's existing row or entry, as they do for a rejected file value.
234 It would cost the key's metadata refresh instead: where retained
235 keys get refreshed metadata (the database manager, and env-locked
236 keys in the in-memory one), a key whose stored value fails the
237 current rules would keep its previous metadata (``ui_element``,
238 options, min/max). "Trusted" in the call-site comments refers to this
239 preservation decision; it does not imply prior schema validation.
240 Programmatic setters can bypass this check, and older values may
241 not satisfy the current defaults schema.
243 Returns ``None`` when the value is acceptable, or a human-readable
244 reason string when it is not.
245 """
246 if key == "search.fetch.summary_input_max_length":
247 try:
248 to_positive_int(value)
249 except ValueError:
250 return "Value must be a positive integer"
252 ui_element = default_meta.get("ui_element")
253 if (
254 not isinstance(ui_element, str)
255 or ui_element not in UI_ELEMENT_TO_SETTING_TYPE
256 ):
257 return "Unsupported setting type"
259 if value == default_meta.get("value"):
260 # The value equals the currently shipped default, so the defaults
261 # file itself vouches for it. Without this guard, a shipped default
262 # that is technically outside its own constraint metadata (e.g. a
263 # null optional numeric default) would be rejected and the key
264 # dropped on fresh-install seeding.
265 #
266 # ``app.theme`` used to be the example here: it shipped as ``dark``
267 # while the theme registry, which replaces that setting's options at
268 # runtime, had no ``dark`` entry. This guard is what let that invalid
269 # default seed successfully — and therefore what kept it invisible
270 # until the no-JS Save path validated it and reported the user's
271 # untouched theme as "failing". The default is now ``system``, which
272 # the registry does serve.
273 return None
275 try:
276 typed_value = get_typed_setting_value(
277 key=key,
278 value=value,
279 ui_element=ui_element,
280 default=None,
281 check_env=False,
282 )
283 except SettingsValueError as error:
284 return error.reason
285 if typed_value is None: 285 ↛ 291line 285 didn't jump to line 291 because the condition on line 285 was never true
286 # A shipped optional default may legitimately be null; the equality
287 # guard above accepts that exact value. Otherwise this is either an
288 # unconvertible input or a non-default null. The save path rejects it
289 # for typed controls, so imports must do the same rather than storing
290 # a value that the UI could not save.
291 if ui_element == "checkbox":
292 return "Value must be a boolean"
293 if ui_element in ("number", "slider", "range"):
294 return "Value must be a number"
295 return None
297 if ui_element == "checkbox":
298 if not isinstance(typed_value, bool): 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true
299 return "Value must be a boolean"
300 elif ui_element in ("number", "slider", "range"):
301 if not isinstance(typed_value, (int, float)): 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 return "Value must be a number"
303 min_value = default_meta.get("min_value")
304 max_value = default_meta.get("max_value")
305 if min_value is not None and typed_value < min_value: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 return f"Value must be at least {min_value}"
307 if max_value is not None and typed_value > max_value:
308 return f"Value must be at most {max_value}"
309 elif ui_element == "select":
310 options = default_meta.get("options")
311 # Skip options validation for dynamically populated dropdowns —
312 # their static options are only a sample of legal values.
313 if options and key not in DYNAMIC_SETTINGS:
314 allowed_values = [
315 opt.get("value") if isinstance(opt, dict) else opt
316 for opt in list(options)
317 ]
318 if typed_value not in allowed_values:
319 return "Value must be one of: " + ", ".join(
320 str(v) for v in allowed_values
321 )
323 return None
326_POLICY_AUDIT_KEYS = frozenset(
327 {
328 "llm.require_local_endpoint",
329 "llm.allowed_local_hostnames",
330 "embeddings.require_local",
331 }
332)
335def _is_policy_setting(key: str) -> bool:
336 """Return True for security-relevant setting keys that need an
337 audit-log entry on change. Scope is intentionally narrow so this
338 audit hook doesn't widen into general settings-change logging.
340 Narrow is not the same as enumerated: the ``policy.`` arm is a PREFIX
341 match, not an allowlist, so this covers every current and future
342 ``policy.*`` key including ones a caller creates. See
343 ``_policy_audit_value`` for why the audit line therefore cannot assume
344 the values it records are scalars from a fixed vocabulary.
345 """
346 if key.startswith("policy."):
347 return True
348 return key in _POLICY_AUDIT_KEYS
351def _policy_audit_allowed_values(
352 default_meta: Optional[Dict[str, Any]],
353) -> Optional[List[Any]]:
354 """The fixed vocabulary a policy setting's value must come from, or
355 ``None`` when the key has no such vocabulary.
357 Only a ``select``/``radio`` row with a static ``options`` list has one.
358 Used by ``_policy_audit_value`` to decide whether a STRING value is
359 safe to record verbatim: a value drawn from a shipped option list is a
360 known token (``adaptive``, ``strict``, ...), not free-form input, so it
361 cannot be a credential.
362 """
363 if not isinstance(default_meta, dict):
364 return None
365 if default_meta.get("ui_element") not in ("select", "radio"):
366 return None
367 options = default_meta.get("options")
368 if not options: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 return None
370 return [
371 opt.get("value") if isinstance(opt, dict) else opt
372 for opt in list(options)
373 ]
376def _policy_audit_value(
377 value: Any, allowed_values: Optional[List[Any]] = None
378) -> str:
379 """Render a value for the ``policy.*`` audit line so the line itself
380 can never become a credential-disclosure channel.
382 Recording old/new is the whole point of an audit entry, so this does
383 not drop the information -- it drops the part that can carry a secret.
384 The audit hook's scope was previously described as "``policy.*`` plus
385 three keys holding booleans and a hostname list", which is not what the
386 code does: ``_is_policy_setting`` PREFIX-matches ``policy.``, and three
387 keys already in scope ship as free-form, editable JSON with no item
388 schema at all -- ``policy.trusted_search_engines``,
389 ``policy.trusted_inference_providers`` and
390 ``llm.allowed_local_hostnames`` are all ``ui_element="json"``,
391 ``editable=True``, default ``[]``. Their egress validator
392 (``validate_trusted_search_engines``) ``continue``s past any non-``str``
393 entry rather than rejecting it, so a list like
394 ``[{"api_key": "sk-...", "name": "x"}]`` is accepted and reaches
395 ``set_setting`` -- which then wrote both ``old=`` and ``new=`` to the
396 log in plaintext at WARNING. That line also sits OUTSIDE the
397 ``if commit:`` block, so the bulk save routes (``commit=False``) reach
398 it too; it is not a PUT-only path (#6201 round 7).
400 So plaintext is kept ONLY where the value is PROVEN unable to carry
401 free-form text, and the proof is a check rather than a comment:
403 * ``None``/``bool``/``int``/``float`` -- no string content at all;
404 * a ``str`` that is one of the key's own registered ``select``
405 options (see ``_policy_audit_allowed_values``) -- a fixed
406 vocabulary shipped in ``defaults/``, e.g. ``policy.egress_scope``.
408 Everything else -- a free-form string, a dict, a list, any other
409 type -- is recorded as STRUCTURAL metadata only: the type name, plus
410 the length when it is a string or the entry count when it is a
411 container. No token derived from the value's CONTENT appears in the
412 rendering. An earlier version logged a truncated SHA-256 of the
413 value's canonical JSON; a stable unkeyed digest -- even cut to 64
414 bits, re-truncated, or keyed with a key shipped in the repository --
415 is an offline dictionary oracle: anyone holding the log can confirm
416 a guessed credential by hashing candidates offline, which is exactly
417 the disclosure this rendering exists to prevent (#6201 round 8: a
418 common password was recovered from the logged prefix by an offline
419 candidate list).
421 The cost of structure-only recording is honest and deliberate:
422 change detection on free-form values is COARSE. "still an
423 11-character string" / "the trusted-engine list went from 2 entries
424 to 3" is all an auditor gets; two different 11-character secrets
425 render IDENTICALLY, so the line can never confirm which candidate is
426 the real value. A string's length narrows a candidate list but
427 confirms nothing (every same-length candidate matches), which is
428 what separates it from a digest. Scalars from a fixed vocabulary and
429 values with no string content stay verbatim, so the policy decisions
430 themselves -- the actual subject of this audit -- remain fully
431 observable.
432 """
433 if value is None or isinstance(value, (bool, int, float)):
434 return repr(value)
435 if allowed_values and value in allowed_values:
436 return repr(value)
437 if isinstance(value, (dict, list)):
438 return f"<{type(value).__name__}[{len(value)}]>"
439 if isinstance(value, str): 439 ↛ 441line 439 didn't jump to line 441 because the condition on line 439 was always true
440 return f"<str[{len(value)}]>"
441 return f"<{type(value).__name__}>"
444def is_valid_setting_key(key: Any) -> bool:
445 """Return True if *key* is a well-formed setting key.
447 A valid key is a non-empty string of dot-separated segments where each
448 segment is non-empty and contains no whitespace or ASCII control characters.
449 This rejects the
450 malformed keys that corrupt prefix/namespace lookups (see #4840) — a
451 trailing dot ``"foo."``, a leading dot ``".foo"``, an empty segment
452 ``"foo..bar"``, a blank key — as well as keys carrying whitespace
453 (``" foo"``, ``"foo. bar"``, ``"llm.o k"``), which would also break the
454 ``key.split(".")`` → ``LDR_...`` env-var mapping. A malformed row makes
455 ``get_setting("foo")`` return a ``{"": value}`` wrapper dict, which the
456 UI renders as ``[object Object]``.
458 Used on both sides: writes reject new malformed keys, and both read paths
459 (``SettingsManager.get_setting`` on the DB and ``get_setting_from_snapshot``
460 on a settings snapshot) filter malformed rows out of a prefix read so a
461 stray legacy row can never wrap a leaf value into an ``[object Object]``
462 dict.
463 """
464 if not isinstance(key, str) or not key:
465 return False
466 return all(
467 seg
468 and not any(c.isspace() or ord(c) < 0x20 or ord(c) == 0x7F for c in seg)
469 for seg in key.split(".")
470 )
473def _infer_ui_element(value: Any, current: str = "text") -> str:
474 """Infer the appropriate ui_element string from a Python value's type.
476 Args:
477 value: The value to infer the ui_element from.
478 current: The existing ui_element. If it is already something more
479 specific than ``"text"``, it is kept as-is.
480 """
481 if current != "text":
482 return current
483 if isinstance(value, bool):
484 return "checkbox"
485 if isinstance(value, (int, float)):
486 return "number"
487 if isinstance(value, (list, dict)):
488 return "json"
489 return "text"
492# Default categories for each typed setting prefix, used by the self-heal
493# block in set_setting() when a row's type column doesn't match its key
494# prefix. The values mirror the canonical strings in
495# web/routers/settings.py: a legacy chat.* row with type=APP and a
496# stale category gets repointed to type=CHAT + category="chat" on next
497# save. We use the most-general category per prefix here — sub-classifying
498# llm_general vs llm_parameters depends on the specific key, but the
499# self-heal only fires when the row was already mis-typed, so over-
500# generalizing the category is preferable to leaving it stale.
501_INFERRED_CATEGORY: Dict[str, str] = {
502 "llm.": "llm_general",
503 "search.": "search_general",
504 "report.": "report_parameters",
505 "database.": "database_parameters",
506 "chat.": "chat",
507}
510UI_ELEMENT_TO_SETTING_TYPE: Dict[str, Callable[..., Any]] = {
511 "text": str,
512 "json": _parse_json_value,
513 "password": str,
514 "select": str,
515 "number": _parse_number,
516 "range": _parse_number, # Same behavior as number for consistency
517 "checkbox": parse_boolean,
518 "textarea": str,
519 "multiselect": _parse_multiselect,
520}
523def _reject_json_constant(_value: str):
524 """Reject JSON's non-standard NaN/Infinity extensions without echoing input."""
525 # The rejected argument is unused; this diagnostic is constant.
526 # bearer:disable python_lang_exception
527 raise ValueError("Expected a finite JSON number")
530def _parse_environment_boolean(value: str) -> bool:
531 """Environment input has no HTML checkbox presence semantics."""
532 normalized = value.strip().lower()
533 if normalized in ("true", "1", "yes", "on", "enabled"):
534 return True
535 if normalized in ("false", "0", "no", "off", "disabled"):
536 return False
537 # Fixed validation text; the environment value is not included.
538 # bearer:disable python_lang_exception
539 raise ValueError("Expected a boolean token")
542def get_typed_setting_value(
543 key: str,
544 value: Any,
545 ui_element: str,
546 default: Any = None,
547 check_env: bool = True,
548) -> Any:
549 """
550 Extracts the value for a particular setting, ensuring that it has the
551 correct type.
553 Args:
554 key: The setting key.
555 value: The setting value from the database.
556 ui_element: The setting UI element ID.
557 default: Default value to return when the setting is unset (None).
558 check_env: If true, it will check the environment variable for
559 this setting before reading from the DB.
561 Returns:
562 The typed value. The separate fetch-summary budget preserves an
563 invalid value, with a warning, until a summary fetch rejects it.
565 Raises:
566 SettingsValueError: The selected value cannot be converted, or its
567 declared setting type is unsupported. Invalid overrides never
568 fall through to a lower-priority value.
570 """
571 env_value = check_env_setting(key) if check_env else None
572 source = "environment" if env_value is not None else "value"
573 if (
574 not isinstance(ui_element, str)
575 or ui_element not in UI_ELEMENT_TO_SETTING_TYPE
576 ):
577 # Report only the setting key, source and this fixed reason.
578 # bearer:disable python_lang_exception
579 raise SettingsValueError(
580 key, source=source, reason="unsupported setting type"
581 )
582 setting_type = UI_ELEMENT_TO_SETTING_TYPE[ui_element]
583 if env_value is not None:
584 value = env_value
585 if key == "search.fetch.summary_input_max_length":
586 # This exact positive integer is validated again before a summary
587 # fetch. Keep a malformed budget visible without blocking unrelated
588 # research modes or the settings page used to repair it. All other
589 # settings and unsupported UI types retain the strict read contract.
590 try:
591 return to_positive_int(value)
592 except ValueError:
593 pass
594 location = (
595 f"LDR_{'_'.join(key.split('.')).upper()}"
596 if env_value is not None
597 else f"Setting {key}"
598 )
599 logger.warning(
600 "{} must be a positive integer (value omitted). Summary-mode "
601 "fetches will fail until it is corrected.",
602 location,
603 )
604 return value
606 if value is None:
607 return default
608 try:
609 if ui_element == "json" and env_value is not None:
610 return json.loads(value, parse_constant=_reject_json_constant)
611 if ui_element == "checkbox" and env_value is not None:
612 # Environment variables are configuration, not submitted HTML
613 # checkboxes. An unrecognized token must not enable a feature.
614 return _parse_environment_boolean(value)
615 return setting_type(value)
616 except (ValueError, TypeError, OverflowError, RecursionError):
617 pass
618 # Raise outside the handler: even a suppressed __context__ can contain
619 # credentials from float()/json.loads() errors. See #6201.
620 # The control type is allowlisted above; no value or parser error is used.
621 # bearer:disable python_lang_exception
622 raise SettingsValueError(
623 key, source=source, reason=f"expected a valid {ui_element} value"
624 )
627def check_env_setting(key: str) -> str | None:
628 """
629 Checks environment variables for a particular setting.
631 Args:
632 key: The database key for the setting.
634 Returns:
635 The setting from the environment variables, or None if the variable
636 is not set or is empty.
638 Note:
639 Empty environment variables ("") are treated as unset. This is standard
640 practice across the ecosystem — see CPython's official docs (PYTHON*
641 env vars require "a non-empty string"), botocore PR #1687, Pallets/Click
642 PR #2223, and Vercel Turborepo PR #6929. Orchestration tools like Unraid,
643 Terraform, and Kubernetes manifests often cannot conditionally omit env
644 var declarations, so they pass "" for unconfigured values. Treating ""
645 as unset prevents these empty strings from overriding database defaults.
646 See: https://github.com/LearningCircuit/local-deep-research/pull/3362
648 """
649 env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}"
650 env_value = os.getenv(env_variable_name)
651 # Treat empty string as unset — orchestration tools (Unraid, Terraform, K8s)
652 # often cannot omit env var declarations and pass "" for unconfigured values.
653 if env_value is not None and env_value != "":
654 logger.debug(f"Overriding {key} setting from environment variable.")
655 return env_value
656 if env_value == "":
657 logger.warning(
658 "Environment variable {} is set but empty — "
659 "ignoring it and falling back to DB/default for setting '{}'. "
660 "This is expected on Unraid or Docker templates that create "
661 "all variables even when left blank. To suppress this warning, "
662 "remove the variable from your environment or set a value.",
663 env_variable_name,
664 key,
665 )
666 return None
669def apply_environment_overrides_to_snapshot(
670 snapshot: Dict[str, Any],
671) -> Dict[str, Any]:
672 """Return a copied snapshot with current LDR_* overrides reapplied.
674 Queued research snapshots can outlive a process restart. Environment values
675 are operator policy and retain highest precedence at actual dispatch time.
676 """
677 if not isinstance(snapshot, dict): 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true
678 return snapshot
679 effective = dict(snapshot)
680 # Old or partial queued snapshots may omit keys added after enqueue. Use
681 # the registered defaults as metadata so every active LDR_* override is
682 # injected at dispatch, not only overrides for keys already serialized.
683 default_metadata = SettingsManager().default_settings
684 candidate_keys = set(snapshot) | set(default_metadata)
685 for key in candidate_keys:
686 if check_env_setting(str(key)) is None:
687 continue
688 stored = snapshot.get(key, default_metadata.get(key))
689 metadata = stored if isinstance(stored, dict) else None
690 current = metadata.get("value") if metadata is not None else stored
691 if metadata is not None:
692 ui_element = str(metadata.get("ui_element", "text"))
693 elif isinstance(current, bool):
694 ui_element = "checkbox"
695 elif isinstance(current, (list, dict)):
696 ui_element = "json"
697 elif isinstance(current, (int, float)): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true
698 ui_element = "number"
699 else:
700 ui_element = "text"
701 typed = get_typed_setting_value(
702 str(key), current, ui_element, default=current, check_env=True
703 )
704 if metadata is not None:
705 updated = dict(metadata)
706 updated["value"] = typed
707 updated["editable"] = False
708 effective[key] = updated
709 else:
710 effective[key] = typed
711 return effective
714class SettingsManager(ISettingsManager):
715 """
716 Manager for handling application settings with database storage and file fallback.
717 Provides methods to get and set settings, with the ability to override settings in memory.
718 """
720 def __init__(
721 self,
722 db_session: Optional[Session] = None,
723 owns_session: bool = False,
724 *,
725 seed_defaults: bool = True,
726 ):
727 """
728 Initialize the settings manager
730 Args:
731 db_session: SQLAlchemy session for database operations
732 owns_session: If True, close() will close the session.
733 Defaults to False (safe for borrowed sessions). Set to True
734 only when this manager created/owns the session — currently
735 only get_settings_manager() in db_utils.py does this.
736 seed_defaults: If True (the default), an empty settings table is
737 seeded from the defaults files and COMMITTED on
738 ``db_session`` here. Pass False for a read-only manager on a
739 session that belongs to someone else's transaction (e.g. a
740 deletion reading the user's snapshot mid-transaction): reads
741 still merge the in-memory defaults, so an empty table reads
742 as all-defaults without writing anything.
743 """
744 self.db_session = db_session
745 self._owns_session = owns_session
746 self._closed = False
747 self.db_first = True # Always prioritize DB settings
749 # Store the thread ID this instance was created in
750 self._creation_thread_id = threading.get_ident()
752 # Initialize settings lock as None - will be checked lazily
753 self.__settings_locked: Optional[bool] = None
755 # Auto-initialize settings if database is empty
756 if self.db_session and seed_defaults:
757 self._ensure_settings_initialized()
759 def close(self):
760 """Close the DB session if this manager owns it.
762 Borrowed sessions (owns_session=False) are left open for their
763 owner to close (e.g. a caller that passed in its own db_session).
764 Safe to call multiple times — subsequent calls are no-ops.
765 """
766 if self._owns_session and self.db_session is not None:
767 try:
768 logger.debug("Closing owned DB session in SettingsManager")
769 self.db_session.close()
770 except Exception:
771 logger.warning(
772 "Failed to close SettingsManager DB session — "
773 "connection may leak",
774 )
775 self._closed = True
776 self.db_session = None
778 def _ensure_settings_initialized(self):
779 """Ensure settings are initialized in the database."""
780 # Check if we have any settings at all
781 from ..database.models import Setting
783 if self.db_session is None:
784 # Fixed lifecycle text; no session contents are included.
785 # bearer:disable python_lang_exception
786 raise RuntimeError("Database session is not initialized")
787 settings_count = self.db_session.query(Setting).count()
789 if settings_count == 0:
790 logger.info("No settings found in database, loading defaults")
791 # Trusted first-run seeding bypasses the lock via import_settings
792 # directly — the load_from_defaults_file wrapper no longer
793 # accepts override_locked (#5841). Time it directly too: routing
794 # through the wrapper used to log this for free.
795 start = time.perf_counter()
796 row_count = len(self.default_settings)
797 self.import_settings(
798 self.default_settings, commit=True, override_locked=True
799 )
800 elapsed_ms = (time.perf_counter() - start) * 1000
801 logger.info(
802 f"Default settings loaded successfully: {row_count} "
803 f"settings in {elapsed_ms:.0f}ms"
804 )
806 def _check_thread_safety(self):
807 """Check if this instance is being used in the same thread it was created in."""
808 current_thread_id = threading.get_ident()
809 if self.db_session and current_thread_id != self._creation_thread_id:
810 # Only numeric IDs from threading.get_ident() are interpolated.
811 # bearer:disable python_lang_exception
812 raise RuntimeError(
813 f"SettingsManager instance created in thread {self._creation_thread_id} "
814 f"is being used in thread {current_thread_id}. This is not thread-safe! "
815 f"Create a new SettingsManager instance within the current thread context."
816 )
818 @property
819 def settings_locked(self) -> bool:
820 """Check if settings are locked (lazy evaluation)."""
821 if self.__settings_locked is None:
822 try:
823 self.__settings_locked = parse_boolean(
824 self.get_setting("app.lock_settings", False)
825 )
826 if self.__settings_locked:
827 logger.info(
828 "Settings are locked. Disabling all settings changes."
829 )
830 except SettingsValueError:
831 raise
832 except Exception:
833 logger.bind(policy_audit=True).warning(
834 "Failed to check settings lock status; denying changes"
835 )
836 self.__settings_locked = True
837 return bool(self.__settings_locked)
839 def _is_environment_locked(self, key: str, operation: str) -> bool:
840 if check_env_setting(key) is None:
841 return False
842 logger.bind(policy_audit=True).warning(
843 "environment-locked setting rejected at {}", operation, key=key
844 )
845 return True
847 @functools.cached_property
848 def default_settings(self) -> Dict[str, Any]:
849 """
850 Returns:
851 The default settings, loaded from JSON files and merged.
852 Automatically discovers and loads all .json files in the defaults
853 directory and its subdirectories.
854 Theme options are dynamically injected from the theme registry.
856 """
857 settings: Dict[str, Any] = {}
859 try:
860 # Get the defaults package path
861 defaults_path = Path(defaults.__file__).parent
863 # Find all JSON files recursively in the defaults directory
864 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Only JSON defaults beneath the installed defaults package are enumerated
865 json_files = sorted(defaults_path.rglob("*.json"))
867 logger.debug(f"Found {len(json_files)} JSON settings files")
869 # Load and merge all JSON files
870 for json_file in json_files:
871 try:
872 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Each file comes from the installed defaults package JSON glob above
873 with open(json_file, "r", encoding="utf-8-sig") as f:
874 file_settings = json.load(f)
876 # Get relative path for logging
877 relative_path = json_file.relative_to(defaults_path)
879 # Warn about key conflicts
880 conflicts = set(settings.keys()) & set(file_settings.keys())
881 if conflicts:
882 logger.warning(
883 f"Keys {conflicts} from {relative_path} "
884 f"override existing values"
885 )
887 settings.update(file_settings)
888 logger.debug(f"Loaded {relative_path}")
890 except json.JSONDecodeError:
891 logger.exception(f"Invalid JSON in {json_file}")
892 except Exception:
893 logger.warning(f"Could not load {json_file}")
895 except Exception:
896 logger.warning("Error loading settings files")
898 # Inject dynamic theme options from theme registry
899 if "app.theme" in settings:
900 try:
901 from local_deep_research.web.themes import theme_registry
903 settings["app.theme"]["options"] = (
904 theme_registry.get_settings_options()
905 )
906 except ImportError:
907 # Theme registry not available, use static options from JSON
908 pass
910 # Inject search strategy options from code (single source of truth)
911 if "search.search_strategy" in settings:
912 from local_deep_research.constants import get_available_strategies
914 strategies = get_available_strategies()
915 settings["search.search_strategy"]["options"] = [
916 {"label": s["label"], "value": s["name"]} for s in strategies
917 ]
919 logger.debug(f"Loaded {len(settings)} total settings")
920 return settings
922 def __get_typed_setting_value(
923 self,
924 setting: Setting,
925 default: Any = None,
926 check_env: bool = True,
927 ) -> Any:
928 """
929 Extracts the value for a particular setting, ensuring that it has the
930 correct type.
932 Args:
933 setting: The setting to get the value for.
934 default: Default value to return if the value of the setting is
935 invalid.
936 check_env: If true, it will check the environment variable for
937 this setting before reading from the DB.
939 Returns:
940 The value of the setting.
942 """
943 return get_typed_setting_value(
944 str(setting.key),
945 setting.value,
946 str(setting.ui_element),
947 default=default,
948 check_env=check_env,
949 )
951 def __query_settings(self, key: str | None = None) -> List[Setting]:
952 """
953 Abstraction for querying settings that also transparently handles
954 reading the default settings file if the DB is not enabled.
956 Args:
957 key: The key to read. If None, it will read everything.
959 Returns:
960 The settings it queried.
962 """
963 if self.db_session:
964 self._check_thread_safety()
965 query = self.db_session.query(Setting)
966 if key is not None:
967 # This will find exact matches and any subkeys.
968 #
969 # The ``startswith`` arm is intentionally restricted to
970 # subkeys that have AT LEAST one character past the dot
971 # (``key LIKE 'foo.%' AND key != 'foo.'``). The bare
972 # ``LIKE 'foo.%'`` form silently also matches ``'foo.'``
973 # (because ``%`` matches zero or more chars), which would
974 # treat a malformed trailing-dot duplicate row as a
975 # subkey of ``foo`` and cause ``get_setting`` to return
976 # a 2-key dict (``{"": value, "foo": value}``) instead
977 # of the primitive. See embedding-settings page bug.
978 # escape_like: the requested key is user-supplied (e.g. via
979 # /settings/api/bulk?keys[]=...), so escape its LIKE wildcards
980 # or ``keys[]=%`` would match every dotted key and dump the
981 # whole table. The ``!= f"{key}."`` guard compares against the
982 # raw stored key, so it stays unescaped.
983 query = query.filter(
984 or_(
985 Setting.key == key,
986 and_(
987 Setting.key.like(
988 f"{escape_like(key)}.%", escape="\\"
989 ),
990 Setting.key != f"{key}.",
991 ),
992 )
993 )
994 return query.all()
996 logger.debug(
997 "DB is disabled, reading setting '{}' from defaults file.", key
998 )
1000 settings = []
1001 for candidate_key, setting in self.default_settings.items():
1002 if key is None or (
1003 candidate_key == key or candidate_key.startswith(f"{key}.")
1004 ):
1005 settings.append(
1006 Setting(
1007 key=candidate_key, # gitleaks:allow
1008 **_filter_setting_columns(setting),
1009 )
1010 )
1012 return settings
1014 def get_setting(
1015 self, key: str, default: Any = None, check_env: bool = True
1016 ) -> Any:
1017 """
1018 Get a setting value
1020 Args:
1021 key: Setting key
1022 default: Default value if setting is not found
1023 check_env: If true, it will check the environment variable for
1024 this setting before reading from the DB.
1026 Returns:
1027 Setting value or default if not found. An environment override
1028 for a key with no row and no declared type is returned as the
1029 raw string.
1031 Raises:
1032 SettingsValueError: The selected value cannot be converted to its
1033 declared type, or that type is unsupported.
1034 """
1035 if self._closed:
1036 logger.error(
1037 "SettingsManager.get_setting('{}') called after close() — "
1038 "this is a bug; the caller should not reuse a closed manager",
1039 key,
1040 )
1041 # Fixed lifecycle text; no setting value is included.
1042 # bearer:disable python_lang_exception
1043 raise RuntimeError(
1044 "SettingsManager has been closed. "
1045 "Create a new instance or call close() only at end of lifecycle."
1046 )
1048 # First check if this is an env-only setting
1049 if env_registry.is_env_only(key):
1050 return env_registry.get_strict(key, default, check_env=check_env)
1052 # If using database first approach and session available, check database
1053 try:
1054 settings = self.__query_settings(key)
1055 # Drop malformed subkey rows (e.g. legacy "foo." / "foo.." keys)
1056 # that slip past the SQL prefix filter. #4852 excludes only the
1057 # exact single-trailing-dot row; a "foo.." / "foo. " row would
1058 # otherwise turn a leaf read into a `{".": v}`-style wrapper dict
1059 # rendered as `[object Object]` (#4840). The exact-match row is
1060 # always kept so a direct read of a malformed key still works.
1061 #
1062 # An operator-only key is never read as a namespace. Its child
1063 # rows, whether saved before the policy or matched by LIKE as an
1064 # ASCII case variant, are dropped, so the read returns the exact
1065 # row's value, the LDR_* override or the default, never a
1066 # mapping.
1067 exact_row_only = is_operator_only_setting(key)
1068 settings = [
1069 s
1070 for s in settings
1071 if str(s.key) == key
1072 or (not exact_row_only and is_valid_setting_key(str(s.key)))
1073 ]
1074 if len(settings) == 1 and str(settings[0].key) == key:
1075 # Only an exact row is a bottom-level key. A lone child row
1076 # still represents a namespace and must return a mapping,
1077 # matching get_setting_from_snapshot().
1078 return self.__get_typed_setting_value(
1079 settings[0], default, check_env
1080 )
1081 if settings:
1082 # This is a higher-level key.
1083 settings_map = {}
1084 for setting in settings:
1085 output_key = str(setting.key).removeprefix(f"{key}.")
1086 settings_map[output_key] = self.__get_typed_setting_value(
1087 setting, default, check_env
1088 )
1089 return settings_map
1090 except SQLAlchemyError:
1091 logger.exception(f"Error retrieving setting {key} from database")
1092 if key == "app.lock_settings":
1093 raise
1095 # Check env var before returning default (setting not in DB)
1096 if check_env:
1097 env_value = check_env_setting(key)
1098 if env_value is not None:
1099 default_meta = self.default_settings.get(key)
1100 if default_meta and isinstance(default_meta, dict):
1101 ui_element = default_meta.get("ui_element", "text")
1102 return get_typed_setting_value(
1103 key,
1104 None,
1105 ui_element,
1106 default=default,
1107 check_env=True,
1108 )
1109 # No type is declared, so there is nothing to validate the
1110 # override against: return it unconverted, as before strict
1111 # reads. Untyped keys such as app.api_rate_limit and
1112 # app.enable_api are read on every /api/v1 request, and their
1113 # consumers parse the string themselves. Typed keys still
1114 # raise above on a value that cannot be converted.
1115 logger.warning(
1116 "Setting '{}' has env var override but is not in "
1117 "defaults — returning raw string without type "
1118 "conversion. Add this setting to a defaults JSON "
1119 "file with a ui_element type to enable proper "
1120 "type conversion.",
1121 key,
1122 )
1123 return env_value
1125 # Return default if not found
1126 return default
1128 def get_bool_setting(
1129 self, key: str, default: bool = False, check_env: bool = True
1130 ) -> bool:
1131 """
1132 Get a setting value as a boolean, handling string conversion.
1134 Args:
1135 key: Setting key
1136 default: Default boolean value if setting is not found
1137 check_env: If true, it will check the environment variable for
1138 this setting before reading from the DB.
1140 Returns:
1141 Boolean value of the setting
1142 """
1143 value = self.get_setting(key, default, check_env)
1144 return to_bool(value, default)
1146 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool:
1147 """
1148 Set a setting value
1150 Args:
1151 key: Setting key
1152 value: Setting value
1153 commit: Whether to commit the change
1155 Returns:
1156 True if successful, False otherwise
1157 """
1158 if self._closed:
1159 logger.error(
1160 "SettingsManager.set_setting('{}') called after close() — "
1161 "this is a bug; the caller should not reuse a closed manager",
1162 key,
1163 )
1164 # Fixed lifecycle text; no setting value is included.
1165 # bearer:disable python_lang_exception
1166 raise RuntimeError(
1167 "SettingsManager has been closed. "
1168 "Create a new instance or call close() only at end of lifecycle."
1169 )
1170 if not self.db_session:
1171 logger.error(
1172 "Cannot edit setting {} because no DB was provided.", key
1173 )
1174 return False
1175 if is_operator_only_setting(key):
1176 logger.bind(policy_audit=True).warning(
1177 "operator-only setting rejected at set_setting", key=key
1178 )
1179 return False
1180 if self.settings_locked:
1181 logger.error("Cannot edit setting {} because they are locked.", key)
1182 return False
1183 if self._is_environment_locked(key, "set_setting"):
1184 return False
1186 # Always update database if available
1187 try:
1188 self._check_thread_safety()
1189 setting = (
1190 self.db_session.query(Setting)
1191 .filter(Setting.key == key)
1192 .first()
1193 )
1194 # Capture old value for the policy-change audit log below.
1195 old_value = setting.value if setting is not None else None
1196 if setting:
1197 if not setting.editable:
1198 logger.error(
1199 "Cannot change setting '{}' because it "
1200 "is marked as non-editable.",
1201 key,
1202 )
1203 return False
1205 setting.value = value # type: ignore[assignment]
1206 setting.updated_at = ( # type: ignore[assignment]
1207 func.now()
1208 ) # Explicitly set the current timestamp
1210 # Self-heal stale ui_element from before inference was added
1211 setting.ui_element = _infer_ui_element(
1212 value, setting.ui_element
1213 )
1215 # Self-heal stale type from before the prefix dispatch was
1216 # added (e.g. legacy chat.* rows created with type=APP).
1217 # Also re-points category to the canonical per-prefix
1218 # value, since a row with the wrong type column was
1219 # almost certainly created before category dispatch was
1220 # in place either.
1221 inferred_type: Optional[SettingType] = None
1222 inferred_category: Optional[str] = None
1223 for prefix, category in _INFERRED_CATEGORY.items():
1224 if key.startswith(prefix):
1225 if prefix == "llm.":
1226 inferred_type = SettingType.LLM
1227 elif prefix == "search.":
1228 inferred_type = SettingType.SEARCH
1229 elif prefix == "report.":
1230 inferred_type = SettingType.REPORT
1231 elif prefix == "database.": 1231 ↛ 1232line 1231 didn't jump to line 1232 because the condition on line 1231 was never true
1232 inferred_type = SettingType.DATABASE
1233 elif prefix == "chat.": 1233 ↛ 1235line 1233 didn't jump to line 1235 because the condition on line 1233 was always true
1234 inferred_type = SettingType.CHAT
1235 inferred_category = category
1236 break
1237 # Only self-heal when the key matches a known prefix. Keys
1238 # outside the dispatch map (e.g. focused_iteration.*,
1239 # langgraph_agent.* which ship as type=SEARCH) must keep their
1240 # shipped type — defaulting to APP here would wrongly demote
1241 # them on every edit.
1242 if inferred_type is not None and setting.type != inferred_type:
1243 setting.type = inferred_type # type: ignore[assignment]
1244 if inferred_category is not None: 1244 ↛ 1284line 1244 didn't jump to line 1284 because the condition on line 1244 was always true
1245 setting.category = inferred_category # type: ignore[assignment]
1246 else:
1247 # Refuse to CREATE a new row for a malformed key (e.g. a
1248 # trailing-dot key like "foo."). Existing rows are still
1249 # updatable above; this only blocks minting new corruption.
1250 if not is_valid_setting_key(key):
1251 logger.error(
1252 "Refusing to create setting with malformed key {!r}",
1253 key,
1254 )
1255 return False
1257 # Determine setting type from key
1258 setting_type = SettingType.APP
1259 if key.startswith("llm."):
1260 setting_type = SettingType.LLM
1261 elif key.startswith("search."):
1262 setting_type = SettingType.SEARCH
1263 elif key.startswith("report."):
1264 setting_type = SettingType.REPORT
1265 elif key.startswith("database."): 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true
1266 setting_type = SettingType.DATABASE
1267 elif key.startswith("chat."): 1267 ↛ 1268line 1267 didn't jump to line 1268 because the condition on line 1267 was never true
1268 setting_type = SettingType.CHAT
1270 # Infer ui_element from the value type
1271 ui_element = _infer_ui_element(value)
1273 # Create a new setting
1274 new_setting = Setting(
1275 key=key,
1276 value=value,
1277 type=setting_type,
1278 name=key.split(".")[-1].replace("_", " ").title(),
1279 ui_element=ui_element,
1280 description=f"Setting for {key}",
1281 )
1282 self.db_session.add(new_setting)
1284 if commit:
1285 self.db_session.commit()
1286 # Emit WebSocket event for settings change
1287 self._emit_settings_changed([key])
1289 # N16: audit log on policy.* / llm.require_local_endpoint /
1290 # embeddings.require_local changes. Targeted scope — only
1291 # security-relevant settings are logged, to avoid widening
1292 # this PR into a general audit-log refactor.
1293 #
1294 # The values are rendered through ``_policy_audit_value``, NOT
1295 # interpolated raw: ``policy.`` is a prefix match and three of
1296 # the keys in scope hold free-form, user-editable JSON, so a
1297 # raw ``old={}``/``new={}`` here is a plaintext credential sink
1298 # (#6201 round 7). This line is also outside the ``if commit:``
1299 # block above on purpose — the bulk save routes pass
1300 # ``commit=False`` and must still be audited.
1301 if _is_policy_setting(key):
1302 try:
1303 _default_meta = self.default_settings.get(key)
1304 except Exception:
1305 # An audit line must never be able to fail the write it
1306 # is auditing; without the metadata every string simply
1307 # falls back to the structural form.
1308 _default_meta = None
1309 _audit_options = _policy_audit_allowed_values(_default_meta)
1310 logger.bind(policy_audit=True).warning(
1311 "policy setting changed | key={} old={} new={}",
1312 key,
1313 _policy_audit_value(old_value, _audit_options),
1314 _policy_audit_value(value, _audit_options),
1315 )
1317 return True
1318 except SQLAlchemyError:
1319 logger.exception(f"Error setting value for key: {key}")
1320 self.db_session.rollback()
1321 return False
1323 def clear_cache(self):
1324 """Clear the settings cache."""
1325 self.__dict__.pop("default_settings", None)
1326 logger.debug("Settings cache cleared")
1328 def get_all_settings(
1329 self,
1330 bypass_cache: bool = False,
1331 include_environment_overrides: bool = True,
1332 strict: bool = False,
1333 ) -> Dict[str, Any]:
1334 """
1335 Get all settings, merging defaults with database values.
1337 This ensures that new settings added to defaults.json automatically
1338 appear in the UI without requiring a database reset.
1340 Args:
1341 bypass_cache: If True, bypass the cache and read directly from database
1342 include_environment_overrides: If True, validate effective values
1343 and overlay current LDR_* values. False exports stored values
1344 and metadata unchanged, including invalid values for repair.
1345 strict: If True, propagate database and enum query failures.
1347 Returns:
1348 Dictionary of all settings
1350 Raises:
1351 SettingsValueError: With ``include_environment_overrides``, any
1352 effective value cannot be converted to its declared type, or
1353 that type is unsupported.
1354 """
1355 if self._closed:
1356 logger.error(
1357 "SettingsManager.get_all_settings() called after close() — "
1358 "this is a bug; the caller should not reuse a closed manager",
1359 )
1360 # Fixed lifecycle text; no settings snapshot is included.
1361 # bearer:disable python_lang_exception
1362 raise RuntimeError(
1363 "SettingsManager has been closed. "
1364 "Create a new instance or call close() only at end of lifecycle."
1365 )
1367 result = {}
1369 # Start with defaults so new settings are always included
1370 for key, default_setting in self.default_settings.items():
1371 result[key] = dict(default_setting)
1373 # Override with database settings
1374 try:
1375 db_settings = self.__query_settings()
1376 except (SQLAlchemyError, LookupError):
1377 # LookupError fires when a row's `type` column holds an enum
1378 # value that's no longer in `SettingType` (e.g. legacy 'CHAT'-
1379 # typed rows from removed features). The previous handler only
1380 # caught SQLAlchemyError, so a single stale row would crash the
1381 # whole snapshot — and every caller of get_all_settings (the
1382 # /settings/api endpoint, benchmark start, research start, MCP
1383 # entry points, …) downstream of it. Falling back to defaults-
1384 # only is strictly safer than crashing.
1385 logger.exception(
1386 "Error querying settings from database in get_all_settings"
1387 )
1388 if strict:
1389 raise
1390 db_settings = []
1392 for setting in db_settings:
1393 # Handle type field - it might be a string or an enum
1394 setting_type = setting.type
1395 if hasattr(setting_type, "name"):
1396 setting_type = setting_type.name
1398 # Log if this is a custom setting not in defaults
1399 if str(setting.key) not in result:
1400 logger.debug(
1401 f"Database contains custom setting not in "
1402 f"defaults: {setting.key} (type={setting_type}, "
1403 f"category={setting.category})"
1404 )
1406 # Override the default with the full database row — value AND
1407 # schema (options, description, name, constraints).
1408 #
1409 # This is deliberate, not a bug: the DB row is a self-contained
1410 # snapshot. Schema is NOT read live from JSON on every call; it is
1411 # reconciled from the JSON defaults only at version bump, via the
1412 # direct `import_settings(overwrite=False, override_locked=True)`
1413 # bootstrap calls in `database/initialize.py` and post-login in
1414 # `web/routers/auth.py`). Every release bumps the package version, so
1415 # JSON metadata changes reach a user on their next login after
1416 # upgrade. Do NOT change this to overlay defaults schema on every
1417 # read — it bypasses that version gate and breaks the snapshot
1418 # invariant (clean export/import round-trip, no mid-session drift).
1419 # See closed PR #2474 for the rejected schema/value-separation
1420 # approach and the reasoning.
1421 result[str(setting.key)] = {
1422 "value": setting.value,
1423 "type": setting_type,
1424 "name": setting.name,
1425 "description": setting.description,
1426 "category": setting.category,
1427 "ui_element": setting.ui_element,
1428 "options": setting.options,
1429 "min_value": setting.min_value,
1430 "max_value": setting.max_value,
1431 "step": setting.step,
1432 "visible": setting.visible,
1433 "editable": False
1434 if (include_environment_overrides and self.settings_locked)
1435 or is_operator_only_setting(str(setting.key))
1436 else setting.editable,
1437 }
1439 # Validate only after choosing the effective row. A valid database row
1440 # may replace an obsolete default, and a valid environment override may
1441 # replace an invalid stored value. Neither may hide an invalid selected
1442 # value by silently substituting another setting.
1443 if include_environment_overrides:
1444 for key, metadata in result.items():
1445 typed_value = get_typed_setting_value(
1446 key,
1447 metadata.get("value"),
1448 metadata.get("ui_element", "text"),
1449 )
1450 if check_env_setting(key) is not None:
1451 metadata["value"] = typed_value
1452 metadata["editable"] = False
1454 # Re-inject search strategy options from code after DB merge,
1455 # since the DB stores options=null for this setting.
1456 if "search.search_strategy" in result:
1457 from local_deep_research.constants import get_available_strategies
1459 strategies = get_available_strategies()
1460 result["search.search_strategy"]["options"] = [
1461 {"label": s["label"], "value": s["name"]} for s in strategies
1462 ]
1464 return result
1466 def get_settings_snapshot(self, strict: bool = False) -> Dict[str, Any]:
1467 """
1468 Get a simplified settings snapshot with just key-value pairs.
1469 This is useful for passing settings to background threads or storing in metadata.
1471 Args:
1472 strict: If True, propagate database and enum query failures.
1474 Returns:
1475 Dictionary with setting keys mapped to their values
1476 """
1477 if self._closed:
1478 logger.error(
1479 "SettingsManager.get_settings_snapshot() called after close() — "
1480 "this is a bug; the caller should not reuse a closed manager",
1481 )
1482 # Fixed lifecycle text; no settings snapshot is included.
1483 # bearer:disable python_lang_exception
1484 raise RuntimeError(
1485 "SettingsManager has been closed. "
1486 "Create a new instance or call close() only at end of lifecycle."
1487 )
1489 all_settings = self.get_all_settings(strict=strict)
1490 settings_snapshot = {}
1492 for key, setting in all_settings.items():
1493 if isinstance(setting, dict) and "value" in setting: 1493 ↛ 1496line 1493 didn't jump to line 1496 because the condition on line 1493 was always true
1494 settings_snapshot[key] = setting["value"]
1495 else:
1496 settings_snapshot[key] = setting
1498 return settings_snapshot
1500 def create_or_update_setting(
1501 self, setting: Union[BaseSetting, Dict[str, Any]], commit: bool = True
1502 ) -> Optional[Setting]:
1503 """
1504 Create or update a setting
1506 Args:
1507 setting: Setting object or dictionary
1508 commit: Whether to commit the change
1510 Returns:
1511 The created or updated Setting model, or None if failed
1512 """
1513 if not self.db_session:
1514 logger.warning(
1515 "No database session available, cannot create/update setting"
1516 )
1517 return None
1518 if self.settings_locked:
1519 logger.error("Cannot edit settings because they are locked.")
1520 return None
1522 # Convert dict to BaseSetting if needed
1523 if isinstance(setting, dict): 1523 ↛ 1550line 1523 didn't jump to line 1550 because the condition on line 1523 was always true
1524 # Determine type from key if not specified
1525 if "type" not in setting and "key" in setting:
1526 setting_obj: BaseSetting
1527 key = setting["key"]
1528 if key.startswith("llm."):
1529 setting_obj = LLMSetting(**setting)
1530 elif key.startswith("search."):
1531 setting_obj = SearchSetting(**setting)
1532 elif key.startswith("report."):
1533 setting_obj = ReportSetting(**setting)
1534 elif key.startswith("chat."): 1534 ↛ 1535line 1534 didn't jump to line 1535 because the condition on line 1534 was never true
1535 setting_obj = ChatSetting(**setting)
1536 elif key.startswith("app."):
1537 setting_obj = AppSetting(**setting)
1538 else:
1539 # Keys outside the four buckets (e.g. local_search_*,
1540 # embeddings.*, rag.*) live in their own namespaces.
1541 # Use BaseSetting so the key is written verbatim —
1542 # AppSetting's validator would otherwise prepend
1543 # `app.` and silently relocate the row away from
1544 # where every reader looks it up. See #4208.
1545 setting_obj = BaseSetting(type=SettingType.APP, **setting)
1546 else:
1547 # Use generic BaseSetting
1548 setting_obj = BaseSetting(**setting)
1549 else:
1550 setting_obj = setting
1552 if is_operator_only_setting(setting_obj.key):
1553 logger.bind(policy_audit=True).warning(
1554 "operator-only setting rejected at create_or_update_setting",
1555 key=setting_obj.key,
1556 )
1557 return None
1558 if self._is_environment_locked(
1559 setting_obj.key, "create_or_update_setting"
1560 ):
1561 return None
1563 try:
1564 # Check if setting exists
1565 db_setting = (
1566 self.db_session.query(Setting)
1567 .filter(Setting.key == setting_obj.key)
1568 .first()
1569 )
1571 if db_setting:
1572 # Update existing setting
1573 if not db_setting.editable:
1574 logger.error(
1575 "Cannot change setting '{}' because it "
1576 "is marked as non-editable.",
1577 setting_obj.key,
1578 )
1579 return None
1581 db_setting.value = setting_obj.value # type: ignore[assignment]
1582 db_setting.name = setting_obj.name # type: ignore[assignment]
1583 db_setting.description = setting_obj.description # type: ignore[assignment]
1584 db_setting.category = setting_obj.category # type: ignore[assignment]
1585 db_setting.type = setting_obj.type # type: ignore[assignment]
1586 db_setting.ui_element = setting_obj.ui_element # type: ignore[assignment]
1587 db_setting.options = setting_obj.options # type: ignore[assignment]
1588 db_setting.min_value = setting_obj.min_value # type: ignore[assignment]
1589 db_setting.max_value = setting_obj.max_value # type: ignore[assignment]
1590 db_setting.step = setting_obj.step # type: ignore[assignment]
1591 db_setting.visible = setting_obj.visible # type: ignore[assignment]
1592 db_setting.editable = setting_obj.editable # type: ignore[assignment]
1593 db_setting.updated_at = ( # type: ignore[assignment]
1594 func.now()
1595 ) # Explicitly set the current timestamp
1596 else:
1597 # Refuse to CREATE a new row for a malformed key (see
1598 # is_valid_setting_key / #4840). Updates to existing rows
1599 # above are unaffected.
1600 if not is_valid_setting_key(setting_obj.key):
1601 logger.error(
1602 "Refusing to create setting with malformed key {!r}",
1603 setting_obj.key,
1604 )
1605 return None
1607 # Create new setting
1608 db_setting = Setting(
1609 key=setting_obj.key,
1610 value=setting_obj.value,
1611 type=setting_obj.type,
1612 name=setting_obj.name,
1613 description=setting_obj.description,
1614 category=setting_obj.category,
1615 ui_element=setting_obj.ui_element,
1616 options=setting_obj.options,
1617 min_value=setting_obj.min_value,
1618 max_value=setting_obj.max_value,
1619 step=setting_obj.step,
1620 visible=setting_obj.visible,
1621 editable=setting_obj.editable,
1622 )
1623 self.db_session.add(db_setting)
1625 if commit:
1626 self.db_session.commit()
1627 # Emit WebSocket event for settings change
1628 self._emit_settings_changed([setting_obj.key])
1630 return db_setting
1632 except SQLAlchemyError:
1633 logger.exception(
1634 f"Error creating/updating setting {setting_obj.key}"
1635 )
1636 self.db_session.rollback()
1637 return None
1639 def delete_setting(
1640 self, key: str, commit: bool = True, override_locked: bool = False
1641 ) -> bool:
1642 """
1643 Delete a setting
1645 Args:
1646 key: Setting key
1647 commit: Whether to commit the change
1648 override_locked: Delete even when settings are locked.
1650 Returns:
1651 True if successful, False otherwise
1652 """
1653 if not self.db_session:
1654 logger.warning(
1655 "No database session available, cannot delete setting"
1656 )
1657 return False
1659 if is_operator_only_setting(key):
1660 logger.bind(policy_audit=True).warning(
1661 "operator-only setting rejected at delete_setting", key=key
1662 )
1663 return False
1664 if self._is_environment_locked(key, "delete_setting"):
1665 return False
1667 if not override_locked and self.settings_locked:
1668 logger.bind(policy_audit=True).warning(
1669 "locked setting rejected at delete_setting", key=key
1670 )
1671 return False
1673 try:
1674 # Remove from database
1675 result = (
1676 self.db_session.query(Setting)
1677 .filter(Setting.key == key)
1678 .delete()
1679 )
1681 if commit: 1681 ↛ 1684line 1681 didn't jump to line 1684 because the condition on line 1681 was always true
1682 self.db_session.commit()
1684 return result > 0
1685 except SQLAlchemyError:
1686 logger.exception("Error deleting setting")
1687 self.db_session.rollback()
1688 return False
1690 def load_from_defaults_file(
1691 self,
1692 commit: bool = True,
1693 preserve_environment_locked: bool = False,
1694 ) -> None:
1695 """
1696 Import settings from the defaults settings file.
1698 Deliberately does NOT forward ``**kwargs`` to ``import_settings``:
1699 the passthrough exposed the settings-lock bypass
1700 (``override_locked=True``) through this wrapper, so a future
1701 request-side caller splatting user-controlled kwargs
1702 (``load_from_defaults_file(**payload)``) could have imported
1703 settings on a locked account (#5841). Only the two safe parameters
1704 are enumerated; any other keyword — ``override_locked`` included —
1705 raises ``TypeError``. The bootstrap/migration call sites that must
1706 bypass the lock call ``import_settings(..., override_locked=True)``
1707 directly (pinned by ``test_bootstrap_call_sites_pass_override_locked``).
1709 Args:
1710 commit: Whether to commit changes to database. The post-login
1711 atomic block in `web/routers/auth.py` performs its bulk
1712 default import through a direct ``import_settings(commit=False,
1713 override_locked=True)`` call and combines it with
1714 ``update_db_version(commit=False)`` under a single terminal
1715 ``db_session.commit()`` — preserving the all-or-nothing
1716 invariant is what prevents the sticky-loop bug where
1717 `app.version` is missing after a partial write.
1718 preserve_environment_locked: Passed to `import_settings` so a
1719 bulk reset/import cannot overwrite the stored values of
1720 settings with active ``LDR_*`` environment overrides.
1721 """
1722 start = time.perf_counter()
1723 row_count = len(self.default_settings)
1724 self.import_settings(
1725 self.default_settings,
1726 commit=commit,
1727 preserve_environment_locked=preserve_environment_locked,
1728 )
1729 elapsed_ms = (time.perf_counter() - start) * 1000
1730 if elapsed_ms > 100:
1731 logger.info(
1732 f"load_from_defaults_file imported {row_count} settings "
1733 f"in {elapsed_ms:.0f}ms (commit={commit})"
1734 )
1735 else:
1736 logger.debug(
1737 f"load_from_defaults_file imported {row_count} settings "
1738 f"in {elapsed_ms:.0f}ms (commit={commit})"
1739 )
1741 def db_version_matches_package(self) -> bool:
1742 """
1743 Returns:
1744 True if the version saved in the DB matches the package version.
1746 Reads the stored ``app.version`` row directly (``check_env=False``)
1747 rather than going through the env-aware read path. The schema-version
1748 marker must reflect what is persisted so a stale DB triggers a
1749 migration even when ``LDR_APP_VERSION`` happens to match the running
1750 package; reading the env value would mask the staleness and skip
1751 the migration.
1752 """
1753 db_version = self.get_setting("app.version", check_env=False)
1754 logger.debug(
1755 f"App version saved in DB is {db_version}, have package "
1756 f"settings from version {package_version}."
1757 )
1759 return bool(db_version == package_version)
1761 def update_db_version(self, commit: bool = True) -> None:
1762 """
1763 Updates the version saved in the DB based on the package version.
1765 Args:
1766 commit: Whether to commit the version write to the database.
1767 Callers that want to combine this with other writes into
1768 a single atomic transaction should pass commit=False and
1769 commit the session themselves. The post-login block in
1770 `web/auth/routes.py` relies on this to bundle the defaults
1771 import and the `app.version` write into one SQLite
1772 transaction; splitting them risks the sticky-loop state
1773 where `app.version` never gets written.
1774 """
1775 logger.debug(f"Updating saved DB version to {package_version}.")
1777 # Internal migration plumbing: this row is the schema-version
1778 # marker, not a user-facing setting. The env lock applies to
1779 # ordinary set/delete mutations via the public API; using the
1780 # env-guarded delete_setting() here would let a stale
1781 # LDR_APP_VERSION suppress the row replacement and either leave
1782 # the unique constraint violated (next commit raises) or leave
1783 # the stale marker in place. The narrowest correct path is to
1784 # delete the prior row directly and abort the call on failure
1785 # rather than blindly inserting a duplicate.
1786 if self.db_session is None:
1787 # Fixed lifecycle text; no session contents are included.
1788 # bearer:disable python_lang_exception
1789 raise RuntimeError("Database session is not initialized")
1790 try:
1791 self._check_thread_safety()
1792 self.db_session.query(Setting).filter(
1793 Setting.key == "app.version"
1794 ).delete(synchronize_session=False)
1795 except SQLAlchemyError:
1796 logger.exception("Error deleting prior app.version row")
1797 self.db_session.rollback()
1798 return
1800 version = Setting(
1801 key="app.version",
1802 value=package_version,
1803 description="Version of the app this database is associated with.",
1804 editable=False,
1805 name="App Version",
1806 type=SettingType.APP,
1807 ui_element="text",
1808 visible=False,
1809 )
1810 self.db_session.add(version)
1811 if commit:
1812 self.db_session.commit()
1814 def import_settings(
1815 self,
1816 settings_data: Dict[str, Any],
1817 commit: bool = True,
1818 overwrite: bool = True,
1819 delete_extra: bool = False,
1820 preserve_environment_locked: bool = False,
1821 override_locked: bool = False,
1822 ) -> None:
1823 """
1824 Import settings directly from the export format. This can be used to
1825 re-import settings that have been exported with `get_all_settings()`.
1827 Args:
1828 settings_data: The raw settings data to import.
1829 commit: Whether to commit the DB after loading the settings.
1830 overwrite: If true, it will overwrite the value of settings that
1831 are already in the database. If false, each key keeps the
1832 value of its own stored row; the values of its child keys
1833 are never folded into it. A stored value that is invalid
1834 for its type is kept as stored, with a warning that names
1835 the key but not the value, and the import continues.
1836 delete_extra: If true, it will delete any settings that are in
1837 the database but don't have a corresponding entry in
1838 `settings_data`.
1839 preserve_environment_locked: If true, preserve stored values for
1840 settings with active environment overrides while imported
1841 metadata is refreshed.
1842 override_locked: Import even when settings are locked. The
1843 bootstrap callers that add newly shipped defaults with
1844 `overwrite=False` pass this, since a locked account still
1845 has to receive settings introduced by an upgrade.
1847 """
1848 if not override_locked and self.settings_locked:
1849 logger.bind(policy_audit=True).warning(
1850 "locked settings rejected at import_settings"
1851 )
1852 return
1854 if self.db_session is None:
1855 # Fixed lifecycle text; no imported settings are included.
1856 # bearer:disable python_lang_exception
1857 raise RuntimeError("Database session is not initialized")
1858 logger.debug(f"Importing {len(settings_data)} settings")
1859 changed_keys: list[str] = []
1860 retained_keys: set[str] = set()
1861 # Schema-aware import (#5589) — see validate_imported_setting_value's
1862 # docstring for the trusted-vs-untrusted rationale (values retained
1863 # from the database are trusted here, not validated).
1864 defaults_for_import = self.default_settings
1865 try:
1866 # `overwrite=False` is the version-bump reconciliation point:
1867 # refresh JSON-defined schema while preserving the stored value.
1868 # Environment overrides must not become persisted values.
1869 for key, raw_setting_values in settings_data.items():
1870 if not is_valid_setting_key(key):
1871 logger.warning(
1872 "Skipping import of setting with malformed key {!r}",
1873 key,
1874 )
1875 continue
1877 retained_keys.add(key)
1878 if is_operator_only_setting(key) and not (
1879 override_locked and settings_data is self.default_settings
1880 ):
1881 # User-driven import/reset must not replace or delete a
1882 # legacy path. Upgrade/bootstrap imports use the exact
1883 # trusted defaults mapping and reconcile its metadata
1884 # with overwrite=False, retaining the stored location.
1885 logger.bind(policy_audit=True).warning(
1886 "operator-only setting rejected at import_settings",
1887 key=key,
1888 )
1889 continue
1890 setting_values = dict(raw_setting_values)
1891 value_replaced_from_db = False
1892 if (
1893 preserve_environment_locked
1894 and check_env_setting(key) is not None
1895 ):
1896 existing_value = (
1897 self.db_session.query(Setting.value)
1898 .filter(Setting.key == key)
1899 .first()
1900 )
1901 if existing_value is not None:
1902 logger.bind(policy_audit=True).warning(
1903 "Preserving stored value for environment-locked "
1904 "setting during import: {}",
1905 key,
1906 )
1907 setting_values["value"] = existing_value[0]
1908 value_replaced_from_db = True
1909 if not overwrite:
1910 try:
1911 existing_value = self.get_setting(key, check_env=False)
1912 reread_own_row = isinstance(existing_value, dict)
1913 except SettingsValueError:
1914 # get_setting() also converts child rows, so the
1915 # invalid value may belong to a child. Re-read the
1916 # row stored under exactly this key.
1917 reread_own_row = True
1918 if reread_own_row:
1919 # get_setting() folds child rows, including ASCII
1920 # case variants its LIKE lookup matches, into a
1921 # mapping. Writing that mapping back would replace
1922 # this row's own value, so re-read the row stored
1923 # under exactly this key.
1924 stored_row = (
1925 self.db_session.query(Setting)
1926 .filter(Setting.key == key)
1927 .first()
1928 )
1929 existing_value = None
1930 if stored_row is not None:
1931 try:
1932 existing_value = self.__get_typed_setting_value(
1933 stored_row, None, check_env=False
1934 )
1935 except SettingsValueError as error:
1936 # One invalid stored row must not abort the
1937 # reconciliation for every other key, and
1938 # replacing it with the default would hide
1939 # the error. Keep the stored value; reads
1940 # keep reporting it until it is corrected.
1941 # The reason is a fixed string that never
1942 # contains the value.
1943 reason = error.reason
1944 logger.warning(
1945 "Keeping the stored value of setting "
1946 "{!r} during defaults reconciliation: {}",
1947 key,
1948 reason,
1949 )
1950 existing_value = stored_row.value
1951 if existing_value is not None:
1952 setting_values["value"] = existing_value
1953 value_replaced_from_db = True
1955 if not value_replaced_from_db:
1956 # Schema-aware import (#5589): untrusted file value —
1957 # see validate_imported_setting_value's docstring for
1958 # the trusted-vs-untrusted rationale.
1959 default_meta = defaults_for_import.get(key)
1960 if default_meta is not None:
1961 invalid_reason = validate_imported_setting_value(
1962 key,
1963 setting_values.get("value"),
1964 default_meta,
1965 )
1966 if invalid_reason is not None:
1967 # The value itself is never logged: an
1968 # imported settings value is a plausible
1969 # secret carrier. This mirrors the
1970 # equivalent line in
1971 # ``api/settings_utils.py``. ``invalid_reason``
1972 # is built from the *defaults* metadata
1973 # (min/max/options), never from the imported
1974 # value.
1975 logger.warning(
1976 "Skipping import of setting {!r}: value of "
1977 "type {} is invalid under the current "
1978 "defaults schema — {}",
1979 key,
1980 type(setting_values.get("value")).__name__,
1981 invalid_reason,
1982 )
1983 continue
1985 # Import needs strict failure semantics. The public
1986 # delete_setting() helper intentionally swallows SQL errors,
1987 # which would allow this transaction to continue after a
1988 # rollback and commit only a suffix of the import.
1989 (
1990 self.db_session.query(Setting)
1991 .filter(Setting.key == key)
1992 .delete(synchronize_session=False)
1993 )
1995 if "type" in setting_values and isinstance( 1995 ↛ 2000line 1995 didn't jump to line 2000 because the condition on line 1995 was always true
1996 setting_values["type"], str
1997 ):
1998 setting_values["type"] = SettingType[setting_values["type"]]
2000 self.db_session.add(
2001 Setting(key=key, **_filter_setting_columns(setting_values))
2002 )
2003 changed_keys.append(key)
2005 if delete_extra:
2006 existing_keys = [
2007 str(row[0])
2008 for row in self.db_session.query(Setting.key).all()
2009 ]
2010 for key in existing_keys:
2011 if (
2012 key in retained_keys
2013 or is_operator_only_setting(key)
2014 or (
2015 preserve_environment_locked
2016 and check_env_setting(key) is not None
2017 )
2018 ):
2019 continue
2020 logger.debug(f"Deleting extraneous setting: {key}")
2021 (
2022 self.db_session.query(Setting)
2023 .filter(Setting.key == key)
2024 .delete(synchronize_session=False)
2025 )
2026 changed_keys.append(key)
2028 if commit:
2029 self.db_session.commit()
2030 logger.info(
2031 f"Successfully imported {len(changed_keys)} settings"
2032 )
2033 self._emit_settings_changed(changed_keys)
2034 except Exception:
2035 self.db_session.rollback()
2036 raise
2038 def emit_settings_changed_after_commit(
2039 self, changed_keys: List[str]
2040 ) -> None:
2041 """Notify the current user's clients after the caller commits settings."""
2042 self._emit_settings_changed(changed_keys)
2044 def _emit_settings_changed(self, changed_keys: Optional[List[Any]] = None):
2045 """
2046 Emit WebSocket event when settings change
2048 Args:
2049 changed_keys: List of setting keys that changed
2050 """
2051 try:
2052 # Import here to avoid circular imports
2053 from ..web.services.socketio_asgi import emit_to_user
2054 from ..security.data_sanitizer import DataSanitizer
2056 # Keep the event scoped to the owning user's browser tabs, never
2057 # every connected client on this shared Socket.IO server (#4437).
2058 # A change made outside a
2059 # request context (app start-up defaults, background workers,
2060 # migrations) has no user tab to notify, so we skip the emit
2061 # entirely rather than fall back to a broadcast.
2062 from datetime import datetime, UTC
2064 # Resolve the user from the contextvar set by DatabaseMiddleware.
2065 # SettingsManager itself does not hold a username attribute.
2066 from ..utilities.request_context import get_current_username
2068 username = get_current_username()
2069 if not username:
2070 logger.debug(
2071 "Skipping settings_changed emit: no request username context"
2072 )
2073 return
2075 # Read the changed values only after the recipient check: each
2076 # read types the stored row, so an invalid value or unknown
2077 # ui_element raises for an emit that never happens.
2078 settings_data = {}
2079 if changed_keys: 2079 ↛ 2109line 2079 didn't jump to line 2109 because the condition on line 2079 was always true
2080 for key in changed_keys:
2081 setting_value = self.get_setting(key)
2082 if setting_value is not None:
2083 # Match the HTTP read contract, including custom
2084 # password controls whose key has no secret suffix.
2085 # The exact DB row takes precedence over defaults.
2086 exact_row = next(
2087 (
2088 row
2089 for row in self.__query_settings(key)
2090 if str(row.key) == key
2091 ),
2092 None,
2093 )
2094 if exact_row is not None: 2094 ↛ 2097line 2094 didn't jump to line 2097 because the condition on line 2094 was always true
2095 ui_element = exact_row.ui_element
2096 else:
2097 default_meta = self.default_settings.get(key, {})
2098 ui_element = (
2099 default_meta.get("ui_element")
2100 if isinstance(default_meta, dict)
2101 else None
2102 )
2103 settings_data[key] = {
2104 "value": DataSanitizer.redact_value(
2105 key, ui_element, setting_value
2106 )
2107 }
2109 emit_to_user(
2110 "settings_changed",
2111 username,
2112 {
2113 "changed_keys": changed_keys or [],
2114 "settings": settings_data,
2115 "timestamp": datetime.now(UTC).isoformat(),
2116 },
2117 )
2119 logger.debug(
2120 f"Emitted settings_changed event for keys: {changed_keys}"
2121 )
2123 except Exception:
2124 logger.exception("Failed to emit settings change event")
2125 # Don't let WebSocket emission failures break settings saving
2127 @staticmethod
2128 def get_bootstrap_env_vars() -> Dict[str, str]:
2129 """
2130 Get environment variables that must be available before database access.
2131 These are critical for system initialization.
2133 Returns:
2134 Dict mapping env var names to their descriptions
2135 """
2136 # Get bootstrap vars from env registry
2137 return env_registry.get_bootstrap_vars()
2139 @staticmethod
2140 def is_bootstrap_env_var(env_var: str) -> bool:
2141 """
2142 Check if an environment variable is a bootstrap variable (needed before DB access).
2144 Args:
2145 env_var: Environment variable name
2147 Returns:
2148 True if this is a bootstrap variable
2149 """
2150 bootstrap_vars = SettingsManager.get_bootstrap_env_vars()
2151 return env_var in bootstrap_vars
2153 @staticmethod
2154 def is_env_only_setting(key: str) -> bool:
2155 """
2156 Check if a setting key is environment-only.
2158 Args:
2159 key: Setting key to check
2161 Returns:
2162 True if it's an env-only setting, False otherwise
2163 """
2164 return env_registry.is_env_only(key)
2166 @staticmethod
2167 def get_env_var_for_setting(setting_key: str) -> str:
2168 """
2169 Get the environment variable name for a given setting key.
2171 Args:
2172 setting_key: Setting key (e.g., "app.host")
2174 Returns:
2175 Environment variable name (e.g., "LDR_APP_HOST")
2176 """
2177 # Use the same logic as check_env_setting for consistency
2178 return f"LDR_{'_'.join(setting_key.split('.')).upper()}"
2180 @staticmethod
2181 def get_setting_key_for_env_var(env_var: str) -> Optional[str]:
2182 """
2183 Get the setting key for a given environment variable.
2185 Args:
2186 env_var: Environment variable name (e.g., "LDR_APP_HOST")
2188 Returns:
2189 Setting key (e.g., "app.host") or None if not a valid LDR env var
2190 """
2191 if not env_var.startswith("LDR_"):
2192 return None
2194 # Remove LDR_ prefix and convert to lowercase
2195 without_prefix = env_var[4:]
2196 parts = without_prefix.split("_")
2198 return ".".join(part.lower() for part in parts)
2201class SnapshotSettingsContext:
2202 """Read-only settings context backed by a snapshot dict.
2204 Unwraps {"value": x} setting objects into plain values and provides
2205 get_setting(key, default) for thread-safe snapshot access.
2206 """
2208 def __init__(
2209 self, snapshot=None, username=None, missing_key_log_level="DEBUG"
2210 ):
2211 self.snapshot = snapshot or {}
2212 self.username = username
2213 self._missing_key_log_level = missing_key_log_level
2214 self.values = {}
2215 for key, setting in self.snapshot.items():
2216 if isinstance(setting, dict) and "value" in setting:
2217 self.values[key] = setting["value"]
2218 else:
2219 self.values[key] = setting
2221 def get_setting(self, key, default=None):
2222 """Return the setting value for *key*, or *default* if absent."""
2223 if key in self.values:
2224 return self.values[key]
2225 logger.log(
2226 self._missing_key_log_level,
2227 "Setting '{}' not found in snapshot, using default",
2228 key,
2229 )
2230 return default