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.
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/PKG-INFO +1 -1
- coding_bridge-2026.7.3.4/coding_bridge/channels_cli.py +368 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/cli.py +11 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/pyproject.toml +1 -1
- coding_bridge-2026.7.3.4/tests/test_channels_cli.py +309 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_config.py +15 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.env.example +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/.gitignore +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/LICENSE +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/NOTICE +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/README.md +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/__init__.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/base.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/config.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/dispatcher.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/policy.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/__init__.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/adapter.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/client.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/config.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/connection.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/history.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_base.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_dispatcher.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_policy.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_channels_wechat.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_history.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/tests/test_streaming.py +0 -0
- {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
|
+
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.
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/__init__.py
RENAMED
|
File without changes
|
{coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/adapter.py
RENAMED
|
File without changes
|
{coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_bridge-2026.7.3.3 → coding_bridge-2026.7.3.4}/docs/design/reliable-event-delivery.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|