debug-control-plane 0.1.1__tar.gz → 0.4.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.4.0}/PKG-INFO +5 -3
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/README.md +3 -2
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/__init__.py +1 -1
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/bridge_client.py +193 -11
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/capability_mirror.py +102 -7
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/server.py +198 -10
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/PKG-INFO +5 -3
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/requires.txt +1 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/pyproject.toml +7 -2
- debug_control_plane-0.4.0/tests/test_acceptance_flutter_app_auth.py +310 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_bridge_client.py +343 -1
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_capability_mirror.py +249 -0
- debug_control_plane-0.4.0/tests/test_cross_lang_kotlin_plane.py +390 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_server.py +471 -2
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/LICENSE +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/protocol.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/top_level.txt +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/setup.cfg +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_device_candidates.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_device_pool.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_cross_identify.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_lan_scan.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_manual_registry.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_usb_identity.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_vpn_immune.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_e2e_mock.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_endpoint.py +0 -0
- {debug_control_plane-0.1.1 → debug_control_plane-0.4.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.4.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.4.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.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
|
|
96
|
+
`0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.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.4.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.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
|
|
77
|
+
`0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.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
|
|
@@ -253,6 +279,11 @@ class BridgeClient:
|
|
|
253
279
|
method: str,
|
|
254
280
|
path: list[str],
|
|
255
281
|
body: Any = None,
|
|
282
|
+
*,
|
|
283
|
+
capability_id: str | None = None,
|
|
284
|
+
scope: str | None = None,
|
|
285
|
+
page_id: str | None = None,
|
|
286
|
+
scope_revision: int | None = None,
|
|
256
287
|
) -> Any:
|
|
257
288
|
"""Forward ``method path body`` to the phone (byte-level pass-through).
|
|
258
289
|
|
|
@@ -269,6 +300,9 @@ class BridgeClient:
|
|
|
269
300
|
body: request body. If it's a dict/list it's sent as JSON
|
|
270
301
|
(``json=``); otherwise it's sent raw (``content=``) and may
|
|
271
302
|
be ``None``.
|
|
303
|
+
capability_id/scope/page_id/scope_revision: optional R003
|
|
304
|
+
selector metadata forwarded as ``X-DCP-*`` headers. Omitted
|
|
305
|
+
values keep the legacy flat dispatch behavior.
|
|
272
306
|
|
|
273
307
|
Returns:
|
|
274
308
|
The phone's response body, parsed: dict/list for JSON, ``str``
|
|
@@ -282,26 +316,53 @@ class BridgeClient:
|
|
|
282
316
|
"""
|
|
283
317
|
host = self.resolve(device_id)
|
|
284
318
|
url = self._build_url(host, path)
|
|
319
|
+
headers = self._auth_headers(device_id)
|
|
320
|
+
headers.update(
|
|
321
|
+
selector_headers(
|
|
322
|
+
capability_id=capability_id,
|
|
323
|
+
scope=scope,
|
|
324
|
+
page_id=page_id,
|
|
325
|
+
scope_revision=scope_revision,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
285
328
|
try:
|
|
286
329
|
if isinstance(body, (dict, list)):
|
|
287
|
-
resp = self._client.request(method, url, json=body)
|
|
330
|
+
resp = self._client.request(method, url, json=body, headers=headers)
|
|
288
331
|
else:
|
|
289
|
-
resp = self._client.request(method, url, content=body)
|
|
332
|
+
resp = self._client.request(method, url, content=body, headers=headers)
|
|
290
333
|
except httpx.HTTPError as exc:
|
|
291
334
|
# Connect refused / DNS / timeout / etc — surface as transport
|
|
292
335
|
# failure so the caller catches a single exception type.
|
|
293
336
|
raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
|
|
294
337
|
if resp.status_code >= 400:
|
|
295
|
-
raise
|
|
338
|
+
raise self._http_error(device_id, resp)
|
|
296
339
|
return _safe_body(resp)
|
|
297
340
|
|
|
298
|
-
def read(
|
|
341
|
+
def read(
|
|
342
|
+
self,
|
|
343
|
+
device_id: str,
|
|
344
|
+
path: list[str],
|
|
345
|
+
*,
|
|
346
|
+
capability_id: str | None = None,
|
|
347
|
+
scope: str | None = None,
|
|
348
|
+
page_id: str | None = None,
|
|
349
|
+
scope_revision: int | None = None,
|
|
350
|
+
) -> Any:
|
|
299
351
|
"""GET convenience for ``read_resource`` / ``get_state``.
|
|
300
352
|
|
|
301
353
|
Equivalent to ``invoke(device_id, "GET", path, None)`` but signals
|
|
302
354
|
intent at the call site. Returns the parsed phone body.
|
|
303
355
|
"""
|
|
304
|
-
return self.invoke(
|
|
356
|
+
return self.invoke(
|
|
357
|
+
device_id,
|
|
358
|
+
"GET",
|
|
359
|
+
path,
|
|
360
|
+
None,
|
|
361
|
+
capability_id=capability_id,
|
|
362
|
+
scope=scope,
|
|
363
|
+
page_id=page_id,
|
|
364
|
+
scope_revision=scope_revision,
|
|
365
|
+
)
|
|
305
366
|
|
|
306
367
|
def hello(self, device_id: str) -> NetworkTarget:
|
|
307
368
|
"""Fetch ``/hello`` and parse to a typed :class:`NetworkTarget`.
|
|
@@ -317,12 +378,13 @@ class BridgeClient:
|
|
|
317
378
|
"""
|
|
318
379
|
host = self.resolve(device_id)
|
|
319
380
|
url = self._build_url(host, ["hello"])
|
|
381
|
+
headers = self._auth_headers(device_id)
|
|
320
382
|
try:
|
|
321
|
-
resp = self._client.get(url)
|
|
383
|
+
resp = self._client.get(url, headers=headers)
|
|
322
384
|
except httpx.HTTPError as exc:
|
|
323
385
|
raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
|
|
324
386
|
if resp.status_code != 200:
|
|
325
|
-
raise
|
|
387
|
+
raise self._http_error(device_id, resp)
|
|
326
388
|
data = resp.json()
|
|
327
389
|
if not isinstance(data, dict):
|
|
328
390
|
raise DeviceHttpError(
|
|
@@ -370,13 +432,16 @@ class BridgeClient:
|
|
|
370
432
|
"""
|
|
371
433
|
host = self.resolve(device_id)
|
|
372
434
|
url = self._build_url(host, ["events"])
|
|
435
|
+
headers = self._auth_headers(device_id)
|
|
373
436
|
type_set = set(event_types) if event_types else None
|
|
374
437
|
try:
|
|
375
|
-
with self._client.stream(
|
|
438
|
+
with self._client.stream(
|
|
439
|
+
"GET", url, headers=headers, timeout=self._stream_timeout
|
|
440
|
+
) as resp:
|
|
376
441
|
if resp.status_code >= 400:
|
|
377
442
|
# Read the body so the caller sees the error payload.
|
|
378
443
|
resp.read()
|
|
379
|
-
raise
|
|
444
|
+
raise self._http_error(device_id, resp)
|
|
380
445
|
for raw_data, _event_field in _iter_sse(resp):
|
|
381
446
|
try:
|
|
382
447
|
payload = _parse_json_object(raw_data)
|
|
@@ -392,10 +457,75 @@ class BridgeClient:
|
|
|
392
457
|
except httpx.HTTPError as exc:
|
|
393
458
|
raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
|
|
394
459
|
|
|
460
|
+
def auth_request(
|
|
461
|
+
self,
|
|
462
|
+
device_id: str,
|
|
463
|
+
client_nonce: str,
|
|
464
|
+
*,
|
|
465
|
+
client_label: str | None = None,
|
|
466
|
+
requested_method: str | None = None,
|
|
467
|
+
requested_path: str | None = None,
|
|
468
|
+
) -> Any:
|
|
469
|
+
"""Create a pending App-side authorization request."""
|
|
470
|
+
body: dict[str, Any] = {"clientNonce": client_nonce}
|
|
471
|
+
if client_label is not None:
|
|
472
|
+
body["clientLabel"] = client_label
|
|
473
|
+
if requested_method is not None:
|
|
474
|
+
body["requestedMethod"] = requested_method
|
|
475
|
+
if requested_path is not None:
|
|
476
|
+
body["requestedPath"] = requested_path
|
|
477
|
+
return self.invoke(device_id, "POST", ["auth", "request"], body)
|
|
478
|
+
|
|
479
|
+
def auth_status(self, device_id: str, request_id: str, client_nonce: str) -> Any:
|
|
480
|
+
"""Poll App-side authorization status."""
|
|
481
|
+
return self.invoke(
|
|
482
|
+
device_id,
|
|
483
|
+
"POST",
|
|
484
|
+
["auth", "status"],
|
|
485
|
+
{"requestId": request_id, "clientNonce": client_nonce},
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def auth_claim(self, device_id: str, request_id: str, client_nonce: str) -> Any:
|
|
489
|
+
"""Claim an approved App-side authorization token and save it if present."""
|
|
490
|
+
body = self.invoke(
|
|
491
|
+
device_id,
|
|
492
|
+
"POST",
|
|
493
|
+
["auth", "claim"],
|
|
494
|
+
{"requestId": request_id, "clientNonce": client_nonce},
|
|
495
|
+
)
|
|
496
|
+
if isinstance(body, dict):
|
|
497
|
+
token = body.get("token")
|
|
498
|
+
if isinstance(token, str) and self._token_provider is not None:
|
|
499
|
+
metadata = {
|
|
500
|
+
key: body[key]
|
|
501
|
+
for key in ("tokenId", "expiresAt")
|
|
502
|
+
if key in body
|
|
503
|
+
}
|
|
504
|
+
self._token_provider.save_token(device_id, token, metadata)
|
|
505
|
+
return body
|
|
506
|
+
|
|
395
507
|
# ------------------------------------------------------------------
|
|
396
508
|
# Internal helpers
|
|
397
509
|
# ------------------------------------------------------------------
|
|
398
510
|
|
|
511
|
+
def _auth_headers(self, device_id: str) -> dict[str, str]:
|
|
512
|
+
provider = self._token_provider
|
|
513
|
+
if provider is None:
|
|
514
|
+
return {}
|
|
515
|
+
token = provider.get_token(device_id)
|
|
516
|
+
if not token:
|
|
517
|
+
return {}
|
|
518
|
+
return {"Authorization": f"Bearer {token}"}
|
|
519
|
+
|
|
520
|
+
def _http_error(self, device_id: str, resp: httpx.Response) -> DeviceHttpError:
|
|
521
|
+
body = _safe_body(resp)
|
|
522
|
+
code = _auth_error_code(resp.status_code, body)
|
|
523
|
+
if code is None:
|
|
524
|
+
return DeviceHttpError(resp.status_code, body)
|
|
525
|
+
if code in _CLEAR_TOKEN_AUTH_CODES and self._token_provider is not None:
|
|
526
|
+
self._token_provider.clear_token(device_id, reason=code)
|
|
527
|
+
return DeviceAuthError(resp.status_code, body, code=code)
|
|
528
|
+
|
|
399
529
|
def _build_url(self, host: str, path: list[str]) -> str:
|
|
400
530
|
"""Assemble ``http://{host}:{port}/{seg1}/{seg2}...``.
|
|
401
531
|
|
|
@@ -434,6 +564,26 @@ def _safe_body(resp: httpx.Response) -> Any:
|
|
|
434
564
|
return resp.text
|
|
435
565
|
|
|
436
566
|
|
|
567
|
+
def selector_headers(
|
|
568
|
+
*,
|
|
569
|
+
capability_id: str | None = None,
|
|
570
|
+
scope: str | None = None,
|
|
571
|
+
page_id: str | None = None,
|
|
572
|
+
scope_revision: int | None = None,
|
|
573
|
+
) -> dict[str, str]:
|
|
574
|
+
"""Build optional R003 selector headers for capability-scoped dispatch."""
|
|
575
|
+
headers: dict[str, str] = {}
|
|
576
|
+
if isinstance(capability_id, str) and capability_id:
|
|
577
|
+
headers["X-DCP-Capability-Id"] = capability_id
|
|
578
|
+
if scope in ("app", "page"):
|
|
579
|
+
headers["X-DCP-Capability-Scope"] = scope
|
|
580
|
+
if isinstance(page_id, str) and page_id:
|
|
581
|
+
headers["X-DCP-Page-Id"] = page_id
|
|
582
|
+
if isinstance(scope_revision, int) and not isinstance(scope_revision, bool):
|
|
583
|
+
headers["X-DCP-Scope-Revision"] = str(scope_revision)
|
|
584
|
+
return headers
|
|
585
|
+
|
|
586
|
+
|
|
437
587
|
def _quote_segment(seg: str) -> str:
|
|
438
588
|
"""URL-encode a single path segment (preserving ``/`` within a segment).
|
|
439
589
|
|
|
@@ -512,14 +662,46 @@ def _parse_json_object(raw: str) -> dict[str, Any]:
|
|
|
512
662
|
return parsed
|
|
513
663
|
|
|
514
664
|
|
|
665
|
+
def _auth_error_code(status_code: int, body: Any) -> str | None:
|
|
666
|
+
if status_code not in (401, 403) or not isinstance(body, dict):
|
|
667
|
+
return None
|
|
668
|
+
code = body.get("code")
|
|
669
|
+
if isinstance(code, str) and code in _AUTH_ERROR_CODES:
|
|
670
|
+
return code
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
_AUTH_ERROR_CODES = frozenset(
|
|
675
|
+
{
|
|
676
|
+
"authorization_required",
|
|
677
|
+
"invalid_token",
|
|
678
|
+
"token_expired",
|
|
679
|
+
"token_revoked",
|
|
680
|
+
"authorization_denied",
|
|
681
|
+
"forbidden",
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
_CLEAR_TOKEN_AUTH_CODES = frozenset(
|
|
686
|
+
{
|
|
687
|
+
"invalid_token",
|
|
688
|
+
"token_expired",
|
|
689
|
+
"token_revoked",
|
|
690
|
+
}
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
515
694
|
__all__ = [
|
|
516
695
|
"DEFAULT_PORT",
|
|
517
696
|
"DEFAULT_REQUEST_TIMEOUT",
|
|
518
697
|
"DEFAULT_STREAM_TIMEOUT",
|
|
519
698
|
"BridgeClient",
|
|
520
699
|
"BridgeError",
|
|
700
|
+
"DebugAuthTokenProvider",
|
|
701
|
+
"DeviceAuthError",
|
|
521
702
|
"DeviceHttpError",
|
|
522
703
|
"DeviceStale",
|
|
704
|
+
"selector_headers",
|
|
523
705
|
# AD-B9: DeviceUnreachable 已下沉 device_discovery.protocol,
|
|
524
706
|
# 本模块 forward import(BF007),不 re-export。
|
|
525
707
|
]
|
|
@@ -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,
|
|
@@ -128,12 +129,21 @@ class CapabilitySchema:
|
|
|
128
129
|
resources: declared REST resources (read side).
|
|
129
130
|
commands: declared commands (write side).
|
|
130
131
|
description: optional capability-level description (may be absent).
|
|
132
|
+
scope: ``app`` or ``page``; missing/malformed wire values downgrade
|
|
133
|
+
locally to ``app``.
|
|
134
|
+
page_id: optional business-provided page identity for page scope.
|
|
135
|
+
page_name: optional display metadata; never participates in tool names.
|
|
136
|
+
scope_revision: optional integer mirror revision for stale detection.
|
|
131
137
|
"""
|
|
132
138
|
|
|
133
139
|
capability_id: str
|
|
134
140
|
resources: tuple[ResourceDecl, ...] = ()
|
|
135
141
|
commands: tuple[CommandDecl, ...] = ()
|
|
136
142
|
description: str | None = None
|
|
143
|
+
scope: str = "app"
|
|
144
|
+
page_id: str | None = None
|
|
145
|
+
page_name: str | None = None
|
|
146
|
+
scope_revision: int | None = None
|
|
137
147
|
|
|
138
148
|
|
|
139
149
|
# ---------------------------------------------------------------------------
|
|
@@ -248,6 +258,19 @@ _STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
|
|
|
248
258
|
"description": "Request body (object) or null.",
|
|
249
259
|
"additionalProperties": True,
|
|
250
260
|
},
|
|
261
|
+
"scope": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"enum": ["app", "page"],
|
|
264
|
+
"description": "Optional capability scope selector.",
|
|
265
|
+
},
|
|
266
|
+
"page_id": {
|
|
267
|
+
"type": "string",
|
|
268
|
+
"description": "Optional page id selector for page scope.",
|
|
269
|
+
},
|
|
270
|
+
"scope_revision": {
|
|
271
|
+
"type": "integer",
|
|
272
|
+
"description": "Optional scope revision selector.",
|
|
273
|
+
},
|
|
251
274
|
},
|
|
252
275
|
"required": ["device_id", "capability_id", "command_path"],
|
|
253
276
|
"additionalProperties": False,
|
|
@@ -273,6 +296,19 @@ _STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
|
|
|
273
296
|
"type": ["object", "null"],
|
|
274
297
|
"additionalProperties": True,
|
|
275
298
|
},
|
|
299
|
+
"scope": {
|
|
300
|
+
"type": "string",
|
|
301
|
+
"enum": ["app", "page"],
|
|
302
|
+
"description": "Optional capability scope selector.",
|
|
303
|
+
},
|
|
304
|
+
"page_id": {
|
|
305
|
+
"type": "string",
|
|
306
|
+
"description": "Optional page id selector for page scope.",
|
|
307
|
+
},
|
|
308
|
+
"scope_revision": {
|
|
309
|
+
"type": "integer",
|
|
310
|
+
"description": "Optional scope revision selector.",
|
|
311
|
+
},
|
|
276
312
|
},
|
|
277
313
|
"required": ["device_id", "capability_id", "resource_path"],
|
|
278
314
|
"additionalProperties": False,
|
|
@@ -397,6 +433,12 @@ class CapabilityMirror:
|
|
|
397
433
|
# (design §4.1 note: capability schema is CapabilityMirror's runtime
|
|
398
434
|
# mirror, NOT a DeviceRecord field).
|
|
399
435
|
self._cache: dict[str, list[CapabilitySchema]] = {}
|
|
436
|
+
# R001-BB001: per-device last auth error (DeviceAuthError). Auth
|
|
437
|
+
# failure ≠ offline — the schema cache is preserved and the signal is
|
|
438
|
+
# queryable via auth_error() instead of silently degrading. Cleared on
|
|
439
|
+
# the next successful refresh. GIL-guarded dict ops suffice (contract
|
|
440
|
+
# risk note).
|
|
441
|
+
self._auth_errors: dict[str, DeviceAuthError] = {}
|
|
400
442
|
|
|
401
443
|
# ------------------------------------------------------------------
|
|
402
444
|
# Provider registry (BF010 registration hook)
|
|
@@ -430,19 +472,31 @@ class CapabilityMirror:
|
|
|
430
472
|
True iff the parsed schema differs from the cached snapshot
|
|
431
473
|
(including "no cache → cache populated", which IS a change).
|
|
432
474
|
False if /hello failed (cache cleared) or the schema is identical.
|
|
475
|
+
Auth errors (R001-BB001): cache is PRESERVED and False returned —
|
|
476
|
+
the phone is reachable, just unauthorized; see :meth:`auth_error`.
|
|
433
477
|
"""
|
|
434
478
|
try:
|
|
435
479
|
target = self._client.hello(device_id)
|
|
480
|
+
except DeviceAuthError as exc:
|
|
481
|
+
# R001-BB001: auth error ≠ offline. Preserve the schema cache
|
|
482
|
+
# (capabilities remain valid once authorized), record the auth
|
|
483
|
+
# failure for auth_error() queries, return False (no spurious
|
|
484
|
+
# list_changed). refresh stays poll-safe (never raises).
|
|
485
|
+
self._auth_errors[device_id] = exc
|
|
486
|
+
return False
|
|
436
487
|
except (DeviceUnreachable, DeviceStale, DeviceHttpError, BridgeError):
|
|
437
|
-
# Any /hello failure → degrade. Clear cache
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
# signals
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
488
|
+
# Any non-auth /hello failure → degrade. Clear cache (and any
|
|
489
|
+
# stale auth state — an unreachable phone has no auth verdict)
|
|
490
|
+
# so build_tools falls to static-only and a later successful
|
|
491
|
+
# refresh re-signals the transition back. We do NOT report a
|
|
492
|
+
# change here: list_changed signals "the manifest grew/shrank",
|
|
493
|
+
# and going from "had tools" to "static only" is a real change —
|
|
494
|
+
# but only if we actually had a non-empty manifest before.
|
|
495
|
+
# Reporting False on a cache→empty transition would be a lie, so
|
|
496
|
+
# we surface it honestly.
|
|
444
497
|
had_cache = device_id in self._cache
|
|
445
498
|
self._cache.pop(device_id, None)
|
|
499
|
+
self._auth_errors.pop(device_id, None)
|
|
446
500
|
# Transition from "had dynamic tools" to "static only" IS a change
|
|
447
501
|
# the AI client needs to know about (its cached tools are stale).
|
|
448
502
|
return had_cache
|
|
@@ -452,8 +506,20 @@ class CapabilityMirror:
|
|
|
452
506
|
changed = old_schemas != new_schemas
|
|
453
507
|
if changed:
|
|
454
508
|
self._cache[device_id] = new_schemas
|
|
509
|
+
# Successful /hello means the device is authorized again.
|
|
510
|
+
self._auth_errors.pop(device_id, None)
|
|
455
511
|
return changed
|
|
456
512
|
|
|
513
|
+
def auth_error(self, device_id: str) -> DeviceAuthError | None:
|
|
514
|
+
"""Return the last :class:`DeviceAuthError` seen for ``device_id``.
|
|
515
|
+
|
|
516
|
+
R001-BB001: lets callers (h_list_capabilities) distinguish "auth
|
|
517
|
+
failed" from "offline degrade" without refresh raising. ``None`` when
|
|
518
|
+
the device is healthy or the last failure was non-auth (offline /
|
|
519
|
+
stale / HTTP). Cleared by the next successful refresh.
|
|
520
|
+
"""
|
|
521
|
+
return self._auth_errors.get(device_id)
|
|
522
|
+
|
|
457
523
|
# ------------------------------------------------------------------
|
|
458
524
|
# Cache access (no I/O)
|
|
459
525
|
# ------------------------------------------------------------------
|
|
@@ -564,6 +630,10 @@ class CapabilityMirror:
|
|
|
564
630
|
resources=(),
|
|
565
631
|
commands=(),
|
|
566
632
|
description=None,
|
|
633
|
+
scope="app",
|
|
634
|
+
page_id=None,
|
|
635
|
+
page_name=None,
|
|
636
|
+
scope_revision=None,
|
|
567
637
|
)
|
|
568
638
|
for tag in target.capabilities
|
|
569
639
|
]
|
|
@@ -587,11 +657,19 @@ class CapabilityMirror:
|
|
|
587
657
|
description = (
|
|
588
658
|
description_raw if isinstance(description_raw, str) and description_raw else None
|
|
589
659
|
)
|
|
660
|
+
scope = _parse_scope(cap.get("scope"))
|
|
661
|
+
page_id = _parse_optional_string(cap.get("pageId"))
|
|
662
|
+
page_name = _parse_optional_string(cap.get("pageName"))
|
|
663
|
+
scope_revision = _parse_scope_revision(cap.get("scopeRevision"))
|
|
590
664
|
return CapabilitySchema(
|
|
591
665
|
capability_id=cap_id,
|
|
592
666
|
resources=resources,
|
|
593
667
|
commands=commands,
|
|
594
668
|
description=description,
|
|
669
|
+
scope=scope,
|
|
670
|
+
page_id=page_id,
|
|
671
|
+
page_name=page_name,
|
|
672
|
+
scope_revision=scope_revision,
|
|
595
673
|
)
|
|
596
674
|
|
|
597
675
|
|
|
@@ -632,6 +710,23 @@ def _parse_decls(
|
|
|
632
710
|
return tuple(out) # type: ignore[return-value]
|
|
633
711
|
|
|
634
712
|
|
|
713
|
+
def _parse_scope(raw: Any) -> str:
|
|
714
|
+
"""Parse registeredCapabilities[].scope with legacy-safe downgrade."""
|
|
715
|
+
return raw if raw in ("app", "page") else "app"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _parse_optional_string(raw: Any) -> str | None:
|
|
719
|
+
"""Return non-empty string metadata, otherwise local downgrade to None."""
|
|
720
|
+
return raw if isinstance(raw, str) and raw else None
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _parse_scope_revision(raw: Any) -> int | None:
|
|
724
|
+
"""Return real integer revisions only; bool/malformed values downgrade."""
|
|
725
|
+
if isinstance(raw, bool):
|
|
726
|
+
return None
|
|
727
|
+
return raw if isinstance(raw, int) else None
|
|
728
|
+
|
|
729
|
+
|
|
635
730
|
__all__ = [
|
|
636
731
|
"CapabilityMirror",
|
|
637
732
|
"CapabilitySchema",
|