feat(settings): expose tunable runtime settings with server-side validation

Move hardcoded constants into GlobalSettings and surface them in the
Settings UI: task retention, auth rate limit, assistant history retention,
translate baseline expiry, default environment, and extended logging fields.

- consolidated settings API: new fields in GET/PATCH with re-validation
  through GlobalSettings (422 on out-of-range instead of silent persist)
- rate limiter policy read live from settings with 60s cache + lock-free
  fast path; cache invalidated centrally in ConfigManager on auth policy
  change (covers PATCH /settings/global and /consolidated)
- shared settings_provider.get_global_settings() replaces three copies of
  the fallback pattern; scheduler baseline fallback derives from model
  default
- remove dead GlobalSettings fields (pagination_limit, ff_dataset_*,
  LLM_*_RETENTION_DAYS, GLOBAL_VALIDATION_WORKER_LIMIT, AppAsyncRuntimeConfig)
- SystemSettings blocks save on out-of-range values; LoggingSettings gains
  max_bytes/backup_count/agent_view/hide_routine_infra/log_level_for_agents;
  EnvironmentsTab gains default environment selector
- tests: rate limiter settings-driven policy, consolidated PATCH 422 paths,
  System tab save-blocking UX test
This commit is contained in:
2026-08-02 23:51:32 +07:00
parent 912583acb7
commit 52a987e415
15 changed files with 785 additions and 67 deletions

View File

