From afc4b409ccd4d38db546e074693bb10c937b7714 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Sun, 5 Apr 2026 21:06:24 +0530 Subject: [PATCH 01/14] security: add session TTL and auto-expiry [#24] Session now tracks last_activity via time.monotonic(). is_expired() returns True when idle time since last activity exceeds the configurable TTL (default 1h). touch() refreshes the clock; add_message() calls touch() automatically. 10 tests covering expiry, touch, and edge cases. --- operator_use/session/views.py | 22 +++++++++--- tests/test_session_ttl.py | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/test_session_ttl.py diff --git a/operator_use/session/views.py b/operator_use/session/views.py index 35fa73b..70414dc 100644 --- a/operator_use/session/views.py +++ b/operator_use/session/views.py @@ -1,26 +1,32 @@ """Session views.""" -from dataclasses import dataclass,field +import time +from dataclasses import dataclass, field from datetime import datetime from typing import Any from operator_use.messages.service import BaseMessage +DEFAULT_SESSION_TTL = 3600.0 # 1 hour + @dataclass class Session: """Session data class.""" id: str - messages: list[BaseMessage]=field(default_factory=list) - created_at: datetime=field(default_factory=datetime.now) - updated_at: datetime=field(default_factory=datetime.now) + messages: list[BaseMessage] = field(default_factory=list) + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) metadata: dict[str, Any] = field(default_factory=dict) + ttl: float = DEFAULT_SESSION_TTL + _last_activity: float = field(init=False, default_factory=time.monotonic) def add_message(self, message: BaseMessage) -> None: """Add a message and update updated_at.""" self.messages.append(message) self.updated_at = datetime.now() + self.touch() def get_history(self) -> list[BaseMessage]: """Return the message history.""" @@ -30,3 +36,11 @@ def clear(self) -> None: """Clear all messages.""" self.messages.clear() self.updated_at = datetime.now() + + def touch(self) -> None: + """Refresh last_activity timestamp, extending the session TTL window.""" + self._last_activity = time.monotonic() + + def is_expired(self) -> bool: + """Return True if idle time since last activity exceeds the TTL.""" + return (time.monotonic() - self._last_activity) > self.ttl diff --git a/tests/test_session_ttl.py b/tests/test_session_ttl.py new file mode 100644 index 0000000..6731c87 --- /dev/null +++ b/tests/test_session_ttl.py @@ -0,0 +1,65 @@ +"""Tests for session TTL and auto-expiry. + +Validates that Session tracks last_activity, expires after its +configurable TTL, and that touch() extends the session lifetime. +""" + +from __future__ import annotations + +import time + +from operator_use.session.views import Session, DEFAULT_SESSION_TTL + + +class TestSessionTTL: + def test_new_session_not_expired(self) -> None: + session = Session(id="test-1") + assert not session.is_expired() + + def test_default_ttl_is_one_hour(self) -> None: + session = Session(id="test-2") + assert session.ttl == DEFAULT_SESSION_TTL + assert session.ttl == 3600.0 + + def test_custom_ttl(self) -> None: + session = Session(id="test-3", ttl=120.0) + assert session.ttl == 120.0 + + def test_session_expires_after_ttl(self) -> None: + session = Session(id="test-4", ttl=0.05) # 50ms TTL + assert not session.is_expired() + time.sleep(0.1) + assert session.is_expired() + + def test_touch_resets_expiry_clock(self) -> None: + session = Session(id="test-5", ttl=0.1) # 100ms TTL + time.sleep(0.06) # 60ms elapsed — not expired yet + session.touch() # reset the clock + time.sleep(0.06) # 60ms since touch — still within TTL + assert not session.is_expired() + + def test_session_expires_after_touch_if_ttl_passes(self) -> None: + session = Session(id="test-6", ttl=0.05) + session.touch() + time.sleep(0.1) # past TTL since last touch + assert session.is_expired() + + def test_zero_ttl_immediately_expired(self) -> None: + session = Session(id="test-7", ttl=0.0) + time.sleep(0.001) # any elapsed time exceeds 0s TTL + assert session.is_expired() + + def test_negative_ttl_immediately_expired(self) -> None: + session = Session(id="test-8", ttl=-1.0) + assert session.is_expired() + + def test_very_large_ttl_does_not_expire(self) -> None: + session = Session(id="test-9", ttl=1e9) + assert not session.is_expired() + + def test_multiple_touches_keep_session_alive(self) -> None: + session = Session(id="test-10", ttl=0.05) + for _ in range(5): + time.sleep(0.02) + session.touch() + assert not session.is_expired() From c5631b68fbddc7f8321b0929d15acea0bb2f81cc Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Sun, 5 Apr 2026 21:14:19 +0530 Subject: [PATCH 02/14] ci: fix ruff lint errors and upgrade aiohttp to 3.13.5 --- operator_use/agent/tools/builtin/mcp.py | 2 +- operator_use/agent/tools/builtin/skill.py | 3 +- operator_use/cli/mcp_setup.py | 2 - operator_use/cli/start.py | 7 +- operator_use/mcp/manager.py | 2 +- operator_use/mcp/tool.py | 2 +- operator_use/providers/__init__.py | 2 - operator_use/subagent/service.py | 1 - operator_use/tracing/service.py | 1 - operator_use/tracing/views.py | 2 +- operator_use/web/subagent.py | 1 - pyproject.toml | 2 +- tests/test_mcp_integration.py | 4 +- tests/test_mcp_manager.py | 4 +- uv.lock | 144 +++++++++++----------- 15 files changed, 88 insertions(+), 91 deletions(-) diff --git a/operator_use/agent/tools/builtin/mcp.py b/operator_use/agent/tools/builtin/mcp.py index f5f4673..e4068ff 100644 --- a/operator_use/agent/tools/builtin/mcp.py +++ b/operator_use/agent/tools/builtin/mcp.py @@ -73,7 +73,7 @@ async def mcp( for s in servers: status = "connected" if s["connected"] else "disconnected" tool_info = f" ({s['tool_count']} tools)" if s["connected"] else "" - agent_status = f" [you: connected]" if s["agent_connected"] else f" [you: disconnected]" + agent_status = " [you: connected]" if s["agent_connected"] else " [you: disconnected]" shared_info = f" [shared: {s['connection_count']} agent(s)]" if s["connection_count"] > 1 else "" lines.append(f" • {s['name']} [{status}]{tool_info}{agent_status}{shared_info}") return ToolResult.success_result("\n".join(lines)) diff --git a/operator_use/agent/tools/builtin/skill.py b/operator_use/agent/tools/builtin/skill.py index fd21d20..95d71a2 100644 --- a/operator_use/agent/tools/builtin/skill.py +++ b/operator_use/agent/tools/builtin/skill.py @@ -1,6 +1,5 @@ """Skill tool: load and invoke procedural skills from workspace.""" -from pathlib import Path import yaml from operator_use.tools.service import Tool, ToolResult from operator_use.config.paths import get_named_workspace_dir @@ -89,7 +88,7 @@ async def skill( response_parts.append("") if args: - response_parts.append(f"## Arguments") + response_parts.append("## Arguments") response_parts.append(f"{args}") response_parts.append("") diff --git a/operator_use/cli/mcp_setup.py b/operator_use/cli/mcp_setup.py index bcc4dba..451457c 100644 --- a/operator_use/cli/mcp_setup.py +++ b/operator_use/cli/mcp_setup.py @@ -2,8 +2,6 @@ import json from typing import Optional -from pathlib import Path -from operator_use.config.service import MCPServerConfig from operator_use.cli.tui import ( clear_screen, print_start, select, text_input, confirm, console ) diff --git a/operator_use/cli/start.py b/operator_use/cli/start.py index b2d8678..13e3c30 100644 --- a/operator_use/cli/start.py +++ b/operator_use/cli/start.py @@ -1,14 +1,20 @@ """Run Operator with channels and agents.""" +from __future__ import annotations + import asyncio import os import shutil from pathlib import Path +from typing import TYPE_CHECKING from dotenv import load_dotenv import logging from rich.console import Console +if TYPE_CHECKING: + from operator_use.mcp import MCPManager + load_dotenv() logger = logging.getLogger(__name__) @@ -72,7 +78,6 @@ def setup_logging(userdata_dir: Path, verbose: bool = False) -> None: from operator_use.config import Config, load_config, AgentDefinition from operator_use.config.paths import get_named_workspace_dir from typing import Optional -from pathlib import Path LLM_CLASS_MAP = { "openai": "ChatOpenAI", diff --git a/operator_use/mcp/manager.py b/operator_use/mcp/manager.py index 53d6554..e7b77e6 100644 --- a/operator_use/mcp/manager.py +++ b/operator_use/mcp/manager.py @@ -2,7 +2,7 @@ import logging from contextlib import AsyncExitStack -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from operator_use.mcp.tool import MCPTool diff --git a/operator_use/mcp/tool.py b/operator_use/mcp/tool.py index 5db0745..3afe75e 100644 --- a/operator_use/mcp/tool.py +++ b/operator_use/mcp/tool.py @@ -1,6 +1,6 @@ """MCP Tool — a Tool backed by a remote MCP server.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from operator_use.tools.service import Tool, ToolResult if TYPE_CHECKING: diff --git a/operator_use/providers/__init__.py b/operator_use/providers/__init__.py index 56fed6d..e1d0a7f 100644 --- a/operator_use/providers/__init__.py +++ b/operator_use/providers/__init__.py @@ -78,7 +78,6 @@ # Image generation providers from operator_use.providers.openai import ImageOpenAI from operator_use.providers.google import ImageGoogle -from operator_use.providers.xai import ImageXai try: from operator_use.providers.together import ImageTogether @@ -90,7 +89,6 @@ except ImportError: pass from operator_use.providers.groq import TTSGroq -from operator_use.providers.xai import TTSXai try: from operator_use.providers.elevenlabs import TTSElevenLabs diff --git a/operator_use/subagent/service.py b/operator_use/subagent/service.py index c918b76..05f9ddc 100644 --- a/operator_use/subagent/service.py +++ b/operator_use/subagent/service.py @@ -31,7 +31,6 @@ import asyncio import logging -import uuid from datetime import datetime from typing import TYPE_CHECKING diff --git a/operator_use/tracing/service.py b/operator_use/tracing/service.py index ef05679..fdb6198 100644 --- a/operator_use/tracing/service.py +++ b/operator_use/tracing/service.py @@ -1,7 +1,6 @@ """Tracer — unified observability system that hooks into agent execution.""" import asyncio -import inspect import logging import uuid from datetime import datetime diff --git a/operator_use/tracing/views.py b/operator_use/tracing/views.py index b099bcf..d110a62 100644 --- a/operator_use/tracing/views.py +++ b/operator_use/tracing/views.py @@ -1,6 +1,6 @@ """Observability trace data structures.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from enum import Enum diff --git a/operator_use/web/subagent.py b/operator_use/web/subagent.py index 6f539fd..008164f 100644 --- a/operator_use/web/subagent.py +++ b/operator_use/web/subagent.py @@ -1,7 +1,6 @@ """browser_task tool — runs browser automation in an isolated context window.""" import logging -from pathlib import Path from pydantic import BaseModel, Field from operator_use.tools import Tool, ToolResult diff --git a/pyproject.toml b/pyproject.toml index ed9c1ee..1e58e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "pillow>=11.2.1", "python-telegram-bot>=22.0", "slack-bolt>=1.18.0", - "aiohttp>=3.9.0", + "aiohttp>=3.13.4", "discord.py>=2.0.0", "twitchio>=2.0.0,<3.0.0", "ipykernel>=7.2.0", diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index 59d5b2d..cbbb8ec 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -78,7 +78,7 @@ async def test_multi_agent_shared_connection_scenario(): assert "mcp_1" in manager._stacks, "Server should still be alive" print(f" [OK] Agent A disconnected, removed {len(tool_names_a)} tools") print(f" [OK] Connection count: {manager._connection_count['mcp_1']}") - print(f" [OK] Server still running (for Agent B)") + print(" [OK] Server still running (for Agent B)") # --- SCENARIO: Agent B disconnects (kills server) --- print("\n[Agent B] Disconnecting from MCP 1...") @@ -90,7 +90,7 @@ async def test_multi_agent_shared_connection_scenario(): assert "mcp_1" not in manager._stacks, "Server should be dead" print(f" [OK] Agent B disconnected, removed {len(tool_names_b)} tools") print(f" [OK] Connection count: {manager._connection_count['mcp_1']}") - print(f" [OK] Server CLOSED (no more agents)") + print(" [OK] Server CLOSED (no more agents)") # --- VERIFY: List servers shows correct state --- print("\n[List Servers] Querying connection status...") diff --git a/tests/test_mcp_manager.py b/tests/test_mcp_manager.py index 01374d9..e299999 100644 --- a/tests/test_mcp_manager.py +++ b/tests/test_mcp_manager.py @@ -173,7 +173,7 @@ async def test_disconnect_second_agent_keeps_server_alive(self, manager): manager._agent_connections["agent_a"] = {server_name} manager._agent_connections["agent_b"] = {server_name} - mock_session = AsyncMock() + _mock_session = AsyncMock() manager._tools[server_name] = [MagicMock(name="tool")] # Mock stack to avoid actual closing @@ -182,7 +182,7 @@ async def test_disconnect_second_agent_keeps_server_alive(self, manager): manager._stacks[server_name] = mock_stack # Agent A disconnects - tool_names = await manager.disconnect("agent_a", server_name) + await manager.disconnect("agent_a", server_name) # Server should still be alive assert manager._connection_count[server_name] == 1 diff --git a/uv.lock b/uv.lock index 85cb171..7318248 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,76 +29,76 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -1557,7 +1557,7 @@ tavily = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "aiohttp", specifier = ">=3.13.4" }, { name = "aiomqtt", specifier = ">=2.0.0" }, { name = "anthropic", specifier = ">=0.68.1" }, { name = "cerebras-cloud-sdk", specifier = ">=1.50.1" }, From cfcb5f7a3e0985392241444f0dd6f881bbe9d024 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Sun, 5 Apr 2026 21:19:14 +0530 Subject: [PATCH 03/14] test: update plugin tests to match refactored hook architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrowserPlugin and ComputerPlugin no longer register hooks to the main agent — subagents manage their own state injection. Test assertions updated accordingly: - Remove stale XML-tag assertions from SYSTEM_PROMPT tests - Fix browser tool name: 'browser' -> 'browser_task' - Update hook tests: register_hooks() is now a no-op for main agent, so assertions verify hooks are NOT wired (not that they are) --- tests/test_browser_plugin.py | 35 ++++++++++++------------ tests/test_computer_plugin.py | 51 +++++++++++++---------------------- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/tests/test_browser_plugin.py b/tests/test_browser_plugin.py index 970011b..4f2175b 100644 --- a/tests/test_browser_plugin.py +++ b/tests/test_browser_plugin.py @@ -23,9 +23,9 @@ def test_enabled_plugin_returns_system_prompt(): prompt = plugin.get_system_prompt() assert prompt is SYSTEM_PROMPT assert "browser" in prompt.lower() - assert "" in prompt - assert "" in prompt - assert "" in prompt + # Prompt is plain Markdown — assert on actual content, not old XML tags + assert "browser_task" in prompt + assert "Chrome" in prompt # --------------------------------------------------------------------------- @@ -36,7 +36,7 @@ def test_disabled_plugin_registers_no_tools(): plugin = BrowserPlugin(enabled=False) registry = ToolRegistry() plugin.register_tools(registry) - assert registry.get("browser") is None + assert registry.get("browser_task") is None def test_enabled_plugin_registers_browser_tool(): @@ -45,7 +45,7 @@ def test_enabled_plugin_registers_browser_tool(): plugin.browser = MagicMock() registry = ToolRegistry() plugin.register_tools(registry) - assert registry.get("browser") is not None + assert registry.get("browser_task") is not None def test_unregister_tools_removes_browser_tool(): @@ -55,11 +55,11 @@ def test_unregister_tools_removes_browser_tool(): registry = ToolRegistry() plugin.register_tools(registry) plugin.unregister_tools(registry) - assert registry.get("browser") is None + assert registry.get("browser_task") is None # --------------------------------------------------------------------------- -# register_hooks — BEFORE_LLM_CALL gated on _enabled +# register_hooks — hooks NOT registered to main agent (subagent arch) # --------------------------------------------------------------------------- def test_disabled_plugin_registers_no_hooks(): @@ -69,20 +69,20 @@ def test_disabled_plugin_registers_no_hooks(): assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] -def test_enabled_plugin_registers_state_hook(): +def test_enabled_plugin_does_not_register_state_hook_to_main_agent(): + """Hooks are intentionally not wired to main agent — subagent manages its own state.""" plugin = BrowserPlugin(enabled=False) plugin._enabled = True hooks = Hooks() plugin.register_hooks(hooks) - assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL] + assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] -def test_unregister_hooks_removes_state_hook(): +def test_unregister_hooks_is_safe_noop(): plugin = BrowserPlugin(enabled=False) - plugin._enabled = True hooks = Hooks() plugin.register_hooks(hooks) - plugin.unregister_hooks(hooks) + plugin.unregister_hooks(hooks) # must not raise assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] @@ -95,7 +95,7 @@ def test_disabled_plugin_does_not_inject_prompt(): context = MagicMock() plugin.attach_prompt(context) context.register_plugin_prompt.assert_not_called() - assert plugin._context is context # reference still stored + assert plugin._context is context def test_enabled_plugin_injects_prompt(): @@ -120,7 +120,8 @@ def test_detach_prompt_removes_injected_prompt(): # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_enable_registers_hooks_and_injects_prompt(): +async def test_enable_injects_prompt_no_hooks(): + """enable() registers tools and injects prompt — hooks NOT wired to main agent.""" plugin = BrowserPlugin(enabled=False) hooks = Hooks() plugin.register_hooks(hooks) @@ -130,12 +131,12 @@ async def test_enable_registers_hooks_and_injects_prompt(): await plugin.enable() assert plugin._enabled is True - assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL] + assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] context.register_plugin_prompt.assert_called_once_with(SYSTEM_PROMPT) @pytest.mark.asyncio -async def test_disable_unregisters_hooks_and_removes_prompt(): +async def test_disable_removes_prompt(): plugin = BrowserPlugin(enabled=False) plugin._enabled = True hooks = Hooks() @@ -172,7 +173,7 @@ async def test_enable_then_disable_leaves_no_hooks(): async def test_state_hook_skips_when_no_browser_client(): plugin = BrowserPlugin(enabled=False) plugin.browser = MagicMock() - plugin.browser._client = None # no active session + plugin.browser._client = None ctx = MagicMock() ctx.messages = [] diff --git a/tests/test_computer_plugin.py b/tests/test_computer_plugin.py index 68ad29e..14ff461 100644 --- a/tests/test_computer_plugin.py +++ b/tests/test_computer_plugin.py @@ -27,13 +27,12 @@ def test_enabled_plugin_returns_system_prompt(): prompt = plugin.get_system_prompt() assert prompt is SYSTEM_PROMPT assert "desktop" in prompt.lower() - assert "" in prompt - assert "" in prompt - assert "" in prompt + # Prompt is plain Markdown — assert on actual content, not old XML tags + assert "computer_task" in prompt # --------------------------------------------------------------------------- -# register_hooks — BEFORE_LLM_CALL + AFTER_TOOL_CALL, gated on _enabled +# register_hooks — hooks NOT registered to main agent (subagent arch) # --------------------------------------------------------------------------- def test_disabled_plugin_registers_no_hooks(): @@ -44,21 +43,21 @@ def test_disabled_plugin_registers_no_hooks(): assert plugin._wait_for_ui_hook not in hooks._handlers[HookEvent.AFTER_TOOL_CALL] -def test_enabled_plugin_registers_both_hooks(): +def test_enabled_plugin_does_not_register_hooks_to_main_agent(): + """Hooks are intentionally not wired to main agent — subagent manages its own state.""" plugin = ComputerPlugin(enabled=False) plugin._enabled = True hooks = Hooks() plugin.register_hooks(hooks) - assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL] - assert plugin._wait_for_ui_hook in hooks._handlers[HookEvent.AFTER_TOOL_CALL] + assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] + assert plugin._wait_for_ui_hook not in hooks._handlers[HookEvent.AFTER_TOOL_CALL] -def test_unregister_hooks_removes_both(): +def test_unregister_hooks_is_safe_noop(): plugin = ComputerPlugin(enabled=False) - plugin._enabled = True hooks = Hooks() plugin.register_hooks(hooks) - plugin.unregister_hooks(hooks) + plugin.unregister_hooks(hooks) # must not raise assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] assert plugin._wait_for_ui_hook not in hooks._handlers[HookEvent.AFTER_TOOL_CALL] @@ -97,7 +96,8 @@ def test_detach_prompt_removes_injected_prompt(): # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_enable_registers_both_hooks_and_prompt(): +async def test_enable_injects_prompt_no_hooks(): + """enable() registers tools and injects prompt — hooks NOT wired to main agent.""" plugin = ComputerPlugin(enabled=False) hooks = Hooks() plugin.register_hooks(hooks) @@ -107,13 +107,13 @@ async def test_enable_registers_both_hooks_and_prompt(): await plugin.enable() assert plugin._enabled is True - assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL] - assert plugin._wait_for_ui_hook in hooks._handlers[HookEvent.AFTER_TOOL_CALL] + assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL] + assert plugin._wait_for_ui_hook not in hooks._handlers[HookEvent.AFTER_TOOL_CALL] context.register_plugin_prompt.assert_called_once_with(SYSTEM_PROMPT) @pytest.mark.asyncio -async def test_disable_unregisters_both_hooks_and_removes_prompt(): +async def test_disable_removes_prompt(): plugin = ComputerPlugin(enabled=False) plugin._enabled = True hooks = Hooks() @@ -155,23 +155,11 @@ async def test_state_hook_appends_desktop_state(): mock_state.to_string.return_value = "Active: Notepad | Elements: [button 'Save']" plugin.desktop = MagicMock() - import asyncio - loop = asyncio.get_event_loop() - async def _fake_executor(exc, fn): - return fn() - plugin.desktop.get_state = MagicMock(return_value=mock_state) - - ctx = MagicMock() - ctx.messages = [] - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(loop, "run_in_executor", lambda exc, fn: asyncio.coroutine(lambda: fn())()) - # Simpler: just patch run_in_executor at the asyncio level - - # Direct call with mocked executor from unittest.mock import patch with patch("asyncio.get_event_loop") as mock_loop: mock_loop.return_value.run_in_executor = AsyncMock(return_value=mock_state) + ctx = MagicMock() + ctx.messages = [] await plugin._state_hook(ctx) assert len(ctx.messages) == 1 @@ -183,16 +171,15 @@ async def test_state_hook_handles_exception_gracefully(): plugin = ComputerPlugin(enabled=False) plugin.desktop = MagicMock() - ctx = MagicMock() - ctx.messages = [] - from unittest.mock import patch with patch("asyncio.get_event_loop") as mock_loop: mock_loop.return_value.run_in_executor = AsyncMock(side_effect=RuntimeError("accessibility error")) + ctx = MagicMock() + ctx.messages = [] result = await plugin._state_hook(ctx) assert result is ctx - assert ctx.messages == [] # no message appended on error + assert ctx.messages == [] # --------------------------------------------------------------------------- From 2040df5339d31a4caacfe6dde0e3d3e2b8bf2f2c Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Sun, 5 Apr 2026 21:25:22 +0530 Subject: [PATCH 04/14] fix: pass graceful_fn to _do_restart on restart [from security-hardening] --- operator_use/agent/tools/builtin/control_center.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/operator_use/agent/tools/builtin/control_center.py b/operator_use/agent/tools/builtin/control_center.py index bd18228..1b7845e 100644 --- a/operator_use/agent/tools/builtin/control_center.py +++ b/operator_use/agent/tools/builtin/control_center.py @@ -12,6 +12,7 @@ import json import logging import os +import subprocess import sys from typing import Optional @@ -131,7 +132,7 @@ async def _do_restart(graceful_fn=None) -> None: ``os._exit(75)`` which skips cleanup but guarantees the process terminates. """ global _requested_exit_code - os.system("cls" if os.name == "nt" else "clear") + subprocess.run(["cls"] if os.name == "nt" else ["clear"], check=False) frames = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"] for i in range(20): sys.stdout.write(f"\r {frames[i % len(frames)]} Restarting Operator...") @@ -288,7 +289,8 @@ async def control_center( if callable(on_restart): asyncio.ensure_future(on_restart()) else: - asyncio.ensure_future(_do_restart(graceful_fn=None)) # fallback: no gateway wired + graceful_fn = kwargs.get("_graceful_restart_fn") + asyncio.ensure_future(_do_restart(graceful_fn=graceful_fn)) return ToolResult.success_result(f"{msg}\nRestart initiated.", metadata={"stop_loop": True}) return ToolResult.success_result(msg) From c2e7540269815a60edb950361fadfa509da16109 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:32:13 +0530 Subject: [PATCH 05/14] =?UTF-8?q?test:=20expand=20session=20TTL=20tests=20?= =?UTF-8?q?=E2=80=94=20monkeypatch=20clock,=20cleanup,=20encryption=20[#24?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces timing-sensitive time.sleep() tests with deterministic monkeypatch clock (Bug 5). Adds test classes covering: - Config-driven TTL (Req Gap 1) - Loaded-session expiry from updated_at (Req Gap 2) - Cleanup method (Req Gap 3) - Encryption round-trip (Req Gap 3) - clear() calling touch() (Bug 4) All 25 tests are currently failing; implementation follows. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tests/test_session_ttl.py | 366 +++++++++++++++++++++++++++++++++++--- 1 file changed, 344 insertions(+), 22 deletions(-) diff --git a/tests/test_session_ttl.py b/tests/test_session_ttl.py index 6731c87..58fb207 100644 --- a/tests/test_session_ttl.py +++ b/tests/test_session_ttl.py @@ -2,64 +2,386 @@ Validates that Session tracks last_activity, expires after its configurable TTL, and that touch() extends the session lifetime. + +Covers all qodo findings for PR #32: +- Req Gap 1: TTL must be config-driven (24h default from SessionConfig) +- Req Gap 2: Loaded sessions must expire based on real age (updated_at) +- Req Gap 3: Cleanup and encryption round-trip coverage +- Bug 4: clear() must call touch() to refresh _last_activity +- Bug 5: Timing-sensitive tests replaced with monkeypatch """ from __future__ import annotations +import json +import os import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import operator_use.session.views as views_module from operator_use.session.views import Session, DEFAULT_SESSION_TTL +from operator_use.session.service import SessionStore +from operator_use.messages.service import HumanMessage +# --------------------------------------------------------------------------- +# Existing TTL expiry / touch behaviour (Bug 5 fix: use monkeypatch clock) +# --------------------------------------------------------------------------- + class TestSessionTTL: def test_new_session_not_expired(self) -> None: session = Session(id="test-1") assert not session.is_expired() - def test_default_ttl_is_one_hour(self) -> None: + def test_default_ttl_is_24_hours(self) -> None: + """After Req Gap 1 fix: default TTL must be 24 hours (86400s), not 1 hour.""" session = Session(id="test-2") assert session.ttl == DEFAULT_SESSION_TTL - assert session.ttl == 3600.0 + assert session.ttl == 86400.0 def test_custom_ttl(self) -> None: session = Session(id="test-3", ttl=120.0) assert session.ttl == 120.0 - def test_session_expires_after_ttl(self) -> None: - session = Session(id="test-4", ttl=0.05) # 50ms TTL + def test_session_expires_after_ttl(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Use monkeypatch clock — no real sleep, no CI flakiness.""" + fake_time = [0.0] + + def fake_monotonic() -> float: + return fake_time[0] + + monkeypatch.setattr(views_module.time, "monotonic", fake_monotonic) + + session = Session(id="test-4", ttl=100.0) assert not session.is_expired() - time.sleep(0.1) + + fake_time[0] = 101.0 # advance past TTL assert session.is_expired() - def test_touch_resets_expiry_clock(self) -> None: - session = Session(id="test-5", ttl=0.1) # 100ms TTL - time.sleep(0.06) # 60ms elapsed — not expired yet - session.touch() # reset the clock - time.sleep(0.06) # 60ms since touch — still within TTL + def test_touch_resets_expiry_clock(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + session = Session(id="test-5", ttl=100.0) + fake_time[0] = 60.0 # 60s elapsed — not expired + session.touch() + fake_time[0] = 120.0 # 60s after touch — within TTL assert not session.is_expired() - def test_session_expires_after_touch_if_ttl_passes(self) -> None: - session = Session(id="test-6", ttl=0.05) + def test_session_expires_after_touch_if_ttl_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + session = Session(id="test-6", ttl=100.0) + fake_time[0] = 50.0 session.touch() - time.sleep(0.1) # past TTL since last touch + fake_time[0] = 160.0 # 110s since touch — past TTL assert session.is_expired() - def test_zero_ttl_immediately_expired(self) -> None: + def test_zero_ttl_immediately_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + session = Session(id="test-7", ttl=0.0) - time.sleep(0.001) # any elapsed time exceeds 0s TTL + fake_time[0] = 0.001 # any elapsed time exceeds 0s TTL assert session.is_expired() - def test_negative_ttl_immediately_expired(self) -> None: + def test_negative_ttl_immediately_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + session = Session(id="test-8", ttl=-1.0) - assert session.is_expired() + assert session.is_expired() # negative TTL: 0 > -1 is always true + + def test_very_large_ttl_does_not_expire(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) - def test_very_large_ttl_does_not_expire(self) -> None: session = Session(id="test-9", ttl=1e9) + fake_time[0] = 1_000_000.0 assert not session.is_expired() - def test_multiple_touches_keep_session_alive(self) -> None: - session = Session(id="test-10", ttl=0.05) - for _ in range(5): - time.sleep(0.02) + def test_multiple_touches_keep_session_alive(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + session = Session(id="test-10", ttl=100.0) + for i in range(1, 6): + fake_time[0] = i * 50.0 # 50s increments — each would expire without touch session.touch() assert not session.is_expired() + + +# --------------------------------------------------------------------------- +# Bug 4: clear() must call touch() +# --------------------------------------------------------------------------- + +class TestClearCallsTouch: + def test_clear_refreshes_last_activity(self, monkeypatch: pytest.MonkeyPatch) -> None: + """clear() is a mutating operation; it must extend the TTL window.""" + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + session = Session(id="clear-1", ttl=100.0) + # Advance to just before expiry + fake_time[0] = 95.0 + # Clearing the session is activity — it must reset _last_activity + session.clear() + # Now advance another 95s (total 190s, but only 95s since clear) + fake_time[0] = 190.0 + assert not session.is_expired() + + def test_clear_without_touch_would_expire(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify the test logic: without calling touch() the session expires.""" + fake_time = [0.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + session = Session(id="clear-2", ttl=100.0) + fake_time[0] = 101.0 # past TTL — would expire if clear doesn't touch + # clear() must touch, so session should NOT be expired after clear + session.clear() + assert not session.is_expired() + + +# --------------------------------------------------------------------------- +# Req Gap 1: TTL from config (SessionConfig in Config) +# --------------------------------------------------------------------------- + +class TestConfigDrivenTTL: + def test_session_config_has_ttl_hours_field(self) -> None: + """SessionConfig must exist with a ttl_hours field defaulting to 24.0.""" + from operator_use.config.service import SessionConfig + sc = SessionConfig() + assert sc.ttl_hours == 24.0 + + def test_config_has_session_block(self) -> None: + """Root Config must have a session: SessionConfig field.""" + from operator_use.config.service import Config + c = Config() + assert hasattr(c, "session") + assert c.session.ttl_hours == 24.0 + + def test_from_config_uses_config_ttl(self) -> None: + """Session.from_config() must derive ttl from config.session.ttl_hours.""" + from operator_use.config.service import Config, SessionConfig + config = Config() + # Patch ttl_hours to a known value + config.session = SessionConfig(ttl_hours=2.0) + session = Session.from_config(id="cfg-1", config=config) + assert session.ttl == 2.0 * 3600 # 7200s + + def test_from_config_default_24h(self) -> None: + """from_config() with default config must produce 86400s TTL.""" + from operator_use.config.service import Config + config = Config() + session = Session.from_config(id="cfg-2", config=config) + assert session.ttl == 86400.0 + + +# --------------------------------------------------------------------------- +# Req Gap 2: Loaded sessions expire based on real age (updated_at) +# --------------------------------------------------------------------------- + +class TestLoadedSessionExpiry: + def test_loaded_session_last_activity_reflects_updated_at( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A session loaded from disk must base _last_activity on updated_at, + not on the current monotonic time at load time.""" + fake_time = [1000.0] # monotonic at "load time" + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + store = SessionStore(tmp_path) + session_id = "loaded-expiry-1" + + # Write a session that was last updated 2 hours ago + two_hours_ago = datetime.now() - timedelta(hours=2) + path = store._sessions_path(session_id) + meta = { + "type": "metadata", + "id": session_id, + "created_at": (datetime.now() - timedelta(hours=4)).isoformat(), + "updated_at": two_hours_ago.isoformat(), + "metadata": {}, + } + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + f.write(json.dumps(meta) + "\n") + + # Load the session with a 1-hour TTL — it should appear expired + # because updated_at is 2 hours ago + loaded = store.load(session_id) + assert loaded is not None + loaded.ttl = 3600.0 # 1 hour TTL + assert loaded.is_expired(), ( + "Session updated 2 hours ago with 1h TTL must appear expired on load" + ) + + def test_loaded_recent_session_not_expired( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A session updated 5 minutes ago with 1h TTL must NOT be expired.""" + fake_time = [1000.0] + monkeypatch.setattr(views_module.time, "monotonic", lambda: fake_time[0]) + + store = SessionStore(tmp_path) + session_id = "loaded-fresh-1" + + five_min_ago = datetime.now() - timedelta(minutes=5) + path = store._sessions_path(session_id) + meta = { + "type": "metadata", + "id": session_id, + "created_at": (datetime.now() - timedelta(hours=1)).isoformat(), + "updated_at": five_min_ago.isoformat(), + "metadata": {}, + } + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + f.write(json.dumps(meta) + "\n") + + loaded = store.load(session_id) + assert loaded is not None + loaded.ttl = 3600.0 # 1 hour TTL + assert not loaded.is_expired(), ( + "Session updated 5 minutes ago with 1h TTL must NOT be expired" + ) + + def test_get_or_create_deletes_expired_session(self, tmp_path: Path) -> None: + """get_or_create() must invalidate/delete expired sessions on access.""" + store = SessionStore(tmp_path) + session_id = "expired-cleanup-1" + + # Write a session that was last updated 48 hours ago + old_time = datetime.now() - timedelta(hours=48) + path = store._sessions_path(session_id) + meta = { + "type": "metadata", + "id": session_id, + "created_at": old_time.isoformat(), + "updated_at": old_time.isoformat(), + "metadata": {}, + } + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + f.write(json.dumps(meta) + "\n") + + # get_or_create with 1h TTL must return a fresh session, not the expired one + session = store.get_or_create(session_id=session_id, ttl=3600.0) + assert session.messages == [], "Expired session must be replaced with fresh one" + assert not session.is_expired(), "Newly created replacement session must not be expired" + + +# --------------------------------------------------------------------------- +# Req Gap 3: Cleanup method +# --------------------------------------------------------------------------- + +class TestSessionCleanup: + def test_cleanup_removes_expired_sessions(self, tmp_path: Path) -> None: + """SessionStore.cleanup() must delete expired sessions from disk.""" + store = SessionStore(tmp_path) + + # Create an expired session file (48h old) + old_time = datetime.now() - timedelta(hours=48) + expired_id = "cleanup-expired-1" + path = store._sessions_path(expired_id) + meta = { + "type": "metadata", + "id": expired_id, + "created_at": old_time.isoformat(), + "updated_at": old_time.isoformat(), + "metadata": {}, + } + with open(path, "w") as f: + f.write(json.dumps(meta) + "\n") + + # Create a fresh session file (5 min old) + fresh_id = "cleanup-fresh-1" + fresh_path = store._sessions_path(fresh_id) + fresh_time = datetime.now() - timedelta(minutes=5) + fresh_meta = { + "type": "metadata", + "id": fresh_id, + "created_at": fresh_time.isoformat(), + "updated_at": fresh_time.isoformat(), + "metadata": {}, + } + with open(fresh_path, "w") as f: + f.write(json.dumps(fresh_meta) + "\n") + + removed = store.cleanup(ttl=3600.0) # 1h TTL + assert expired_id in removed, "Expired session must be in removed list" + assert fresh_id not in removed, "Fresh session must NOT be removed" + assert not path.exists(), "Expired session file must be deleted from disk" + assert fresh_path.exists(), "Fresh session file must survive cleanup" + + def test_cleanup_returns_empty_list_when_nothing_expired(self, tmp_path: Path) -> None: + """cleanup() returns an empty list when no sessions are expired.""" + store = SessionStore(tmp_path) + removed = store.cleanup(ttl=86400.0) + assert removed == [] + + +# --------------------------------------------------------------------------- +# Req Gap 3: Encryption round-trip +# --------------------------------------------------------------------------- + +class TestSessionEncryption: + def test_save_and_load_with_encryption(self, tmp_path: Path) -> None: + """Encrypted-at-rest sessions must survive a save→load round-trip.""" + from cryptography.fernet import Fernet + key = Fernet.generate_key().decode() + + store = SessionStore(tmp_path, encryption_key=key) + session = Session(id="enc-1", ttl=86400.0) + session.add_message(HumanMessage(content="secret message")) + store.save(session) + + loaded = store.load("enc-1") + assert loaded is not None + assert len(loaded.messages) == 1 + assert loaded.messages[0].content == "secret message" + + def test_encrypted_file_is_not_plaintext(self, tmp_path: Path) -> None: + """When encryption is enabled, the raw .jsonl file must not contain + plaintext message content.""" + from cryptography.fernet import Fernet + key = Fernet.generate_key().decode() + + store = SessionStore(tmp_path, encryption_key=key) + session = Session(id="enc-2", ttl=86400.0) + session.add_message(HumanMessage(content="top secret")) + store.save(session) + + raw = store._sessions_path("enc-2").read_bytes() + assert b"top secret" not in raw, ( + "Plaintext message content must not appear in the encrypted file" + ) + + def test_load_without_key_when_saved_with_key_raises(self, tmp_path: Path) -> None: + """Loading an encrypted session without a key must raise an error.""" + from cryptography.fernet import Fernet + key = Fernet.generate_key().decode() + + store_with_key = SessionStore(tmp_path, encryption_key=key) + session = Session(id="enc-3", ttl=86400.0) + session.add_message(HumanMessage(content="confidential")) + store_with_key.save(session) + + store_no_key = SessionStore(tmp_path) + with pytest.raises(Exception): + store_no_key.load("enc-3") + + def test_unencrypted_save_load_round_trip(self, tmp_path: Path) -> None: + """Without encryption, save→load must still work correctly (regression guard).""" + store = SessionStore(tmp_path) + session = Session(id="plain-1", ttl=86400.0) + session.add_message(HumanMessage(content="hello")) + store.save(session) + + loaded = store.load("plain-1") + assert loaded is not None + assert loaded.messages[0].content == "hello" From 1ed6978396f50fbeb02a2f9b3f9e478a4edf9955 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:32:21 +0530 Subject: [PATCH 06/14] fix: make session TTL config-driven with 24h default [#24] - Change DEFAULT_SESSION_TTL from 3600.0 (1h) to 86400.0 (24h) - Use __post_init__ for _last_activity so monkeypatch can override time.monotonic before Session() is constructed (fixes timing-sensitive tests) - Add touch() call to clear() so session clears refresh the TTL window (Bug 4) - Add from_config() classmethod to source TTL from Config.session.ttl_hours - Add _from_persisted() classmethod that back-dates _last_activity from updated_at so loaded sessions expire based on real idle time (Req Gap 2) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/session/views.py | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/operator_use/session/views.py b/operator_use/session/views.py index 70414dc..d2f39e4 100644 --- a/operator_use/session/views.py +++ b/operator_use/session/views.py @@ -1,13 +1,16 @@ -"""Session views.""" +"""Session views.""" import time from dataclasses import dataclass, field from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from operator_use.messages.service import BaseMessage -DEFAULT_SESSION_TTL = 3600.0 # 1 hour +if TYPE_CHECKING: + from operator_use.config.service import Config + +DEFAULT_SESSION_TTL = 86400.0 # 24 hours (config-driven default) @dataclass @@ -20,7 +23,12 @@ class Session: updated_at: datetime = field(default_factory=datetime.now) metadata: dict[str, Any] = field(default_factory=dict) ttl: float = DEFAULT_SESSION_TTL - _last_activity: float = field(init=False, default_factory=time.monotonic) + # _last_activity is set in __post_init__ so that tests can monkeypatch + # time.monotonic before instantiation and get a consistent starting value. + _last_activity: float = field(init=False, default=0.0) + + def __post_init__(self) -> None: + self._last_activity = time.monotonic() def add_message(self, message: BaseMessage) -> None: """Add a message and update updated_at.""" @@ -33,9 +41,10 @@ def get_history(self) -> list[BaseMessage]: return list(self.messages) def clear(self) -> None: - """Clear all messages.""" + """Clear all messages and refresh the TTL window.""" self.messages.clear() self.updated_at = datetime.now() + self.touch() def touch(self) -> None: """Refresh last_activity timestamp, extending the session TTL window.""" @@ -44,3 +53,34 @@ def touch(self) -> None: def is_expired(self) -> bool: """Return True if idle time since last activity exceeds the TTL.""" return (time.monotonic() - self._last_activity) > self.ttl + + @classmethod + def from_config(cls, id: str, config: "Config") -> "Session": + """Construct a Session using TTL from config.session.ttl_hours.""" + ttl = config.session.ttl_hours * 3600 + return cls(id=id, ttl=ttl) + + @classmethod + def _from_persisted( + cls, + id: str, + messages: list[BaseMessage], + created_at: datetime, + updated_at: datetime, + metadata: dict[str, Any], + ttl: float = DEFAULT_SESSION_TTL, + ) -> "Session": + """Reconstruct a Session from disk, anchoring _last_activity to the + real idle time derived from updated_at so that loaded sessions expire + correctly rather than resetting to 'now'.""" + session = cls( + id=id, + messages=messages, + created_at=created_at, + updated_at=updated_at, + metadata=metadata, + ttl=ttl, + ) + idle_seconds = max(0.0, (datetime.now() - updated_at).total_seconds()) + session._last_activity = time.monotonic() - idle_seconds + return session From f3def72f5505355c85ae31f5cf843462e5d0a0d8 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:32:26 +0530 Subject: [PATCH 07/14] fix: add SessionConfig to Config with ttl_hours=24.0 default [#24] - Add SessionConfig(Base) to operator_use/config/service.py with ttl_hours (float, default 24.0) and encrypt (bool, default False) fields - Add session: SessionConfig field to root Config class - Export SessionConfig from operator_use/config/__init__.py This satisfies Req Gap 1: session TTL is now configurable via config.json under the "session" block, with a 24-hour default. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/config/__init__.py | 2 ++ operator_use/config/service.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/operator_use/config/__init__.py b/operator_use/config/__init__.py index f9ce15e..583459d 100644 --- a/operator_use/config/__init__.py +++ b/operator_use/config/__init__.py @@ -21,6 +21,7 @@ ACPAgentEntry, ACPServerSettings, HeartbeatConfig, + SessionConfig, ToolsConfig, RetryConfig, SubagentConfig, @@ -50,6 +51,7 @@ "ACPAgentEntry", "ACPServerSettings", "HeartbeatConfig", + "SessionConfig", "ToolsConfig", "RetryConfig", "SubagentConfig", diff --git a/operator_use/config/service.py b/operator_use/config/service.py index ad2bb23..94cdac0 100644 --- a/operator_use/config/service.py +++ b/operator_use/config/service.py @@ -286,6 +286,13 @@ class HeartbeatConfig(Base): llm_config: Optional[LLMConfig] = None # Dedicated LLM for heartbeat tasks +class SessionConfig(Base): + """Session lifecycle configuration.""" + + ttl_hours: float = 24.0 # Session idle timeout in hours (default: 24h) + encrypt: bool = False # Encrypt session files at rest (AES-256 via Fernet) + + class Config(BaseSettings): """Root configuration for Operator.""" @@ -297,6 +304,7 @@ class Config(BaseSettings): search: SearchConfig = Field(default_factory=SearchConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) + session: SessionConfig = Field(default_factory=SessionConfig) # Named registry of pre-approved remote ACP agents. # The LLM can only call agents listed here — it never supplies raw URLs. acp_agents: Dict[str, ACPAgentEntry] = Field(default_factory=dict) From 159f2a1dd00362184185e5c5dcb6fcffdae980f5 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:32:33 +0530 Subject: [PATCH 08/14] fix: base _last_activity on updated_at for loaded sessions; add cleanup and encryption [#24] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load() now calls Session._from_persisted() so _last_activity is anchored to real idle time (updated_at), not load time (Req Gap 2) - get_or_create() deletes expired sessions on access instead of serving them - Add cleanup(ttl) method that purges all disk sessions older than ttl - Add encryption_key param to __init__; save/load use Fernet (AES-256) when set — plaintext content never written to disk in encrypted mode (Req Gap 3) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/session/service.py | 133 ++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 16 deletions(-) diff --git a/operator_use/session/service.py b/operator_use/session/service.py index f52075d..9d04d33 100644 --- a/operator_use/session/service.py +++ b/operator_use/session/service.py @@ -1,23 +1,33 @@ -"""Session store service.""" +"""Session store service.""" import json import uuid from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, Optional from operator_use.messages.service import BaseMessage from operator_use.utils.helper import ensure_directory -from operator_use.session.views import Session +from operator_use.session.views import Session, DEFAULT_SESSION_TTL class SessionStore: - """Store for sessions, keyed by session id. Persists to JSONL files.""" + """Store for sessions, keyed by session id. Persists to JSONL files. - def __init__(self, workspace: Path): + When *encryption_key* is provided (a URL-safe base-64 Fernet key), session + files are written as a single encrypted blob instead of plain JSONL lines. + The key can be generated with ``cryptography.fernet.Fernet.generate_key()``. + """ + + def __init__(self, workspace: Path, encryption_key: Optional[str] = None): self.workspace = Path(workspace) self.sessions_dir = ensure_directory(self.workspace / "sessions") self._sessions: dict[str, Session] = {} + self._fernet = None + if encryption_key: + from cryptography.fernet import Fernet + key_bytes = encryption_key.encode() if isinstance(encryption_key, str) else encryption_key + self._fernet = Fernet(key_bytes) def _session_id_to_filename(self, session_id: str) -> str: """Make session_id filesystem-safe (e.g. `:` invalid on Windows).""" @@ -26,10 +36,14 @@ def _session_id_to_filename(self, session_id: str) -> str: def _sessions_path(self, session_id: str) -> Path: return self.sessions_dir / f"{self._session_id_to_filename(session_id)}.jsonl" - def load(self, session_id: str) -> Session | None: + def load(self, session_id: str, ttl: float = DEFAULT_SESSION_TTL) -> Session | None: path = self._sessions_path(session_id) if not path.exists(): return None + + if self._fernet: + return self._load_encrypted(session_id, path, ttl) + messages: list[BaseMessage] = [] created_at = datetime.now() updated_at = datetime.now() @@ -49,16 +63,52 @@ def load(self, session_id: str) -> Session | None: continue if "role" in obj: messages.append(BaseMessage.from_dict(obj)) - return Session( + + return Session._from_persisted( id=session_id, messages=messages, created_at=created_at, updated_at=updated_at, metadata=metadata, + ttl=ttl, + ) + + def _load_encrypted(self, session_id: str, path: Path, ttl: float) -> Session | None: + """Load and decrypt a session file written by _save_encrypted().""" + if self._fernet is None: + raise ValueError( + f"Session {session_id!r} appears to be encrypted but no encryption_key was provided." + ) + raw = path.read_bytes() + try: + decrypted = self._fernet.decrypt(raw) + except Exception as exc: + raise ValueError( + f"Failed to decrypt session {session_id!r}. " + "Ensure the correct encryption_key is configured." + ) from exc + + payload = json.loads(decrypted.decode()) + created_at = datetime.fromisoformat(payload.get("created_at", datetime.now().isoformat())) + updated_at = datetime.fromisoformat(payload.get("updated_at", datetime.now().isoformat())) + metadata = payload.get("metadata", {}) + messages = [BaseMessage.from_dict(m) for m in payload.get("messages", [])] + return Session._from_persisted( + id=session_id, + messages=messages, + created_at=created_at, + updated_at=updated_at, + metadata=metadata, + ttl=ttl, ) def save(self, session: Session) -> None: path = self._sessions_path(session.id) + + if self._fernet: + self._save_encrypted(session, path) + return + with open(path, "w", encoding="utf-8") as f: meta = { "type": "metadata", @@ -71,15 +121,45 @@ def save(self, session: Session) -> None: for msg in session.messages: f.write(json.dumps(msg.to_dict()) + "\n") - def get_or_create(self, session_id: str | None = None) -> Session: - """Get a session by id, or create and store a new one. Loads from JSONL if exists.""" + def _save_encrypted(self, session: Session, path: Path) -> None: + """Serialize the session to JSON and write as a Fernet-encrypted blob.""" + payload = { + "id": session.id, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + "metadata": session.metadata, + "messages": [msg.to_dict() for msg in session.messages], + } + token = self._fernet.encrypt(json.dumps(payload).encode()) + path.write_bytes(token) + + def get_or_create( + self, + session_id: Optional[str] = None, + ttl: float = DEFAULT_SESSION_TTL, + ) -> Session: + """Get a session by id, or create and store a new one. + + Loads from JSONL if exists. If the loaded session is expired (based on + real idle time derived from *updated_at*), it is deleted and a fresh + session is returned instead. + """ id = session_id or str(uuid.uuid4()) - if session := self._sessions.get(id): - return session - if session := self.load(id): - self._sessions[id] = session - return session - session = Session(id=id) + + if cached := self._sessions.get(id): + if not cached.is_expired(): + return cached + # In-memory session has expired — evict and fall through to create + del self._sessions[id] + + if session := self.load(id, ttl=ttl): + if session.is_expired(): + self.delete(id) + else: + self._sessions[id] = session + return session + + session = Session(id=id, ttl=ttl) self._sessions[id] = session return session @@ -108,6 +188,28 @@ def archive(self, session_id: str) -> bool: return True return False + def cleanup(self, ttl: float = DEFAULT_SESSION_TTL) -> list[str]: + """Delete all sessions whose idle time (since *updated_at*) exceeds *ttl*. + + Returns the list of session IDs (filesystem-safe stems) that were removed. + Archived session files are skipped. + """ + removed: list[str] = [] + for path in self.sessions_dir.glob("*.jsonl"): + # Skip archived sessions + if "_archived_" in path.stem: + continue + session_id_fs = path.stem + session = self.load(session_id_fs, ttl=ttl) + if session is None: + continue + if session.is_expired(): + path.unlink() + if session_id_fs in self._sessions: + del self._sessions[session_id_fs] + removed.append(session_id_fs) + return removed + def list_sessions(self) -> list[dict[str, Any]]: """Load sessions from the sessions directory. Returns list of dicts with id, created_at, updated_at, path.""" result: list[dict[str, Any]] = [] @@ -132,4 +234,3 @@ def list_sessions(self) -> list[dict[str, Any]]: "path": str(path), }) return result - From 36e060646140a702920f4862fe5066b27eaeaa2d Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:37:44 +0530 Subject: [PATCH 09/14] fix: add cryptography dependency for session encryption [#24] cryptography.fernet is used by SessionStore for at-rest encryption but was never declared as a project dependency, causing ImportError at runtime. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1e58e05..bcd81f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "platformdirs>=4.0.0", "psutil>=7.0.0", "pynacl>=1.6.2", + "cryptography>=41.0", "comtypes>=1.4.15; sys_platform == 'win32'", "pywin32>=311; sys_platform == 'win32'", "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", From d0589632c098ae46ec22c01822283611d86801bc Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:37:56 +0530 Subject: [PATCH 10/14] fix: remove dead SessionConfig.encrypt field [#24] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypt field was never wired to SessionStore — setting it in config silently did nothing. Encryption is opt-in via the encryption_key constructor arg on SessionStore, not a config toggle. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/config/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/operator_use/config/service.py b/operator_use/config/service.py index 94cdac0..54afd05 100644 --- a/operator_use/config/service.py +++ b/operator_use/config/service.py @@ -290,7 +290,6 @@ class SessionConfig(Base): """Session lifecycle configuration.""" ttl_hours: float = 24.0 # Session idle timeout in hours (default: 24h) - encrypt: bool = False # Encrypt session files at rest (AES-256 via Fernet) class Config(BaseSettings): From d5ccc76f35775ee044f7436b32da1e5cbdc625e4 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:38:16 +0530 Subject: [PATCH 11/14] fix: raise clear ValueError when loading encrypted session without key [#24] Before this fix, loading a Fernet-encrypted file without a key would fall through to the JSONL parser and raise an opaque JSONDecodeError. Now detects the Fernet token prefix (gAAAAA) early and raises a descriptive ValueError. Also tightens the test assertion from pytest.raises(Exception) to pytest.raises(ValueError). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/session/service.py | 6 ++++++ tests/test_session_ttl.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/operator_use/session/service.py b/operator_use/session/service.py index 9d04d33..6228afa 100644 --- a/operator_use/session/service.py +++ b/operator_use/session/service.py @@ -44,6 +44,12 @@ def load(self, session_id: str, ttl: float = DEFAULT_SESSION_TTL) -> Session | N if self._fernet: return self._load_encrypted(session_id, path, ttl) + raw = path.read_bytes() + if raw.startswith(b"gAAAAA") and self._fernet is None: + raise ValueError( + f"Session file for '{session_id}' is Fernet-encrypted but no encryption_key was provided." + ) + messages: list[BaseMessage] = [] created_at = datetime.now() updated_at = datetime.now() diff --git a/tests/test_session_ttl.py b/tests/test_session_ttl.py index 58fb207..596bfec 100644 --- a/tests/test_session_ttl.py +++ b/tests/test_session_ttl.py @@ -372,7 +372,7 @@ def test_load_without_key_when_saved_with_key_raises(self, tmp_path: Path) -> No store_with_key.save(session) store_no_key = SessionStore(tmp_path) - with pytest.raises(Exception): + with pytest.raises(ValueError): store_no_key.load("enc-3") def test_unencrypted_save_load_round_trip(self, tmp_path: Path) -> None: From 32ea3a74720b7fa7b57b006ee0cd32a49d51497e Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:38:26 +0530 Subject: [PATCH 12/14] fix: narrow except clause to InvalidToken in _load_encrypted [#24] Catching bare Exception masked any unexpected error during decryption. Now only catches cryptography.fernet.InvalidToken (wrong key or corrupt data), letting genuine unexpected errors propagate normally. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/session/service.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/operator_use/session/service.py b/operator_use/session/service.py index 6228afa..4716e3c 100644 --- a/operator_use/session/service.py +++ b/operator_use/session/service.py @@ -81,6 +81,8 @@ def load(self, session_id: str, ttl: float = DEFAULT_SESSION_TTL) -> Session | N def _load_encrypted(self, session_id: str, path: Path, ttl: float) -> Session | None: """Load and decrypt a session file written by _save_encrypted().""" + from cryptography.fernet import InvalidToken + if self._fernet is None: raise ValueError( f"Session {session_id!r} appears to be encrypted but no encryption_key was provided." @@ -88,10 +90,9 @@ def _load_encrypted(self, session_id: str, path: Path, ttl: float) -> Session | raw = path.read_bytes() try: decrypted = self._fernet.decrypt(raw) - except Exception as exc: + except InvalidToken as exc: raise ValueError( - f"Failed to decrypt session {session_id!r}. " - "Ensure the correct encryption_key is configured." + f"Failed to decrypt session '{session_id}': wrong key or corrupted data." ) from exc payload = json.loads(decrypted.decode()) From fb856525558cc492265e3683226a49a562b2bc5a Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 10:38:46 +0530 Subject: [PATCH 13/14] fix: cleanup() evicts correct session ID from memory cache [#24] cleanup() was looking up the filesystem stem (colon replaced with underscore) in self._sessions, which is keyed by the original session ID. Sessions with ':' in their IDs were never evicted from memory even after their files were deleted. Now reverse-maps stems to original IDs before evicting. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- operator_use/session/service.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/operator_use/session/service.py b/operator_use/session/service.py index 4716e3c..4f11d67 100644 --- a/operator_use/session/service.py +++ b/operator_use/session/service.py @@ -198,9 +198,16 @@ def archive(self, session_id: str) -> bool: def cleanup(self, ttl: float = DEFAULT_SESSION_TTL) -> list[str]: """Delete all sessions whose idle time (since *updated_at*) exceeds *ttl*. - Returns the list of session IDs (filesystem-safe stems) that were removed. + Returns the list of session IDs that were removed. Archived session files are skipped. """ + # Build a reverse map: filesystem stem -> original session_id (in-memory key). + # Sessions with `:` in their IDs are stored under the original ID in + # self._sessions but their filename stem uses `_` as a replacement. + stem_to_original: dict[str, str] = { + self._session_id_to_filename(sid): sid for sid in self._sessions + } + removed: list[str] = [] for path in self.sessions_dir.glob("*.jsonl"): # Skip archived sessions @@ -212,9 +219,12 @@ def cleanup(self, ttl: float = DEFAULT_SESSION_TTL) -> list[str]: continue if session.is_expired(): path.unlink() - if session_id_fs in self._sessions: - del self._sessions[session_id_fs] - removed.append(session_id_fs) + # Evict from in-memory cache using the original session ID if known, + # otherwise fall back to the filesystem-safe stem. + original_id = stem_to_original.get(session_id_fs, session_id_fs) + if original_id in self._sessions: + del self._sessions[original_id] + removed.append(original_id) return removed def list_sessions(self) -> list[dict[str, Any]]: From 2e9f957560c54dae0fabd6e5852022b5496ffb3d Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Mon, 13 Apr 2026 12:17:48 +0530 Subject: [PATCH 14/14] fix: resolve ruff lint errors [ci] - E702: split semicolon-separated statements onto individual lines in macos desktop service - F401: remove unused `Any` import from zai/llm.py - F401: remove unused `os` and `time` imports from tests/test_session_ttl.py - Include uv.lock update for cryptography dependency (from prior branch commit) --- operator_use/computer/macos/desktop/service.py | 6 ++++-- operator_use/providers/zai/llm.py | 2 +- tests/test_session_ttl.py | 2 -- uv.lock | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/operator_use/computer/macos/desktop/service.py b/operator_use/computer/macos/desktop/service.py index 0bc617f..7084e10 100644 --- a/operator_use/computer/macos/desktop/service.py +++ b/operator_use/computer/macos/desktop/service.py @@ -279,8 +279,10 @@ def draw_annotation(label: int, node: TreeElementNode) -> None: else: x1, y1 = _logical_to_pixel(box.left, box.top) x2, y2 = _logical_to_pixel(box.right, box.bottom) - x1 += padding; y1 += padding - x2 += padding; y2 += padding + x1 += padding + y1 += padding + x2 += padding + y2 += padding # Deterministic color per label random.seed(label) diff --git a/operator_use/providers/zai/llm.py b/operator_use/providers/zai/llm.py index e8a992e..44e2c55 100644 --- a/operator_use/providers/zai/llm.py +++ b/operator_use/providers/zai/llm.py @@ -1,7 +1,7 @@ import os import json import logging -from typing import Iterator, AsyncIterator, List, Optional, Any, overload +from typing import Iterator, AsyncIterator, List, Optional, overload from pydantic import BaseModel import httpx from operator_use.providers.base import BaseChatLLM diff --git a/tests/test_session_ttl.py b/tests/test_session_ttl.py index 596bfec..d78f3a8 100644 --- a/tests/test_session_ttl.py +++ b/tests/test_session_ttl.py @@ -14,8 +14,6 @@ from __future__ import annotations import json -import os -import time from datetime import datetime, timedelta from pathlib import Path diff --git a/uv.lock b/uv.lock index 7318248..69fb650 100644 --- a/uv.lock +++ b/uv.lock @@ -1505,6 +1505,7 @@ dependencies = [ { name = "cerebras-cloud-sdk" }, { name = "comtypes", marker = "sys_platform == 'win32'" }, { name = "croniter" }, + { name = "cryptography" }, { name = "ddgs" }, { name = "discord-py" }, { name = "google-genai" }, @@ -1563,6 +1564,7 @@ requires-dist = [ { name = "cerebras-cloud-sdk", specifier = ">=1.50.1" }, { name = "comtypes", marker = "sys_platform == 'win32'", specifier = ">=1.4.15" }, { name = "croniter", specifier = ">=2.0.0" }, + { name = "cryptography", specifier = ">=41.0" }, { name = "ddgs", specifier = ">=9.11.1" }, { name = "discord-py", specifier = ">=2.0.0" }, { name = "exa-py", marker = "extra == 'exa'", specifier = ">=1.0.0" },