Просмотр исходного кода

Add basedpyright gate and sweep dead instance state

Type checking now runs on basedpyright (pyrightconfig.json, dev
dependency) over all first-party code, with vendor/, build output and
the mock-heavy test modules excluded via pyrightconfig. Narrowing fixes
in the join/leave paths clear every finding it raised.

Neither ruff nor pyright sees instance state that is written but never
read, so a manual sweep of self.* fields removed leftovers no code or
test reads: _dedup (real dedupe is the _seen list), _resume_cursor,
the _ws_active/_ws_ready/_ws_ref trio from the pre-chattolib websocket
loop, and the _token/_user_* mirrors (identity lives in self.me).
Ghost fixture setters in test_adapter.py went with them.

The reconnect docs claimed resuming "from the last event cursor"; no
cursor exists anywhere — redelivered events are suppressed by an
in-memory event-ID list, and the README now says exactly that.
The Security section duplicated Usage Notes and moved into Allowed
Users.

AGENTS.md: swap Pylance for basedpyright, state that both gates cover
the whole repo's first-party code, and record the sporadic dead-field
sweep as their known blind spot.
Paul Klumpp 1 неделя назад
Родитель
Сommit
20a6047c59
7 измененных файлов с 110 добавлено и 48 удалено
  1. 19 8
      AGENTS.md
  2. 5 9
      README.md
  3. 23 24
      adapter.py
  4. 1 0
      pyproject.toml
  5. 32 0
      pyrightconfig.json
  6. 0 7
      test_adapter.py
  7. 30 0
      uv.lock

+ 19 - 8
AGENTS.md

@@ -163,23 +163,34 @@ guidelines. Optimising for that reader is not optional polish. Concretely:
 
 ### Linting & Type Checking
 
-**Run Ruff on everything you touch, and leave no Pylance findings behind.**
-
-Ruff is the linter and formatter for this repo:
+**Both gates run over the repo's whole first-party code — not just the lines
+you touched. Someone always forgets an older file otherwise.**
 
 ```bash
 ruff check .          # lint
 ruff format .         # format
+uv run basedpyright   # types
 ```
 
-Fix what `ruff check` reports instead of adding `# noqa` — if a rule genuinely
-does not fit here, that is a change to make deliberately, not inline.
+Ruff excludes `vendor/` and `dist/` via `pyproject.toml`. basedpyright's scope
+lives in `pyrightconfig.json`, which likewise skips `vendor/`, build output and
+the mock-heavy test modules; its `extraPaths` resolve both Hermes layouts from
+*Development* above.
 