@@ -14,6 +14,7 @@ import uuid
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.core.settings_provider import get_global_settings
from src.models.assistant import (
AssistantAuditRecord,
AssistantConfirmationRecord,
@@ -297,12 +298,31 @@ def _resolve_or_create_conversation(
# #endregion Api.History.ResolveOrCreateConversation
# #region Api.History.AssistantRetentionSettings [C:2] [TYPE Function]
# @BRIEF Read assistant history retention policy from GlobalSettings with fallback to module constants.
# @RATIONALE _schemas.py must stay free of ConfigManager dependencies (architectural purity), so the
# retention constants remain there as fallback defaults and this module resolves live values.
# @POST Returns (archive_after_days, message_ttl_days) from settings, or constants when config is unavailable.
def _assistant_retention_days() -> tuple[int, int]:
settings = get_global_settings()
if settings is None:
return ASSISTANT_ARCHIVE_AFTER_DAYS, ASSISTANT_MESSAGE_TTL_DAYS
return (
int(settings.assistant_archive_after_days),
int(settings.assistant_message_ttl_days),
)
# #endregion Api.History.AssistantRetentionSettings
# #region Api.History.CleanupHistoryTtl [C:2] [TYPE Function]
# @BRIEF Enforce assistant message retention window by deleting expired rows and in-memory records.
# @PRE db session is available and user_id references current actor scope.
# @POST Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors.
# @POST Messages older than the message TTL (GlobalSettings.assistant_message_ttl_days) are removed.
def _cleanup_history_ttl(db: Session, user_id: str):
cutoff = datetime.now() - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS)
_, ttl_days = _assistant_retention_days()
cutoff = datetime.now() - timedelta(days=ttl_days)
try:
query = db.query(AssistantMessageRecord).filter(
AssistantMessageRecord.user_id == user_id,
@@ -348,7 +368,8 @@ def _cleanup_history_ttl(db: Session, user_id: str):
def _is_conversation_archived(updated_at: datetime | None) -> bool:
if not updated_at:
return False
cutoff = datetime.now() - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS)
archive_days, _ = _assistant_retention_days()
cutoff = datetime.now() - timedelta(days=archive_days)
# Ensure both operands are naive (DB stores naive datetimes)
ref = updated_at.replace(tzinfo=None) if updated_at.tzinfo else updated_at
return ref < cutoff

View File

@@ -455,7 +455,10 @@ async def update_logging_config(
# Merge: admin UI only sends level / task_log_level / enable_belief_state.
# Pydantic fills other LoggingConfig fields with defaults (file_path=None),
# which would wipe file logging if we replaced wholesale.
# which would wipe file logging if we replaced wholesale. exclude_unset
# keeps only fields the caller actually provided, so extended fields
# (max_bytes, backup_count, agent_view, hide_routine_infra,
# log_level_for_agents) are preserved when not sent.
settings = config_manager.get_config().settings
existing = settings.logging
if isinstance(existing, LoggingConfig):
@@ -474,11 +477,11 @@ async def update_logging_config(
"hide_routine_infra": getattr(existing, "hide_routine_infra", True),
"log_level_for_agents": getattr(existing, "log_level_for_agents", "INFO"),
}
merged["level"] = config.level
merged["task_log_level"] = config.task_log_level
merged["enable_belief_state"] = config.enable_belief_state
if config.file_path:
merged["file_path"] = config.file_path
provided = config.model_dump(exclude_unset=True)
# Keep the original file_path unless the caller explicitly sent a non-empty one
if "file_path" in provided and not provided.get("file_path"):
provided.pop("file_path")
merged.update(provided)
settings.logging = LoggingConfig(**merged)
config_manager.update_global_settings(settings)
@@ -521,6 +524,15 @@ class ConsolidatedSettingsResponse(BaseModel):
session_idle_timeout_minutes: int = 0
session_absolute_timeout_minutes: int = 0
session_warning_minutes: int = 5
default_environment_id: str | None = None
task_retention_days: int = 30
task_retention_limit: int = 100
auth_max_attempts: int = 10
auth_attempt_window: int = 300
auth_ban_duration: int = 900
assistant_archive_after_days: int = 14
assistant_message_ttl_days: int = 90
translate_baseline_expiry_days: int = 90
# #endregion Api.Settings.ConsolidatedSettingsResponse
@@ -605,6 +617,15 @@ async def get_consolidated_settings(
session_idle_timeout_minutes=config.settings.session_idle_timeout_minutes,
session_absolute_timeout_minutes=config.settings.session_absolute_timeout_minutes,
session_warning_minutes=config.settings.session_warning_minutes,
default_environment_id=config.settings.default_environment_id,
task_retention_days=config.settings.task_retention_days,
task_retention_limit=config.settings.task_retention_limit,
auth_max_attempts=config.settings.auth_max_attempts,
auth_attempt_window=config.settings.auth_attempt_window,
auth_ban_duration=config.settings.auth_ban_duration,
assistant_archive_after_days=config.settings.assistant_archive_after_days,
assistant_message_ttl_days=config.settings.assistant_message_ttl_days,
translate_baseline_expiry_days=config.settings.translate_baseline_expiry_days,
)
logger.reflect(
"Consolidated settings payload assembled",
@@ -712,6 +733,50 @@ async def update_consolidated_settings(
if "allowed_languages" in settings_patch:
current_settings.allowed_languages = settings_patch["allowed_languages"]
# Update task retention if provided
if "task_retention_days" in settings_patch:
current_settings.task_retention_days = settings_patch["task_retention_days"]
if "task_retention_limit" in settings_patch:
current_settings.task_retention_limit = settings_patch["task_retention_limit"]
# Update default environment if provided
if "default_environment_id" in settings_patch:
new_default = settings_patch["default_environment_id"]
current_settings.default_environment_id = new_default or None
# Update auth rate limiting policy if provided
if "auth_max_attempts" in settings_patch:
current_settings.auth_max_attempts = settings_patch["auth_max_attempts"]
if "auth_attempt_window" in settings_patch:
current_settings.auth_attempt_window = settings_patch["auth_attempt_window"]
if "auth_ban_duration" in settings_patch:
current_settings.auth_ban_duration = settings_patch["auth_ban_duration"]
# Update assistant history retention if provided
if "assistant_archive_after_days" in settings_patch:
current_settings.assistant_archive_after_days = settings_patch["assistant_archive_after_days"]
if "assistant_message_ttl_days" in settings_patch:
current_settings.assistant_message_ttl_days = settings_patch["assistant_message_ttl_days"]
# Update translate baseline expiry if provided
if "translate_baseline_expiry_days" in settings_patch:
current_settings.translate_baseline_expiry_days = settings_patch["translate_baseline_expiry_days"]
# Re-validate through the model: raw dict assignments above bypass Pydantic's
# ge/le constraints (validate_assignment is off), so reconstructing via
# model_validate enforces Field bounds and rejects out-of-range values with 422
# instead of silently persisting a config that would fall back to defaults on
# restart (environment loss) or disable the auth rate limiter.
from pydantic import ValidationError
try:
current_settings = GlobalSettings.model_validate(current_settings.model_dump())
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail={"message": "Invalid settings value", "errors": exc.errors()},
) from exc
config_manager.update_global_settings(current_settings)
return {"status": "success", "message": "Settings updated"}

View File

@@ -37,6 +37,10 @@ from .config_models import AppConfig, Environment, GlobalSettings
from .database import SessionLocal
from .encryption import EncryptionManager, is_fernet_token
from .logger import belief_scope, configure_logger, logger
from .rate_limiter import invalidate_rate_limiter_config
# Auth-policy fields whose change must invalidate the rate limiter cache.
_AUTH_POLICY_FIELDS = ("auth_max_attempts", "auth_attempt_window", "auth_ban_duration")
# #region Core.ConfigManager [C:5] [TYPE Class]
@@ -547,7 +551,16 @@ class ConfigManager:
"task_log_level": getattr(settings.logging, "task_log_level", None),
},
)
# Auth policy changed → drop the rate limiter's cached policy so the
# new values apply immediately on every write path (consolidated PATCH,
# PATCH /global, PATCH /logging all funnel through this method).
auth_policy_changed = any(
getattr(self.config.settings, field, None) != getattr(settings, field, None)
for field in _AUTH_POLICY_FIELDS
)
self.config.settings = settings
if auth_policy_changed:
invalidate_rate_limiter_config()
# Hot-apply logging policy immediately (FR-005 / Settings → Logging).
# Without this, DB updates while live logger stays at boot-time level.
configure_logger(settings.logging)

View File

@@ -77,41 +77,6 @@ class Environment(BaseModel):
# #endregion Core.ConfigModels.Environment
# #region Core.ConfigModels.AppAsyncRuntimeConfig [TYPE DataClass]
# @ingroup Core
# @BRIEF Global application-level async runtime configuration.
# @RATIONALE Separated from EnvironmentConfig because these settings are not per-env:
# executor workers, shutdown timeout, blocking queue timeout are app-wide.
class AppAsyncRuntimeConfig(BaseModel):
db_executor_workers: int = Field(
default=10, ge=1, le=50,
description="Max worker threads for database blocking operations"
)
file_executor_workers: int = Field(
default=10, ge=1, le=50,
description="Max worker threads for file I/O blocking operations"
)
git_executor_workers: int = Field(
default=5, ge=1, le=20,
description="Max worker threads for Git blocking operations"
)
graceful_shutdown_timeout: int = Field(
default=30, ge=10, le=120,
description="Graceful shutdown timeout in seconds"
)
blocking_queue_timeout: int = Field(
default=30, ge=5, le=120,
description="Timeout for acquiring blocking executor queue slot"
)
event_bus_maxsize: int = Field(
default=10000, ge=100, le=100000,
description="Max pending events in EventBus per subscriber queue"
)
# #endregion Core.ConfigModels.AppAsyncRuntimeConfig
# #region Core.ConfigModels.LoggingConfig [TYPE DataClass]
# @ingroup Core
# @BRIEF Defines the configuration for the application's logging system.
@@ -280,24 +245,44 @@ class GlobalSettings(BaseModel):
return v
# Task retention settings
task_retention_days: int = 30
task_retention_days: int = Field(
default=30, ge=1, le=3650,
description="Tasks older than this many days are deleted by cleanup"
)
task_retention_limit: int = 100
pagination_limit: int = 10
# Migration sync settings
migration_sync_cron: str = "0 2 * * *"
# Dataset Review Feature Flags
ff_dataset_auto_review: bool = True
ff_dataset_clarification: bool = True
ff_dataset_execution: bool = True
# Auth rate limiting policy (failed login attempts per IP)
auth_max_attempts: int = Field(
default=10, ge=1, le=100,
description="Max failed auth attempts per window before IP ban"
)
auth_attempt_window: int = Field(
default=300, ge=30, le=3600,
description="Failed-attempts window in seconds"
)
auth_ban_duration: int = Field(
default=900, ge=60, le=86400,
description="IP ban duration after limit is reached (seconds)"
)
# LLM validation retention (days)
LLM_SCREENSHOT_RETENTION_DAYS: int = 30
LLM_RAW_RESPONSE_RETENTION_DAYS: int = 30
# Assistant (agent chat) history retention
assistant_archive_after_days: int = Field(
default=14, ge=1, le=365,
description="Days of inactivity before a conversation is archived"
)
assistant_message_ttl_days: int = Field(
default=90, ge=1, le=3650,
description="Message TTL in days for assistant history"
)
# Global worker limit for concurrent validation runs
GLOBAL_VALIDATION_WORKER_LIMIT: int = 3
# Translation baseline expiry (days) before new_key_only falls back to full translation
translate_baseline_expiry_days: int = Field(
default=90, ge=1, le=3650,
description="Baseline expiry in days for new_key_only translation mode"
)
# Session timeout enforcement
session_idle_timeout_minutes: int = Field(

View File

@@ -12,17 +12,71 @@ from collections import defaultdict
import threading
import time
# ── Configuration ──
from src.core.settings_provider import get_global_settings
# ── Configuration (fallback defaults; overridable via GlobalSettings) ──
# These constants mirror the GlobalSettings field defaults (auth_max_attempts /
# auth_attempt_window / auth_ban_duration) and are used only when the live
# config is unavailable. Keep them in sync with core/config_models.py.
MAX_ATTEMPTS: int = 10 # Max failed attempts per window
ATTEMPT_WINDOW: int = 300 # Window in seconds (5 min)
BAN_DURATION: int = 900 # Ban duration after limit reached (15 min)
_CONFIG_CACHE_TTL = 60.0
_config_cache: dict | None = None
_config_cache_at: float = 0.0
_config_lock = threading.Lock()
def invalidate_rate_limiter_config() -> None:
"""Drop cached settings so the next call re-reads GlobalSettings."""
global _config_cache, _config_cache_at
with _config_lock:
_config_cache = None
_config_cache_at = 0.0
def _read_config() -> dict:
"""Read rate limiting policy from GlobalSettings with fallback to module defaults.
Lock-free fast path: reference reads are atomic in CPython, so a fresh cache
hit returns without touching the global mutex. The lock is acquired only for
the miss/refill path (bounded by TTL or explicit invalidation).
"""
global _config_cache, _config_cache_at
now = time.monotonic()
cached = _config_cache
if cached is not None and (now - _config_cache_at) < _CONFIG_CACHE_TTL:
return cached
with _config_lock:
# Re-check under the lock — another thread may have refilled meanwhile.
now = time.monotonic()
if _config_cache is not None and (now - _config_cache_at) < _CONFIG_CACHE_TTL:
return _config_cache
settings = get_global_settings()
config = {
"max_attempts": MAX_ATTEMPTS,
"attempt_window": ATTEMPT_WINDOW,
"ban_duration": BAN_DURATION,
}
if settings is not None:
config = {
"max_attempts": int(settings.auth_max_attempts),
"attempt_window": int(settings.auth_attempt_window),
"ban_duration": int(settings.auth_ban_duration),
}
_config_cache = config
_config_cache_at = now
return config
class RateLimiter:
"""In-memory sliding-window rate limiter keyed by IP address.
Thread-safe. Tracks failed attempts. After MAX_ATTEMPTS failures within
ATTEMPT_WINDOW seconds, the IP is banned for BAN_DURATION seconds.
Defaults come from GlobalSettings (auth_max_attempts / auth_attempt_window /
auth_ban_duration) and fall back to module constants.
"""
def __init__(self):
@@ -54,16 +108,17 @@ class RateLimiter:
# @PRE ip is a valid IP string.
# @POST If MAX_ATTEMPTS exceeded within window, IP is banned.
def record_attempt(self, ip: str) -> None:
config = _read_config()
with self._lock:
now = time.monotonic()
window_start = now - ATTEMPT_WINDOW
window_start = now - config["attempt_window"]
# Prune old attempts
self._attempts[ip] = [t for t in self._attempts[ip] if t > window_start]
self._attempts[ip].append(now)
if len(self._attempts[ip]) > MAX_ATTEMPTS:
self._bans[ip] = now + BAN_DURATION
if len(self._attempts[ip]) > config["max_attempts"]:
self._bans[ip] = now + config["ban_duration"]
del self._attempts[ip]
# #endregion Core.RateLimiter.RecordAttempt

View File

@@ -0,0 +1,35 @@
# #region Core.SettingsProvider [C:2] [TYPE Module] [SEMANTICS settings,config,access,fallback]
# @defgroup Core Module group.
# @BRIEF Single chokepoint for reading live GlobalSettings outside request context.
# @LAYER Core
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetConfigManager]
# @RATIONALE Previously each consumer (rate limiter, assistant history, translate
# scheduler) duplicated the lazy get_config_manager() import + exception
# handling. A single provider keeps the fallback decision in one place
# and makes field renames loud instead of silently swallowed.
# @POST Returns the live GlobalSettings instance, or None when the config
# manager is unavailable (uninitialized, broken, mocked) — callers then
# apply their own documented defaults.
# @SIDE_EFFECT None — read-only accessor.
from __future__ import annotations
from typing import Any
# #region Core.SettingsProvider.GetGlobalSettings [C:2] [TYPE Function] [SEMANTICS settings,access,read]
# @ingroup Core
# @BRIEF Resolve the current GlobalSettings instance with graceful degradation.
# @PRE None.
# @POST Returns GlobalSettings instance or None. Never raises.
def get_global_settings() -> Any | None:
try:
from src.dependencies import get_config_manager
return get_config_manager().get_config().settings
except Exception:
return None
# #endregion Core.SettingsProvider.GetGlobalSettings
# #endregion Core.SettingsProvider

View File

@@ -18,15 +18,40 @@ from datetime import UTC, datetime, timedelta
import uuid
from sqlalchemy.orm import Session
from ss_tools.shared.cot_logger import seed_trace_id
from ...core.config_manager import ConfigManager
from ss_tools.shared.cot_logger import seed_trace_id
from ...core.config_models import GlobalSettings
from ...core.logger import belief_scope, logger
from ...models.translate import TranslationJob, TranslationRun, TranslationSchedule
from ...services.notifications.service import NotificationService
from .events import TranslationEventLog
# #region Plugin.Scheduler.BaselineExpiryDays [C:2] [TYPE Function] [SEMANTICS settings,baseline,expiry]
# @ingroup Translate
# @BRIEF Resolve translate baseline expiry (days) from GlobalSettings with a safe fallback.
# @RATIONALE Config managers may be mocked in tests or partially initialised, so the value
# is type-checked and falls back to the GlobalSettings model default, keeping
# the fallback in sync with the source of truth instead of a detached literal.
# @POST Returns an int >= 1.
def _baseline_expiry_days(config_manager) -> int:
if config_manager is not None:
try:
raw = config_manager.get_config().settings.translate_baseline_expiry_days
if isinstance(raw, int) and raw > 0:
return raw
except Exception:
pass
return _DEFAULT_BASELINE_EXPIRY_DAYS
_DEFAULT_BASELINE_EXPIRY_DAYS: int = GlobalSettings().translate_baseline_expiry_days
# #endregion Plugin.Scheduler.BaselineExpiryDays
# #region Plugin.Scheduler.EnsureAware [C:2] [TYPE Function] [SEMANTICS datetime,normalize,utc]
# @ingroup Translate
# @BRIEF Convert naive DB datetime to UTC-aware for safe arithmetic.
@@ -387,7 +412,7 @@ def execute_scheduled_translation(
age = datetime.now(UTC) - recent_created
else:
age = timedelta(0)
if age > timedelta(days=90):
if age > timedelta(days=_baseline_expiry_days(config_manager)):
baseline_expired = True
logger.reason("Baseline expired — full translation", {
"job_id": job_id,

View File

@@ -612,4 +612,46 @@ def test_get_connection_service_dependency():
assert isinstance(result, ConnectionService)
#endregion Test.SettingsConsolidated.TestGetConnectionServiceDependency
#region Test.SettingsConsolidated.TestPatchOutOfRangeRejected [C:2] [TYPE Function]
# @BRIEF Out-of-range auth/task values via consolidated PATCH are rejected with 422.
# @TEST_EDGE: out_of_range_rejected -> raw dict assignment must not bypass Field ge/le
def test_patch_out_of_range_rejected(mock_deps):
"""auth_max_attempts=5000 must be rejected — otherwise the rate limiter would
silently accept it, the persisted config would fail validation on restart and
fall back to defaults (environment loss)."""
payload = {"auth_max_attempts": 5000}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 422
# Nothing may be persisted when the patch is invalid
mock_deps["config"].update_global_settings.assert_not_called()
#endregion Test.SettingsConsolidated.TestPatchOutOfRangeRejected
#region Test.SettingsConsolidated.TestPatchInRangeAccepted [C:2] [TYPE Function]
# @BRIEF In-range values still pass through and reach update_global_settings.
def test_patch_in_range_accepted(mock_deps):
"""auth_max_attempts=25 is within 1..100 and must be applied."""
payload = {"auth_max_attempts": 25}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
mock_deps["config"].update_global_settings.assert_called_once()
args, _ = mock_deps["config"].update_global_settings.call_args
assert args[0].auth_max_attempts == 25
#endregion Test.SettingsConsolidated.TestPatchInRangeAccepted
#region Test.SettingsConsolidated.TestPatchZeroTaskRetentionRejected [C:2] [TYPE Function]
# @BRIEF task_retention_days=0 would delete all tasks on next cleanup — must be rejected.
# @TEST_EDGE: zero_retention_rejected -> lower bound enforced
def test_patch_zero_task_retention_rejected(mock_deps):
"""task_retention_days=0 violates ge=1 and must 422."""
payload = {"task_retention_days": 0}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 422
mock_deps["config"].update_global_settings.assert_not_called()
#endregion Test.SettingsConsolidated.TestPatchZeroTaskRetentionRejected
#endregion Test.SettingsConsolidated.TestSettingsConsolidated

View File

@@ -16,11 +16,10 @@ import threading
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import patch
import pytest
from unittest.mock import patch
from src.core.rate_limiter import RateLimiter
from src.core.rate_limiter import RateLimiter, _read_config as _original_read_config
# ── Constant overrides for fast, deterministic tests ──
# MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`)
@@ -29,10 +28,19 @@ from src.core.rate_limiter import RateLimiter
@pytest.fixture(autouse=True)
def _fast_constants(monkeypatch):
"""Monkeypatch module constants to small values for fast testing."""
"""Monkeypatch module constants to small values for fast testing.
The limiter resolves policy from GlobalSettings with a fallback to the
module constants, so the settings reader is patched to return the fast
values — keeping the sliding-window logic deterministic.
"""
monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2)
monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600)
monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600)
monkeypatch.setattr(
"src.core.rate_limiter._read_config",
lambda: {"max_attempts": 2, "attempt_window": 3600, "ban_duration": 3600},
)
@pytest.fixture
@@ -224,4 +232,63 @@ class TestRateLimiterSingleton:
assert hasattr(rate_limiter, "record_success")
# #endregion Test.RateLimiter.TestSingletonExists
class TestRateLimiterSettingsDriven:
"""Rate limiting policy resolves from GlobalSettings with constant fallback."""
# #region Test.RateLimiter.TestSettingsOverrideConstants [C:2] [TYPE Function]
# @BRIEF GlobalSettings auth_* fields override module constants when available.
def test_settings_override_constants(self, monkeypatch):
import src.core.rate_limiter as rl
class FakeSettings:
auth_max_attempts = 1
auth_attempt_window = 60
auth_ban_duration = 120
class FakeConfig:
settings = FakeSettings()
class FakeConfigManager:
def get_config(self):
return FakeConfig()
monkeypatch.setattr(
"src.dependencies.get_config_manager", lambda: FakeConfigManager(), raising=False
)
monkeypatch.setattr(rl, "_read_config", _original_read_config)
monkeypatch.setattr(rl, "_config_cache", None)
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
config = rl._read_config()
assert config == {"max_attempts": 1, "attempt_window": 60, "ban_duration": 120}
limiter = rl.RateLimiter()
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
# max_attempts=1 → 2nd attempt exceeds limit → ban
assert limiter.is_banned("10.0.0.1") is True
# #endregion Test.RateLimiter.TestSettingsOverrideConstants
# #region Test.RateLimiter.TestFallbackOnConfigError [C:2] [TYPE Function]
# @BRIEF Missing/broken config manager falls back to module constants.
def test_fallback_on_config_error(self, monkeypatch):
import src.core.rate_limiter as rl
def boom():
raise RuntimeError("no config")
monkeypatch.setattr(rl, "_read_config", _original_read_config)
monkeypatch.setattr(rl, "_config_cache", None)
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
monkeypatch.setattr("src.dependencies.get_config_manager", boom, raising=False)
config = rl._read_config()
assert config["max_attempts"] == rl.MAX_ATTEMPTS
assert config["attempt_window"] == rl.ATTEMPT_WINDOW
assert config["ban_duration"] == rl.BAN_DURATION
# #endregion Test.RateLimiter.TestFallbackOnConfigError
# #endregion Test.RateLimiter

View File

@@ -312,5 +312,42 @@
"session_timeout_logout": "Log out now",
"session_expired_title": "Session expired",
"session_expired_message": "Your session has expired. Please log in again.",
"session_extending": "Extending session..."
"session_extending": "Extending session...",
"default_environment": "Default environment",
"default_environment_hint": "Environment used by default when no explicit environment is selected",
"env_default_saved": "Default environment saved",
"task_retention": "Task retention",
"task_retention_description": "Automatic cleanup policy for finished task records",
"task_retention_days": "Retention (days)",
"task_retention_days_hint": "Tasks older than this many days are deleted",
"task_retention_limit": "Max stored tasks",
"task_retention_limit_hint": "Keep at most this many most recent tasks",
"auth_rate_limit": "Auth rate limit",
"auth_rate_limit_description": "Failed login attempt policy per IP address",
"auth_max_attempts": "Max failed attempts",
"auth_max_attempts_hint": "Ban the IP after this many failed attempts per window",
"auth_attempt_window": "Attempt window (seconds)",
"auth_attempt_window_hint": "Failed attempts are counted within this sliding window",
"auth_ban_duration": "Ban duration (seconds)",
"auth_ban_duration_hint": "How long the IP stays banned after the limit is reached",
"assistant_retention": "Assistant history retention",
"assistant_retention_description": "Retention policy for agent chat conversations and messages",
"assistant_archive_after_days": "Archive after (days)",
"assistant_archive_after_days_hint": "Conversations inactive longer than this are archived",
"assistant_message_ttl_days": "Message TTL (days)",
"assistant_message_ttl_days_hint": "Messages older than this are deleted from history",
"translate_baseline_expiry": "Translation baseline",
"translate_baseline_expiry_days": "Baseline expiry (days)",
"translate_baseline_expiry_hint": "After this many days without a successful run, new_key_only falls back to full translation",
"log_max_bytes": "Log file max size (bytes)",
"log_max_bytes_hint": "Rotate the log file after it reaches this size",
"log_backup_count": "Log backup count",
"log_backup_count_hint": "Number of rotated log files to keep",
"log_agent_view": "Agent view",
"log_agent_view_hint": "Prefer higher-level intents for LLM agent traces",
"log_hide_routine_infra": "Hide routine infrastructure logs",
"log_hide_routine_infra_hint": "Suppress repetitive per-request auth/client noise",
"log_level_for_agents": "Agent log level",
"log_level_for_agents_hint": "Filtering level for agent-friendly log consumers",
"validation_errors": "Fix invalid values before saving"
}

