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.
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/PKG-INFO +9 -6
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/README.md +6 -5
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/__init__.py +3 -3
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/__init__.py +5 -7
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_candidates.py +2 -2
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +1 -1
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/endpoint.py +1 -1
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/protocol.py +6 -6
- debug_control_plane-0.3.0/debug_control_plane/mcp_plane/__init__.py +44 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/bridge_client.py +136 -9
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/capability_mirror.py +38 -7
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/semantic_provider.py +4 -5
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/server.py +53 -6
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/PKG-INFO +9 -6
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/requires.txt +2 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/pyproject.toml +11 -6
- debug_control_plane-0.3.0/tests/test_acceptance_flutter_app_auth.py +310 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_bridge_client.py +247 -1
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_capability_mirror.py +91 -0
- debug_control_plane-0.3.0/tests/test_cross_lang_kotlin_plane.py +389 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_device_candidates.py +1 -1
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_e2e_mock.py +15 -16
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_endpoint.py +1 -1
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_server.py +128 -13
- debug_control_plane-0.1.0/debug_control_plane/mcp_plane/__init__.py +0 -47
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/LICENSE +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/top_level.txt +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/setup.cfg +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_device_pool.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_cross_identify.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_lan_scan.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_manual_registry.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_usb_identity.py +0 -0
- {debug_control_plane-0.1.0 → debug_control_plane-0.3.0}/tests/test_discovery_vpn_immune.py +0 -0
- {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.
|
|
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
|
|
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
|
|
42
|
-
本包是**纯基础设施**:绝不 import
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
25
|
-
本包是**纯基础设施**:绝不 import
|
|
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.
|
|
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.
|
|
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
|
|
5
|
-
R020
|
|
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.
|
|
7
|
+
__version__ = "0.3.0"
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
"""device_discovery — 设备发现平面 (网络 DTO + 设备池 + 发现逻辑).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
-
|
|
5
|
-
自旧 network 子包)
|
|
3
|
+
模块职责:
|
|
4
|
+
- protocol / endpoint / device_candidates: 网络 DTO 与探测
|
|
6
5
|
- device_pool (认身份不认地址)
|
|
7
6
|
- discovery/ 5 模块 (USB / LAN / 手动 / 交叉识别)
|
|
8
|
-
-
|
|
9
|
-
BF007 删旧定义 + 改正向 import)
|
|
7
|
+
- DeviceUnreachable 定义于 protocol (网络平面异常, 直继承 Exception)
|
|
10
8
|
|
|
11
|
-
零业务依赖: 不 import
|
|
12
|
-
|
|
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,显式回退路径(
|
|
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
|
-
供
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#:
|
|
225
|
-
#:
|
|
226
|
-
#:
|
|
227
|
-
#:
|
|
228
|
-
#: 正向 import
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
# signals
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
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
|
-
|
|
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
|
|
30
|
-
:class:`CapabilityMirror
|
|
31
|
-
|
|
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
|