coding-bridge 2026.7.3.2__tar.gz → 2026.7.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/PKG-INFO +1 -1
  2. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/__init__.py +3 -0
  3. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/config.py +92 -2
  4. coding_bridge-2026.7.3.3/coding_bridge/channels/policy.py +230 -0
  5. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/wechat/client.py +37 -0
  6. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/pyproject.toml +1 -1
  7. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_channels_config.py +122 -0
  8. coding_bridge-2026.7.3.3/tests/test_channels_policy.py +473 -0
  9. coding_bridge-2026.7.3.3/tests/test_wechat_task_status.py +124 -0
  10. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/.env.example +0 -0
  11. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/.github/workflows/ci.yaml +0 -0
  12. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/.github/workflows/publish.yml +0 -0
  13. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/.gitignore +0 -0
  14. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/LICENSE +0 -0
  15. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/NOTICE +0 -0
  16. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/README.md +0 -0
  17. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/__init__.py +0 -0
  18. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/__main__.py +0 -0
  19. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/attachments.py +0 -0
  20. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/capabilities.py +0 -0
  21. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/base.py +0 -0
  22. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/dispatcher.py +0 -0
  23. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/wechat/__init__.py +0 -0
  24. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/channels/wechat/adapter.py +0 -0
  25. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/cli.py +0 -0
  26. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/config.py +0 -0
  27. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/connection.py +0 -0
  28. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/fs.py +0 -0
  29. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/history.py +0 -0
  30. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/images.py +0 -0
  31. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/locking.py +0 -0
  32. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/logs.py +0 -0
  33. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/pairing.py +0 -0
  34. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/permissions.py +0 -0
  35. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/protocol.py +0 -0
  36. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/providers/__init__.py +0 -0
  37. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/providers/base.py +0 -0
  38. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/providers/claude.py +0 -0
  39. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/providers/codex.py +0 -0
  40. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/providers/copilot.py +0 -0
  41. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/session.py +0 -0
  42. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/session_meta.py +0 -0
  43. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/coding_bridge/store.py +0 -0
  44. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/docs/design/reliable-event-delivery.md +0 -0
  45. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/conftest.py +0 -0
  46. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_ask_user_question.py +0 -0
  47. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_attachments.py +0 -0
  48. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_capabilities.py +0 -0
  49. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_channels_base.py +0 -0
  50. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_channels_dispatcher.py +0 -0
  51. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_channels_wechat.py +0 -0
  52. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_codex.py +0 -0
  53. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_config.py +0 -0
  54. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_connection.py +0 -0
  55. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_copilot.py +0 -0
  56. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_edit_fork.py +0 -0
  57. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_fs.py +0 -0
  58. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_history.py +0 -0
  59. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_images.py +0 -0
  60. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_locking.py +0 -0
  61. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_outbox.py +0 -0
  62. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_pairing.py +0 -0
  63. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_permissions.py +0 -0
  64. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_protocol.py +0 -0
  65. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_resume_guard.py +0 -0
  66. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_session.py +0 -0
  67. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_session_meta.py +0 -0
  68. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_slash_commands.py +0 -0
  69. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_store.py +0 -0
  70. {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.3}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-bridge
3
- Version: 2026.7.3.2
3
+ Version: 2026.7.3.3
4
4
  Summary: Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web.
5
5
  Project-URL: Homepage, https://github.com/AceDataCloud/CodingBridge
6
6
  Project-URL: Issues, https://github.com/AceDataCloud/CodingBridge/issues
@@ -25,14 +25,17 @@ from .config import (
25
25
  parse_channels_config,
26
26
  )
27
27
  from .dispatcher import SessionDispatcher
28
+ from .policy import ChannelPolicy, PolicyGate
28
29
 
