debug-control-plane 0.1.0__tar.gz → 0.3.0__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 (44) hide show
  1. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/PKG-INFO +9 -6
  2. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/README.md +6 -5
  3. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/__init__.py +3 -3
  4. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/__init__.py +5 -7
  5. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_candidates.py +2 -2
  6. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +1 -1
  7. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/endpoint.py +1 -1
  8. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/protocol.py +6 -6
  9. debug_control_plane-0.3.0/debug_control_plane/mcp_plane/__init__.py +44 -0
  10. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/bridge_client.py +136 -9
  11. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/capability_mirror.py +38 -7
  12. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/semantic_provider.py +4 -5
  13. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/server.py +53 -6
  14. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/PKG-INFO +9 -6
  15. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
  16. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/requires.txt +2 -0
  17. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/pyproject.toml +11 -6
  18. debug_control_plane-0.3.0/tests/test_acceptance_flutter_app_auth.py +310 -0
  19. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_bridge_client.py +247 -1
  20. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_capability_mirror.py +91 -0
  21. debug_control_plane-0.3.0/tests/test_cross_lang_kotlin_plane.py +389 -0
  22. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_device_candidates.py +1 -1
  23. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_e2e_mock.py +15 -16
  24. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_endpoint.py +1 -1
  25. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_server.py +128 -13
  26. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/__init__.py +0 -47
  27. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/LICENSE +0 -0
  28. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  29. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  30. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  31. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  32. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  33. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  34. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  35. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  36. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  37. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/setup.cfg +0 -0
  38. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_device_pool.py +0 -0
  39. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_cross_identify.py +0 -0
  40. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_lan_scan.py +0 -0
  41. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_manual_registry.py +0 -0
  42. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_usb_identity.py +0 -0
  43. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_vpn_immune.py +0 -0
  44. {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_import_sanity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -9,10 +9,12 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: mcp<2,>=1.28
11
11
  Requires-Dist: httpx>=0.28
12
+ Requires-Dist: anyio<5,>=4
12
13
  Provides-Extra: test
13
14
  Requires-Dist: pytest>=7; extra == "test"
14
15
  Requires-Dist: pytest-cov>=4; extra == "test"
15
16
  Requires-Dist: ruff>=0.4; extra == "test"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
16
18
  Dynamic: license-file
17
19
 
18
20
  # debug-control-plane (Python)
@@ -29,7 +31,7 @@ Provides / 提供:
29
31
  ## Dependency direction / 依赖方向
30
32
 
31
33
  ```
32
- business app (pantas_launcher / future products) 业务应用(pantas_launcher / 未来产品)
34
+ business apps 业务应用
33
35
  │ depends on (downward) 依赖(向下)
34
36
  ▼
35
37
  debug_control_plane (this package) 本包
@@ -38,8 +40,8 @@ debug_control_plane (this package) 本包
38
40
  mcp (SDK) · httpx (HTTP client)
39
41
  ```
40
42
 
41
- This package is **pure infrastructure**: it MUST NOT import any business package (`pantas_launcher`, `host4_flutter_gmacro`, gamepad/feature code). Business apps depend on this package; never the reverse.
42
- 本包是**纯基础设施**:绝不 import 任何业务包(`pantas_launcher`、`host4_flutter_gmacro`、手柄/特性代码)。业务应用依赖本包,反之禁止。
43
+ This package is **pure infrastructure**: it MUST NOT import any business package. Business apps depend on this package; never the reverse.
44
+ 本包是**纯基础设施**:绝不 import 任何业务包。业务应用依赖本包,反之禁止。
43
45
 
44
46
  ## Install / 安装
45
47
 
@@ -60,7 +62,7 @@ python/
60
62
  ├── README.md # this file
61
63
  ├── LICENSE # MIT
62
64
  └── debug_control_plane/
63
- ├── __init__.py # __version__ = "0.1.0"
65
+ ├── __init__.py # __version__ = "0.3.0"
64
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
65
67
  │ ├── device_candidates.py
66
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -90,7 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
90
92
 
91
93
  ## Version / 版本
92
94
 
93
- `0.1.0` — initial release, API unstable. 首发,API 不稳定。
95
+ `0.3.0` — aligned with Kotlin/Dart/Flutter `0.3.0`, API unstable.
96
+ `0.3.0` —— 与 Kotlin/Dart/Flutter `0.3.0` 对齐,API 不稳定。
94
97
 
95
98
  ## License / 许可证
96
99
 
@@ -12,7 +12,7 @@ Provides / 提供:
12
12
  ## Dependency direction / 依赖方向
13
13
 
14
14
  ```
15
- business app (pantas_launcher / future products) 业务应用(pantas_launcher / 未来产品)
15
+ business apps 业务应用
16
16
  │ depends on (downward) 依赖(向下)
17
17
  ▼
18
18
  debug_control_plane (this package) 本包
@@ -21,8 +21,8 @@ debug_control_plane (this package) 本包
21
21
  mcp (SDK) · httpx (HTTP client)
22
22
  ```
23
23
 
24
- This package is **pure infrastructure**: it MUST NOT import any business package (`pantas_launcher`, `host4_flutter_gmacro`, gamepad/feature code). Business apps depend on this package; never the reverse.
25
- 本包是**纯基础设施**:绝不 import 任何业务包(`pantas_launcher`、`host4_flutter_gmacro`、手柄/特性代码)。业务应用依赖本包,反之禁止。
24
+ This package is **pure infrastructure**: it MUST NOT import any business package. Business apps depend on this package; never the reverse.
25
+ 本包是**纯基础设施**:绝不 import 任何业务包。业务应用依赖本包,反之禁止。
26
26
 
27
27
  ## Install / 安装
28
28
 
@@ -43,7 +43,7 @@ python/
43
43
  ├── README.md # this file
44
44
  ├── LICENSE # MIT
45
45
  └── debug_control_plane/
46
- ├── __init__.py # __version__ = "0.1.0"
46
+ ├── __init__.py # __version__ = "0.3.0"
47
47
  ├── device_discovery/ # USB/LAN device discovery + device pool
48
48
  │ ├── device_candidates.py
49
49
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -73,7 +73,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
73
73
 
74
74
  ## Version / 版本
75
75
 
76
- `0.1.0` — initial release, API unstable. 首发,API 不稳定。
76
+ `0.3.0` — aligned with Kotlin/Dart/Flutter `0.3.0`, API unstable.
77
+ `0.3.0` —— 与 Kotlin/Dart/Flutter `0.3.0` 对齐,API 不稳定。
77
78
 
78
79
  ## License / 许可证
79
80
 
@@ -1,7 +1,7 @@
1
1
  """debug_control_plane — multi-product debug control plane.
2
2
 
3
3
  Reusable across products: device discovery (USB/WiFi/identity) + MCP adapter
4
- (debug HTTP protocol → MCP tool surface). Extracted from pantas_launcher R019/
5
- R020 (S2 Python slice, R021-BF004).
4
+ (debug HTTP protocol → MCP tool surface). Extracted from an internal app
5
+ (R019/R020, S2 Python slice, R021-BF004).
6
6
  """
7
- __version__ = "0.1.0"
7
+ __version__ = "0.3.0"
@@ -1,15 +1,13 @@
1
1
  """device_discovery — 设备发现平面 (网络 DTO + 设备池 + 发现逻辑).
2
2
 
3
- BF006 迁入 (R021):
4
- - network 3 模块 (protocol / endpoint / device_candidates, 字节级零修改,
5
- 自旧 network 子包)
3
+ 模块职责:
4
+ - protocol / endpoint / device_candidates: 网络 DTO 与探测
6
5
  - device_pool (认身份不认地址)
7
6
  - discovery/ 5 模块 (USB / LAN / 手动 / 交叉识别)
8
- - AD-B9: DeviceUnreachable 下沉 (直继承 Exception 脱离协议层基类反向依赖;
9
- BF007 删旧定义 + 改正向 import)
7
+ - DeviceUnreachable 定义于 protocol (网络平面异常, 直继承 Exception)
10
8
 
11
- 零业务依赖: 不 import 旧 network 子包 / host4 gmacro / launcher /
12
- 协议层 client / mcp plane. 纯 stdlib + 同包相对 import, 可独立 pip install.
9
+ 零业务依赖: 不 import 任何业务包.
10
+ 纯 stdlib + 同包相对 import, 可独立 pip install.
13
11
  """
14
12
  from .device_candidates import (
15
13
  CommandRunner,
@@ -215,7 +215,7 @@ def _run_command(command: list[str], timeout: float) -> str:
215
215
  # ---------------------------------------------------------------------------
216
216
 
217
217
 
218
- # macOS 上 flutter 不在默认 PATH,显式回退路径(防止 mcp_debug_bridge 进程环境没装 fvm)
218
+ # macOS 上 flutter 不在默认 PATH,显式回退路径(防止子进程环境没装 fvm)
219
219
  _FLUTTER_CANDIDATES: tuple[str, ...] = (
220
220
  "flutter",
221
221
  "/usr/local/bin/flutter",
@@ -228,7 +228,7 @@ _FLUTTER_CANDIDATES: tuple[str, ...] = (
228
228
  class IosDeviceCandidate:
229
229
  """iOS 设备身份候选(来自 `flutter devices` 解析)。
230
230
 
231
- 供 R020 mcp_debug_bridge/discovery USB 身份源消费(BF004 UsbCandidate 用此)。
231
+ 供 USB 身份源消费(BF004 UsbCandidate 用此)。
232
232
  `device_id` = usbmuxd id(如 3992f440...,稳定身份源);
233
233
  `model` = 机型显示名(iPhone X / iPhone 14 Pro 等,弱唯一供交叉识别用)。
234
234
  """
@@ -43,7 +43,7 @@ from typing import TYPE_CHECKING
43
43
  from ..device_pool import DevicePool, DeviceRecord, manual_device_id
44
44
  from ..endpoint import Endpoint, UrlOpen, default_urlopen, probe_hello
45
45
 
46
- # AD-B9: DeviceUnreachable 自 mcp_debug_bridge/bridge_client 下沉至 device_discovery/protocol.
46
+ # AD-B9: DeviceUnreachable 定义于 device_discovery/protocol.
47
47
  from ..protocol import DeviceUnreachable
48
48
 
49
49
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -170,7 +170,7 @@ def _default_route_ipv4_addresses() -> set[str]:
170
170
  #
171
171
  # 新增独立函数,不改任何旧签名(旧 _default_route_ipv4_addresses/
172
172
  # local_ipv4_addresses/discover_default_endpoints 保持原状供 GUI 复用)。
173
- # 供 R020 mcp_debug_bridge/discovery 经 sys.path 复用。
173
+ # 供经 sys.path 复用。
174
174
  # 设计见 .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md §5.4
175
175
  # ---------------------------------------------------------------------------
176
176
 
@@ -82,7 +82,7 @@ class NetworkTarget:
82
82
  events_endpoint: str = "/events"
83
83
  # --- R020 FF001/FF002 bridge fields (optional, backward compatible) ---
84
84
  # Older /hello responses omit these; they default to None so existing GUI
85
- # / R019 contract behavior is unchanged. Consumed by mcp_debug_bridge
85
+ # / R019 contract behavior is unchanged. Consumed by the MCP plane
86
86
  # BF005 LanCandidate / BF006 cross_identify for device disambiguation.
87
87
  hardware_name: str | None = None
88
88
  machine_id: str | None = None
@@ -221,10 +221,10 @@ class ControllerProfile:
221
221
  # ---------------------------------------------------------------------------
222
222
  # AD-B9: DeviceUnreachable 下沉 (device_discovery 网络平面异常)
223
223
  # ---------------------------------------------------------------------------
224
- #: BF006 自 ``mcp_debug_bridge/bridge_client.py:99`` 下沉至此。原继承 ``BridgeError``
225
- #: (MCP 协议层基类),下沉后直继承 ``Exception`` 脱离 mcp_plane 反向依赖 —— 本属
226
- #: 网络平面异常 (设备不可达/设备池未识别 device_id)。BF007 删 ``bridge_client.py:99``
227
- #: 旧定义 + 改 ``from debug_control_plane.device_discovery.protocol import DeviceUnreachable``
228
- #: 正向 import;BF006 后双定义中间态 (不同类/不同基类/不同模块路径) 语义不冲突。
224
+ #: AD-B9: 网络平面异常 (设备不可达/设备池未识别 device_id)。直继承 ``Exception``,
225
+ #: 不继承 MCP 协议层 ``BridgeError`` 基类 —— 避免网络层反向依赖 mcp_plane。
226
+ #: BF006 + BF007 后, ``bridge_client.py`` 改为
227
+ #: ``from debug_control_plane.device_discovery.protocol import DeviceUnreachable``
228
+ #: 正向 import (单一真源)。
229
229
  class DeviceUnreachable(Exception):
230
230
  """The device_id is unknown to the pool (连手机失败, 网络平面异常)."""
@@ -0,0 +1,44 @@
1
+ """mcp_plane — MCP 适配平面(server/bridge_client/capability_mirror).
2
+
3
+ 模块职责:
4
+ - server.py: MCP stdio server, R019 8 端点 handler
5
+ - bridge_client.py: HTTP + SSE 客户端
6
+ - capability_mirror.py: Capability 镜像 (动态 tools)
7
+ - semantic_provider.py: BF002 SemanticProvider Protocol (字符串前向引用)
8
+
9
+ 零业务依赖: 不 import 任何业务包(留业务).
10
+ mcp_plane → device_discovery 单向正向依赖.
11
+ """
12
+ from .bridge_client import (
13
+ BridgeClient,
14
+ BridgeError,
15
+ DeviceHttpError,
16
+ DeviceStale,
17
+ )
18
+ from .capability_mirror import (
19
+ CapabilityMirror,
20
+ CapabilitySchema,
21
+ CommandDecl,
22
+ ResourceDecl,
23
+ ToolSpec,
24
+ )
25
+ from .semantic_provider import SemanticProvider
26
+ from .server import McpServer
27
+
28
+ __all__ = [
29
+ # bridge_client
30
+ "BridgeClient",
31
+ "BridgeError",
32
+ "DeviceHttpError",
33
+ "DeviceStale",
34
+ # capability_mirror
35
+ "CapabilityMirror",
36
+ "CapabilitySchema",
37
+ "CommandDecl",
38
+ "ResourceDecl",
39
+ "ToolSpec",
40
+ # semantic_provider (BF002)
41
+ "SemanticProvider",
42
+ # server
43
+ "McpServer",
44
+ ]
@@ -54,8 +54,8 @@ Refs:
54
54
 
55
55
  from __future__ import annotations
56
56
 
57
- from collections.abc import Iterator
58
- from typing import TYPE_CHECKING, Any
57
+ from collections.abc import Iterator, Mapping
58
+ from typing import TYPE_CHECKING, Any, Protocol
59
59
 
60
60
  import httpx
61
61
 
@@ -138,6 +138,30 @@ class DeviceHttpError(BridgeError):
138
138
  super().__init__(f"{prefix}{detail}")
139
139
 
140
140
 
141
+ class DeviceAuthError(DeviceHttpError):
142
+ """The phone returned a stable debug auth failure code."""
143
+
144
+ def __init__(self, status_code: int, body: Any, code: str, message: str = "") -> None:
145
+ self.code = code
146
+ super().__init__(status_code, body, message or f" auth_code={code}")
147
+
148
+
149
+ class DebugAuthTokenProvider(Protocol):
150
+ """Per-device debug auth token provider.
151
+
152
+ Implementations own storage. DevicePool remains identity-only and must not
153
+ be used to persist bearer tokens.
154
+ """
155
+
156
+ def get_token(self, device_id: str) -> str | None: ...
157
+
158
+ def save_token(
159
+ self, device_id: str, token: str, metadata: Mapping[str, Any]
160
+ ) -> None: ...
161
+
162
+ def clear_token(self, device_id: str, reason: str) -> None: ...
163
+
164
+
141
165
  # ---------------------------------------------------------------------------
142
166
  # Service
143
167
  # ---------------------------------------------------------------------------
@@ -181,6 +205,7 @@ class BridgeClient:
181
205
  client: httpx.Client | None = None,
182
206
  request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
183
207
  stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
208
+ token_provider: DebugAuthTokenProvider | None = None,
184
209
  ) -> None:
185
210
  self._pool = pool
186
211
  self._port = port
@@ -192,6 +217,7 @@ class BridgeClient:
192
217
  self._owns_client = True
193
218
  self._request_timeout = request_timeout
194
219
  self._stream_timeout = stream_timeout
220
+ self._token_provider = token_provider
195
221
 
196
222
  # ------------------------------------------------------------------
197
223
  # Lifecycle
@@ -282,17 +308,18 @@ class BridgeClient:
282
308
  """
283
309
  host = self.resolve(device_id)
284
310
  url = self._build_url(host, path)
311
+ headers = self._auth_headers(device_id)
285
312
  try:
286
313
  if isinstance(body, (dict, list)):
287
- resp = self._client.request(method, url, json=body)
314
+ resp = self._client.request(method, url, json=body, headers=headers)
288
315
  else:
289
- resp = self._client.request(method, url, content=body)
316
+ resp = self._client.request(method, url, content=body, headers=headers)
290
317
  except httpx.HTTPError as exc:
291
318
  # Connect refused / DNS / timeout / etc — surface as transport
292
319
  # failure so the caller catches a single exception type.
293
320
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
294
321
  if resp.status_code >= 400:
295
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
322
+ raise self._http_error(device_id, resp)
296
323
  return _safe_body(resp)
297
324
 
298
325
  def read(self, device_id: str, path: list[str]) -> Any:
@@ -317,12 +344,13 @@ class BridgeClient:
317
344
  """
318
345
  host = self.resolve(device_id)
319
346
  url = self._build_url(host, ["hello"])
347
+ headers = self._auth_headers(device_id)
320
348
  try:
321
- resp = self._client.get(url)
349
+ resp = self._client.get(url, headers=headers)
322
350
  except httpx.HTTPError as exc:
323
351
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
324
352
  if resp.status_code != 200:
325
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
353
+ raise self._http_error(device_id, resp)
326
354
  data = resp.json()
327
355
  if not isinstance(data, dict):
328
356
  raise DeviceHttpError(
@@ -370,13 +398,16 @@ class BridgeClient:
370
398
  """
371
399
  host = self.resolve(device_id)
372
400
  url = self._build_url(host, ["events"])
401
+ headers = self._auth_headers(device_id)
373
402
  type_set = set(event_types) if event_types else None
374
403
  try:
375
- with self._client.stream("GET", url, timeout=self._stream_timeout) as resp:
404
+ with self._client.stream(
405
+ "GET", url, headers=headers, timeout=self._stream_timeout
406
+ ) as resp:
376
407
  if resp.status_code >= 400:
377
408
  # Read the body so the caller sees the error payload.
378
409
  resp.read()
379
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
410
+ raise self._http_error(device_id, resp)
380
411
  for raw_data, _event_field in _iter_sse(resp):
381
412
  try:
382
413
  payload = _parse_json_object(raw_data)
@@ -392,10 +423,75 @@ class BridgeClient:
392
423
  except httpx.HTTPError as exc:
393
424
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
394
425
 
426
+ def auth_request(
427
+ self,
428
+ device_id: str,
429
+ client_nonce: str,
430
+ *,
431
+ client_label: str | None = None,
432
+ requested_method: str | None = None,
433
+ requested_path: str | None = None,
434
+ ) -> Any:
435
+ """Create a pending App-side authorization request."""
436
+ body: dict[str, Any] = {"clientNonce": client_nonce}
437
+ if client_label is not None:
438
+ body["clientLabel"] = client_label
439
+ if requested_method is not None:
440
+ body["requestedMethod"] = requested_method
441
+ if requested_path is not None:
442
+ body["requestedPath"] = requested_path
443
+ return self.invoke(device_id, "POST", ["auth", "request"], body)
444
+
445
+ def auth_status(self, device_id: str, request_id: str, client_nonce: str) -> Any:
446
+ """Poll App-side authorization status."""
447
+ return self.invoke(
448
+ device_id,
449
+ "POST",
450
+ ["auth", "status"],
451
+ {"requestId": request_id, "clientNonce": client_nonce},
452
+ )
453
+
454
+ def auth_claim(self, device_id: str, request_id: str, client_nonce: str) -> Any:
455
+ """Claim an approved App-side authorization token and save it if present."""
456
+ body = self.invoke(
457
+ device_id,
458
+ "POST",
459
+ ["auth", "claim"],
460
+ {"requestId": request_id, "clientNonce": client_nonce},
461
+ )
462
+ if isinstance(body, dict):
463
+ token = body.get("token")
464
+ if isinstance(token, str) and self._token_provider is not None:
465
+ metadata = {
466
+ key: body[key]
467
+ for key in ("tokenId", "expiresAt")
468
+ if key in body
469
+ }
470
+ self._token_provider.save_token(device_id, token, metadata)
471
+ return body
472
+
395
473
  # ------------------------------------------------------------------
396
474
  # Internal helpers
397
475
  # ------------------------------------------------------------------
398
476
 
477
+ def _auth_headers(self, device_id: str) -> dict[str, str]:
478
+ provider = self._token_provider
479
+ if provider is None:
480
+ return {}
481
+ token = provider.get_token(device_id)
482
+ if not token:
483
+ return {}
484
+ return {"Authorization": f"Bearer {token}"}
485
+
486
+ def _http_error(self, device_id: str, resp: httpx.Response) -> DeviceHttpError:
487
+ body = _safe_body(resp)
488
+ code = _auth_error_code(resp.status_code, body)
489
+ if code is None:
490
+ return DeviceHttpError(resp.status_code, body)
491
+ if code in _CLEAR_TOKEN_AUTH_CODES and self._token_provider is not None:
492
+ self._token_provider.clear_token(device_id, reason=code)
493
+ return DeviceAuthError(resp.status_code, body, code=code)
494
+
399
495
  def _build_url(self, host: str, path: list[str]) -> str:
400
496
  """Assemble ``http://{host}:{port}/{seg1}/{seg2}...``.
401
497
 
@@ -512,12 +608,43 @@ def _parse_json_object(raw: str) -> dict[str, Any]:
512
608
  return parsed
513
609
 
514
610
 
611
+ def _auth_error_code(status_code: int, body: Any) -> str | None:
612
+ if status_code not in (401, 403) or not isinstance(body, dict):
613
+ return None
614
+ code = body.get("code")
615
+ if isinstance(code, str) and code in _AUTH_ERROR_CODES:
616
+ return code
617
+ return None
618
+
619
+
620
+ _AUTH_ERROR_CODES = frozenset(
621
+ {
622
+ "authorization_required",
623
+ "invalid_token",
624
+ "token_expired",
625
+ "token_revoked",
626
+ "authorization_denied",
627
+ "forbidden",
628
+ }
629
+ )
630
+
631
+ _CLEAR_TOKEN_AUTH_CODES = frozenset(
632
+ {
633
+ "invalid_token",
634
+ "token_expired",
635
+ "token_revoked",
636
+ }
637
+ )
638
+
639
+
515
640
  __all__ = [
516
641
  "DEFAULT_PORT",
517
642
  "DEFAULT_REQUEST_TIMEOUT",
518
643
  "DEFAULT_STREAM_TIMEOUT",
519
644
  "BridgeClient",
520
645
  "BridgeError",
646
+ "DebugAuthTokenProvider",
647
+ "DeviceAuthError",
521
648
  "DeviceHttpError",
522
649
  "DeviceStale",
523
650
  # AD-B9: DeviceUnreachable 已下沉 device_discovery.protocol,
@@ -78,6 +78,7 @@ from debug_control_plane.device_discovery.protocol import JsonMap, NetworkTarget
78
78
  from .bridge_client import (
79
79
  BridgeClient,
80
80
  BridgeError,
81
+ DeviceAuthError,
81
82
  DeviceHttpError,
82
83
  DeviceStale,
83
84
  DeviceUnreachable,
@@ -397,6 +398,12 @@ class CapabilityMirror:
397
398
  # (design §4.1 note: capability schema is CapabilityMirror's runtime
398
399
  # mirror, NOT a DeviceRecord field).
399
400
  self._cache: dict[str, list[CapabilitySchema]] = {}
401
+ # R001-BB001: per-device last auth error (DeviceAuthError). Auth
402
+ # failure ≠ offline — the schema cache is preserved and the signal is
403
+ # queryable via auth_error() instead of silently degrading. Cleared on
404
+ # the next successful refresh. GIL-guarded dict ops suffice (contract
405
+ # risk note).
406
+ self._auth_errors: dict[str, DeviceAuthError] = {}
400
407
 
401
408
  # ------------------------------------------------------------------
402
409
  # Provider registry (BF010 registration hook)
@@ -430,19 +437,31 @@ class CapabilityMirror:
430
437
  True iff the parsed schema differs from the cached snapshot
431
438
  (including "no cache → cache populated", which IS a change).
432
439
  False if /hello failed (cache cleared) or the schema is identical.
440
+ Auth errors (R001-BB001): cache is PRESERVED and False returned —
441
+ the phone is reachable, just unauthorized; see :meth:`auth_error`.
433
442
  """
434
443
  try:
435
444
  target = self._client.hello(device_id)
445
+ except DeviceAuthError as exc:
446
+ # R001-BB001: auth error ≠ offline. Preserve the schema cache
447
+ # (capabilities remain valid once authorized), record the auth
448
+ # failure for auth_error() queries, return False (no spurious
449
+ # list_changed). refresh stays poll-safe (never raises).
450
+ self._auth_errors[device_id] = exc
451
+ return False
436
452
  except (DeviceUnreachable, DeviceStale, DeviceHttpError, BridgeError):
437
- # Any /hello failure → degrade. Clear cache so build_tools falls
438
- # to static-only and a later successful refresh re-signals the
439
- # transition back. We do NOT report a change here: list_changed
440
- # signals "the manifest grew/shrank", and going from "had tools"
441
- # to "static only" is a real change — but only if we actually had
442
- # a non-empty manifest before. Reporting False on a cache→empty
443
- # transition would be a lie, so we surface it honestly.
453
+ # Any non-auth /hello failure → degrade. Clear cache (and any
454
+ # stale auth state — an unreachable phone has no auth verdict)
455
+ # so build_tools falls to static-only and a later successful
456
+ # refresh re-signals the transition back. We do NOT report a
457
+ # change here: list_changed signals "the manifest grew/shrank",
458
+ # and going from "had tools" to "static only" is a real change —
459
+ # but only if we actually had a non-empty manifest before.
460
+ # Reporting False on a cache→empty transition would be a lie, so
461
+ # we surface it honestly.
444
462
  had_cache = device_id in self._cache
445
463
  self._cache.pop(device_id, None)
464
+ self._auth_errors.pop(device_id, None)
446
465
  # Transition from "had dynamic tools" to "static only" IS a change
447
466
  # the AI client needs to know about (its cached tools are stale).
448
467
  return had_cache
@@ -452,8 +471,20 @@ class CapabilityMirror:
452
471
  changed = old_schemas != new_schemas
453
472
  if changed:
454
473
  self._cache[device_id] = new_schemas
474
+ # Successful /hello means the device is authorized again.
475
+ self._auth_errors.pop(device_id, None)
455
476
  return changed
456
477
 
478
+ def auth_error(self, device_id: str) -> DeviceAuthError | None:
479
+ """Return the last :class:`DeviceAuthError` seen for ``device_id``.
480
+
481
+ R001-BB001: lets callers (h_list_capabilities) distinguish "auth
482
+ failed" from "offline degrade" without refresh raising. ``None`` when
483
+ the device is healthy or the last failure was non-auth (offline /
484
+ stale / HTTP). Cleared by the next successful refresh.
485
+ """
486
+ return self._auth_errors.get(device_id)
487
+
457
488
  # ------------------------------------------------------------------
458
489
  # Cache access (no I/O)
459
490
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  """SemanticProvider — pluggable hook for known-capability sugar (BF002).
2
2
 
3
- 固化自 R020 ``pantas_launcher/tools/mcp_debug_bridge/capability_mirror.py:177``。
3
+ R020 capability_mirror 语义糖接口固化。
4
4
  本轮(R021-BF002)将 R020 既有 Protocol 抽出固化到独立 repo,作为契约源头迁移
5
5
  (非新设计 — R020 BF010 hook 已落地代码)。
6
6
 
@@ -26,10 +26,9 @@ if TYPE_CHECKING:
26
26
  class SemanticProvider(Protocol):
27
27
  """Builds semantic-sugar tools for a known capability.
28
28
 
29
- BF010 (gamepad) implements this protocol and registers its instance on
30
- :class:`CapabilityMirror` via the ``providers`` constructor arg. BF009
31
- ships no built-in provider — the hook itself is exercised by a stub in
32
- BF009's own tests.
29
+ 业务侧(BF010)实现此协议,并通过 ``providers`` 构造参数把实例注册到
30
+ :class:`CapabilityMirror`。BF009 不内置 provider —— 钩子本身由 BF009 自己
31
+ 的测试用 stub 验证。
33
32
 
34
33
  Contract:
35
34
  * :meth:`matches` is called once per parsed schema; return ``True`` if