View File

@@ -312,5 +312,42 @@
"session_timeout_logout": "Выйти сейчас",
"session_expired_title": "Сессия истекла",
"session_expired_message": "Ваша сессия истекла. Пожалуйста, войдите снова.",
"session_extending": "Продление сессии..."
"session_extending": "Продление сессии...",
"default_environment": "Окружение по умолчанию",
"default_environment_hint": "Окружение, используемое по умолчанию, когда явное не выбрано",
"env_default_saved": "Окружение по умолчанию сохранено",
"task_retention": "Хранение задач",
"task_retention_description": "Политика автоматической очистки завершённых задач",
"task_retention_days": "Срок хранения (дни)",
"task_retention_days_hint": "Задачи старше указанного количества дней удаляются",
"task_retention_limit": "Максимум задач",
"task_retention_limit_hint": "Хранить не более этого количества последних задач",
"auth_rate_limit": "Лимит попыток входа",
"auth_rate_limit_description": "Политика неудачных попыток входа на IP-адрес",
"auth_max_attempts": "Максимум неудачных попыток",
"auth_max_attempts_hint": "Блокировать IP после этого числа неудачных попыток за окно",
"auth_attempt_window": "Окно попыток (секунды)",
"auth_attempt_window_hint": "Неудачные попытки считаются в скользящем окне",
"auth_ban_duration": "Длительность блокировки (секунды)",
"auth_ban_duration_hint": "Сколько IP остаётся заблокированным после достижения лимита",
"assistant_retention": "Хранение истории ассистента",
"assistant_retention_description": "Политика хранения диалогов и сообщений агент-чата",
"assistant_archive_after_days": "Архивация после (дни)",
"assistant_archive_after_days_hint": "Диалоги без активности дольше этого срока архивируются",
"assistant_message_ttl_days": "Срок жизни сообщений (дни)",
"assistant_message_ttl_days_hint": "Сообщения старше этого срока удаляются из истории",
"translate_baseline_expiry": "Базовая линия перевода",
"translate_baseline_expiry_days": "Истечение базовой линии (дни)",
"translate_baseline_expiry_hint": "После этого числа дней без успешного запуска new_key_only переходит к полному переводу",
"log_max_bytes": "Макс. размер файла лога (байты)",
"log_max_bytes_hint": "Ротация файла лога по достижении этого размера",
"log_backup_count": "Число резервных логов",
"log_backup_count_hint": "Сколько ротированных файлов лога хранить",
"log_agent_view": "Режим агента",
"log_agent_view_hint": "Предпочитать намерения высокого уровня для трасс LLM-агента",
"log_hide_routine_infra": "Скрывать инфраструктурные логи",
"log_hide_routine_infra_hint": "Подавлять повторяющийся шум авторизации/клиентов на запрос",
"log_level_for_agents": "Уровень лога для агентов",
"log_level_for_agents_hint": "Уровень фильтрации для потребителей логов агента",
"validation_errors": "Исправьте некорректные значения перед сохранением"
}

