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.
Files changed (43) hide show
  1. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/PKG-INFO +5 -3
  2. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/README.md +3 -2
  3. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/bridge_client.py +193 -11
  5. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/capability_mirror.py +102 -7
  6. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/server.py +198 -10
  7. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/PKG-INFO +5 -3
  8. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
  9. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/requires.txt +1 -0
  10. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/pyproject.toml +7 -2
  11. debug_control_plane-0.4.0/tests/test_acceptance_flutter_app_auth.py +310 -0
  12. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_bridge_client.py +343 -1
  13. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_capability_mirror.py +249 -0
  14. debug_control_plane-0.4.0/tests/test_cross_lang_kotlin_plane.py +390 -0
  15. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_server.py +471 -2
  16. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/LICENSE +0 -0
  17. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/__init__.py +0 -0
  18. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  19. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  20. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  21. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  22. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  23. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  24. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  25. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  26. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
  27. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/protocol.py +0 -0
  28. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
  29. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  30. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  31. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  32. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  33. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/setup.cfg +0 -0
  34. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_device_candidates.py +0 -0
  35. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_device_pool.py +0 -0
  36. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_cross_identify.py +0 -0
  37. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_lan_scan.py +0 -0
  38. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_manual_registry.py +0 -0
  39. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_usb_identity.py +0 -0
  40. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_discovery_vpn_immune.py +0 -0
  41. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_e2e_mock.py +0 -0
  42. {debug_control_plane-0.1.1 → debug_control_plane-0.4.0}/tests/test_endpoint.py +0 -0
  43. {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.1.1
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.1.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.1.0` — initial release, API unstable. 首发,API 不稳定。
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.1.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.1.0` — initial release, API unstable. 首发,API 不稳定。
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
 
@@ -4,4 +4,4 @@ Reusable across products: device discovery (USB/WiFi/identity) + MCP adapter
4
4
  (debug HTTP protocol → MCP tool surface). Extracted from an internal app
5
5
  (R019/R020, S2 Python slice, R021-BF004).
6
6
  """
7
- __version__ = "0.1.0"
7
+ __version__ = "0.4.0"
@@ -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 DeviceHttpError(resp.status_code, _safe_body(resp))
338
+ raise self._http_error(device_id, resp)
296
339
  return _safe_body(resp)
297
340
 
298
- def read(self, device_id: str, path: list[str]) -> Any:
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(device_id, "GET", path, None)
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 DeviceHttpError(resp.status_code, _safe_body(resp))
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("GET", url, timeout=self._stream_timeout) as resp:
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 DeviceHttpError(resp.status_code, _safe_body(resp))
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 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.
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",