debug-control-plane 0.1.1__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.1 → debug_control_plane-0.3.0}/PKG-INFO +5 -3
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/README.md +3 -2
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/__init__.py +1 -1
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/bridge_client.py +136 -9
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/capability_mirror.py +38 -7
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/server.py +51 -4
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/PKG-INFO +5 -3
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/requires.txt +1 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/pyproject.toml +7 -2
- debug_control_plane-0.3.0/tests/test_acceptance_flutter_app_auth.py +310 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_bridge_client.py +247 -1
- {debug_control_plane-0.1.1 → 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.1 → debug_control_plane-0.3.0}/tests/test_server.py +119 -1
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/LICENSE +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/protocol.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/top_level.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/setup.cfg +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_device_candidates.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_device_pool.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_cross_identify.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_lan_scan.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_manual_registry.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_usb_identity.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_vpn_immune.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_e2e_mock.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_endpoint.py +0 -0
- {debug_control_plane-0.1.1 → 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
|
|
@@ -14,6 +14,7 @@ Provides-Extra: test
|
|
|
14
14
|
Requires-Dist: pytest>=7; extra == "test"
|
|
15
15
|
Requires-Dist: pytest-cov>=4; extra == "test"
|
|
16
16
|
Requires-Dist: ruff>=0.4; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
17
18
|
Dynamic: license-file
|
|
18
19
|
|
|
19
20
|
# debug-control-plane (Python)
|
|
@@ -61,7 +62,7 @@ python/
|
|
|
61
62
|
├── README.md # this file
|
|
62
63
|
├── LICENSE # MIT
|
|
63
64
|
└── debug_control_plane/
|
|
64
|
-
├── __init__.py # __version__ = "0.
|
|
65
|
+
├── __init__.py # __version__ = "0.3.0"
|
|
65
66
|
├── device_discovery/ # USB/LAN device discovery + device pool
|
|
66
67
|
│ ├── device_candidates.py
|
|
67
68
|
│ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
|
|
@@ -91,7 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
|
|
|
91
92
|
|
|
92
93
|
## Version / 版本
|
|
93
94
|
|
|
94
|
-
`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 不稳定。
|
|
95
97
|
|
|
96
98
|
## License / 许可证
|
|
97
99
|
|
|
@@ -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
|
|
|
@@ -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
|
# ------------------------------------------------------------------
|
{debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/server.py
RENAMED
|
@@ -80,6 +80,7 @@ from debug_control_plane.device_discovery.discovery.vpn_immune import VpnImmune
|
|
|
80
80
|
from .bridge_client import (
|
|
81
81
|
BridgeClient,
|
|
82
82
|
BridgeError,
|
|
83
|
+
DeviceAuthError,
|
|
83
84
|
DeviceHttpError,
|
|
84
85
|
DeviceStale,
|
|
85
86
|
DeviceUnreachable,
|
|
@@ -141,6 +142,34 @@ def _tool_spec_to_mcp(spec: ToolSpec) -> types.Tool:
|
|
|
141
142
|
# ---------------------------------------------------------------------------
|
|
142
143
|
|
|
143
144
|
|
|
145
|
+
# R001-BB001 (BB001.1/BB001.3): auth errors carry a stable ``code``
|
|
146
|
+
# (PROTOCOL.md §2.4). Map each code to the user's next step — never a bare
|
|
147
|
+
# "token validation failed". Token clearing already happened in BridgeClient
|
|
148
|
+
# (BF009); the MCP layer only surfaces the hint, and the message must never
|
|
149
|
+
# contain a plaintext token.
|
|
150
|
+
_TOKEN_CLEARED_ACTION = (
|
|
151
|
+
" — the locally cached token was cleared; re-authorize on the App "
|
|
152
|
+
"(open the App and grant access again), then retry"
|
|
153
|
+
)
|
|
154
|
+
_DENIED_ACTION = (
|
|
155
|
+
" — the App denied or does not grant this access; do not retry "
|
|
156
|
+
"automatically (check the App's permission settings or ask the "
|
|
157
|
+
"user to allow this client)"
|
|
158
|
+
)
|
|
159
|
+
_AUTH_CODE_ACTIONS: dict[str, str] = {
|
|
160
|
+
"authorization_required": (
|
|
161
|
+
" — open the App on the device and approve the authorization request "
|
|
162
|
+
"(this client is not yet authorized; no token was sent or accepted)"
|
|
163
|
+
),
|
|
164
|
+
# Token-family codes share one remediation.
|
|
165
|
+
**dict.fromkeys(
|
|
166
|
+
("token_expired", "token_revoked", "invalid_token"), _TOKEN_CLEARED_ACTION
|
|
167
|
+
),
|
|
168
|
+
# authorization_denied / forbidden: no automatic retry.
|
|
169
|
+
**dict.fromkeys(("authorization_denied", "forbidden"), _DENIED_ACTION),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
144
173
|
def _bridge_error_to_mcp(exc: BridgeError | DeviceUnreachable) -> McpError:
|
|
145
174
|
"""Translate a :class:`BridgeError` into an MCP ``isError`` result.
|
|
146
175
|
|
|
@@ -158,7 +187,17 @@ def _bridge_error_to_mcp(exc: BridgeError | DeviceUnreachable) -> McpError:
|
|
|
158
187
|
so AI clients that branch on the code treat it as a retryable user error
|
|
159
188
|
rather than a fatal server fault.
|
|
160
189
|
"""
|
|
161
|
-
if isinstance(exc,
|
|
190
|
+
if isinstance(exc, DeviceAuthError):
|
|
191
|
+
# MUST precede the DeviceHttpError branch: DeviceAuthError is a
|
|
192
|
+
# DeviceHttpError subclass, so isinstance order decides which branch
|
|
193
|
+
# fires. Unknown codes fall back to the deny-style hint (safe default:
|
|
194
|
+
# no automatic retry).
|
|
195
|
+
action = _AUTH_CODE_ACTIONS.get(exc.code, _DENIED_ACTION)
|
|
196
|
+
message = (
|
|
197
|
+
f"device authorization error: auth_code={exc.code} "
|
|
198
|
+
f"status={exc.status_code}{action}"
|
|
199
|
+
)
|
|
200
|
+
elif isinstance(exc, DeviceHttpError):
|
|
162
201
|
hint = ""
|
|
163
202
|
# 409 real_controller_active: the phone's contract says the real pad
|
|
164
203
|
# wins; surface that as an actionable hint (analysis fault injection).
|
|
@@ -217,7 +256,7 @@ class McpServer:
|
|
|
217
256
|
pool: DevicePool,
|
|
218
257
|
*,
|
|
219
258
|
server_name: str = "mcp-debug-bridge",
|
|
220
|
-
server_version: str = "0.
|
|
259
|
+
server_version: str = "0.3.0",
|
|
221
260
|
providers: list[SemanticProvider] | None = None,
|
|
222
261
|
tool_handlers: dict[str, Any] | None = None,
|
|
223
262
|
) -> None:
|
|
@@ -329,11 +368,19 @@ class McpServer:
|
|
|
329
368
|
# list. We capture refresh's change signal here (one HTTP probe)
|
|
330
369
|
# and feed it straight into ``_emit_list_changed`` below, rather
|
|
331
370
|
# than re-probing via ``_maybe_emit_list_changed_for``.
|
|
371
|
+
# R001-BB001: refresh also stays non-raising for auth errors
|
|
372
|
+
# (recording them queryably instead); we surface them HERE as an
|
|
373
|
+
# MCP error so "unauthorized" is never silently degraded to an
|
|
374
|
+
# empty "no capabilities" answer.
|
|
332
375
|
def _probe():
|
|
333
376
|
changed = mirror.refresh(device_id)
|
|
334
|
-
|
|
377
|
+
auth_err = mirror.auth_error(device_id)
|
|
378
|
+
return mirror.schemas(device_id), changed, auth_err
|
|
379
|
+
|
|
380
|
+
schemas, changed, auth_err = await anyio.to_thread.run_sync(_probe)
|
|
335
381
|
|
|
336
|
-
|
|
382
|
+
if auth_err is not None:
|
|
383
|
+
raise _bridge_error_to_mcp(auth_err)
|
|
337
384
|
|
|
338
385
|
# Drive list_changed from this request's context (session is
|
|
339
386
|
# reachable here — see module docstring spike note). Best-effort:
|
{debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/PKG-INFO
RENAMED
|
@@ -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
|
|
@@ -14,6 +14,7 @@ Provides-Extra: test
|
|
|
14
14
|
Requires-Dist: pytest>=7; extra == "test"
|
|
15
15
|
Requires-Dist: pytest-cov>=4; extra == "test"
|
|
16
16
|
Requires-Dist: ruff>=0.4; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
17
18
|
Dynamic: license-file
|
|
18
19
|
|
|
19
20
|
# debug-control-plane (Python)
|
|
@@ -61,7 +62,7 @@ python/
|
|
|
61
62
|
├── README.md # this file
|
|
62
63
|
├── LICENSE # MIT
|
|
63
64
|
└── debug_control_plane/
|
|
64
|
-
├── __init__.py # __version__ = "0.
|
|
65
|
+
├── __init__.py # __version__ = "0.3.0"
|
|
65
66
|
├── device_discovery/ # USB/LAN device discovery + device pool
|
|
66
67
|
│ ├── device_candidates.py
|
|
67
68
|
│ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
|
|
@@ -91,7 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
|
|
|
91
92
|
|
|
92
93
|
## Version / 版本
|
|
93
94
|
|
|
94
|
-
`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 不稳定。
|
|
95
97
|
|
|
96
98
|
## License / 许可证
|
|
97
99
|
|
{debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/SOURCES.txt
RENAMED
|
@@ -24,8 +24,10 @@ debug_control_plane/mcp_plane/bridge_client.py
|
|
|
24
24
|
debug_control_plane/mcp_plane/capability_mirror.py
|
|
25
25
|
debug_control_plane/mcp_plane/semantic_provider.py
|
|
26
26
|
debug_control_plane/mcp_plane/server.py
|
|
27
|
+
tests/test_acceptance_flutter_app_auth.py
|
|
27
28
|
tests/test_bridge_client.py
|
|
28
29
|
tests/test_capability_mirror.py
|
|
30
|
+
tests/test_cross_lang_kotlin_plane.py
|
|
29
31
|
tests/test_device_candidates.py
|
|
30
32
|
tests/test_device_pool.py
|
|
31
33
|
tests/test_discovery_cross_identify.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "debug-control-plane"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Multi-product reusable debug control plane (device discovery + MCP adapter)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -17,7 +17,9 @@ dependencies = [
|
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
[project.optional-dependencies]
|
|
20
|
-
|
|
20
|
+
# CI 首跑暴露: test_server.py 用 @pytest.mark.asyncio(strict 模式), 本地全局环境
|
|
21
|
+
# 碰巧装了 pytest-asyncio 所以七步门一直绿; runner 按 extras 声明装缺它 → async 测试全挂。
|
|
22
|
+
test = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "pytest-asyncio>=0.23"]
|
|
21
23
|
|
|
22
24
|
[project.scripts]
|
|
23
25
|
# R021-CLEANUP-P1(方案 X): console-script 冲突解决。
|
|
@@ -38,6 +40,9 @@ include = ["debug_control_plane*"]
|
|
|
38
40
|
line-length = 100
|
|
39
41
|
target-version = "py310"
|
|
40
42
|
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "strict"
|
|
45
|
+
|
|
41
46
|
[tool.ruff.lint]
|
|
42
47
|
select = ["E", "F", "I", "W", "UP", "B"]
|
|
43
48
|
ignore = ["E501"]
|