coding-bridge 2026.7.3.3__tar.gz → 2026.7.3.4__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 (72) hide show
  1. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/PKG-INFO +1 -1
  2. coding_bridge-2026.7.3.4/coding_bridge/channels_cli.py +368 -0
  3. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/cli.py +11 -0
  4. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/pyproject.toml +1 -1
  5. coding_bridge-2026.7.3.4/tests/test_channels_cli.py +309 -0
  6. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_config.py +15 -0
  7. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.env.example +0 -0
  8. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.github/workflows/ci.yaml +0 -0
  9. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.github/workflows/publish.yml +0 -0
  10. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.gitignore +0 -0
  11. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/LICENSE +0 -0
  12. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/NOTICE +0 -0
  13. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/README.md +0 -0
  14. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/__init__.py +0 -0
  15. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/__main__.py +0 -0
  16. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/attachments.py +0 -0
  17. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/capabilities.py +0 -0
  18. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/__init__.py +0 -0
  19. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/base.py +0 -0
  20. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/config.py +0 -0
  21. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/dispatcher.py +0 -0
  22. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/policy.py +0 -0
  23. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/__init__.py +0 -0
  24. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/adapter.py +0 -0
  25. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/client.py +0 -0
  26. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/config.py +0 -0
  27. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/connection.py +0 -0
  28. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/fs.py +0 -0
  29. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/history.py +0 -0
  30. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/images.py +0 -0
  31. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/locking.py +0 -0
  32. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/logs.py +0 -0
  33. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/pairing.py +0 -0
  34. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/permissions.py +0 -0
  35. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/protocol.py +0 -0
  36. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/__init__.py +0 -0
  37. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/base.py +0 -0
  38. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/claude.py +0 -0
  39. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/codex.py +0 -0
  40. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/copilot.py +0 -0
  41. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/session.py +0 -0
  42. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/session_meta.py +0 -0
  43. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/store.py +0 -0
  44. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/docs/design/reliable-event-delivery.md +0 -0
  45. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/conftest.py +0 -0
  46. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_ask_user_question.py +0 -0
  47. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_attachments.py +0 -0
  48. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_capabilities.py +0 -0
  49. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_base.py +0 -0
  50. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_dispatcher.py +0 -0
  51. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_policy.py +0 -0
  52. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_wechat.py +0 -0
  53. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_codex.py +0 -0
  54. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_config.py +0 -0
  55. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_connection.py +0 -0
  56. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_copilot.py +0 -0
  57. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_edit_fork.py +0 -0
  58. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_fs.py +0 -0
  59. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_history.py +0 -0
  60. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_images.py +0 -0
  61. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_locking.py +0 -0
  62. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_outbox.py +0 -0
  63. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_pairing.py +0 -0
  64. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_permissions.py +0 -0
  65. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_protocol.py +0 -0
  66. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_resume_guard.py +0 -0
  67. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_session.py +0 -0
  68. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_session_meta.py +0 -0
  69. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_slash_commands.py +0 -0
  70. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_store.py +0 -0
  71. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_streaming.py +0 -0
  72. {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_wechat_task_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-bridge
3
- Version: 2026.7.3.3
3
+ Version: 2026.7.3.4
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
@@ -0,0 +1,368 @@
1
+ """CLI plumbing for the ``coding-bridge channels`` subcommand group.
2
+
3
+ Sub-subcommands:
4
+
5
+ * ``coding-bridge channels init`` — write a skeleton ``channels.toml`` next to
6
+ the credentials file (safe defaults: ``enabled=false``).
7
+ * ``coding-bridge channels start`` — read ``channels.toml``, spin up one
8
+ ``PolicyGate → SessionDispatcher`` per enabled instance, and run the
9
+ adapter loop until Ctrl-C.
10
+ * ``coding-bridge channels doctor`` — validate ``channels.toml``, resolve
11
+ each token (without ever printing it), and ping each WeChat gateway endpoint.
12
+
13
+ The dispatcher-wiring lives ONLY here — the `coding_bridge.channels.*`
14
+ package stays pure library code that can be reused by other CLIs or by tests.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import contextlib
22
+ import logging
23
+ import stat
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ import httpx
29
+
30
+ from .channels import ConfigError, PolicyGate, load_channels_config
31
+ from .channels.wechat import WeChatAdapter, WeChatClient
32
+ from .config import Settings
33
+
34
+ if TYPE_CHECKING:
35
+ from .channels import ChannelAdapter, WeChatInstanceConfig
36
+ from .channels.dispatcher import SessionDispatcher
37
+
38
+ logger = logging.getLogger("coding-bridge.channels.cli")
39
+
40
+
41
+ # ---------- init --------------------------------------------------------------
42
+
43
+ _INIT_TEMPLATE = """\
44
+ # coding-bridge channels config
45
+ # Every instance is `enabled = false` by default — flip to true only when the
46
+ # token env var is set and the sender allowlist is filled in. See
47
+ # https://github.com/AceDataCloud/CodingBridge for docs.
48
+
49
+ # [[channels.wechat]]
50
+ # instance_id = "my-wechat"
51
+ # base_url = "http://127.0.0.1:8000"
52
+ # token_env = "WECHAT_TOKEN_MY_WECHAT"
53
+ # enabled = false
54
+ #
55
+ # # Only respond when a message starts with this prefix. Empty string disables
56
+ # # the prefix check. Group chats basically require this.
57
+ # trigger_prefix = "/ask "
58
+ #
59
+ # # Only accept messages from these sender IDs (WeChat wxid). Empty list means
60
+ # # allow all — safe only when the token is exclusively yours.
61
+ # allowed_senders = []
62
+ #
63
+ # # At most this many messages per sender_id per 60 s. 0 disables the limit.
64
+ # rate_limit_per_min = 6
65
+ #
66
+ # # Drop repeat msg_id inside this window (upstream retries). 0 disables.
67
+ # dedup_window_seconds = 300.0
68
+ """
69
+
70
+
71
+ def _write_secure_file(path: Path, body: str) -> None:
72
+ """Atomically create ``path`` with ``body``. Fails if it already exists.
73
+
74
+ Uses ``open(..., 'x')`` (exclusive create) so a concurrent second
75
+ ``channels init`` can't race between our ``exists()`` check and our
76
+ ``write_text()``. On POSIX, chmod 0600 so token references aren't
77
+ world-readable. On Windows, the file inherits the parent's ACL — since
78
+ the file only ever contains env-var *names* and file *paths*, not the
79
+ tokens themselves, this is acceptable.
80
+ """
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ # `x` = exclusive create; raises FileExistsError if the file exists.
83
+ with path.open("x", encoding="utf-8") as f:
84
+ f.write(body)
85
+ if hasattr(stat, "S_IMODE") and sys.platform != "win32":
86
+ with contextlib.suppress(OSError):
87
+ path.chmod(0o600)
88
+
89
+
90
+ def cmd_channels_init(settings: Settings) -> int:
91
+ """Write a skeleton `channels.toml`. Refuses to overwrite an existing file."""
92
+ path = settings.channels_config_path
93
+ try:
94
+ _write_secure_file(path, _INIT_TEMPLATE)
95
+ except FileExistsError:
96
+ print(f"Refusing to overwrite existing config: {path}", file=sys.stderr)
97
+ print(
98
+ "Delete it or edit it in place. `coding-bridge channels init` is "
99
+ "one-shot on purpose so you never lose a working config.",
100
+ file=sys.stderr,
101
+ )
102
+ return 1
103
+ print(f"Wrote {path}")
104
+ print("Next steps:")
105
+ print(" 1. Uncomment the [[channels.wechat]] block and fill in instance_id + base_url.")
106
+ print(" 2. Export the token env var referenced by `token_env`.")
107
+ print(" 3. Fill `allowed_senders` with your own WeChat wxid.")
108
+ print(" 4. Flip `enabled = true`.")
109
+ print(" 5. Run `coding-bridge channels doctor` to verify.")
110
+ print(" 6. Run `coding-bridge channels start`.")
111
+ return 0
112
+
113
+
114
+ # ---------- doctor ------------------------------------------------------------
115
+
116
+
117
+ async def _doctor_one(inst: WeChatInstanceConfig) -> tuple[bool, str]:
118
+ """Return (ok, message) for one instance. Never raises; never logs secrets.
119
+
120
+ Actually tests that the token is accepted by hitting an authenticated
121
+ endpoint (``GET /api/messages/tasks/<probe>``). The gateway's response:
122
+
123
+ * 401 → token rejected → FAIL (loudly, since this is the whole point of doctor)
124
+ * 404 → token accepted but probe id unknown → PASS (this is expected)
125
+ * 2xx → token accepted, endpoint returned a status → PASS
126
+ * 5xx / other → server broken → FAIL
127
+ * network error → unreachable → FAIL
128
+
129
+ Falls back to ``/health`` if the tasks endpoint isn't implemented, but
130
+ warns that auth wasn't verified.
131
+ """
132
+ try:
133
+ token = inst.resolve_token()
134
+ except ConfigError as exc:
135
+ return False, f"token: {exc}"
136
+
137
+ token_len = len(token) # only the length is safe to display
138
+ client = WeChatClient(inst.base_url, token, timeout=5.0)
139
+ try:
140
+ # Probe id is a syntactically valid but almost-certainly-unknown token.
141
+ # It uses only characters allowed by _TASK_ID_RE so `get_task_status`
142
+ # will actually issue the request.
143
+ probe = "coding-bridge-doctor-probe"
144
+ try:
145
+ await client.get_task_status(probe)
146
+ # 2xx means the probe id happened to exist (extremely unlikely) —
147
+ # still counts as auth OK.
148
+ return True, f"OK (token accepted, {token_len} bytes)"
149
+ except httpx.HTTPStatusError as http_exc:
150
+ code = http_exc.response.status_code
151
+ if code == 401:
152
+ return False, "token rejected (401)"
153
+ if code == 403:
154
+ return False, "token forbidden (403)"
155
+ if code == 404:
156
+ # Token was fine — server just didn't have that task. This is
157
+ # the happy path for a fresh install.
158
+ return True, f"OK (token accepted, {token_len} bytes)"
159
+ if 500 <= code < 600:
160
+ # Try /health as a last-ditch reachability check so we don't
161
+ # mis-report a 5xx on a gateway that doesn't implement the
162
+ # tasks endpoint at all.
163
+ try:
164
+ resp = await client._client.get("/health") # noqa: SLF001
165
+ if 200 <= resp.status_code < 300:
166
+ return False, (
167
+ f"reachable at /health but /api/messages/tasks "
168
+ f"returned {code} (gateway may be misconfigured)"
169
+ )
170
+ except Exception: # noqa: BLE001
171
+ pass
172
+ return False, f"server error {code}"
173
+ return False, f"unexpected {code}"
174
+ except Exception as exc: # noqa: BLE001 - diagnostic; class name only
175
+ return False, f"unreachable: {exc.__class__.__name__}"
176
+ finally:
177
+ await client.aclose()
178
+
179
+
180
+ def cmd_channels_doctor(settings: Settings) -> int:
181
+ """Load config + check each instance's token + reachability."""
182
+ path = settings.channels_config_path
183
+ try:
184
+ cfg = load_channels_config(path)
185
+ except ConfigError as exc:
186
+ print(f"config error in {path}: {exc}", file=sys.stderr)
187
+ return 2
188
+
189
+ if not cfg.wechat:
190
+ print(f"No channels configured in {path}. Run `channels init` first.")
191
+ return 0
192
+
193
+ print(f"Config file: {path}")
194
+ print(f"Total instances: {len(cfg.wechat)} (enabled: {len(cfg.enabled_wechat)})")
195
+ print()
196
+
197
+ all_ok = True
198
+
199
+ async def _run() -> None:
200
+ nonlocal all_ok
201
+ for inst in cfg.wechat:
202
+ state = "ENABLED" if inst.enabled else "disabled"
203
+ print(f"[{state}] {inst.instance_id}: {inst.base_url}")
204
+ if not inst.enabled:
205
+ print(" → skipped (enabled=false)")
206
+ continue
207
+ ok, msg = await _doctor_one(inst)
208
+ marker = "✓" if ok else "✗"
209
+ print(f" {marker} {msg}")
210
+ if not ok:
211
+ all_ok = False
212
+
213
+ asyncio.run(_run())
214
+ return 0 if all_ok else 1
215
+
216
+
217
+ # ---------- start -------------------------------------------------------------
218
+
219
+
220
+ def cmd_channels_start(settings: Settings) -> int:
221
+ """Load channels + spin up one adapter per enabled instance until Ctrl-C.
222
+
223
+ Blocks. Exits 0 on Ctrl-C, non-zero on config error or if no enabled
224
+ instances are configured.
225
+ """
226
+ from .channels.dispatcher import SessionDispatcher # local import: heavy deps
227
+ from .providers import default_provider_factory
228
+
229
+ path = settings.channels_config_path
230
+ try:
231
+ cfg = load_channels_config(path)
232
+ except ConfigError as exc:
233
+ print(f"config error in {path}: {exc}", file=sys.stderr)
234
+ return 2
235
+
236
+ enabled = cfg.enabled_wechat
237
+ if not enabled:
238
+ print(
239
+ "No enabled channels — set `enabled = true` on at least one "
240
+ "[[channels.wechat]] block in " + str(path),
241
+ file=sys.stderr,
242
+ )
243
+ return 1
244
+
245
+ # Resolve tokens up front so a missing env var kills us before any
246
+ # network connection instead of silently.
247
+ resolved: list[tuple[WeChatInstanceConfig, str]] = []
248
+ for inst in enabled:
249
+ try:
250
+ token = inst.resolve_token()
251
+ except ConfigError as exc:
252
+ print(f"config error: {exc}", file=sys.stderr)
253
+ return 2
254
+ resolved.append((inst, token))
255
+
256
+ provider_factory = default_provider_factory(settings)
257
+
258
+ async def _go() -> None:
259
+ adapters: list[ChannelAdapter] = []
260
+ dispatchers: list[SessionDispatcher] = []
261
+ runners: list[asyncio.Task[None]] = []
262
+ stop = asyncio.Event()
263
+
264
+ for inst, token in resolved:
265
+ dispatcher = SessionDispatcher(
266
+ settings,
267
+ provider_factory,
268
+ default_provider=inst.default_provider or "claude",
269
+ )
270
+ gate = PolicyGate(inst.to_policy(), dispatcher.handle_message)
271
+ adapter = WeChatAdapter(
272
+ instance_id=inst.instance_id,
273
+ base_url=inst.base_url,
274
+ token=token,
275
+ stop_event=stop,
276
+ )
277
+ adapter.set_handler(gate.handle)
278
+ dispatchers.append(dispatcher)
279
+ adapters.append(adapter)
280
+ runners.append(asyncio.create_task(adapter.run()))
281
+ print(f"channel started: {inst.instance_id} → {inst.base_url}")
282
+
283
+ try:
284
+ # Wait for any runner to exit (auth failure, etc.) or Ctrl-C.
285
+ done, pending = await asyncio.wait(
286
+ runners, return_when=asyncio.FIRST_COMPLETED
287
+ )
288
+ for t in done:
289
+ if t.exception():
290
+ logger.error("adapter exited: %s", t.exception().__class__.__name__)
291
+ finally:
292
+ stop.set()
293
+ for a in adapters:
294
+ with contextlib.suppress(Exception):
295
+ await a.aclose()
296
+ for d in dispatchers:
297
+ with contextlib.suppress(Exception):
298
+ await d.aclose()
299
+ for t in runners:
300
+ if not t.done():
301
+ t.cancel()
302
+ with contextlib.suppress(BaseException):
303
+ await t
304
+
305
+ print(f"Starting {len(resolved)} channel(s). Ctrl-C to stop.")
306
+ try:
307
+ asyncio.run(_go())
308
+ except KeyboardInterrupt:
309
+ print("\nStopped.")
310
+ return 0
311
+
312
+
313
+ # ---------- argparse wiring ---------------------------------------------------
314
+
315
+
316
+ def register_subparsers(
317
+ channels_parser: argparse.ArgumentParser, common: argparse.ArgumentParser
318
+ ) -> None:
319
+ """Attach ``init`` / ``start`` / ``doctor`` under a top-level ``channels``.
320
+
321
+ Called from ``coding_bridge.cli.main`` so the entry point stays one file.
322
+ """
323
+ sub = channels_parser.add_subparsers(dest="channels_command", required=True)
324
+
325
+ p_init = sub.add_parser(
326
+ "init", help="Write a skeleton channels.toml (safe defaults)", parents=[common]
327
+ )
328
+ p_init.set_defaults(func=_dispatch_init)
329
+
330
+ p_start = sub.add_parser(
331
+ "start",
332
+ help="Run every enabled [[channels.wechat]] instance until Ctrl-C",
333
+ parents=[common],
334
+ )
335
+ p_start.set_defaults(func=_dispatch_start)
336
+
337
+ p_doctor = sub.add_parser(
338
+ "doctor",
339
+ help="Validate channels.toml + ping every enabled WeChat endpoint",
340
+ parents=[common],
341
+ )
342
+ p_doctor.set_defaults(func=_dispatch_doctor)
343
+
344
+
345
+ def _dispatch_init(args: argparse.Namespace) -> None:
346
+ from .cli import _build_settings # local import to avoid circular
347
+
348
+ raise SystemExit(cmd_channels_init(_build_settings(args)))
349
+
350
+
351
+ def _dispatch_start(args: argparse.Namespace) -> None:
352
+ from .cli import _build_settings
353
+
354
+ raise SystemExit(cmd_channels_start(_build_settings(args)))
355
+
356
+
357
+ def _dispatch_doctor(args: argparse.Namespace) -> None:
358
+ from .cli import _build_settings
359
+
360
+ raise SystemExit(cmd_channels_doctor(_build_settings(args)))
361
+
362
+
363
+ __all__ = [
364
+ "cmd_channels_doctor",
365
+ "cmd_channels_init",
366
+ "cmd_channels_start",
367
+ "register_subparsers",
368
+ ]
@@ -244,6 +244,17 @@ def main(argv: list[str] | None = None) -> None:
244
244
  func=cmd_logout
245
245
  )
