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

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 

10 

11from loguru import logger 

12from sqlalchemy import and_, func, or_ 

13from sqlalchemy.exc import SQLAlchemyError 

14from sqlalchemy.orm import Session 

15 

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 

32 

33 

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"}) 

37 

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) 

42 

43 

44def is_operator_only_setting(key: str) -> bool: 

45 """Whether only trusted defaults or an LDR_* environment override may set key. 

46 

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 

56 

57 

58def parse_boolean(value: Any) -> bool: 

59 """ 

60 Convert various representations to boolean using HTML checkbox semantics. 

61 

62 This function handles form values, JSON booleans, and environment variables, 

63 ensuring consistent behavior across client and server. 

64 

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 

69 

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 

77 

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 

83 

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. 

88 

89 Args: 

90 value: Value to convert to boolean. Accepts strings, booleans, or None. 

91 

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) 

95 

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") 

102 

103 # Handle already-boolean values 

104 if isinstance(value, bool): 

105 return value 

106 

107 # Handle None (missing values) 

108 if value is None: 

109 return False 

110 

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 

119 

120 # For other types (numbers, lists, etc.), use Python's bool conversion 

121 return bool(value) 

122 

123 

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 

138 

139 

140def _parse_json_value(x): 

141 """Parse JSON ui_element values. 

142 

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 

159 

160 

161def _parse_multiselect(x): 

162 """Parse multiselect value, handling both lists and strings. 

163 

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") 

187 

188 

189def _filter_setting_columns(data: dict) -> dict: 

190 """Filter a dict to only keys that are valid Setting model columns. 

191 

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} 

197 

198 

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"] 

206 

207 

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. 

215 

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). 

223 

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. 

242 

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" 

251 

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" 

258 

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 

274 

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 

296 

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 ) 

322 

323 return None 

324 

325 

326_POLICY_AUDIT_KEYS = frozenset( 

327 { 

328 "llm.require_local_endpoint", 

329 "llm.allowed_local_hostnames", 

330 "embeddings.require_local", 

331 } 

332) 

333 

334 

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. 

339 

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 

349 

350 

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. 

356 

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 ] 

374 

375 

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. 

381 

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). 

399 

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: 

402 

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``. 

407 

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). 

420 

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__}>" 

442 

443 

444def is_valid_setting_key(key: Any) -> bool: 

445 """Return True if *key* is a well-formed setting key. 

446 

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]``. 

457 

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 ) 

471 

472 

473def _infer_ui_element(value: Any, current: str = "text") -> str: 

474 """Infer the appropriate ui_element string from a Python value's type. 

475 

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" 

490 

491 

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} 

508 

509 

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} 

521 

522 

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") 

528 

529 

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") 

540 

541 

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. 

552 

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. 

560 

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. 

564 

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. 

569 

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 

605 

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 ) 

625 

626 

627def check_env_setting(key: str) -> str | None: 

628 """ 

629 Checks environment variables for a particular setting. 

630 

631 Args: 

632 key: The database key for the setting. 

633 

634 Returns: 

635 The setting from the environment variables, or None if the variable 

636 is not set or is empty. 

637 

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 

647 

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 

667 

668 

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. 

673 

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 

712 

713 

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

719 

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 

729 

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 

748 

749 # Store the thread ID this instance was created in 

750 self._creation_thread_id = threading.get_ident() 

751 

752 # Initialize settings lock as None - will be checked lazily 

753 self.__settings_locked: Optional[bool] = None 

754 

755 # Auto-initialize settings if database is empty 

756 if self.db_session and seed_defaults: 

757 self._ensure_settings_initialized() 

758 

759 def close(self): 

760 """Close the DB session if this manager owns it. 

761 

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 

777 

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 

782 

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() 

788 

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 ) 

805 

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 ) 

817 

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) 

838 

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 

846 

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. 

855 

856 """ 

857 settings: Dict[str, Any] = {} 

858 

859 try: 

860 # Get the defaults package path 

861 defaults_path = Path(defaults.__file__).parent 

862 

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")) 

866 

867 logger.debug(f"Found {len(json_files)} JSON settings files") 

868 

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) 

875 

876 # Get relative path for logging 

877 relative_path = json_file.relative_to(defaults_path) 

878 

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 ) 

886 

887 settings.update(file_settings) 

888 logger.debug(f"Loaded {relative_path}") 

889 

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}") 

894 

895 except Exception: 

896 logger.warning("Error loading settings files") 

897 

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 

902 

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 

909 

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 

913 

914 strategies = get_available_strategies() 

915 settings["search.search_strategy"]["options"] = [ 

916 {"label": s["label"], "value": s["name"]} for s in strategies 

917 ] 

918 

919 logger.debug(f"Loaded {len(settings)} total settings") 

920 return settings 

921 

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. 

931 

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. 

938 

939 Returns: 

940 The value of the setting. 

941 

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 ) 

950 

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. 

955 

956 Args: 

957 key: The key to read. If None, it will read everything. 

958 

959 Returns: 

960 The settings it queried. 

961 

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() 

995 

996 logger.debug( 

997 "DB is disabled, reading setting '{}' from defaults file.", key 

998 ) 

999 

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 ) 

1011 

1012 return settings 

1013 

1014 def get_setting( 

1015 self, key: str, default: Any = None, check_env: bool = True 

1016 ) -> Any: 

1017 """ 

1018 Get a setting value 

1019 

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. 

1025 

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. 

1030 

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 ) 

1047 

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) 

1051 

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 

1094 

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 

1124 

1125 # Return default if not found 

1126 return default 

1127 

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. 

1133 

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. 

1139 

1140 Returns: 

1141 Boolean value of the setting 

1142 """ 

1143 value = self.get_setting(key, default, check_env) 

1144 return to_bool(value, default) 

1145 

1146 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool: 

1147 """ 

1148 Set a setting value 

1149 

1150 Args: 

1151 key: Setting key 

1152 value: Setting value 

1153 commit: Whether to commit the change 

1154 

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 

1185 

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 

1204 

1205 setting.value = value # type: ignore[assignment] 

1206 setting.updated_at = ( # type: ignore[assignment] 

1207 func.now() 

1208 ) # Explicitly set the current timestamp 

1209 

1210 # Self-heal stale ui_element from before inference was added 

1211 setting.ui_element = _infer_ui_element( 

1212 value, setting.ui_element 

1213 ) 

1214 

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 

1256 

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 

1269 

1270 # Infer ui_element from the value type 

1271 ui_element = _infer_ui_element(value) 

1272 

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) 

1283 

1284 if commit: 

1285 self.db_session.commit() 

1286 # Emit WebSocket event for settings change 

1287 self._emit_settings_changed([key]) 

1288 

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 ) 

1316 

1317 return True 

1318 except SQLAlchemyError: 

1319 logger.exception(f"Error setting value for key: {key}") 

1320 self.db_session.rollback() 

1321 return False 

1322 

1323 def clear_cache(self): 

1324 """Clear the settings cache.""" 

1325 self.__dict__.pop("default_settings", None) 

1326 logger.debug("Settings cache cleared") 

1327 

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. 

1336 

1337 This ensures that new settings added to defaults.json automatically 

1338 appear in the UI without requiring a database reset. 

1339 

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. 

1346 

1347 Returns: 

1348 Dictionary of all settings 

1349 

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 ) 

1366 

1367 result = {} 

1368 

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) 

1372 

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 = [] 

1391 

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 

1397 

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 ) 

1405 

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 } 

1438 

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 

1453 

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 

1458 

1459 strategies = get_available_strategies() 

1460 result["search.search_strategy"]["options"] = [ 

1461 {"label": s["label"], "value": s["name"]} for s in strategies 

1462 ] 

1463 

1464 return result 

1465 

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. 

1470 

1471 Args: 

1472 strict: If True, propagate database and enum query failures. 

1473 

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 ) 

1488 

1489 all_settings = self.get_all_settings(strict=strict) 

1490 settings_snapshot = {} 

1491 

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 

1497 

1498 return settings_snapshot 

1499 

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 

1505 

1506 Args: 

1507 setting: Setting object or dictionary 

1508 commit: Whether to commit the change 

1509 

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 

1521 

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 

1551 

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 

1562 

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 ) 

1570 

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 

1580 

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 

1606 

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) 

1624 

1625 if commit: 

1626 self.db_session.commit() 

1627 # Emit WebSocket event for settings change 

1628 self._emit_settings_changed([setting_obj.key]) 

1629 

1630 return db_setting 

1631 

1632 except SQLAlchemyError: 

1633 logger.exception( 

1634 f"Error creating/updating setting {setting_obj.key}" 

1635 ) 

1636 self.db_session.rollback() 

1637 return None 

1638 

1639 def delete_setting( 

1640 self, key: str, commit: bool = True, override_locked: bool = False 

1641 ) -> bool: 

1642 """ 

