diff --git a/api/scheduler_task_create.py b/api/scheduler_task_create.py index 31522d3772..53e947585a 100644 --- a/api/scheduler_task_create.py +++ b/api/scheduler_task_create.py @@ -16,9 +16,11 @@ async def process(self, input: Input, request: Request) -> Output: """ printer = PrintStyle(italic=True, font_color="blue", padding=False) - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) scheduler = TaskScheduler.get() await scheduler.reload() diff --git a/api/scheduler_task_delete.py b/api/scheduler_task_delete.py index 80848948ae..6cdaea4a4b 100644 --- a/api/scheduler_task_delete.py +++ b/api/scheduler_task_delete.py @@ -10,9 +10,11 @@ async def process(self, input: Input, request: Request) -> Output: """ Delete a task from the scheduler by ID """ - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) scheduler = TaskScheduler.get() await scheduler.reload() diff --git a/api/scheduler_task_run.py b/api/scheduler_task_run.py index 205ab636ce..e873ee7890 100644 --- a/api/scheduler_task_run.py +++ b/api/scheduler_task_run.py @@ -12,9 +12,11 @@ async def process(self, input: Input, request: Request) -> Output: """ Manually run a task from the scheduler by ID """ - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) # Get task ID from input task_id: str = input.get("task_id", "") diff --git a/api/scheduler_task_update.py b/api/scheduler_task_update.py index dc7401c704..7e58ffae54 100644 --- a/api/scheduler_task_update.py +++ b/api/scheduler_task_update.py @@ -11,9 +11,11 @@ async def process(self, input: Input, request: Request) -> Output: """ Update an existing task in the scheduler """ - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) scheduler = TaskScheduler.get() await scheduler.reload() diff --git a/api/scheduler_tasks_list.py b/api/scheduler_tasks_list.py index 48dea38d58..f069455685 100644 --- a/api/scheduler_tasks_list.py +++ b/api/scheduler_tasks_list.py @@ -11,9 +11,11 @@ async def process(self, input: Input, request: Request) -> Output: List all tasks in the scheduler with their types """ try: - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) # Get task scheduler scheduler = TaskScheduler.get() diff --git a/api/scheduler_tick.py b/api/scheduler_tick.py index f041417567..caef3c3895 100644 --- a/api/scheduler_tick.py +++ b/api/scheduler_tick.py @@ -20,9 +20,11 @@ def requires_csrf(cls) -> bool: return False async def process(self, input: Input, request: Request) -> Output: - # Get timezone from input (do not set if not provided, we then rely on poll() to set it) + # Get timezone from input (do not set if not provided, we then rely on poll() to set it). + # The value is browser-reported, so apply it runtime-only: persisting it + # would clobber the user's saved DEFAULT_USER_TIMEZONE default. if timezone := input.get("timezone", None): - Localization.get().set_timezone(timezone) + Localization.get().set_timezone(timezone, persist=False) timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") printer = PrintStyle(font_color="green", padding=False) diff --git a/helpers/localization.py b/helpers/localization.py index eaf8c749a6..0693f5dcdb 100644 --- a/helpers/localization.py +++ b/helpers/localization.py @@ -97,8 +97,14 @@ def localize_naive_datetime(self, dt: datetime) -> datetime: except pytz.exceptions.NonExistentTimeError: return tzinfo.localize(dt, is_dst=True) - def set_timezone(self, timezone: str) -> None: - """Set the user's IANA timezone and propagate it to child processes.""" + def set_timezone(self, timezone: str, persist: bool = True) -> None: + """Set the user's IANA timezone and propagate it to child processes. + + With persist=False the change is runtime-only and the saved + DEFAULT_USER_TIMEZONE default is left untouched. Auto mode uses this + so a browser-reported (or spoofed) timezone cannot overwrite the + user's persisted default. + """ try: # Validate timezone and compute its current offset _ = pytz.timezone(timezone) @@ -114,8 +120,9 @@ def set_timezone(self, timezone: str) -> None: ) self._offset_minutes = new_offset self.timezone = timezone - save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone) - save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes)) + if persist: + save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone) + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes)) self.apply_process_timezone() self._last_timezone_change = datetime.now() except pytz.exceptions.UnknownTimeZoneError: diff --git a/helpers/localization.py.dox.md b/helpers/localization.py.dox.md index b33e410b83..5e2d89d282 100644 --- a/helpers/localization.py.dox.md +++ b/helpers/localization.py.dox.md @@ -17,6 +17,7 @@ - `get_tzinfo(self)` - `get_offset_minutes(self) -> int` - `apply_process_timezone(self) -> None` + - `set_timezone(self, timezone: str, persist: bool=...) -> None` - `now(self) -> datetime` - `now_iso(self, sep: str=..., timespec: str=...) -> str` - `localize_naive_datetime(self, dt: datetime) -> datetime` @@ -31,6 +32,7 @@ ## Key Concepts - Important called helpers/classes observed in the source: `get_dotenv_value`, `pytz.timezone`, `datetime.now`, `now_in_tz.utcoffset`, `self.now.isoformat`, `self.get_tzinfo`, `cls`, `self.set_timezone`, `self._compute_offset_minutes`, `self.apply_process_timezone`, `tzinfo.localize`, `PrintStyle.debug`, `save_dotenv_value`, `localtime_str.strip.replace`, `local_datetime_obj.astimezone`, `utc_dt.astimezone`, `local_datetime_obj.isoformat`, `dt.astimezone`, `local_dt.isoformat`, `time.tzset`. +- `set_timezone(timezone, persist=True)` writes `DEFAULT_USER_TIMEZONE`/`DEFAULT_USER_UTC_OFFSET_MINUTES` to `.env` only when `persist=True`; callers passing browser- or poll-reported timezones (state snapshot, settings AUTO mode, scheduler endpoints) must use `persist=False` so a client-reported value cannot overwrite the user's saved default. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/helpers/settings.py b/helpers/settings.py index ac6ba01512..27aea675e5 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -582,7 +582,12 @@ def _apply_timezone_setting(previous: Settings | None, browser_timezone: str | N ): return - localization.set_timezone(target_timezone) + # Auto mode follows the browser; do not persist the resolved value over + # the user's saved DEFAULT_USER_TIMEZONE default. + localization.set_timezone( + target_timezone, + persist=_settings["timezone"] != TIMEZONE_AUTO, + ) current_timezone = localization.get_timezone() if current_timezone == previous_timezone: return diff --git a/helpers/state_snapshot.py b/helpers/state_snapshot.py index b26c010a16..8ecbd2de25 100644 --- a/helpers/state_snapshot.py +++ b/helpers/state_snapshot.py @@ -258,7 +258,9 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1: localization = Localization.get() previous_timezone = localization.get_timezone() - localization.set_timezone(request.timezone) + # Poll-reported timezones are per-request rendering context; never + # persist them over the user's saved DEFAULT_USER_TIMEZONE default. + localization.set_timezone(request.timezone, persist=False) current_timezone = localization.get_timezone() if current_timezone != previous_timezone: _notify_timezone_changed(previous_timezone, current_timezone) diff --git a/helpers/state_snapshot.py.dox.md b/helpers/state_snapshot.py.dox.md index ed72f803c7..d3f0733096 100644 --- a/helpers/state_snapshot.py.dox.md +++ b/helpers/state_snapshot.py.dox.md @@ -41,6 +41,7 @@ - Important called helpers/classes observed in the source: `dataclass`, `_build_schema_from_typeddict`, `get_origin`, `timezone.strip`, `StateRequestV1`, `localization.get_timezone`, `localization.set_timezone`, `ctxid.strip`, `_coerce_non_negative_int`, `AgentContext.get_notification_manager`, `notification_manager.output`, `_get_agent_profile_labels`, `ctxs.sort`, `tasks.sort`, `validate_snapshot_schema_v1`, `_coerce_state_request_inputs`, `super.__init__`, `get_args`, `_annotation_to_isinstance_types`, `TypeError`. - Snapshot building prunes non-running in-memory contexts that were previously saved but no longer have a `chat.json`, preventing stale sidebar rows after chat files are deleted outside `/chat_remove`. +- Poll-reported timezones are per-request rendering context: `build_snapshot_from_request` applies them via `localization.set_timezone(..., persist=False)` so a wrong or spoofed client timezone never overwrites the saved `DEFAULT_USER_TIMEZONE` default in `.env`. - Notification payloads use the manager's matching GUID and cursor from the same atomic read, preventing a concurrent notification from being skipped by the WebUI. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. diff --git a/tests/test_timezone_regressions.py b/tests/test_timezone_regressions.py index 55dbcf52a4..cc95a63530 100644 --- a/tests/test_timezone_regressions.py +++ b/tests/test_timezone_regressions.py @@ -147,7 +147,28 @@ def test_settings_auto_timezone_resolves_to_browser_timezone(isolated_localizati assert hooks[0]["kwargs"]["timezone"] == "Europe/Rome" +def test_settings_auto_timezone_does_not_persist_browser_timezone(isolated_localization, monkeypatch): + saved = isolated_localization + set_test_timezone("America/New_York") + base_settings = settings_module.get_default_settings() + auto_settings = {**base_settings, "timezone": settings_module.TIMEZONE_AUTO} + monkeypatch.setattr(settings_module, "_settings", auto_settings) + monkeypatch.setattr( + plugins_module, "call_plugin_hook", lambda *args, **kwargs: None + ) + saved.clear() + + settings_module._apply_timezone_setting( + auto_settings, + browser_timezone="Europe/Rome", + ) + + assert Localization.get().get_timezone() == "Europe/Rome" + assert saved == [] + + def test_settings_fixed_timezone_ignores_browser_timezone(isolated_localization, monkeypatch): + saved = isolated_localization set_test_timezone("Europe/Rome") base_settings = settings_module.get_default_settings() monkeypatch.setattr( @@ -156,6 +177,7 @@ def test_settings_fixed_timezone_ignores_browser_timezone(isolated_localization, {**base_settings, "timezone": "America/New_York"}, ) monkeypatch.setattr(plugins_module, "call_plugin_hook", lambda *args, **kwargs: None) + saved.clear() settings_module._apply_timezone_setting( {**base_settings, "timezone": settings_module.TIMEZONE_AUTO}, @@ -163,9 +185,12 @@ def test_settings_fixed_timezone_ignores_browser_timezone(isolated_localization, ) assert Localization.get().get_timezone() == "America/New_York" + # An explicit (non-auto) timezone choice must still persist to .env. + assert ("DEFAULT_USER_TIMEZONE", "America/New_York") in saved def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localization, monkeypatch): + saved = isolated_localization set_test_timezone("Europe/Rome") base_settings = settings_module.get_default_settings() fixed_settings = {**base_settings, "timezone": "America/New_York"} @@ -182,6 +207,7 @@ def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localiz } ), ) + saved.clear() settings_module._apply_timezone_setting( {**base_settings, "timezone": "America/New_York"}, @@ -189,6 +215,8 @@ def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localiz ) assert Localization.get().get_timezone() == "America/New_York" + # An explicit (non-auto) timezone choice must still persist to .env. + assert ("DEFAULT_USER_TIMEZONE", "America/New_York") in saved assert hooks[0]["plugin_name"] == "_office" assert hooks[0]["hook_name"] == "timezone_changed" assert hooks[0]["kwargs"]["previous_timezone"] == "Europe/Rome" @@ -346,3 +374,107 @@ def fake_restart(session): "timezone": "America/New_York", } assert restarted == [old_session] + + +@pytest.mark.asyncio +async def test_poll_snapshot_applies_timezone_without_persisting(isolated_localization, monkeypatch): + saved = isolated_localization + set_test_timezone("UTC") + calls: list[dict] = [] + original_set_timezone = Localization.set_timezone + + def recording_set_timezone(self, timezone, persist=True): + calls.append({"timezone": timezone, "persist": persist}) + return original_set_timezone(self, timezone, persist=persist) + + monkeypatch.setattr(Localization, "set_timezone", recording_set_timezone) + saved.clear() + + from helpers import state_snapshot + + await state_snapshot.build_snapshot( + context=None, + log_from=0, + notifications_from=0, + timezone="Europe/Rome", + ) + + # The poll path must apply the browser timezone runtime-only. + assert calls == [{"timezone": "Europe/Rome", "persist": False}] + assert not any(key == "DEFAULT_USER_TIMEZONE" for key, _ in saved) + + +class _StubScheduler: + """Minimal TaskScheduler stand-in for scheduler endpoint tests.""" + + @classmethod + def get(cls): + return cls() + + async def reload(self): + return None + + async def add_task(self, task): + return None + + async def tick(self): + return None + + def get_task_by_uuid(self, task_id): + return None + + def get_tasks(self): + return [] + + def serialize_all_tasks(self): + return [] + + +# Behavioral coverage: every scheduler endpoint receives a browser-reported +# timezone from the WebUI scheduler store, so each must call set_timezone with +# persist=False to avoid clobbering the saved DEFAULT_USER_TIMEZONE default. +SCHEDULER_ENDPOINT_CASES = [ + ("api.scheduler_task_create", "SchedulerTaskCreate", {"name": "demo", "prompt": "run it"}), + ("api.scheduler_task_delete", "SchedulerTaskDelete", {}), + ("api.scheduler_task_run", "SchedulerTaskRun", {}), + ("api.scheduler_task_update", "SchedulerTaskUpdate", {}), + ("api.scheduler_tasks_list", "SchedulerTasksList", {}), + ("api.scheduler_tick", "SchedulerTick", {}), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "module_name,class_name,extra_input", + SCHEDULER_ENDPOINT_CASES, + ids=[case[1] for case in SCHEDULER_ENDPOINT_CASES], +) +async def test_scheduler_endpoint_does_not_persist_browser_timezone( + isolated_localization, + monkeypatch, + module_name, + class_name, + extra_input, +): + import importlib + + from flask import Flask + + saved = isolated_localization + set_test_timezone("UTC") + calls: list[dict] = [] + + def recording_set_timezone(self, timezone, persist=True): + calls.append({"timezone": timezone, "persist": persist}) + + monkeypatch.setattr(Localization, "set_timezone", recording_set_timezone) + saved.clear() + + module = importlib.import_module(module_name) + monkeypatch.setattr(module, "TaskScheduler", _StubScheduler) + handler = getattr(module, class_name)(Flask("scheduler-tz-test"), threading.RLock()) + + await handler.process({"timezone": "Europe/Rome", **extra_input}, None) + + assert calls == [{"timezone": "Europe/Rome", "persist": False}] + assert saved == []