View File

@@ -138,6 +138,18 @@
showDeleteEnvConfirm = true;
}
async function handleDefaultEnvChange(event) {
const value = event.currentTarget.value || null;
log("EnvironmentsTab", "REASON", "Setting default environment", { id: value });
try {
await api.updateConsolidatedSettings({ default_environment_id: value });
notifications.success($t.settings?.env_default_saved || "Default environment saved");
} catch (error) {
log("EnvironmentsTab", "EXPLORE", "Failed to save default environment", {}, String(error));
notifications.error(error.message || $t.settings?.env_save_failed);
}
}
async function onConfirmDeleteEnv() {
const id = deleteEnvTargetId;
deleteEnvTargetId = null;
@@ -162,6 +174,26 @@
</p>
{#if !editingEnvId && !isAddingEnv}
<div class="bg-surface-muted p-6 rounded-lg mb-6 border border-border">
<label for="default_environment" class="block text-sm font-medium text-text">
{$t.settings?.default_environment || "Default environment"}
</label>
<p class="text-xs text-text-muted mb-2">
{$t.settings?.default_environment_hint || "Environment used by default when no explicit environment is selected"}
</p>
<select
id="default_environment"
bind:value={settings.default_environment_id}
onchange={handleDefaultEnvChange}
class="mt-1 block w-full max-w-md border border-border-strong rounded-md shadow-sm p-2"
>
<option value=""></option>
{#each settings.environments || [] as env (env.id)}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end mb-6">
<Button
variant="primary"

View File

@@ -74,6 +74,88 @@
{$t.settings?.belief_state_hint}
</p>
</div>
<div>
<label
for="log_level_for_agents"
class="block text-sm font-medium text-text"
>{$t.settings?.log_level_for_agents || "Agent log level"}</label
>
<select
id="log_level_for_agents"
bind:value={settings.logging.log_level_for_agents}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_level_for_agents_hint || "Filtering level for agent-friendly log consumers"}</p>
</div>
<div>
<label
for="log_max_bytes"
class="block text-sm font-medium text-text"
>{$t.settings?.log_max_bytes || "Log file max size (bytes)"}</label
>
<input
type="number"
id="log_max_bytes"
min="1048576"
step="1048576"
bind:value={settings.logging.max_bytes}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_max_bytes_hint || "Rotate the log file after it reaches this size"}</p>
</div>
<div>
<label
for="log_backup_count"
class="block text-sm font-medium text-text"
>{$t.settings?.log_backup_count || "Log backup count"}</label
>
<input
type="number"
id="log_backup_count"
min="1"
step="1"
bind:value={settings.logging.backup_count}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_backup_count_hint || "Number of rotated log files to keep"}</p>
</div>
<div>
<label class="flex items-center">
<input
type="checkbox"
id="agent_view"
bind:checked={settings.logging.agent_view}
class="h-4 w-4 text-primary border-border-strong rounded"
/>
<span class="ml-2 block text-sm text-text"
>{$t.settings?.log_agent_view || "Agent view"}</span
>
</label>
<p class="text-xs text-text-muted mt-1 ml-6">
{$t.settings?.log_agent_view_hint || "Prefer higher-level intents for LLM agent traces"}
</p>
</div>
<div>
<label class="flex items-center">
<input
type="checkbox"
id="hide_routine_infra"
bind:checked={settings.logging.hide_routine_infra}
class="h-4 w-4 text-primary border-border-strong rounded"
/>
<span class="ml-2 block text-sm text-text"
>{$t.settings?.log_hide_routine_infra || "Hide routine infrastructure logs"}</span
>
</label>
<p class="text-xs text-text-muted mt-1 ml-6">
{$t.settings?.log_hide_routine_infra_hint || "Suppress repetitive per-request auth/client noise"}
</p>
</div>
</div>
<div class="mt-6 flex justify-end">

View File

@@ -10,6 +10,7 @@
import KeyRecoveryWizard from "$lib/components/security/KeyRecoveryWizard.svelte";
import { appTimezone } from "$lib/stores/timezone.svelte.js";
import { getEncryptionHealth } from "$lib/api";
import { notifications } from "$lib/toasts.svelte.js";
import type { EncryptionHealthResponse } from "../../types/encryptionRecovery";
let { settings = $bindable(), onSave } = $props();
@@ -28,7 +29,34 @@
}
$effect(() => { loadEncHealth(); });
// Numeric bounds mirror the Field(ge/le) constraints in backend core/config_models.py.
// Inline `error` props only display the problem — saving must be blocked too,
// otherwise out-of-range values would be persisted and could reset the whole
// config on restart or disable the auth rate limiter.
const NUMERIC_FIELDS = [
{ key: "task_retention_days", min: 1, max: Infinity },
{ key: "task_retention_limit", min: 1, max: Infinity },
{ key: "auth_max_attempts", min: 1, max: 100 },
{ key: "auth_attempt_window", min: 30, max: 3600 },
{ key: "auth_ban_duration", min: 60, max: 86400 },
{ key: "assistant_archive_after_days", min: 1, max: 365 },
{ key: "assistant_message_ttl_days", min: 1, max: 3650 },
{ key: "translate_baseline_expiry_days", min: 1, max: 3650 },
] as const;
const hasValidationErrors = $derived(
NUMERIC_FIELDS.some(({ key, min, max }) => {
const value = settings[key];
if (typeof value !== "number" || Number.isNaN(value)) return false;
return value < min || value > max;
}),
);
async function handleSave() {
if (hasValidationErrors) {
notifications.error($t.settings?.validation_errors || "Fix invalid values before saving");
return;
}
// Sync app timezone to global store before persisting
if (settings.app_timezone) {
appTimezone.current = settings.app_timezone;
@@ -169,6 +197,159 @@
</div>
</div>
<!-- Task Retention Section -->
<div class="border-t border-border pt-8">
<h2 class="text-xl font-bold mb-4">{$t.settings?.task_retention || "Task retention"}</h2>
<p class="text-text-muted mb-6">{$t.settings?.task_retention_description || "Automatic cleanup policy for finished task records"}</p>
<div class="bg-surface-muted p-6 rounded-lg border border-border">
<div class="space-y-6">
<div>
<Input
type="number"
min="1"
step="1"
label={$t.settings?.task_retention_days || "Retention (days)"}
bind:value={settings.task_retention_days}
error={settings.task_retention_days < 1
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "infinity")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.task_retention_days_hint || "Tasks older than this many days are deleted"}</p>
</div>
<div>
<Input
type="number"
min="1"
step="1"
label={$t.settings?.task_retention_limit || "Max stored tasks"}
bind:value={settings.task_retention_limit}
error={settings.task_retention_limit < 1
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "infinity")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.task_retention_limit_hint || "Keep at most this many most recent tasks"}</p>
</div>
</div>
</div>
</div>
<!-- Auth Rate Limit Section -->
<div class="border-t border-border pt-8">
<h2 class="text-xl font-bold mb-4">{$t.settings?.auth_rate_limit || "Auth rate limit"}</h2>
<p class="text-text-muted mb-6">{$t.settings?.auth_rate_limit_description || "Failed login attempt policy per IP address"}</p>
<div class="bg-surface-muted p-6 rounded-lg border border-border">
<div class="space-y-6">
<div>
<Input
type="number"
min="1"
max="100"
step="1"
label={$t.settings?.auth_max_attempts || "Max failed attempts"}
bind:value={settings.auth_max_attempts}
error={settings.auth_max_attempts < 1 || settings.auth_max_attempts > 100
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "100")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_max_attempts_hint || "Ban the IP after this many failed attempts per window"}</p>
</div>
<div>
<Input
type="number"
min="30"
max="3600"
step="1"
label={$t.settings?.auth_attempt_window || "Attempt window (seconds)"}
bind:value={settings.auth_attempt_window}
error={settings.auth_attempt_window < 30 || settings.auth_attempt_window > 3600
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "30").replace("{max}", "3600")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_attempt_window_hint || "Failed attempts are counted within this sliding window"}</p>
</div>
<div>
<Input
type="number"
min="60"
max="86400"
step="1"
label={$t.settings?.auth_ban_duration || "Ban duration (seconds)"}
bind:value={settings.auth_ban_duration}
error={settings.auth_ban_duration < 60 || settings.auth_ban_duration > 86400
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "60").replace("{max}", "86400")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_ban_duration_hint || "How long the IP stays banned after the limit is reached"}</p>
</div>
</div>
</div>
</div>
<!-- Assistant Retention Section -->
<div class="border-t border-border pt-8">
<h2 class="text-xl font-bold mb-4">{$t.settings?.assistant_retention || "Assistant history retention"}</h2>
<p class="text-text-muted mb-6">{$t.settings?.assistant_retention_description || "Retention policy for agent chat conversations and messages"}</p>
<div class="bg-surface-muted p-6 rounded-lg border border-border">
<div class="space-y-6">
<div>
<Input
type="number"
min="1"
max="365"
step="1"
label={$t.settings?.assistant_archive_after_days || "Archive after (days)"}
bind:value={settings.assistant_archive_after_days}
error={settings.assistant_archive_after_days < 1 || settings.assistant_archive_after_days > 365
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "365")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.assistant_archive_after_days_hint || "Conversations inactive longer than this are archived"}</p>
</div>
<div>
<Input
type="number"
min="1"
max="3650"
step="1"
label={$t.settings?.assistant_message_ttl_days || "Message TTL (days)"}
bind:value={settings.assistant_message_ttl_days}
error={settings.assistant_message_ttl_days < 1 || settings.assistant_message_ttl_days > 3650
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "3650")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.assistant_message_ttl_days_hint || "Messages older than this are deleted from history"}</p>
</div>
</div>
</div>
</div>
<!-- Translation Baseline Section -->
<div class="border-t border-border pt-8">
<h2 class="text-xl font-bold mb-4">{$t.settings?.translate_baseline_expiry || "Translation baseline"}</h2>
<div class="bg-surface-muted p-6 rounded-lg border border-border">
<div class="space-y-6">
<div>
<Input
type="number"
min="1"
max="3650"
step="1"
label={$t.settings?.translate_baseline_expiry_days || "Baseline expiry (days)"}
bind:value={settings.translate_baseline_expiry_days}
error={settings.translate_baseline_expiry_days < 1 || settings.translate_baseline_expiry_days > 3650
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "3650")
: ""}
/>
<p class="text-xs text-text-muted mt-1">{$t.settings?.translate_baseline_expiry_hint || "After this many days without a successful run, new_key_only falls back to full translation"}</p>
</div>
</div>
</div>
</div>
<!-- API Keys Section -->
<div class="border-t border-border pt-8">
<ApiKeysTab />