246
246
 
247
+ # `coding-bridge channels {init,start,doctor}` — wires WeChat
248
+ # adapters to the SessionDispatcher. See channels_cli.py.
249
+ from . import channels_cli
250
+
251
+ p_channels = sub.add_parser(
252
+ "channels",
253
+ help="Manage optional messaging-channel adapters (e.g. WeChat)",
254
+ parents=[common],
255
+ )
256
+ channels_cli.register_subparsers(p_channels, common)
257
+
247
258
  args = parser.parse_args(argv)
248
259
  settings = _build_settings(args)
249
260
  log_path = logs.setup(settings.log_level, settings.log_dir)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coding-bridge"
7
- version = "2026.7.3.3"
7
+ version = "2026.7.3.4"
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"
@@ -0,0 +1,309 @@
1
+ """Tests for the ``coding-bridge channels`` CLI subcommand group.
2
+
3
+ Focus: pure surface — arg parsing, init file write behavior, doctor output
4
+ against mocked httpx. `start` is thin glue and is smoke-tested by the E2E.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import os
11
+ from contextlib import redirect_stderr, redirect_stdout
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ import pytest
16
+
17
+ from coding_bridge import channels_cli, cli
18
+ from coding_bridge.config import Settings
19
+
20
+ # ---------- helpers -----------------------------------------------------------
21
+
22
+
23
+ def _settings(tmp_path: Path) -> Settings:
24
+ return Settings(config_dir=tmp_path, log_dir=tmp_path / "logs")
25
+
26
+
27
+ def _capture(fn) -> tuple[int, str, str]:
28
+ """Run ``fn()`` and capture (rc, stdout, stderr). ``fn`` must return int."""
29
+ out, err = io.StringIO(), io.StringIO()
30
+ with redirect_stdout(out), redirect_stderr(err):
31
+ rc = fn()
32
+ return rc, out.getvalue(), err.getvalue()
33
+
34
+
35
+ # ---------- init --------------------------------------------------------------
36
+
37
+
38
+ class TestChannelsInit:
39
+ def test_creates_file_with_safe_defaults(self, tmp_path: Path) -> None:
40
+ s = _settings(tmp_path)
41
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_init(s))
42
+ assert rc == 0
43
+ assert s.channels_config_path.exists()
44
+ body = s.channels_config_path.read_text(encoding="utf-8")
45
+ # Every example is commented out — nothing enabled by default
46
+ assert "# [[channels.wechat]]" in body
47
+ # `enabled = false` appears in the template
48
+ assert "enabled = false" in body
49
+ # Confirmation printed
50
+ assert str(s.channels_config_path) in out
51
+
52
+ def test_refuses_to_overwrite(self, tmp_path: Path) -> None:
53
+ s = _settings(tmp_path)
54
+ s.channels_config_path.parent.mkdir(parents=True, exist_ok=True)
55
+ s.channels_config_path.write_text("# already here", encoding="utf-8")
56
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_init(s))
57
+ assert rc == 1
58
+ assert "Refusing to overwrite" in err
59
+ # Original file untouched
60
+ assert s.channels_config_path.read_text(encoding="utf-8") == "# already here"
61
+
62
+ @pytest.mark.skipif(
63
+ os.name == "nt", reason="POSIX permission bits don't apply on Windows"
64
+ )
65
+ def test_posix_permissions_are_0600(self, tmp_path: Path) -> None:
66
+ import stat as _stat
67
+
68
+ s = _settings(tmp_path)
69
+ channels_cli.cmd_channels_init(s)
70
+ mode = _stat.S_IMODE(s.channels_config_path.stat().st_mode)
71
+ assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
72
+
73
+
74
+ # ---------- doctor ------------------------------------------------------------
75
+
76
+
77
+ class TestChannelsDoctor:
78
+ def test_no_config_prints_hint(self, tmp_path: Path) -> None:
79
+ s = _settings(tmp_path)
80
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
81
+ assert rc == 0
82
+ assert "No channels configured" in out
83
+ assert "channels init" in out
84
+
85
+ def test_bad_config_exits_2(self, tmp_path: Path) -> None:
86
+ s = _settings(tmp_path)
87
+ s.config_dir.mkdir(parents=True, exist_ok=True)
88
+ s.channels_config_path.write_text(
89
+ "[[channels.wechat]]\ninstance_id = 'x'\n" # missing base_url
90
+ "token_env = 'X'\n",
91
+ encoding="utf-8",
92
+ )
93
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
94
+ assert rc == 2
95
+ assert "config error" in err
96
+
97
+ def test_disabled_instances_are_skipped(self, tmp_path: Path) -> None:
98
+ s = _settings(tmp_path)
99
+ s.config_dir.mkdir(parents=True, exist_ok=True)
100
+ s.channels_config_path.write_text(
101
+ "[[channels.wechat]]\n"
102
+ "instance_id = 'sleepy'\n"
103
+ "base_url = 'http://never-called'\n"
104
+ "token_env = 'MISSING'\n"
105
+ "enabled = false\n",
106
+ encoding="utf-8",
107
+ )
108
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
109
+ assert rc == 0
110
+ assert "sleepy" in out
111
+ assert "skipped" in out.lower()
112
+ # Never tried to resolve the missing env var
113
+ assert "MISSING" not in err
114
+
115
+ def test_enabled_reachable_endpoint_marked_ok(
116
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
117
+ ) -> None:
118
+ # Patch WeChatClient to use a MockTransport where the tasks endpoint
119
+ # returns 404 (token accepted, probe id unknown) — the happy path.
120
+ monkeypatch.setenv("MY_TOKEN", "abc")
121
+ s = _settings(tmp_path)
122
+ s.config_dir.mkdir(parents=True, exist_ok=True)
123
+ s.channels_config_path.write_text(
124
+ "[[channels.wechat]]\n"
125
+ "instance_id = 'live'\n"
126
+ "base_url = 'http://wechat.local'\n"
127
+ "token_env = 'MY_TOKEN'\n"
128
+ "enabled = true\n",
129
+ encoding="utf-8",
130
+ )
131
+
132
+ # Monkey-patch WeChatClient.__init__ to plug in a MockTransport
133
+ original_init = channels_cli.WeChatClient.__init__
134
+
135
+ def patched_init(self, base_url, token, *, timeout=10.0, transport=None):
136
+ def handler(request: httpx.Request) -> httpx.Response:
137
+ assert request.url.path.startswith("/api/messages/tasks/")
138
+ # Token was accepted; the probe task just doesn't exist.
139
+ return httpx.Response(404, json={"error": "task not found"})
140
+
141
+ original_init(
142
+ self, base_url, token, timeout=timeout, transport=httpx.MockTransport(handler)
143
+ )
144
+
145
+ monkeypatch.setattr(channels_cli.WeChatClient, "__init__", patched_init)
146
+
147
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
148
+ assert rc == 0
149
+ assert "live" in out
150
+ assert "OK" in out
151
+ # Token value never appears in output
152
+ assert "abc" not in out
153
+ assert "abc" not in err
154
+
155
+ def test_enabled_401_marked_failure(
156
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
157
+ ) -> None:
158
+ monkeypatch.setenv("BAD_TOKEN", "wrong")
159
+ s = _settings(tmp_path)
160
+ s.config_dir.mkdir(parents=True, exist_ok=True)
161
+ s.channels_config_path.write_text(
162
+ "[[channels.wechat]]\n"
163
+ "instance_id = 'rejected'\n"
164
+ "base_url = 'http://wechat.local'\n"
165
+ "token_env = 'BAD_TOKEN'\n"
166
+ "enabled = true\n",
167
+ encoding="utf-8",
168
+ )
169
+
170
+ original_init = channels_cli.WeChatClient.__init__
171
+
172
+ def patched_init(self, base_url, token, *, timeout=10.0, transport=None):
173
+ def handler(request: httpx.Request) -> httpx.Response:
174
+ return httpx.Response(401, json={"error": "unauthorized"})
175
+
176
+ original_init(
177
+ self, base_url, token, timeout=timeout, transport=httpx.MockTransport(handler)
178
+ )
179
+
180
+ monkeypatch.setattr(channels_cli.WeChatClient, "__init__", patched_init)
181
+
182
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
183
+ assert rc == 1
184
+ assert "rejected" in out
185
+ assert "401" in out
186
+ # Token never leaked
187
+ assert "wrong" not in out
188
+
189
+ def test_enabled_500_marked_failure(
190
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
191
+ ) -> None:
192
+ # 5xx on tasks + no /health → server error reported, not "OK"
193
+ monkeypatch.setenv("TOK", "t")
194
+ s = _settings(tmp_path)
195
+ s.config_dir.mkdir(parents=True, exist_ok=True)
196
+ s.channels_config_path.write_text(
197
+ "[[channels.wechat]]\n"
198
+ "instance_id = 'broken'\n"
199
+ "base_url = 'http://wechat.local'\n"
200
+ "token_env = 'TOK'\n"
201
+ "enabled = true\n",
202
+ encoding="utf-8",
203
+ )
204
+
205
+ original_init = channels_cli.WeChatClient.__init__
206
+
207
+ def patched_init(self, base_url, token, *, timeout=10.0, transport=None):
208
+ def handler(request: httpx.Request) -> httpx.Response:
209
+ return httpx.Response(500, json={"error": "boom"})
210
+
211
+ original_init(
212
+ self, base_url, token, timeout=timeout, transport=httpx.MockTransport(handler)
213
+ )
214
+
215
+ monkeypatch.setattr(channels_cli.WeChatClient, "__init__", patched_init)
216
+
217
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
218
+ assert rc == 1
219
+ assert "broken" in out
220
+ assert "500" in out
221
+
222
+ def test_missing_token_env_reports_config_error(self, tmp_path: Path) -> None:
223
+ s = _settings(tmp_path)
224
+ s.config_dir.mkdir(parents=True, exist_ok=True)
225
+ s.channels_config_path.write_text(
226
+ "[[channels.wechat]]\n"
227
+ "instance_id = 'no-tok'\n"
228
+ "base_url = 'http://wechat.local'\n"
229
+ "token_env = 'THIS_ENV_IS_NOT_SET_XYZ'\n"
230
+ "enabled = true\n",
231
+ encoding="utf-8",
232
+ )
233
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_doctor(s))
234
+ # doctor exits non-zero because the enabled instance failed
235
+ assert rc == 1
236
+ # Message references the env var name, not any value
237
+ assert "THIS_ENV_IS_NOT_SET_XYZ" in out
238
+
239
+
240
+ # ---------- start (arg-parse + no-enabled-instances path) --------------------
241
+
242
+
243
+ class TestChannelsStart:
244
+ def test_no_config_exits_2(self, tmp_path: Path) -> None:
245
+ s = _settings(tmp_path)
246
+ s.config_dir.mkdir(parents=True, exist_ok=True)
247
+ s.channels_config_path.write_text("garbage = ", encoding="utf-8")
248
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_start(s))
249
+ assert rc == 2
250
+ assert "config error" in err
251
+
252
+ def test_no_enabled_instances_exits_1(self, tmp_path: Path) -> None:
253
+ s = _settings(tmp_path)
254
+ s.config_dir.mkdir(parents=True, exist_ok=True)
255
+ s.channels_config_path.write_text(
256
+ "[[channels.wechat]]\n"
257
+ "instance_id = 'idle'\n"
258
+ "base_url = 'http://wechat.local'\n"
259
+ "token_env = 'T'\n"
260
+ "enabled = false\n",
261
+ encoding="utf-8",
262
+ )
263
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_start(s))
264
+ assert rc == 1
265
+ assert "No enabled channels" in err
266
+
267
+ def test_missing_token_env_exits_2_before_network(
268
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
269
+ ) -> None:
270
+ s = _settings(tmp_path)
271
+ s.config_dir.mkdir(parents=True, exist_ok=True)
272
+ s.channels_config_path.write_text(
273
+ "[[channels.wechat]]\n"
274
+ "instance_id = 'x'\n"
275
+ "base_url = 'http://wechat.local'\n"
276
+ "token_env = 'ABSOLUTELY_NOT_SET_9999'\n"
277
+ "enabled = true\n",
278
+ encoding="utf-8",
279
+ )
280
+ monkeypatch.delenv("ABSOLUTELY_NOT_SET_9999", raising=False)
281
+ rc, out, err = _capture(lambda: channels_cli.cmd_channels_start(s))
282
+ assert rc == 2
283
+ assert "ABSOLUTELY_NOT_SET_9999" in err
284
+
285
+
286
+ # ---------- argparse integration ---------------------------------------------
287
+
288
+
289
+ class TestArgparseIntegration:
290
+ def test_channels_subcommand_parses(self, tmp_path: Path) -> None:
291
+ # Sanity: verify the top-level parser sees the `channels` subcommand
292
+ # and refuses to run `channels` without a sub-subcommand.
293
+ with pytest.raises(SystemExit):
294
+ cli.main(["channels", "--config-dir", str(tmp_path)])
295
+
296
+ def test_channels_init_end_to_end(self, tmp_path: Path) -> None:
297
+ # Direct `cli.main` invocation of the init subcommand
298
+ with pytest.raises(SystemExit) as ei:
299
+ cli.main(["channels", "--config-dir", str(tmp_path), "init"])
300
+ assert ei.value.code == 0
301
+ assert (tmp_path / "channels.toml").exists()
302
+
303
+ def test_channels_init_refuses_second_run(self, tmp_path: Path) -> None:
304
+ with pytest.raises(SystemExit) as ei1:
305
+ cli.main(["channels", "--config-dir", str(tmp_path), "init"])
306
+ assert ei1.value.code == 0
307
+ with pytest.raises(SystemExit) as ei2:
308
+ cli.main(["channels", "--config-dir", str(tmp_path), "init"])
309
+ assert ei2.value.code == 1
@@ -375,6 +375,21 @@ class TestResolveToken:
375
375
  with pytest.raises(ConfigError, match=r"either token_env or token_file must be set"):
376
376
  inst.resolve_token({})
377
377
 
378
+ def test_repr_never_contains_resolved_token(self) -> None:
379
+ # The config object holds only the env-var NAME / file PATH, never the
380
+ # secret. `repr()` (used by logging %r, tracebacks, debuggers) must not
381
+ # be able to expose a token because the token isn't stored on it.
382
+ inst = WeChatInstanceConfig(
383
+ instance_id="x", base_url="http://a", token_env="MY_SECRET_ENV"
384
+ )
385
+ token = inst.resolve_token({"MY_SECRET_ENV": "super-secret-value-xyz"})
386
+ assert token == "super-secret-value-xyz"
387
+ # The resolved token is a local — the frozen config never captured it.
388
+ assert "super-secret-value-xyz" not in repr(inst)
389
+ assert "super-secret-value-xyz" not in str(inst)
390
+ # The env-var NAME is fine to appear (it's not the secret).
391
+ assert "MY_SECRET_ENV" in repr(inst)
392
+
378
393
 
379
394
  class TestSettingsIntegration:
380
395
  def test_channels_config_path_is_under_config_dir(self, tmp_path: Path) -> None: