coding-bridge 2026.7.3.1__tar.gz → 2026.7.3.2__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.1 → coding_bridge-2026.7.3.2}/PKG-INFO +2 -1
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/__init__.py +12 -0
- coding_bridge-2026.7.3.2/coding_bridge/channels/config.py +282 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/config.py +5 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/pyproject.toml +2 -1
- coding_bridge-2026.7.3.2/tests/test_channels_config.py +384 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.env.example +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.gitignore +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/LICENSE +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/NOTICE +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/README.md +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/base.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/dispatcher.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/__init__.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/adapter.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/client.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/cli.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/connection.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/history.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_base.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_dispatcher.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_wechat.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_history.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/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.
|
|
3
|
+
Version: 2026.7.3.2
|
|
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
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Requires-Dist: claude-agent-sdk>=0.1.0
|
|
18
18
|
Requires-Dist: httpx<0.29,>=0.27
|
|
19
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
19
20
|
Requires-Dist: websockets<14,>=12
|
|
20
21
|
Provides-Extra: dev
|
|
21
22
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
@@ -17,13 +17,25 @@ from .base import (
|
|
|
17
17
|
MessageHandler,
|
|
18
18
|
SendResult,
|
|
19
19
|
)
|
|
20
|
+
from .config import (
|
|
21
|
+
ChannelsConfig,
|
|
22
|
+
ConfigError,
|
|
23
|
+
WeChatInstanceConfig,
|
|
24
|
+
load_channels_config,
|
|
25
|
+
parse_channels_config,
|
|
26
|
+
)
|
|
20
27
|
from .dispatcher import SessionDispatcher
|
|
21
28
|
|
|
22
29
|
__all__ = [
|
|
23
30
|
"ChannelAdapter",
|
|
24
31
|
"ChannelTarget",
|
|
32
|
+
"ChannelsConfig",
|
|
33
|
+
"ConfigError",
|
|
25
34
|
"IncomingMessage",
|
|
26
35
|
"MessageHandler",
|
|
27
36
|
"SendResult",
|
|
28
37
|
"SessionDispatcher",
|
|
38
|
+
"WeChatInstanceConfig",
|
|
39
|
+
"load_channels_config",
|
|
40
|
+
"parse_channels_config",
|
|
29
41
|
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Channels config schema — parses ``~/.ace-bridge/channels.toml``.
|
|
2
|
+
|
|
3
|
+
Split from ``coding_bridge.config`` on purpose: channels are optional and evolve
|
|
4
|
+
independently of the core node settings. Users who never touch channels never
|
|
5
|
+
load this module.
|
|
6
|
+
|
|
7
|
+
Schema (fail-fast; unknown keys raise):
|
|
8
|
+
|
|
9
|
+
.. code-block:: toml
|
|
10
|
+
|
|
11
|
+
[channels]
|
|
12
|
+
# (reserved for future globals)
|
|
13
|
+
|
|
14
|
+
[[channels.wechat]]
|
|
15
|
+
instance_id = "beijing-cvm" # required, unique per file
|
|
16
|
+
base_url = "http://82.156.126.14:8000" # required, http:// or https://
|
|
17
|
+
token_env = "WECHAT_TOKEN_BEIJING" # required unless token_file is set
|
|
18
|
+
token_file = "/run/secrets/beijing" # optional alternative to token_env
|
|
19
|
+
enabled = false # default false — explicit opt-in
|
|
20
|
+
default_provider = "claude" # optional override for this instance
|
|
21
|
+
|
|
22
|
+
Design notes:
|
|
23
|
+
|
|
24
|
+
* The **token itself never lives in the TOML file** — the file references an
|
|
25
|
+
env var name (`token_env`) or a secrets-file path (`token_file`). This
|
|
26
|
+
matches the deployment templates in P8 (systemd EnvironmentFile,
|
|
27
|
+
launchd, Windows service credentials store).
|
|
28
|
+
* `enabled = false` is the default so accidentally shipping a `channels.toml`
|
|
29
|
+
never puts an unsupervised bot online.
|
|
30
|
+
* Unknown keys raise — a typo like `intance_id` or `allowlist_typo` should
|
|
31
|
+
fail loudly at boot, not silently degrade to unsafe defaults.
|
|
32
|
+
* A hard 1 MB cap on the TOML file guards against accidentally pointing at
|
|
33
|
+
``/dev/urandom`` or a runaway log.
|
|
34
|
+
* ``base_url`` may not contain embedded userinfo (``http://user:pass@host``);
|
|
35
|
+
credentials go through ``token_env``/``token_file`` only.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
from urllib.parse import urlparse
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import tomllib as _toml
|
|
48
|
+
except ModuleNotFoundError: # Python 3.10
|
|
49
|
+
import tomli as _toml # type: ignore[import-not-found]
|
|
50
|
+
|
|
51
|
+
_WECHAT_KEYS = frozenset(
|
|
52
|
+
{"instance_id", "base_url", "token_env", "token_file", "enabled", "default_provider"}
|
|
53
|
+
)
|
|
54
|
+
_CHANNELS_KEYS = frozenset({"wechat"})
|
|
55
|
+
|
|
56
|
+
# Cap the TOML file size so a misconfigured `channels.toml` pointing at
|
|
57
|
+
# `/dev/urandom` or similar can't OOM the daemon at startup.
|
|
58
|
+
_MAX_TOML_BYTES = 1_000_000
|
|
59
|
+
|
|
60
|
+
# Cap the token file so `token_file = "/dev/urandom"` also can't OOM us.
|
|
61
|
+
# Real API tokens are ~64–512 bytes; 64 KB is generous.
|
|
62
|
+
_MAX_TOKEN_BYTES = 64 * 1024
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigError(ValueError):
|
|
66
|
+
"""Raised for any schema violation — never caught silently."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class WeChatInstanceConfig:
|
|
71
|
+
"""One `[[channels.wechat]]` block."""
|
|
72
|
+
|
|
73
|
+
instance_id: str
|
|
74
|
+
base_url: str
|
|
75
|
+
token_env: str | None = None
|
|
76
|
+
token_file: str | None = None
|
|
77
|
+
enabled: bool = False
|
|
78
|
+
default_provider: str | None = None
|
|
79
|
+
|
|
80
|
+
def resolve_token(self, environ: dict[str, str] | None = None) -> str:
|
|
81
|
+
"""Load the token from env var or secrets file. Never logs it.
|
|
82
|
+
|
|
83
|
+
Raises ``ConfigError`` with a **redacted** message if the token can't
|
|
84
|
+
be resolved — the message references the env-var *name* or file path,
|
|
85
|
+
never the value.
|
|
86
|
+
"""
|
|
87
|
+
env = environ if environ is not None else dict(os.environ)
|
|
88
|
+
if self.token_env:
|
|
89
|
+
token = env.get(self.token_env)
|
|
90
|
+
if not token:
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
f"wechat instance {self.instance_id!r}: env var "
|
|
93
|
+
f"{self.token_env!r} is unset or empty"
|
|
94
|
+
)
|
|
95
|
+
return token
|
|
96
|
+
if self.token_file:
|
|
97
|
+
path = Path(self.token_file).expanduser()
|
|
98
|
+
try:
|
|
99
|
+
# Reject anything that isn't a regular file up front so a
|
|
100
|
+
# symlink pointing at a directory / device raises a clean
|
|
101
|
+
# ConfigError instead of a raw OSError / UnicodeDecodeError.
|
|
102
|
+
if not path.is_file():
|
|
103
|
+
raise ConfigError(
|
|
104
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
105
|
+
f"{str(path)!r} is not a regular file"
|
|
106
|
+
)
|
|
107
|
+
size = path.stat().st_size
|
|
108
|
+
if size > _MAX_TOKEN_BYTES:
|
|
109
|
+
raise ConfigError(
|
|
110
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
111
|
+
f"{str(path)!r} exceeds {_MAX_TOKEN_BYTES}-byte limit"
|
|
112
|
+
)
|
|
113
|
+
raw = path.read_bytes()
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
f"wechat instance {self.instance_id!r}: cannot read "
|
|
117
|
+
f"token_file {str(path)!r}: {exc.__class__.__name__}"
|
|
118
|
+
) from None
|
|
119
|
+
try:
|
|
120
|
+
token = raw.decode("utf-8").strip()
|
|
121
|
+
except UnicodeDecodeError:
|
|
122
|
+
raise ConfigError(
|
|
123
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
124
|
+
f"{str(path)!r} is not valid UTF-8"
|
|
125
|
+
) from None
|
|
126
|
+
if not token:
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
129
|
+
f"{str(path)!r} is empty"
|
|
130
|
+
)
|
|
131
|
+
return token
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
f"wechat instance {self.instance_id!r}: either token_env or "
|
|
134
|
+
"token_file must be set"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ChannelsConfig:
|
|
140
|
+
"""Root of the channels config file."""
|
|
141
|
+
|
|
142
|
+
wechat: tuple[WeChatInstanceConfig, ...] = field(default_factory=tuple)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def enabled_wechat(self) -> tuple[WeChatInstanceConfig, ...]:
|
|
146
|
+
return tuple(inst for inst in self.wechat if inst.enabled)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _require(kind: str, block: dict[str, Any], key: str) -> Any:
|
|
150
|
+
value = block.get(key)
|
|
151
|
+
if value is None or value == "":
|
|
152
|
+
raise ConfigError(f"{kind}: required field {key!r} is missing or empty")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
|
|
157
|
+
unknown = set(block.keys()) - _WECHAT_KEYS
|
|
158
|
+
if unknown:
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
f"[[channels.wechat]] #{index}: unknown key(s) {sorted(unknown)!r}"
|
|
161
|
+
)
|
|
162
|
+
instance_id = _require(f"[[channels.wechat]] #{index}", block, "instance_id")
|
|
163
|
+
base_url = _require(f"[[channels.wechat]] #{index}", block, "base_url")
|
|
164
|
+
if not isinstance(instance_id, str):
|
|
165
|
+
raise ConfigError(f"[[channels.wechat]] #{index}: instance_id must be a string")
|
|
166
|
+
if not isinstance(base_url, str):
|
|
167
|
+
raise ConfigError(f"[[channels.wechat]] #{index}: base_url must be a string")
|
|
168
|
+
if not (base_url.startswith("http://") or base_url.startswith("https://")):
|
|
169
|
+
raise ConfigError(
|
|
170
|
+
f"[[channels.wechat]] {instance_id!r}: base_url must start with "
|
|
171
|
+
"http:// or https://"
|
|
172
|
+
)
|
|
173
|
+
# Reject `http://user:pass@host` so tokens never travel via base_url.
|
|
174
|
+
# `urlparse` folds userinfo into `netloc`; a bare `@` in the path segment
|
|
175
|
+
# after the host doesn't count because it's not in netloc.
|
|
176
|
+
if "@" in urlparse(base_url).netloc:
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"[[channels.wechat]] {instance_id!r}: base_url must not contain "
|
|
179
|
+
"embedded credentials (user:pass@host); use token_env/token_file"
|
|
180
|
+
)
|
|
181
|
+
token_env = block.get("token_env")
|
|
182
|
+
token_file = block.get("token_file")
|
|
183
|
+
if token_env is not None and not isinstance(token_env, str):
|
|
184
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: token_env must be a string")
|
|
185
|
+
if token_file is not None and not isinstance(token_file, str):
|
|
186
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: token_file must be a string")
|
|
187
|
+
if token_env and token_file:
|
|
188
|
+
raise ConfigError(
|
|
189
|
+
f"[[channels.wechat]] {instance_id!r}: set token_env OR token_file, not both"
|
|
190
|
+
)
|
|
191
|
+
enabled = block.get("enabled", False)
|
|
192
|
+
# `isinstance(True, int) is True` in Python, so guard against the reverse
|
|
193
|
+
# (`enabled = 1` in TOML → int → we want a hard error, not a silent truthy).
|
|
194
|
+
if not isinstance(enabled, bool):
|
|
195
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: enabled must be a bool")
|
|
196
|
+
default_provider = block.get("default_provider")
|
|
197
|
+
if default_provider is not None and not isinstance(default_provider, str):
|
|
198
|
+
raise ConfigError(
|
|
199
|
+
f"[[channels.wechat]] {instance_id!r}: default_provider must be a string"
|
|
200
|
+
)
|
|
201
|
+
return WeChatInstanceConfig(
|
|
202
|
+
instance_id=instance_id,
|
|
203
|
+
base_url=base_url.rstrip("/"),
|
|
204
|
+
token_env=token_env,
|
|
205
|
+
token_file=token_file,
|
|
206
|
+
enabled=enabled,
|
|
207
|
+
default_provider=default_provider,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_channels_config(data: dict[str, Any]) -> ChannelsConfig:
|
|
212
|
+
"""Parse an already-decoded TOML mapping. Public for test use."""
|
|
213
|
+
channels = data.get("channels")
|
|
214
|
+
if channels is None:
|
|
215
|
+
return ChannelsConfig()
|
|
216
|
+
if not isinstance(channels, dict):
|
|
217
|
+
raise ConfigError("[channels]: must be a table")
|
|
218
|
+
unknown = set(channels.keys()) - _CHANNELS_KEYS
|
|
219
|
+
if unknown:
|
|
220
|
+
raise ConfigError(f"[channels]: unknown key(s) {sorted(unknown)!r}")
|
|
221
|
+
|
|
222
|
+
wechat_blocks = channels.get("wechat", [])
|
|
223
|
+
if not isinstance(wechat_blocks, list):
|
|
224
|
+
raise ConfigError("[[channels.wechat]]: must be an array of tables")
|
|
225
|
+
|
|
226
|
+
parsed: list[WeChatInstanceConfig] = []
|
|
227
|
+
seen_ids: set[str] = set()
|
|
228
|
+
for i, block in enumerate(wechat_blocks):
|
|
229
|
+
if not isinstance(block, dict):
|
|
230
|
+
raise ConfigError(f"[[channels.wechat]] #{i}: must be a table")
|
|
231
|
+
inst = _parse_wechat(block, i)
|
|
232
|
+
if inst.instance_id in seen_ids:
|
|
233
|
+
raise ConfigError(
|
|
234
|
+
f"[[channels.wechat]]: duplicate instance_id {inst.instance_id!r}"
|
|
235
|
+
)
|
|
236
|
+
seen_ids.add(inst.instance_id)
|
|
237
|
+
parsed.append(inst)
|
|
238
|
+
return ChannelsConfig(wechat=tuple(parsed))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_channels_config(path: Path) -> ChannelsConfig:
|
|
242
|
+
"""Read + parse a TOML file. Missing file → empty config (no channels).
|
|
243
|
+
|
|
244
|
+
Wraps every filesystem / decode error in ``ConfigError`` so the daemon
|
|
245
|
+
can surface a clean startup message instead of an unhandled traceback.
|
|
246
|
+
"""
|
|
247
|
+
if not path.exists():
|
|
248
|
+
return ChannelsConfig()
|
|
249
|
+
if not path.is_file():
|
|
250
|
+
raise ConfigError(f"channels config {str(path)!r} is not a regular file")
|
|
251
|
+
try:
|
|
252
|
+
size = path.stat().st_size
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
raise ConfigError(
|
|
255
|
+
f"channels config {str(path)!r}: cannot stat: {exc.__class__.__name__}"
|
|
256
|
+
) from None
|
|
257
|
+
if size > _MAX_TOML_BYTES:
|
|
258
|
+
raise ConfigError(
|
|
259
|
+
f"channels config {str(path)!r} exceeds {_MAX_TOML_BYTES}-byte limit "
|
|
260
|
+
f"({size} bytes)"
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
with path.open("rb") as f:
|
|
264
|
+
data = _toml.load(f)
|
|
265
|
+
except _toml.TOMLDecodeError as exc:
|
|
266
|
+
raise ConfigError(
|
|
267
|
+
f"channels config {str(path)!r} is not valid TOML: {exc}"
|
|
268
|
+
) from None
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
raise ConfigError(
|
|
271
|
+
f"channels config {str(path)!r}: cannot read: {exc.__class__.__name__}"
|
|
272
|
+
) from None
|
|
273
|
+
return parse_channels_config(data)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
__all__ = [
|
|
277
|
+
"ChannelsConfig",
|
|
278
|
+
"ConfigError",
|
|
279
|
+
"WeChatInstanceConfig",
|
|
280
|
+
"load_channels_config",
|
|
281
|
+
"parse_channels_config",
|
|
282
|
+
]
|
|
@@ -105,6 +105,11 @@ class Settings:
|
|
|
105
105
|
def credentials_path(self) -> Path:
|
|
106
106
|
return self.config_dir / "credentials.json"
|
|
107
107
|
|
|
108
|
+
@property
|
|
109
|
+
def channels_config_path(self) -> Path:
|
|
110
|
+
"""Location of the optional TOML file describing channel adapters."""
|
|
111
|
+
return self.config_dir / "channels.toml"
|
|
112
|
+
|
|
108
113
|
@classmethod
|
|
109
114
|
def from_env(cls) -> Settings:
|
|
110
115
|
def _f(name: str, default: float) -> float:
|
|
@@ -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.2"
|
|
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"
|
|
@@ -23,6 +23,7 @@ dependencies = [
|
|
|
23
23
|
"websockets>=12,<14",
|
|
24
24
|
"httpx>=0.27,<0.29",
|
|
25
25
|
"claude-agent-sdk>=0.1.0",
|
|
26
|
+
"tomli>=2.0;python_version<'3.11'",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Tests for ``coding_bridge.channels.config`` — the TOML schema loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from coding_bridge.channels.config import (
|
|
10
|
+
ChannelsConfig,
|
|
11
|
+
ConfigError,
|
|
12
|
+
WeChatInstanceConfig,
|
|
13
|
+
load_channels_config,
|
|
14
|
+
parse_channels_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write(tmp_path: Path, body: str) -> Path:
|
|
19
|
+
p = tmp_path / "channels.toml"
|
|
20
|
+
p.write_text(body, encoding="utf-8")
|
|
21
|
+
return p
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestParseHappyPath:
|
|
25
|
+
def test_empty_file_returns_empty_config(self, tmp_path: Path) -> None:
|
|
26
|
+
p = _write(tmp_path, "")
|
|
27
|
+
assert load_channels_config(p) == ChannelsConfig()
|
|
28
|
+
|
|
29
|
+
def test_missing_file_returns_empty_config(self, tmp_path: Path) -> None:
|
|
30
|
+
assert load_channels_config(tmp_path / "does-not-exist.toml") == ChannelsConfig()
|
|
31
|
+
|
|
32
|
+
def test_single_wechat_instance_with_token_env(self, tmp_path: Path) -> None:
|
|
33
|
+
p = _write(
|
|
34
|
+
tmp_path,
|
|
35
|
+
"""
|
|
36
|
+
[[channels.wechat]]
|
|
37
|
+
instance_id = "beijing-cvm"
|
|
38
|
+
base_url = "http://82.156.126.14:8000"
|
|
39
|
+
token_env = "WECHAT_TOKEN_BEIJING"
|
|
40
|
+
enabled = true
|
|
41
|
+
""",
|
|
42
|
+
)
|
|
43
|
+
cfg = load_channels_config(p)
|
|
44
|
+
assert len(cfg.wechat) == 1
|
|
45
|
+
inst = cfg.wechat[0]
|
|
46
|
+
assert inst.instance_id == "beijing-cvm"
|
|
47
|
+
assert inst.base_url == "http://82.156.126.14:8000"
|
|
48
|
+
assert inst.token_env == "WECHAT_TOKEN_BEIJING"
|
|
49
|
+
assert inst.enabled is True
|
|
50
|
+
assert inst.default_provider is None
|
|
51
|
+
assert cfg.enabled_wechat == (inst,)
|
|
52
|
+
|
|
53
|
+
def test_multiple_wechat_instances(self, tmp_path: Path) -> None:
|
|
54
|
+
p = _write(
|
|
55
|
+
tmp_path,
|
|
56
|
+
"""
|
|
57
|
+
[[channels.wechat]]
|
|
58
|
+
instance_id = "one"
|
|
59
|
+
base_url = "http://a"
|
|
60
|
+
token_env = "T1"
|
|
61
|
+
|
|
62
|
+
[[channels.wechat]]
|
|
63
|
+
instance_id = "two"
|
|
64
|
+
base_url = "https://b"
|
|
65
|
+
token_env = "T2"
|
|
66
|
+
enabled = true
|
|
67
|
+
default_provider = "codex"
|
|
68
|
+
""",
|
|
69
|
+
)
|
|
70
|
+
cfg = load_channels_config(p)
|
|
71
|
+
assert [inst.instance_id for inst in cfg.wechat] == ["one", "two"]
|
|
72
|
+
# `enabled=false` by default
|
|
73
|
+
assert cfg.wechat[0].enabled is False
|
|
74
|
+
assert cfg.wechat[1].enabled is True
|
|
75
|
+
assert cfg.wechat[1].default_provider == "codex"
|
|
76
|
+
assert cfg.enabled_wechat == (cfg.wechat[1],)
|
|
77
|
+
|
|
78
|
+
def test_base_url_trailing_slash_stripped(self, tmp_path: Path) -> None:
|
|
79
|
+
p = _write(
|
|
80
|
+
tmp_path,
|
|
81
|
+
"""
|
|
82
|
+
[[channels.wechat]]
|
|
83
|
+
instance_id = "x"
|
|
84
|
+
base_url = "https://example.com/"
|
|
85
|
+
token_env = "T"
|
|
86
|
+
""",
|
|
87
|
+
)
|
|
88
|
+
cfg = load_channels_config(p)
|
|
89
|
+
assert cfg.wechat[0].base_url == "https://example.com"
|
|
90
|
+
|
|
91
|
+
def test_token_file_alternative(self, tmp_path: Path) -> None:
|
|
92
|
+
p = _write(
|
|
93
|
+
tmp_path,
|
|
94
|
+
"""
|
|
95
|
+
[[channels.wechat]]
|
|
96
|
+
instance_id = "x"
|
|
97
|
+
base_url = "http://a"
|
|
98
|
+
token_file = "/run/secrets/wechat"
|
|
99
|
+
""",
|
|
100
|
+
)
|
|
101
|
+
cfg = load_channels_config(p)
|
|
102
|
+
assert cfg.wechat[0].token_file == "/run/secrets/wechat"
|
|
103
|
+
assert cfg.wechat[0].token_env is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestParseFailFast:
|
|
107
|
+
def test_unknown_top_level_key_rejected(self) -> None:
|
|
108
|
+
with pytest.raises(ConfigError, match=r"\[channels\]: unknown key\(s\)"):
|
|
109
|
+
parse_channels_config({"channels": {"foo": []}})
|
|
110
|
+
|
|
111
|
+
def test_channels_must_be_a_table(self) -> None:
|
|
112
|
+
with pytest.raises(ConfigError, match=r"\[channels\]: must be a table"):
|
|
113
|
+
parse_channels_config({"channels": "not a table"})
|
|
114
|
+
|
|
115
|
+
def test_wechat_array_must_be_list(self) -> None:
|
|
116
|
+
with pytest.raises(ConfigError, match=r"\[\[channels\.wechat\]\]"):
|
|
117
|
+
parse_channels_config({"channels": {"wechat": "not a list"}})
|
|
118
|
+
|
|
119
|
+
def test_unknown_wechat_key_rejected(self) -> None:
|
|
120
|
+
# Typo: `intance_id` instead of `instance_id`
|
|
121
|
+
with pytest.raises(ConfigError, match=r"unknown key\(s\).*intance_id"):
|
|
122
|
+
parse_channels_config(
|
|
123
|
+
{
|
|
124
|
+
"channels": {
|
|
125
|
+
"wechat": [
|
|
126
|
+
{
|
|
127
|
+
"intance_id": "typo",
|
|
128
|
+
"base_url": "http://a",
|
|
129
|
+
"token_env": "T",
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def test_missing_instance_id_rejected(self) -> None:
|
|
137
|
+
with pytest.raises(ConfigError, match=r"required field 'instance_id'"):
|
|
138
|
+
parse_channels_config(
|
|
139
|
+
{"channels": {"wechat": [{"base_url": "http://a", "token_env": "T"}]}}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_missing_base_url_rejected(self) -> None:
|
|
143
|
+
with pytest.raises(ConfigError, match=r"required field 'base_url'"):
|
|
144
|
+
parse_channels_config(
|
|
145
|
+
{"channels": {"wechat": [{"instance_id": "x", "token_env": "T"}]}}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def test_empty_instance_id_rejected(self) -> None:
|
|
149
|
+
with pytest.raises(ConfigError, match=r"required field 'instance_id'"):
|
|
150
|
+
parse_channels_config(
|
|
151
|
+
{
|
|
152
|
+
"channels": {
|
|
153
|
+
"wechat": [
|
|
154
|
+
{"instance_id": "", "base_url": "http://a", "token_env": "T"}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def test_bad_base_url_scheme_rejected(self) -> None:
|
|
161
|
+
with pytest.raises(ConfigError, match=r"base_url must start with http"):
|
|
162
|
+
parse_channels_config(
|
|
163
|
+
{
|
|
164
|
+
"channels": {
|
|
165
|
+
"wechat": [
|
|
166
|
+
{
|
|
167
|
+
"instance_id": "x",
|
|
168
|
+
"base_url": "ftp://oops",
|
|
169
|
+
"token_env": "T",
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def test_both_token_env_and_token_file_rejected(self) -> None:
|
|
177
|
+
with pytest.raises(ConfigError, match=r"set token_env OR token_file, not both"):
|
|
178
|
+
parse_channels_config(
|
|
179
|
+
{
|
|
180
|
+
"channels": {
|
|
181
|
+
"wechat": [
|
|
182
|
+
{
|
|
183
|
+
"instance_id": "x",
|
|
184
|
+
"base_url": "http://a",
|
|
185
|
+
"token_env": "T",
|
|
186
|
+
"token_file": "/tmp/tok",
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def test_enabled_must_be_bool(self) -> None:
|
|
194
|
+
with pytest.raises(ConfigError, match=r"enabled must be a bool"):
|
|
195
|
+
parse_channels_config(
|
|
196
|
+
{
|
|
197
|
+
"channels": {
|
|
198
|
+
"wechat": [
|
|
199
|
+
{
|
|
200
|
+
"instance_id": "x",
|
|
201
|
+
"base_url": "http://a",
|
|
202
|
+
"token_env": "T",
|
|
203
|
+
"enabled": "yes",
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def test_default_provider_must_be_string(self) -> None:
|
|
211
|
+
with pytest.raises(ConfigError, match=r"default_provider must be a string"):
|
|
212
|
+
parse_channels_config(
|
|
213
|
+
{
|
|
214
|
+
"channels": {
|
|
215
|
+
"wechat": [
|
|
216
|
+
{
|
|
217
|
+
"instance_id": "x",
|
|
218
|
+
"base_url": "http://a",
|
|
219
|
+
"token_env": "T",
|
|
220
|
+
"default_provider": 123,
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def test_duplicate_instance_id_rejected(self) -> None:
|
|
228
|
+
with pytest.raises(ConfigError, match=r"duplicate instance_id 'dup'"):
|
|
229
|
+
parse_channels_config(
|
|
230
|
+
{
|
|
231
|
+
"channels": {
|
|
232
|
+
"wechat": [
|
|
233
|
+
{"instance_id": "dup", "base_url": "http://a", "token_env": "T"},
|
|
234
|
+
{"instance_id": "dup", "base_url": "http://b", "token_env": "U"},
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def test_malformed_toml_rejected(self, tmp_path: Path) -> None:
|
|
241
|
+
p = _write(tmp_path, "[[channels.wechat\ninstance_id = 'oops'")
|
|
242
|
+
with pytest.raises(ConfigError, match=r"is not valid TOML"):
|
|
243
|
+
load_channels_config(p)
|
|
244
|
+
|
|
245
|
+
def test_channels_file_is_directory_rejected(self, tmp_path: Path) -> None:
|
|
246
|
+
p = tmp_path / "channels.toml"
|
|
247
|
+
p.mkdir()
|
|
248
|
+
with pytest.raises(ConfigError, match=r"is not a regular file"):
|
|
249
|
+
load_channels_config(p)
|
|
250
|
+
|
|
251
|
+
def test_channels_file_over_size_limit_rejected(self, tmp_path: Path) -> None:
|
|
252
|
+
# Write just over 1 MB of harmless comments so parser never runs.
|
|
253
|
+
p = tmp_path / "channels.toml"
|
|
254
|
+
p.write_bytes(b"# padding\n" * 110_000)
|
|
255
|
+
with pytest.raises(ConfigError, match=r"exceeds .*-byte limit"):
|
|
256
|
+
load_channels_config(p)
|
|
257
|
+
|
|
258
|
+
def test_base_url_with_embedded_userinfo_rejected(self) -> None:
|
|
259
|
+
with pytest.raises(ConfigError, match=r"embedded credentials"):
|
|
260
|
+
parse_channels_config(
|
|
261
|
+
{
|
|
262
|
+
"channels": {
|
|
263
|
+
"wechat": [
|
|
264
|
+
{
|
|
265
|
+
"instance_id": "x",
|
|
266
|
+
"base_url": "http://user:pass@host",
|
|
267
|
+
"token_env": "T",
|
|
268
|
+
}
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def test_enabled_as_int_rejected(self) -> None:
|
|
275
|
+
# TOML doesn't parse `1` as bool, but if a caller passes a Python int
|
|
276
|
+
# from a hand-built dict this must still fail — not silently truthy.
|
|
277
|
+
with pytest.raises(ConfigError, match=r"enabled must be a bool"):
|
|
278
|
+
parse_channels_config(
|
|
279
|
+
{
|
|
280
|
+
"channels": {
|
|
281
|
+
"wechat": [
|
|
282
|
+
{
|
|
283
|
+
"instance_id": "x",
|
|
284
|
+
"base_url": "http://a",
|
|
285
|
+
"token_env": "T",
|
|
286
|
+
"enabled": 1,
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TestResolveToken:
|
|
295
|
+
def test_resolve_from_env(self) -> None:
|
|
296
|
+
inst = WeChatInstanceConfig(
|
|
297
|
+
instance_id="x", base_url="http://a", token_env="MY_TOKEN"
|
|
298
|
+
)
|
|
299
|
+
assert inst.resolve_token({"MY_TOKEN": "secret-123"}) == "secret-123"
|
|
300
|
+
|
|
301
|
+
def test_missing_env_raises_without_leaking_value(self) -> None:
|
|
302
|
+
inst = WeChatInstanceConfig(
|
|
303
|
+
instance_id="x", base_url="http://a", token_env="MISSING_VAR"
|
|
304
|
+
)
|
|
305
|
+
with pytest.raises(ConfigError) as exc:
|
|
306
|
+
inst.resolve_token({})
|
|
307
|
+
# Message references the *name*, not any value
|
|
308
|
+
assert "MISSING_VAR" in str(exc.value)
|
|
309
|
+
assert "unset or empty" in str(exc.value)
|
|
310
|
+
|
|
311
|
+
def test_empty_env_value_treated_as_missing(self) -> None:
|
|
312
|
+
inst = WeChatInstanceConfig(
|
|
313
|
+
instance_id="x", base_url="http://a", token_env="EMPTY"
|
|
314
|
+
)
|
|
315
|
+
with pytest.raises(ConfigError, match=r"unset or empty"):
|
|
316
|
+
inst.resolve_token({"EMPTY": ""})
|
|
317
|
+
|
|
318
|
+
def test_resolve_from_file(self, tmp_path: Path) -> None:
|
|
319
|
+
f = tmp_path / "tok"
|
|
320
|
+
f.write_text("file-secret\n", encoding="utf-8")
|
|
321
|
+
inst = WeChatInstanceConfig(
|
|
322
|
+
instance_id="x", base_url="http://a", token_file=str(f)
|
|
323
|
+
)
|
|
324
|
+
# Trailing whitespace stripped
|
|
325
|
+
assert inst.resolve_token({}) == "file-secret"
|
|
326
|
+
|
|
327
|
+
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
|
328
|
+
inst = WeChatInstanceConfig(
|
|
329
|
+
instance_id="x", base_url="http://a", token_file=str(tmp_path / "nope")
|
|
330
|
+
)
|
|
331
|
+
with pytest.raises(ConfigError) as exc:
|
|
332
|
+
inst.resolve_token({})
|
|
333
|
+
assert "is not a regular file" in str(exc.value)
|
|
334
|
+
|
|
335
|
+
def test_token_file_is_directory_rejected(self, tmp_path: Path) -> None:
|
|
336
|
+
d = tmp_path / "dir"
|
|
337
|
+
d.mkdir()
|
|
338
|
+
inst = WeChatInstanceConfig(
|
|
339
|
+
instance_id="x", base_url="http://a", token_file=str(d)
|
|
340
|
+
)
|
|
341
|
+
with pytest.raises(ConfigError, match=r"is not a regular file"):
|
|
342
|
+
inst.resolve_token({})
|
|
343
|
+
|
|
344
|
+
def test_token_file_with_invalid_utf8_rejected(self, tmp_path: Path) -> None:
|
|
345
|
+
f = tmp_path / "tok"
|
|
346
|
+
f.write_bytes(b"\xff\xfe\xfd")
|
|
347
|
+
inst = WeChatInstanceConfig(
|
|
348
|
+
instance_id="x", base_url="http://a", token_file=str(f)
|
|
349
|
+
)
|
|
350
|
+
with pytest.raises(ConfigError, match=r"not valid UTF-8"):
|
|
351
|
+
inst.resolve_token({})
|
|
352
|
+
|
|
353
|
+
def test_token_file_over_size_limit_rejected(self, tmp_path: Path) -> None:
|
|
354
|
+
f = tmp_path / "tok"
|
|
355
|
+
# 64 KB + 1 byte, filled with an ASCII char that would decode fine —
|
|
356
|
+
# the size check must fire *before* the read.
|
|
357
|
+
f.write_bytes(b"a" * (64 * 1024 + 1))
|
|
358
|
+
inst = WeChatInstanceConfig(
|
|
359
|
+
instance_id="x", base_url="http://a", token_file=str(f)
|
|
360
|
+
)
|
|
361
|
+
with pytest.raises(ConfigError, match=r"exceeds .*-byte limit"):
|
|
362
|
+
inst.resolve_token({})
|
|
363
|
+
|
|
364
|
+
def test_empty_file_raises(self, tmp_path: Path) -> None:
|
|
365
|
+
f = tmp_path / "tok"
|
|
366
|
+
f.write_text(" \n\n", encoding="utf-8")
|
|
367
|
+
inst = WeChatInstanceConfig(
|
|
368
|
+
instance_id="x", base_url="http://a", token_file=str(f)
|
|
369
|
+
)
|
|
370
|
+
with pytest.raises(ConfigError, match=r"is empty"):
|
|
371
|
+
inst.resolve_token({})
|
|
372
|
+
|
|
373
|
+
def test_no_source_configured_raises(self) -> None:
|
|
374
|
+
inst = WeChatInstanceConfig(instance_id="x", base_url="http://a")
|
|
375
|
+
with pytest.raises(ConfigError, match=r"either token_env or token_file must be set"):
|
|
376
|
+
inst.resolve_token({})
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TestSettingsIntegration:
|
|
380
|
+
def test_channels_config_path_is_under_config_dir(self, tmp_path: Path) -> None:
|
|
381
|
+
from coding_bridge.config import Settings
|
|
382
|
+
|
|
383
|
+
s = Settings(config_dir=tmp_path)
|
|
384
|
+
assert s.channels_config_path == tmp_path / "channels.toml"
|
|
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.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/__init__.py
RENAMED
|
File without changes
|
{coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/adapter.py
RENAMED
|
File without changes
|
{coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/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.1 → coding_bridge-2026.7.3.2}/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
|