View File

@@ -60,6 +60,12 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
return () => { };
}
},
locale: {
subscribe: (fn) => {
fn('en');
return () => { };
}
},
_: vi.fn((key) => key)
}));
@@ -209,6 +215,41 @@ describe('SettingsPage UX Contracts', () => {
expect(notifications.error).toHaveBeenCalledWith('Failed');
});
});
// @UX_STATE: Error -> Save blocked when numeric settings are out of range
it('should block saving when numeric settings are out of range', async () => {
const invalidSettings = {
...mockSettings,
app_timezone: 'Europe/Moscow',
session_idle_timeout_minutes: 0,
session_absolute_timeout_minutes: 0,
session_warning_minutes: 5,
auth_max_attempts: 5000,
auth_attempt_window: 300,
auth_ban_duration: 900,
task_retention_days: 30,
task_retention_limit: 100,
assistant_archive_after_days: 14,
assistant_message_ttl_days: 90,
translate_baseline_expiry_days: 90,
};
api.getConsolidatedSettings.mockResolvedValue(invalidSettings);
api.requestApi.mockResolvedValue(mockMigrationSettings);
api.updateConsolidatedSettings.mockResolvedValue({ status: 'success' });
render(SettingsPage);
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
// Navigate to the System tab (out-of-range auth_max_attempts lives there)
await fireEvent.click(screen.getAllByText('System')[0]);
const saveBtn = screen.getByText('Save Logging Config');
await fireEvent.click(saveBtn);
// Out-of-range value must not reach the API
expect(api.updateConsolidatedSettings).not.toHaveBeenCalled();
expect(notifications.error).toHaveBeenCalledWith('Fix invalid values before saving');
});
});
// #endregion Tests.SettingsPage.SettingsPageUxTestModule