-Pylance (VS Code's Python language server) is the second gate. Its errors and
-warnings are expected to be clean on files you edit; treat new squiggles as
-unfinished work. Do not silence them with `cast(Any, ...)` or `# type: ignore` —
+Fix what the tools report instead of adding `# noqa` — if a rule genuinely
+does not fit here, that is a change to make deliberately, not inline. The same
+goes for silencing type findings with `cast(Any, ...)` or `# type: ignore` —
 see *Typed access* below for why that trade hides real bugs.
 
+**Sporadic dead-field check.** Neither gate catches instance state that is
+written but never read: Ruff's F841 stops at locals, and pyright counts an
+attribute assignment as a use. Leftovers from long-gone features therefore
+survive indefinitely (`_dedup`, `_resume_cursor`, the `_ws_*` trio and the
+`_token`/`_user_*` mirrors all lived here unnoticed). Every few feature
+cycles, sweep the adapter's `self.*` fields: count assignments vs. other
+mentions per name, confirm each suspect against the test fixtures and the
+Hermes base class, and delete whatever nothing touches.
+
 ## Configuration surface
 
 **One field, three coordinated names — declared once on `ChattoConfiguration`.**

+ 5 - 9
README.md

@@ -44,7 +44,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | presence broadcasting | yes (online, refreshed) |
 | custom status | yes |
 | read state management | yes |
-| auto-reconnect | yes (with resume cursor) |
+| auto-reconnect | yes (with duplicate-event suppression) |
 
 ## Prerequisites
 
@@ -228,7 +228,7 @@ Images, videos, audio and documents are posted as native Chatto attachments; sev
 
 ### Auto-Reconnect
 
-If the realtime stream drops, the adapter reconnects automatically with exponential backoff (1 s → 30 s max, jittered) and honours server-provided retry hints. On reconnect it resumes from the last event cursor, so no messages are lost or replayed during transient disconnects.
+If the realtime stream drops, the adapter reconnects automatically with exponential backoff (1 s → 30 s max, jittered) and honours server-provided retry hints. Events the Chatto server redelivers after a reconnect are recognised by an in-memory event-ID list and processed only once.
 
 ### Read State Management
 
@@ -279,6 +279,8 @@ Setting both at once is rejected as conflicting configuration.
 
 > **Warning:** Setting `CHATTO_ALLOW_ALL_USERS=true` means any user on your Chatto server has full access to the agent's capabilities, including tool use and system access. Use this only on trusted, private Chatto instances.
 
+For deployment-level hardening beyond the bot's own gates, see the security documentation of your Hermes Agent distribution.
+
 ### Home Channel
 
 The home channel is the default outbound target for proactive messages — cron job output, reminders, and notifications when there is no inbound conversation context to reply into. Set it via:
@@ -367,7 +369,7 @@ If you see a `RealtimeError` with a protocol-related message in the logs, upgrad
 
 **Cause**: Network instability, Chatto server restarts, or firewall/proxy issues with WebSocket connections.
 
-**Fix**: The adapter automatically reconnects with exponential backoff and resumes from the last cursor. Check:
+**Fix**: The adapter automatically reconnects with exponential backoff. Check:
 
 1. Your server's WebSocket configuration — reverse proxies need upgrade headers (see above).
 2. No firewall is blocking WebSocket connections on your Chatto server.
@@ -391,12 +393,6 @@ grep -i "chatto\|websocket\|realtime" ~/.hermes/logs/gateway.log | tail -30
 
 **Fix**: Check that `hermes gateway` is running. Look at the terminal output or gateway logs for error messages. Common issues: wrong URL, expired credentials, Chatto server unreachable.
 
-## Security
-
-> **Warning:** Always set `CHATTO_ALLOWED_USERS` to restrict who can interact with the bot. Without it (and without `CHATTO_ALLOW_ALL_USERS=true`), the gateway denies all users by default as a safety measure. Only add logins of people you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
-
-For more information on securing your Hermes Agent deployment, see the security documentation of your Hermes Agent distribution.
-
 ## Notes
 
 - **Self-hosted friendly**: Works with any self-hosted Chatto instance. No cloud account or subscription required.

+ 23 - 24
adapter.py

@@ -13,8 +13,6 @@ from __future__ import annotations
 
 import random
 
-from gateway.platforms.helpers import MessageDeduplicator
-
 # Put the vendored dependencies for THIS platform on sys.path before importing
 # anything from chattolib. Imported relatively as part of the plugin package and
 # absolutely when this module is loaded standalone (e.g. by the tests).
@@ -77,7 +75,13 @@ try:
         MessagePostedPayload,
         ReactionPayload,
     )
-    from chattolib.types import PresenceStatus, RoomKind, RoomWithViewerState, User
+    from chattolib.types import (
+        PresenceStatus,
+        Room,
+        RoomKind,
+        RoomWithViewerState,
+        User,
+    )
 
 except ImportError as e:
     # Fail loudly: continuing here only defers the failure to a confusing
@@ -257,24 +261,20 @@ class ChattoAdapter(BasePlatformAdapter):
         self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
 
         # ------ State -------
-        # SDK runtime handle (injected by Hermes); annotate for Pylance
-        self.sdk: Any = getattr(self, "sdk", None)
-
         # Our own user, filled in by connect(). Events arriving before connect()
         # completes must not blow up on an undefined attribute.
         self.me: User | None = None
 
         # --- Runtime state ---
-        self._user_id: str = ""
-        self._user_display: str = ""
         self._room_names: dict[str, str] = {}
         self._room_kinds: dict[str, RoomKind] = {}
         self._our_thread_roots: set = set()  # thread root event IDs we created
         self._our_message_ids: set = (
             set()
         )  # message IDs we sent (for thread root detection)
-        self._seen: list[str] = []  # Plain RealtimeEvent-id list
-        self._resume_cursor: str | None = None
+        # Event IDs already processed — chattolib may redeliver events across
+        # reconnects, so every inbound event is checked against this list.
+        self._seen: list[str] = []
         self._watch_room_ids: list[str] = []
         # Rooms the server force-joined everyone into (Room.universal) — used
         # only for [universal] tags in the watch log, never for gating.
@@ -283,9 +283,6 @@ class ChattoAdapter(BasePlatformAdapter):
         self._home_warning_logged = False
         self._ws_task: asyncio.Task | None = None
         self._presence_task: asyncio.Task | None = None
-        self._ws_ready: asyncio.Event | None = None
-        self._ws_active = False
-        self._ws_ref = None  # reference to open websocket for dynamic resubscribe
 
         # Persistent typing indicator loops per room
         self._typing_tasks: dict[str, asyncio.Task] = {}
@@ -299,9 +296,6 @@ class ChattoAdapter(BasePlatformAdapter):
         self._chatto_client: ChattoClient | None = None
         self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
 
-        # Dedup — chattolib may redeliver events across reconnects.
-        self._dedup = MessageDeduplicator()
-
     # ------------------------------------------------------------------ #
     # Auth
     # ------------------------------------------------------------------ #
@@ -327,7 +321,6 @@ class ChattoAdapter(BasePlatformAdapter):
                     token=self.chatto_config.token.value,
                 )
                 self._chatto_client = client
-                self._token = client.token
                 logger.info(
                     "Chatto: logged in as '%s' via chattolib",
                     self.chatto_config.login.value,
@@ -420,7 +413,6 @@ class ChattoAdapter(BasePlatformAdapter):
 
         self._closing = False
         # Start background realtime WS event stream loop.
-        self._ws_ready = asyncio.Event()
         self._ws_task = asyncio.create_task(
             self._chattolib_event_loop(),
             name="chatto-event-stream",
@@ -477,7 +469,6 @@ class ChattoAdapter(BasePlatformAdapter):
         # No explicit offline broadcast: chattolib rejects OFFLINE outright
         # ("stop refreshing to go offline"), so cancelling the refresh loop
         # below is what actually takes the bot offline.
-        self._ws_active = False
         self._closing = True
 
         # Cancel all typing tasks
@@ -512,7 +503,6 @@ class ChattoAdapter(BasePlatformAdapter):
             finally:
                 self._chatto_client = None
 
-        self._token = None
         logger.info("Chatto: disconnected")
         self._mark_disconnected()
 
@@ -693,21 +683,23 @@ class ChattoAdapter(BasePlatformAdapter):
             return None, state
 
         wanted = argument[1:].strip().casefold()
-        matches: list[RoomWithViewerState] = []
+        # (state, room) pairs: a listed match's room is already narrowed here,
+        # so the candidate listing below needs no fresh Optional dance.
+        matches: list[tuple[RoomWithViewerState, Room]] = []
         for state in await client.list_rooms() or []:
             room_obj = state.room if state else None
             if room_obj and (room_obj.name or "").strip().casefold() == wanted:
-                matches.append(state)
+                matches.append((state, room_obj))
                 self._room_names[room_obj.id] = room_obj.name
                 self._room_kinds[room_obj.id] = room_obj.kind
         if not matches:
             return f"No room named '{argument}'.", None
         if len(matches) > 1:
-            candidates = "\n".join(f"• {m.room.name} ({m.room.id})" for m in matches)
+            candidates = "\n".join(f"• {room.name} ({room.id})" for _, room in matches)
             return (
                 f"Several rooms are named '{argument}' — pick one by ID:\n{candidates}"
             ), None
-        return None, matches[0]
+        return None, matches[0][0]
 
     async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
         """Join a room via RoomService/JoinRoom and watch it immediately.
@@ -716,6 +708,10 @@ class ChattoAdapter(BasePlatformAdapter):
         needs no JoinRoom call — it only gets seeded into the watch list.
         """
         room_obj = state.room
+        if room_obj is None:
+            # Unreachable via _resolve_room_target: both of its paths only
+            # return states whose room they already inspected.
+            return "Chatto returned an empty room record — try again."
         label = f"'{room_obj.name}' ({room_obj.id})"
         joined_room = room_obj
         if not state.viewer_state.is_member:
@@ -741,6 +737,9 @@ class ChattoAdapter(BasePlatformAdapter):
         delivery, which posts there through the standalone sender.
         """
         room_obj = state.room
+        if room_obj is None:
+            # Same invariant as _run_join: _resolve_room_target pre-inspects.
+            return "Chatto returned an empty room record — try again."
         label = f"'{room_obj.name}' ({room_obj.id})"
         if room_obj.kind == RoomKind.DM:
             return "Direct messages cannot be left."

+ 1 - 0
pyproject.toml

@@ -20,6 +20,7 @@ authors = [
 dev = [
     "pytest>=9.0",
     "pytest-asyncio>=0.24",
+    "basedpyright>=1.29",
 ]
 
 [tool.setuptools.packages.find]

+ 32 - 0
pyrightconfig.json

@@ -0,0 +1,32 @@
+// Type-check scope for basedpyright (`uv run basedpyright`).
+// This file is read as JSONC, so comments are fine here.
+{
+  // First-party code only. vendor/ ships upstream dependencies (see
+  // VENDORING.md) and dist/ is build output; the test modules lean on
+  // mock-attribute patterns that are out of scope for the CLI gate — open
+  // them in an editor and the language server still checks them live.
+  "include": ["."],
+  "exclude": [
+    "vendor",
+    "dist",
+    ".venv",
+    "test_adapter.py",
+    "test_platform_config.py",
+    "trial.py"
+  ],
+  "typeCheckingMode": "standard",
+  "pythonVersion": "3.11",
+  // Imports living outside this checkout: the vendored dependency tree plus
+  // the Hermes Agent sources this plugin plugs into. Both layouts from
+  // AGENTS.md resolve — sibling checkout ("../hermes-agent") and nested
+  // plugins/platforms install ("../../.."); paths that do not exist on a
+  // given machine are ignored.
+  "extraPaths": [
+    "vendor/common",
+    "vendor/platform/linux-x86_64",
+    "vendor/platform/linux-aarch64",
+    "vendor/platform/macos-arm64",
+    "../hermes-agent",
+    "../../.."
+  ]
+}

+ 0 - 7
test_adapter.py

@@ -210,10 +210,6 @@ def _make_adapter(**extra_overrides):
     cfg = _make_config(**extra_overrides)
     adapter = ChattoAdapter(cfg)
     adapter._chatto_client = MagicMock()
-    adapter._token = "test-token"
-    adapter._user_id = "bot-user-id"
-    adapter._user_login = "hermes_bot"
-    adapter._user_display = "Hermes Bot"
     return adapter
 
 
@@ -348,8 +344,6 @@ class TestSend:
         adapter = ChattoAdapter(cfg)
         adapter._chatto_client = MagicMock()
         adapter._chatto_client.post_message = AsyncMock()
-        adapter._token = "test-token"
-        adapter._user_id = "bot-user-id"
         return adapter
 
     async def test_send_calls_post_message(self, adapter):
@@ -396,7 +390,6 @@ class TestReactions:
         adapter._chatto_client = MagicMock()
         adapter._chatto_client.add_reaction = AsyncMock()
         adapter._chatto_client.remove_reaction = AsyncMock()
-        adapter._token = "test-token"
         return adapter
 
     async def test_send_reaction(self, adapter):

+ 30 - 0
uv.lock

@@ -2,6 +2,18 @@ version = 1
 revision = 3
 requires-python = ">=3.11"
 
+[[package]]
+name = "basedpyright"
+version = "1.39.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nodejs-wheel-binaries" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" },
+]
+
 [[package]]
 name = "colorama"
 version = "0.4.6"
@@ -18,6 +30,7 @@ source = { editable = "." }
 
 [package.dev-dependencies]
 dev = [
+    { name = "basedpyright" },
     { name = "pytest" },
     { name = "pytest-asyncio" },
 ]
@@ -26,6 +39,7 @@ dev = [
 
 [package.metadata.requires-dev]
 dev = [
+    { name = "basedpyright", specifier = ">=1.29" },
     { name = "pytest", specifier = ">=9.0" },
     { name = "pytest-asyncio", specifier = ">=0.24" },
 ]
@@ -39,6 +53,22 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
 ]
 
+[[package]]
+name = "nodejs-wheel-binaries"
+version = "24.19.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" },
+    { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" },
+    { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" },
+    { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" },
+    { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" },
+]
+
 [[package]]
 name = "packaging"
 version = "26.3"