1643 Delete a setting 

1644 

1645 Args: 

1646 key: Setting key 

1647 commit: Whether to commit the change 

1648 override_locked: Delete even when settings are locked. 

1649 

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 

1658 

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 

1666 

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 

1672 

1673 try: 

1674 # Remove from database 

1675 result = ( 

1676 self.db_session.query(Setting) 

1677 .filter(Setting.key == key) 

1678 .delete() 

1679 ) 

1680 

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() 

1683 

1684 return result > 0 

1685 except SQLAlchemyError: 

1686 logger.exception("Error deleting setting") 

1687 self.db_session.rollback() 

1688 return False 

1689 

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. 

1697 

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``). 

1708 

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 ) 

1740 

1741 def db_version_matches_package(self) -> bool: 

1742 """ 

1743 Returns: 

1744 True if the version saved in the DB matches the package version. 

1745 

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 ) 

1758 

1759 return bool(db_version == package_version) 

1760 

1761 def update_db_version(self, commit: bool = True) -> None: 

1762 """ 

1763 Updates the version saved in the DB based on the package version. 

1764 

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}.") 

1776 

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 

1799 

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() 

1813 

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()`. 

1826 

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. 

1846 

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 

1853 

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 

1876 

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 

1954 

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 

1984 

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 ) 

1994 

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"]] 

1999 

2000 self.db_session.add( 

2001 Setting(key=key, **_filter_setting_columns(setting_values)) 

2002 ) 

2003 changed_keys.append(key) 

2004 

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) 

2027 

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 

2037 

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) 

2043 

2044 def _emit_settings_changed(self, changed_keys: Optional[List[Any]] = None): 

2045 """ 

2046 Emit WebSocket event when settings change 

2047 

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 

2055 

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 

2063 

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 

2067 

2068 username = get_current_username() 

2069 if not username: 

2070 logger.debug( 

2071 "Skipping settings_changed emit: no request username context" 

2072 ) 

2073 return 

2074 

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 } 

2108 

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 ) 

2118 

2119 logger.debug( 

2120 f"Emitted settings_changed event for keys: {changed_keys}" 

2121 ) 

2122 

2123 except Exception: 

2124 logger.exception("Failed to emit settings change event") 

2125 # Don't let WebSocket emission failures break settings saving 

2126 

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. 

2132 

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() 

2138 

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). 

2143 

2144 Args: 

2145 env_var: Environment variable name 

2146 

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 

2152 

2153 @staticmethod 

2154 def is_env_only_setting(key: str) -> bool: 

2155 """ 

2156 Check if a setting key is environment-only. 

2157 

2158 Args: 

2159 key: Setting key to check 

2160 

2161 Returns: 

2162 True if it's an env-only setting, False otherwise 

2163 """ 

2164 return env_registry.is_env_only(key) 

2165 

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. 

2170 

2171 Args: 

2172 setting_key: Setting key (e.g., "app.host") 

2173 

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()}" 

2179 

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. 

2184 

2185 Args: 

2186 env_var: Environment variable name (e.g., "LDR_APP_HOST") 

2187 

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 

2193 

2194 # Remove LDR_ prefix and convert to lowercase 

2195 without_prefix = env_var[4:] 

2196 parts = without_prefix.split("_") 

2197 

2198 return ".".join(part.lower() for part in parts) 

2199 

2200 

2201class SnapshotSettingsContext: 

2202 """Read-only settings context backed by a snapshot dict. 

2203 

2204 Unwraps {"value": x} setting objects into plain values and provides 

2205 get_setting(key, default) for thread-safe snapshot access. 

2206 """ 

2207 

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 

2220 

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