Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions api/scheduler_task_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions api/scheduler_task_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions api/scheduler_task_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
6 changes: 4 additions & 2 deletions api/scheduler_task_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions api/scheduler_tasks_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions api/scheduler_tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions helpers/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions helpers/localization.py.dox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion helpers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion helpers/state_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions helpers/state_snapshot.py.dox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
132 changes: 132 additions & 0 deletions tests/test_timezone_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -156,16 +177,20 @@ 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},
browser_timezone="Europe/Rome",
)

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"}
Expand All @@ -182,13 +207,16 @@ def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localiz
}
),
)
saved.clear()

settings_module._apply_timezone_setting(
{**base_settings, "timezone": "America/New_York"},
browser_timezone="Europe/Rome",
)

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"
Expand Down Expand Up @@ -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 == []