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.
Files changed (67) hide show
  1. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/PKG-INFO +2 -1
  2. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/__init__.py +12 -0
  3. coding_bridge-2026.7.3.2/coding_bridge/channels/config.py +282 -0
  4. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/config.py +5 -0
  5. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/pyproject.toml +2 -1
  6. coding_bridge-2026.7.3.2/tests/test_channels_config.py +384 -0
  7. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.env.example +0 -0
  8. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.github/workflows/ci.yaml +0 -0
  9. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.github/workflows/publish.yml +0 -0
  10. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/.gitignore +0 -0
  11. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/LICENSE +0 -0
  12. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/NOTICE +0 -0
  13. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/README.md +0 -0
  14. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/__init__.py +0 -0
  15. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/__main__.py +0 -0
  16. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/attachments.py +0 -0
  17. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/capabilities.py +0 -0
  18. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/base.py +0 -0
  19. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/dispatcher.py +0 -0
  20. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/__init__.py +0 -0
  21. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/adapter.py +0 -0
  22. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/channels/wechat/client.py +0 -0
  23. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/cli.py +0 -0
  24. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/connection.py +0 -0
  25. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/fs.py +0 -0
  26. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/history.py +0 -0
  27. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/images.py +0 -0
  28. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/locking.py +0 -0
  29. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/logs.py +0 -0
  30. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/pairing.py +0 -0
  31. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/permissions.py +0 -0
  32. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/protocol.py +0 -0
  33. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/__init__.py +0 -0
  34. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/base.py +0 -0
  35. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/claude.py +0 -0
  36. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/codex.py +0 -0
  37. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/providers/copilot.py +0 -0
  38. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/session.py +0 -0
  39. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/session_meta.py +0 -0
  40. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/coding_bridge/store.py +0 -0
  41. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/docs/design/reliable-event-delivery.md +0 -0
  42. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/conftest.py +0 -0
  43. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_ask_user_question.py +0 -0
  44. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_attachments.py +0 -0
  45. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_capabilities.py +0 -0
  46. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_base.py +0 -0
  47. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_dispatcher.py +0 -0
  48. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_channels_wechat.py +0 -0
  49. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_codex.py +0 -0
  50. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_config.py +0 -0
  51. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_connection.py +0 -0
  52. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_copilot.py +0 -0
  53. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_edit_fork.py +0 -0
  54. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_fs.py +0 -0
  55. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_history.py +0 -0
  56. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_images.py +0 -0
  57. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_locking.py +0 -0
  58. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_outbox.py +0 -0
  59. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_pairing.py +0 -0
  60. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_permissions.py +0 -0
  61. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_protocol.py +0 -0
  62. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_resume_guard.py +0 -0
  63. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_session.py +0 -0
  64. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_session_meta.py +0 -0
  65. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_slash_commands.py +0 -0
  66. {coding_bridge-2026.7.3.1 → coding_bridge-2026.7.3.2}/tests/test_store.py +0 -0
  67. {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.1
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.1"
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"