29
30
  __all__ = [
30
31
  "ChannelAdapter",
32
+ "ChannelPolicy",
31
33
  "ChannelTarget",
32
34
  "ChannelsConfig",
33
35
  "ConfigError",
34
36
  "IncomingMessage",
35
37
  "MessageHandler",
38
+ "PolicyGate",
36
39
  "SendResult",
37
40
  "SessionDispatcher",
38
41
  "WeChatInstanceConfig",
@@ -37,19 +37,34 @@ Design notes:
37
37
 
38
38
  from __future__ import annotations
39
39
 
40
+ import math
40
41
  import os
41
42
  from dataclasses import dataclass, field
42
43
  from pathlib import Path
43
- from typing import Any
44
+ from typing import TYPE_CHECKING, Any
44
45
  from urllib.parse import urlparse
45
46
 
47
+ if TYPE_CHECKING:
48
+ from .policy import ChannelPolicy
49
+
46
50
  try:
47
51
  import tomllib as _toml
48
52
  except ModuleNotFoundError: # Python 3.10
49
53
  import tomli as _toml # type: ignore[import-not-found]
50
54
 
51
55
  _WECHAT_KEYS = frozenset(
52
- {"instance_id", "base_url", "token_env", "token_file", "enabled", "default_provider"}
56
+ {
57
+ "instance_id",
58
+ "base_url",
59
+ "token_env",
60
+ "token_file",
61
+ "enabled",
62
+ "default_provider",
63
+ "trigger_prefix",
64
+ "allowed_senders",
65
+ "rate_limit_per_min",
66
+ "dedup_window_seconds",
67
+ }
53
68
  )
54
69
  _CHANNELS_KEYS = frozenset({"wechat"})
55
70
 
@@ -76,6 +91,33 @@ class WeChatInstanceConfig:
76
91
  token_file: str | None = None
77
92
  enabled: bool = False
78
93
  default_provider: str | None = None
94
+ #: Message text must start with this to be forwarded. Empty string disables
95
+ #: the check. See ``coding_bridge.channels.policy.ChannelPolicy``.
96
+ trigger_prefix: str = "/ask "
97
+ #: Sender allowlist. Empty tuple = allow all (still gated by the WeChat
98
+ #: gateway token). Stored as tuple because ``WeChatInstanceConfig`` is frozen.
99
+ allowed_senders: tuple[str, ...] = ()
100
+ #: Sliding-window rate limit per sender_id over the last 60 s.
101
+ rate_limit_per_min: int = 6
102
+ #: Dedup window for repeat upstream ``msg_id`` (upstream retries). ``0``
103
+ #: disables dedup.
104
+ dedup_window_seconds: float = 300.0
105
+
106
+ def to_policy(self) -> ChannelPolicy:
107
+ """Build the runtime ``ChannelPolicy`` this instance describes."""
108
+ # Local import to avoid a circular module cycle
109
+ # (policy → base → nothing config-specific; config → policy would loop
110
+ # only at type-annotation time, but keep the runtime import lazy so
111
+ # ``coding_bridge.channels.config`` alone still loads without importing
112
+ # the whole channels stack).
113
+ from .policy import ChannelPolicy
114
+
115
+ return ChannelPolicy(
116
+ trigger_prefix=self.trigger_prefix,
117
+ allowed_senders=self.allowed_senders,
118
+ rate_limit_per_min=self.rate_limit_per_min,
119
+ dedup_window_seconds=self.dedup_window_seconds,
120
+ )
79
121
 
80
122
  def resolve_token(self, environ: dict[str, str] | None = None) -> str:
81
123
  """Load the token from env var or secrets file. Never logs it.
@@ -198,6 +240,50 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
198
240
  raise ConfigError(
199
241
  f"[[channels.wechat]] {instance_id!r}: default_provider must be a string"
200
242
  )
243
+
244
+ trigger_prefix = block.get("trigger_prefix", "/ask ")
245
+ if not isinstance(trigger_prefix, str):
246
+ raise ConfigError(
247
+ f"[[channels.wechat]] {instance_id!r}: trigger_prefix must be a string"
248
+ )
249
+
250
+ allowed_senders_raw = block.get("allowed_senders", [])
251
+ if not isinstance(allowed_senders_raw, list) or not all(
252
+ isinstance(x, str) and x for x in allowed_senders_raw
253
+ ):
254
+ raise ConfigError(
255
+ f"[[channels.wechat]] {instance_id!r}: allowed_senders must be a "
256
+ "list of non-empty strings"
257
+ )
258
+
259
+ rate_limit = block.get("rate_limit_per_min", 6)
260
+ # Bool is a subclass of int in Python — reject explicit bools to catch
261
+ # `rate_limit_per_min = true` typos.
262
+ if isinstance(rate_limit, bool) or not isinstance(rate_limit, int) or rate_limit < 0:
263
+ raise ConfigError(
264
+ f"[[channels.wechat]] {instance_id!r}: rate_limit_per_min must be "
265
+ "a non-negative int"
266
+ )
267
+
268
+ dedup_window = block.get("dedup_window_seconds", 300.0)
269
+ if isinstance(dedup_window, bool) or not isinstance(dedup_window, (int, float)):
270
+ raise ConfigError(
271
+ f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
272
+ "be a number"
273
+ )
274
+ # NaN < 0 is False so a plain range check misses it — reject explicitly
275
+ # so ``_prune_dedup`` never sees a non-comparable value.
276
+ if math.isnan(dedup_window) or math.isinf(dedup_window):
277
+ raise ConfigError(
278
+ f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
279
+ "be finite"
280
+ )
281
+ if dedup_window < 0:
282
+ raise ConfigError(
283
+ f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
284
+ "be >= 0"
285
+ )
286
+
201
287
  return WeChatInstanceConfig(
202
288
  instance_id=instance_id,
203
289
  base_url=base_url.rstrip("/"),
@@ -205,6 +291,10 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
205
291
  token_file=token_file,
206
292
  enabled=enabled,
207
293
  default_provider=default_provider,
294
+ trigger_prefix=trigger_prefix,
295
+ allowed_senders=tuple(allowed_senders_raw),
296
+ rate_limit_per_min=rate_limit,
297
+ dedup_window_seconds=float(dedup_window),
208
298
  )
209
299
 
210
300
 
@@ -0,0 +1,230 @@
1
+ """Per-instance abuse-control policy for channel adapters.
2
+
3
+ Wraps the ``MessageHandler`` you pass to ``ChannelAdapter.set_handler``. The
4
+ adapter has no idea abuse controls exist; the wrapper enforces them **before**
5
+ the message ever reaches the ``SessionDispatcher``.
6
+
7
+ Enforced (in this order, cheapest to most-expensive):
8
+
9
+ 1. **Trigger prefix** — the raw message text must start with a configured
10
+ prefix (default ``"/ask "``) so a bot in a group chat only responds when
11
+ explicitly addressed. Prefix is stripped before the message is forwarded.
12
+ Empty string → prefix check disabled.
13
+ 2. **Sender allowlist** — if ``allowed_senders`` is non-empty, only those
14
+ ``sender_id`` values are accepted. Empty list → allow all (rely on
15
+ ``token`` for auth; useful when only the account owner can reach the
16
+ WeChat gateway endpoint anyway).
17
+ 3. **Per-sender rate limit** — sliding-window: at most ``rate_limit_per_min``
18
+ messages from one ``sender_id`` in the last 60 s. Default 6/min.
19
+
20
+ Every rejection is logged at INFO with a stable ``reason`` code so operators
21
+ can grep. Nothing sensitive (token, message body) ever appears in the log.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import time
29
+ from collections import deque
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, field, replace
32
+
33
+ from .base import ChannelAdapter, IncomingMessage, MessageHandler
34
+
35
+ logger = logging.getLogger("coding-bridge.channels")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ChannelPolicy:
40
+ """Per-instance abuse-control knobs. Frozen — build once at start-up."""
41
+
42
+ #: Only accept messages whose text starts with this string (default
43
+ #: ``"/ask "``). Empty string disables the prefix check.
44
+ trigger_prefix: str = "/ask "
45
+
46
+ #: Sender IDs that may talk to this bot. Empty tuple = allow all.
47
+ allowed_senders: tuple[str, ...] = ()
48
+
49
+ #: Per-sender sliding window: at most this many messages per 60 s.
50
+ rate_limit_per_min: int = 6
51
+
52
+ #: Dedup window in seconds. Repeat upstream ``msg_id`` inside this window
53
+ #: is silently dropped (upstream retries, duplicate deliveries). ``0``
54
+ #: disables dedup — useful in tests.
55
+ dedup_window_seconds: float = 300.0
56
+
57
+ #: How many recent ``msg_id`` values to remember per adapter instance.
58
+ dedup_max_ids: int = 1024
59
+
60
+ #: Cap on how many distinct ``sender_id`` rate-limit windows we keep in
61
+ #: memory. Beyond this the least-recently-active bucket is dropped so an
62
+ #: attacker spamming with random sender IDs can't grow the map without
63
+ #: bound. Real deployments serve at most a few hundred distinct senders.
64
+ rate_limit_max_senders: int = 4096
65
+
66
+
67
+ @dataclass
68
+ class _SlidingWindow:
69
+ """Per-sender timestamps for the last minute."""
70
+
71
+ window_seconds: float
72
+ limit: int
73
+ stamps: deque[float] = field(default_factory=deque)
74
+ last_touch: float = 0.0
75
+
76
+ def allow(self, now: float) -> bool:
77
+ self.last_touch = now
78
+ cutoff = now - self.window_seconds
79
+ while self.stamps and self.stamps[0] < cutoff:
80
+ self.stamps.popleft()
81
+ if len(self.stamps) >= self.limit:
82
+ return False
83
+ self.stamps.append(now)
84
+ return True
85
+
86
+
87
+ class PolicyGate:
88
+ """Wraps a downstream ``MessageHandler`` with the policy checks above.
89
+
90
+ Usage::
91
+
92
+ gate = PolicyGate(policy, downstream=dispatcher.handle_message)
93
+ adapter.set_handler(gate.handle)
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ policy: ChannelPolicy,
99
+ downstream: MessageHandler,
100
+ *,
101
+ clock: Callable[[], float] = time.monotonic,
102
+ ) -> None:
103
+ self._policy = policy
104
+ self._downstream = downstream
105
+ self._clock = clock
106
+ self._lock = asyncio.Lock()
107
+ self._windows: dict[str, _SlidingWindow] = {}
108
+ # dedup: ordered dict of msg_id → timestamp (arrival). We use a deque
109
+ # for insertion order + dict for O(1) membership. Bounded by
110
+ # ``dedup_max_ids``; oldest evicted first.
111
+ self._dedup_order: deque[str] = deque()
112
+ self._dedup_seen: dict[str, float] = {}
113
+
114
+ async def handle(self, msg: IncomingMessage, adapter: ChannelAdapter) -> None:
115
+ """Run all policy checks, then delegate. Never raises."""
116
+ # (1) trigger prefix
117
+ prefix = self._policy.trigger_prefix
118
+ text = msg.text
119
+ if prefix:
120
+ if not text.startswith(prefix):
121
+ self._reject(adapter, msg, "trigger_prefix_missing")
122
+ return
123
+ text = text[len(prefix) :].lstrip()
124
+ if not text:
125
+ self._reject(adapter, msg, "empty_after_prefix")
126
+ return
127
+
128
+ # (2) sender allowlist
129
+ allowed = self._policy.allowed_senders
130
+ if allowed and msg.sender_id not in allowed:
131
+ self._reject(adapter, msg, "sender_not_allowed")
132
+ return
133
+
134
+ # (3+4) rate limit + dedup share the lock so a burst can't race
135
+ async with self._lock:
136
+ # dedup
137
+ if self._policy.dedup_window_seconds > 0 and msg.upstream_id:
138
+ now = self._clock()
139
+ self._prune_dedup(now)
140
+ if msg.upstream_id in self._dedup_seen:
141
+ self._reject(adapter, msg, "duplicate_msg_id")
142
+ return
143
+ self._remember(msg.upstream_id, now)
144
+
145
+ # rate limit
146
+ if self._policy.rate_limit_per_min > 0:
147
+ now = self._clock()
148
+ self._evict_stale_windows(now)
149
+ window = self._windows.get(msg.sender_id)
150
+ if window is None:
151
+ # Cap-check BEFORE insert so an attacker spamming with
152
+ # random sender IDs can never grow the map past the cap.
153
+ if len(self._windows) >= self._policy.rate_limit_max_senders:
154
+ self._drop_lru_window()
155
+ window = _SlidingWindow(
156
+ window_seconds=60.0, limit=self._policy.rate_limit_per_min
157
+ )
158
+ self._windows[msg.sender_id] = window
159
+ if not window.allow(now):
160
+ self._reject(adapter, msg, "rate_limited")
161
+ return
162
+
163
+ # (5) forward to dispatcher — strip prefix out of the payload
164
+ forwarded = replace(msg, text=text)
165
+ try:
166
+ await self._downstream(forwarded, adapter)
167
+ except Exception as exc: # dispatcher errors must not kill the adapter
168
+ # NOTE: intentionally `logger.error`, NOT `logger.exception`, so
169
+ # the traceback (which may include `repr(exc)` with the original
170
+ # message text embedded) never reaches the log. The exception
171
+ # class name is enough for operators to grep.
172
+ logger.error(
173
+ "downstream handler raised: adapter=%s instance=%s exc=%s",
174
+ adapter.name,
175
+ adapter.instance_id,
176
+ exc.__class__.__name__,
177
+ )
178
+
179
+ def _reject(self, adapter: ChannelAdapter, msg: IncomingMessage, reason: str) -> None:
180
+ # NOTE: never log ``msg.text`` (may contain user content or secrets that
181
+ # look like commands). Log the sender + reason only.
182
+ logger.info(
183
+ "channel message rejected: adapter=%s instance=%s sender=%s reason=%s",
184
+ adapter.name,
185
+ adapter.instance_id,
186
+ msg.sender_id,
187
+ reason,
188
+ )
189
+
190
+ def _prune_dedup(self, now: float) -> None:
191
+ cutoff = now - self._policy.dedup_window_seconds
192
+ while self._dedup_order and self._dedup_seen.get(self._dedup_order[0], 0) < cutoff:
193
+ oldest = self._dedup_order.popleft()
194
+ self._dedup_seen.pop(oldest, None)
195
+
196
+ def _remember(self, msg_id: str, now: float) -> None:
197
+ # Cap: evict oldest until size is under ``dedup_max_ids``.
198
+ while len(self._dedup_order) >= self._policy.dedup_max_ids:
199
+ oldest = self._dedup_order.popleft()
200
+ self._dedup_seen.pop(oldest, None)
201
+ self._dedup_order.append(msg_id)
202
+ self._dedup_seen[msg_id] = now
203
+
204
+ def _evict_stale_windows(self, now: float) -> None:
205
+ """Drop rate-limit windows that haven't fired in 2 × window_seconds.
206
+
207
+ Called on the hot path (inside ``self._lock``) so it must be cheap.
208
+ Windows are ~120 s stale — well past their rate-limit window — so
209
+ removing them can't affect a genuine sender's counter.
210
+ """
211
+ stale_cutoff = now - 120.0
212
+ stale = [k for k, w in self._windows.items() if w.last_touch < stale_cutoff]
213
+ for k in stale:
214
+ self._windows.pop(k, None)
215
+
216
+ def _drop_lru_window(self) -> None:
217
+ """Evict the least-recently-touched sender window.
218
+
219
+ Fires only when the map is at its ``rate_limit_max_senders`` cap AND
220
+ no idle-eviction cleared enough room. Cost is O(N) over the map, but
221
+ N is capped and this path fires at most once per unique-sender
222
+ insertion above the cap.
223
+ """
224
+ if not self._windows:
225
+ return
226
+ lru_key = min(self._windows, key=lambda k: self._windows[k].last_touch)
227
+ self._windows.pop(lru_key, None)
228
+
229
+
230
+ __all__ = ["ChannelPolicy", "PolicyGate"]
@@ -7,8 +7,10 @@ so tests can inject a transport via :func:`respx`.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import re
10
11
  import time
11
12
  from typing import Any
13
+ from urllib.parse import quote
12
14
 
13
15
  import httpx
14
16
 
@@ -19,6 +21,11 @@ from ..base import ChannelTarget, SendResult
19
21
  # to the caller so operators have real diagnostics in the log.
20
22
  _ACCEPTED_STATUSES = frozenset({200, 201, 202})
21
23
 
24
+ # Safe pattern for a gateway task id. The gateway generates UUID-like tokens; we
25
+ # refuse anything with URL metacharacters so a caller can't inject a query
26
+ # parameter or path segment into the GET.
27
+ _TASK_ID_RE = re.compile(r"^[A-Za-z0-9_\-]{1,128}$")
28
+
22
29
 
23
30
  class WeChatClient:
24
31
  """Async client for one WeChat gateway endpoint (one CVM / one WeChat instance)."""
@@ -104,3 +111,33 @@ class WeChatClient:
104
111
  error=f"HTTP {resp.status_code}: {(err or '')[:200]}",
105
112
  latency_ms=latency_ms,
106
113
  )
114
+
115
+ async def get_task_status(self, task_id: str) -> dict[str, Any]:
116
+ """Fetch delivery status for a task returned by ``send_message``.
117
+
118
+ Returns the parsed JSON body as-is (the gateway's shape may evolve). Raises
119
+ ``httpx.HTTPStatusError`` on non-2xx so the caller can distinguish
120
+ "not delivered yet" (200 with status=queued) from "task unknown" (404).
121
+
122
+ Kept deliberately small: this is a debug / diagnostics primitive used
123
+ by the ``doctor`` CLI command (P4) and E2E validation, not by the
124
+ adapter's fast path.
125
+
126
+ Raises ``ValueError`` on an empty or unsafely-shaped ``task_id`` —
127
+ rather than URL-encode and pray, we refuse anything that could
128
+ contain path/query metacharacters (``?``, ``#``, ``/``, ``..``).
129
+ """
130
+ if not task_id:
131
+ raise ValueError("task_id must not be empty")
132
+ if not _TASK_ID_RE.fullmatch(task_id):
133
+ raise ValueError("task_id contains invalid characters")
134
+ # Belt-and-suspenders: `quote(safe="")` still produces a plain
135
+ # segment since the regex already rejects unsafe input.
136
+ path = f"/api/messages/tasks/{quote(task_id, safe='')}"
137
+ resp = await self._client.get(path)
138
+ resp.raise_for_status()
139
+ try:
140
+ body = resp.json()
141
+ except ValueError:
142
+ return {}
143
+ return body if isinstance(body, dict) else {}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coding-bridge"
7
- version = "2026.7.3.2"
7
+ version = "2026.7.3.3"
8
8
  description = "Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -382,3 +382,125 @@ class TestSettingsIntegration:
382
382
 
383
383
  s = Settings(config_dir=tmp_path)
384
384
  assert s.channels_config_path == tmp_path / "channels.toml"
385
+
386
+
387
+ class TestPolicyFields:
388
+ def test_defaults_match_channelpolicy_defaults(self) -> None:
389
+ from coding_bridge.channels.policy import ChannelPolicy
390
+
391
+ inst = WeChatInstanceConfig(instance_id="x", base_url="http://a", token_env="T")
392
+ default_policy = ChannelPolicy()
393
+ assert inst.trigger_prefix == default_policy.trigger_prefix
394
+ assert inst.allowed_senders == default_policy.allowed_senders
395
+ assert inst.rate_limit_per_min == default_policy.rate_limit_per_min
396
+ assert inst.dedup_window_seconds == default_policy.dedup_window_seconds
397
+
398
+ def test_to_policy_roundtrip(self, tmp_path: Path) -> None:
399
+ p = _write(
400
+ tmp_path,
401
+ """
402
+ [[channels.wechat]]
403
+ instance_id = "x"
404
+ base_url = "http://a"
405
+ token_env = "T"
406
+ trigger_prefix = "@bot "
407
+ allowed_senders = ["wxid_alice", "wxid_bob"]
408
+ rate_limit_per_min = 3
409
+ dedup_window_seconds = 60.0
410
+ """,
411
+ )
412
+ inst = load_channels_config(p).wechat[0]
413
+ pol = inst.to_policy()
414
+ assert pol.trigger_prefix == "@bot "
415
+ assert pol.allowed_senders == ("wxid_alice", "wxid_bob")
416
+ assert pol.rate_limit_per_min == 3
417
+ assert pol.dedup_window_seconds == 60.0
418
+
419
+ def test_trigger_prefix_must_be_string(self) -> None:
420
+ with pytest.raises(ConfigError, match=r"trigger_prefix must be a string"):
421
+ parse_channels_config(
422
+ {
423
+ "channels": {
424
+ "wechat": [
425
+ {
426
+ "instance_id": "x",
427
+ "base_url": "http://a",
428
+ "token_env": "T",
429
+ "trigger_prefix": 123,
430
+ }
431
+ ]
432
+ }
433
+ }
434
+ )
435
+
436
+ def test_allowed_senders_must_be_list_of_nonempty_strings(self) -> None:
437
+ for bad in ["nope", [""], [1, 2], [None]]:
438
+ with pytest.raises(ConfigError, match=r"allowed_senders"):
439
+ parse_channels_config(
440
+ {
441
+ "channels": {
442
+ "wechat": [
443
+ {
444
+ "instance_id": "x",
445
+ "base_url": "http://a",
446
+ "token_env": "T",
447
+ "allowed_senders": bad,
448
+ }
449
+ ]
450
+ }
451
+ }
452
+ )
453
+
454
+ def test_rate_limit_must_be_non_negative_int(self) -> None:
455
+ for bad in [-1, "high", True, 3.5]:
456
+ with pytest.raises(ConfigError, match=r"rate_limit_per_min"):
457
+ parse_channels_config(
458
+ {
459
+ "channels": {
460
+ "wechat": [
461
+ {
462
+ "instance_id": "x",
463
+ "base_url": "http://a",
464
+ "token_env": "T",
465
+ "rate_limit_per_min": bad,
466
+ }
467
+ ]
468
+ }
469
+ }
470
+ )
471
+
472
+ def test_dedup_window_validated(self) -> None:
473
+ for bad in [-1, "long", True]:
474
+ with pytest.raises(ConfigError, match=r"dedup_window"):
475
+ parse_channels_config(
476
+ {
477
+ "channels": {
478
+ "wechat": [
479
+ {
480
+ "instance_id": "x",
481
+ "base_url": "http://a",
482
+ "token_env": "T",
483
+ "dedup_window_seconds": bad,
484
+ }
485
+ ]
486
+ }
487
+ }
488
+ )
489
+
490
+ def test_dedup_window_nan_and_inf_rejected(self) -> None:
491
+ for bad in [float("nan"), float("inf"), float("-inf")]:
492
+ with pytest.raises(ConfigError, match=r"dedup_window_seconds must be finite"):
493
+ parse_channels_config(
494
+ {
495
+ "channels": {
496
+ "wechat": [
497
+ {
498
+ "instance_id": "x",
499
+ "base_url": "http://a",
500
+ "token_env": "T",
501
+ "dedup_window_seconds": bad,
502
+ }
503
+ ]
504
+ }
505
+ }
506
+ )