debug-control-plane 0.4.0__tar.gz → 0.5.1__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 (46) hide show
  1. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/PKG-INFO +4 -4
  2. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/README.md +3 -3
  3. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/mcp_plane/server.py +5 -2
  5. debug_control_plane-0.5.1/debug_control_plane/mcp_plane/token_provider.py +138 -0
  6. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/PKG-INFO +4 -4
  7. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/SOURCES.txt +4 -1
  8. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/pyproject.toml +1 -1
  9. debug_control_plane-0.5.1/tests/test_dart_plane_persistence.py +217 -0
  10. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_server.py +1 -1
  11. debug_control_plane-0.5.1/tests/test_token_provider.py +168 -0
  12. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/LICENSE +0 -0
  13. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/__init__.py +0 -0
  14. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  15. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/device_pool.py +0 -0
  16. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  17. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  18. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  19. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  20. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  21. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  22. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/endpoint.py +0 -0
  23. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/device_discovery/protocol.py +0 -0
  24. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/mcp_plane/__init__.py +0 -0
  25. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/mcp_plane/bridge_client.py +0 -0
  26. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/mcp_plane/capability_mirror.py +0 -0
  27. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  28. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  29. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/entry_points.txt +0 -0
  30. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/requires.txt +0 -0
  31. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/debug_control_plane.egg-info/top_level.txt +0 -0
  32. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/setup.cfg +0 -0
  33. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_acceptance_flutter_app_auth.py +0 -0
  34. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_bridge_client.py +0 -0
  35. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_capability_mirror.py +0 -0
  36. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_cross_lang_kotlin_plane.py +0 -0
  37. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_device_candidates.py +0 -0
  38. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_device_pool.py +0 -0
  39. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_discovery_cross_identify.py +0 -0
  40. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_discovery_lan_scan.py +0 -0
  41. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_discovery_manual_registry.py +0 -0
  42. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_discovery_usb_identity.py +0 -0
  43. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_discovery_vpn_immune.py +0 -0
  44. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_e2e_mock.py +0 -0
  45. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/tests/test_endpoint.py +0 -0
  46. {debug_control_plane-0.4.0 → debug_control_plane-0.5.1}/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.4.0
3
+ Version: 0.5.1
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -62,7 +62,7 @@ python/
62
62
  ├── README.md # this file
63
63
  ├── LICENSE # MIT
64
64
  └── debug_control_plane/
65
- ├── __init__.py # __version__ = "0.4.0"
65
+ ├── __init__.py # __version__ = "0.5.1"
66
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
67
67
  │ ├── device_candidates.py
68
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
92
92
 
93
93
  ## Version / 版本
94
94
 
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
+ `0.5.1` — aligned with Kotlin/Dart/Flutter `0.5.1`, API unstable.
96
+ `0.5.1` —— 与 Kotlin/Dart/Flutter `0.5.1` 对齐,API 不稳定。
97
97
 
98
98
  ## License / 许可证
99
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.4.0"
46
+ ├── __init__.py # __version__ = "0.5.1"
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,8 +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.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
77
- `0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
76
+ `0.5.1` — aligned with Kotlin/Dart/Flutter `0.5.1`, API unstable.
77
+ `0.5.1` —— 与 Kotlin/Dart/Flutter `0.5.1` 对齐,API 不稳定。
78
78
 
79
79
  ## License / 许可证
80
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.4.0"
7
+ __version__ = "0.5.1"
@@ -87,6 +87,7 @@ from .bridge_client import (
87
87
  )
88
88
  from .capability_mirror import CapabilityMirror, CapabilitySchema, ToolSpec
89
89
  from .semantic_provider import SemanticProvider
90
+ from .token_provider import FileTokenProvider
90
91
 
91
92
  # ★ BF008-010 (Contract §0.1 边界 1 收尾 + 方案 X): capability-specific semantic
92
93
  # sugar 已迁业务侧(强耦合产品 protocol,属业务知识)。平面 server 零业务依赖
@@ -256,7 +257,7 @@ class McpServer:
256
257
  pool: DevicePool,
257
258
  *,
258
259
  server_name: str = "mcp-debug-bridge",
259
- server_version: str = "0.4.0",
260
+ server_version: str = "0.5.1",
260
261
  providers: list[SemanticProvider] | None = None,
261
262
  tool_handlers: dict[str, Any] | None = None,
262
263
  ) -> None:
@@ -1052,7 +1053,9 @@ def main() -> None:
1052
1053
  format="%(asctime)s %(name)s %(levelname)s %(message)s",
1053
1054
  )
1054
1055
  pool = DevicePool(persist_path=Path.home() / ".debug-control-plane" / "devices.json")
1055
- client = BridgeClient(pool=pool)
1056
+ client = BridgeClient(
1057
+ pool=pool, token_provider=FileTokenProvider()
1058
+ )
1056
1059
  mirror = CapabilityMirror(client=client)
1057
1060
  server = McpServer(mirror=mirror, client=client, pool=pool)
1058
1061
  server.run_stdio() # blocks; exits when the AI client closes stdin
@@ -0,0 +1,138 @@
1
+ """File-backed debug auth token provider (R004-BF001).
2
+
3
+ FileTokenProvider is the production implementation of
4
+ :class:`debug_control_plane.mcp_plane.bridge_client.DebugAuthTokenProvider`.
5
+ It persists per-device bearer tokens to ``~/.debug-control-plane/tokens.json``
6
+ so tokens survive python process restarts (design R004 §3.2).
7
+
8
+ Schema (independent from devices.json — no shared structure or imports)::
9
+
10
+ {"version": 1, "tokens": {device_id: {"token": ..., "tokenId": ...,
11
+ "expiresAt": ...}}}
12
+
13
+ Concurrency model: lazy-loaded, single-threaded. This provider assumes it is
14
+ only used from the asyncio event-loop thread (the MCP server's threading
15
+ model). No locking is performed — the design explicitly accepted this
16
+ asyncio-single-thread assumption.
17
+
18
+ Security: plaintext tokens on the developer machine with 0600 permissions
19
+ (user-approved decision). The 0600 mode is guaranteed by creating the tmp
20
+ file via ``os.open(..., 0o600)`` (bypassing umask, so no 0644 window exists)
21
+ followed by an atomic ``os.replace``.
22
+
23
+ Corruption policy: a corrupt file or an unknown ``version`` silently falls
24
+ back to an empty store — tokens are regenerable and the server must not
25
+ crash on load.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Mapping
35
+
36
+ _SCHEMA_VERSION = 1
37
+ _DEFAULT_PATH = Path.home() / ".debug-control-plane" / "tokens.json"
38
+
39
+
40
+ def _is_expired(value: str) -> bool:
41
+ """Return True if ``value`` is a tz-aware ISO timestamp in the past.
42
+
43
+ Parse failures and naive timestamps are treated as *not* expired: the
44
+ phone's 401 is the final arbiter, and a parsing bug must not break the
45
+ auth chain (design §5). Evaluated at read time; never written back.
46
+ """
47
+ try:
48
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
49
+ except (ValueError, TypeError):
50
+ return False
51
+ if parsed.tzinfo is None:
52
+ return False
53
+ return parsed <= datetime.now(timezone.utc)
54
+
55
+
56
+ class FileTokenProvider:
57
+ """File-backed token store implementing the DebugAuthTokenProvider protocol."""
58
+
59
+ def __init__(self, path: Path | None = None) -> None:
60
+ self._path = path or _DEFAULT_PATH
61
+ self._loaded = False
62
+ self._tokens: dict[str, dict[str, str]] = {}
63
+
64
+ # ------------------------------------------------------------------
65
+ # Protocol surface
66
+ # ------------------------------------------------------------------
67
+
68
+ def get_token(self, device_id: str) -> str | None:
69
+ """Return the stored token for ``device_id`` if present and unexpired."""
70
+ self._ensure_loaded()
71
+ row = self._tokens.get(device_id)
72
+ if row is None:
73
+ return None
74
+ expires_at = row.get("expiresAt", "")
75
+ if expires_at and _is_expired(expires_at):
76
+ return None
77
+ return row.get("token")
78
+
79
+ def save_token(
80
+ self, device_id: str, token: str, metadata: Mapping[str, Any]
81
+ ) -> None:
82
+ """Store ``token`` for ``device_id`` and persist (full file rewrite)."""
83
+ self._ensure_loaded()
84
+ row: dict[str, str] = {"token": token}
85
+ for key, value in metadata.items():
86
+ row[key] = value if isinstance(value, str) else str(value)
87
+ self._tokens[device_id] = row
88
+ self._flush()
89
+
90
+ def clear_token(self, device_id: str, reason: str) -> None:
91
+ """Drop the row for ``device_id`` and persist (rewrite without it)."""
92
+ self._ensure_loaded()
93
+ if device_id in self._tokens:
94
+ del self._tokens[device_id]
95
+ self._flush()
96
+
97
+ # ------------------------------------------------------------------
98
+ # Internals
99
+ # ------------------------------------------------------------------
100
+
101
+ def _ensure_loaded(self) -> None:
102
+ if self._loaded:
103
+ return
104
+ self._loaded = True
105
+ try:
106
+ data = json.loads(self._path.read_text(encoding="utf-8"))
107
+ except (OSError, ValueError):
108
+ return # missing or corrupt → silent empty fallback
109
+ if not isinstance(data, dict) or data.get("version") != _SCHEMA_VERSION:
110
+ return # unknown schema → silent empty fallback
111
+ tokens = data.get("tokens")
112
+ if not isinstance(tokens, dict):
113
+ return
114
+ self._tokens = {
115
+ device_id: row
116
+ for device_id, row in tokens.items()
117
+ if isinstance(row, dict)
118
+ }
119
+
120
+ def _flush(self) -> None:
121
+ """Atomically write the store with 0600 permissions."""
122
+ payload = json.dumps(
123
+ {"version": _SCHEMA_VERSION, "tokens": self._tokens},
124
+ indent=2,
125
+ ensure_ascii=False,
126
+ )
127
+ self._path.parent.mkdir(parents=True, exist_ok=True)
128
+ tmp_path = self._path.with_name(self._path.name + ".tmp")
129
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
130
+ try:
131
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
132
+ f.write(payload)
133
+ f.flush()
134
+ os.fsync(f.fileno())
135
+ except BaseException:
136
+ tmp_path.unlink(missing_ok=True)
137
+ raise
138
+ os.replace(tmp_path, self._path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -62,7 +62,7 @@ python/
62
62
  ├── README.md # this file
63
63
  ├── LICENSE # MIT
64
64
  └── debug_control_plane/
65
- ├── __init__.py # __version__ = "0.4.0"
65
+ ├── __init__.py # __version__ = "0.5.1"
66
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
67
67
  │ ├── device_candidates.py
68
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
92
92
 
93
93
  ## Version / 版本
94
94
 
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
+ `0.5.1` — aligned with Kotlin/Dart/Flutter `0.5.1`, API unstable.
96
+ `0.5.1` —— 与 Kotlin/Dart/Flutter `0.5.1` 对齐,API 不稳定。
97
97
 
98
98
  ## License / 许可证
99
99
 
@@ -24,10 +24,12 @@ 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
+ debug_control_plane/mcp_plane/token_provider.py
27
28
  tests/test_acceptance_flutter_app_auth.py
28
29
  tests/test_bridge_client.py
29
30
  tests/test_capability_mirror.py
30
31
  tests/test_cross_lang_kotlin_plane.py
32
+ tests/test_dart_plane_persistence.py
31
33
  tests/test_device_candidates.py
32
34
  tests/test_device_pool.py
33
35
  tests/test_discovery_cross_identify.py
@@ -38,4 +40,5 @@ tests/test_discovery_vpn_immune.py
38
40
  tests/test_e2e_mock.py
39
41
  tests/test_endpoint.py
40
42
  tests/test_import_sanity.py
41
- tests/test_server.py
43
+ tests/test_server.py
44
+ tests/test_token_provider.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debug-control-plane"
7
- version = "0.4.0"
7
+ version = "0.5.1"
8
8
  description = "Multi-product reusable debug control plane (device discovery + MCP adapter)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,217 @@
1
+ """R005-BF002 — dart plane token 持久化验收入口(pytest 侧子集)。
2
+
3
+ 消费「已在运行的 example app Dart plane endpoint」(R002-BF005 双通道:
4
+ pytest ``--endpoint`` option 或 env ``ACCEPTANCE_ENDPOINT``;由
5
+ ``.dev-flow/R005/test-overrides/R005-BF002/run-integration.sh`` 驱动或
6
+ 操作者手动启动)。两者皆缺 → 每用例 ``pytest.skip(setup_required)``,
7
+ 绝不误报 pass。session 级健康探测 GET /hello 不可达 → 全部
8
+ skip(setup_required: endpoint unreachable)。
9
+
10
+ 覆盖范围(明示):
11
+ - 本文件只覆盖 I1/I4-TTL/I5 的 wire 子集 + I1 的 python 侧落盘断言。
12
+ - I2(冷重启)/ I3(损坏自愈)/ I4 的「篡改 app 沙箱文件 + simctl
13
+ terminate/relaunch」部分**仅在驱动脚本执行**(需要 simctl udid/bundle
14
+ 与 driver 重启能力,pytest 会话内不掌握这些环境句柄),对应断言在
15
+ ``.dev-flow/R005/test-overrides/R005-BF002/ios-simulator-persistence.py``
16
+ (I1-I5 全量)。
17
+ - 全量执行入口:``bash .dev-flow/R005/test-overrides/R005-BF002/run-integration.sh``。
18
+
19
+ Refs:
20
+ - tasks: .dev-flow/R005/dart-plane-token-persistence-tasks.md (R005-BF002)
21
+ - pattern: python/tests/test_acceptance_flutter_app_auth.py (R002-BF005)
22
+ - wire source: flutter_debug_control_plane/example/lib/src/acceptance_plane.dart
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import stat
30
+ import tempfile
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ import uuid
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import pytest
40
+
41
+ _ENDPOINT_ENV = "ACCEPTANCE_ENDPOINT"
42
+ _REQUEST_TIMEOUT = 5.0
43
+ _APPROVE_WAIT_TIMEOUT = 30.0
44
+ _POLL_INTERVAL = 0.5
45
+
46
+
47
+ def _get_pytest_endpoint(request: pytest.FixtureRequest) -> str | None:
48
+ """--endpoint option 优先,其次 ACCEPTANCE_ENDPOINT,皆缺返回 None。"""
49
+ option_value = request.config.getoption("--endpoint")
50
+ return option_value or os.environ.get(_ENDPOINT_ENV) or None
51
+
52
+
53
+ def _http_json(endpoint: str, method: str, path: str,
54
+ body: dict[str, Any] | None = None, bearer: str | None = None,
55
+ timeout: float = _REQUEST_TIMEOUT) -> tuple[int, Any]:
56
+ url = endpoint.rstrip("/") + path
57
+ data = json.dumps(body or {}).encode()
58
+ req = urllib.request.Request(url, data=data, method=method)
59
+ req.add_header("Content-Type", "application/json")
60
+ if bearer:
61
+ req.add_header("Authorization", f"Bearer {bearer}")
62
+ try:
63
+ with urllib.request.urlopen(req, timeout=timeout) as r:
64
+ raw = r.read()
65
+ return r.status, (json.loads(raw) if raw else {})
66
+ except urllib.error.HTTPError as e:
67
+ raw = e.read()
68
+ try:
69
+ return e.code, (json.loads(raw) if raw else {})
70
+ except ValueError:
71
+ return e.code, raw.decode(errors="replace")
72
+
73
+
74
+ class _EndpointUnreachable(Exception):
75
+ def __init__(self, reason: str) -> None:
76
+ super().__init__(reason)
77
+ self.reason = reason
78
+
79
+
80
+ @pytest.fixture(scope="session")
81
+ def plane_endpoint(request: pytest.FixtureRequest) -> str:
82
+ """endpoint 解析 + session 级健康探测;缺/不可达 → skip(setup_required)。
83
+
84
+ 与 test_acceptance_flutter_app_auth.py 同模式:session fixture 内直接
85
+ pytest.skip,依赖用例呈现 skip 而非 error。
86
+ """
87
+ endpoint = _get_pytest_endpoint(request)
88
+ if not endpoint:
89
+ pytest.skip("setup_required: no endpoint (use --endpoint or ACCEPTANCE_ENDPOINT)")
90
+ try:
91
+ st, _ = _http_json(endpoint, "GET", "/hello", timeout=3.0)
92
+ if st != 200:
93
+ pytest.skip(f"setup_required: endpoint unreachable (/hello -> {st})")
94
+ except Exception as e: # noqa: BLE001
95
+ pytest.skip(f"setup_required: endpoint unreachable ({type(e).__name__}: {e})")
96
+ return endpoint
97
+
98
+
99
+ @pytest.fixture
100
+ def endpoint_or_skip(plane_endpoint: str) -> str:
101
+ """session 探测通过的 endpoint(失败已在 session fixture skip)。"""
102
+ return plane_endpoint
103
+
104
+
105
+ def _wait_approved(endpoint: str, rid: str, nonce: str,
106
+ timeout: float = _APPROVE_WAIT_TIMEOUT) -> None:
107
+ """轮询等 driver(按 clientLabel 非 deny*/hold* 自动 approve)。"""
108
+ deadline = time.monotonic() + timeout
109
+ while time.monotonic() < deadline:
110
+ st, body = _http_json(endpoint, "POST", "/auth/status",
111
+ {"requestId": rid, "clientNonce": nonce})
112
+ if st == 200 and isinstance(body, dict) and body.get("status") == "approved":
113
+ return
114
+ if st == 403:
115
+ raise AssertionError(f"denied: {body}")
116
+ time.sleep(_POLL_INTERVAL)
117
+ raise AssertionError(f"approve timeout for {rid}")
118
+
119
+
120
+ def _auth_chain(endpoint: str, store: Path,
121
+ label: str) -> tuple[str, dict[str, Any]]:
122
+ """授权链(标准库 urllib 版,与 R002 模式一致)。"""
123
+ nonce = f"r005-py-{label}-{uuid.uuid4().hex[:8]}"
124
+ st, body = _http_json(endpoint, "POST", "/auth/request",
125
+ {"clientLabel": f"r005-py-{label}", "clientNonce": nonce})
126
+ assert st in (200, 202), f"/auth/request: {st} {body!r:.60}"
127
+ rid = body["requestId"]
128
+ _wait_approved(endpoint, rid, nonce)
129
+ st, body = _http_json(endpoint, "POST", "/auth/claim",
130
+ {"requestId": rid, "clientNonce": nonce})
131
+ assert st == 200 and isinstance(body.get("token"), str), \
132
+ f"/auth/claim: {st} {body!r:.60}"
133
+ # python 侧 FileTokenProvider 落盘(等价 R004 S1 语义)
134
+ row = {"token": body["token"], "tokenId": body.get("tokenId"),
135
+ "expiresAt": body.get("expiresAt"), "obtainedAt": datetime.now(timezone.utc).isoformat()}
136
+ store.parent.mkdir(parents=True, exist_ok=True)
137
+ store.write_text(json.dumps({"version": 1, "tokens": {"ios-sim": row}},
138
+ indent=2), encoding="utf-8")
139
+ os.chmod(store, 0o600)
140
+ return body["token"], body
141
+
142
+
143
+ @pytest.fixture
144
+ def claimed(endpoint_or_skip: str, tmp_path: Path) -> tuple[str, dict[str, Any]]:
145
+ """session 内每用例独立授权链(新 nonce 新 token)。"""
146
+ return _auth_chain(endpoint_or_skip, tmp_path / "tokens.json", "case")
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # 用例(I1/I4-TTL/I5 子集;I2/I3 仅驱动脚本执行,见模块 docstring)
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def test_i1_first_auth_python_side(claimed: tuple[str, dict[str, Any]],
154
+ tmp_path: Path) -> None:
155
+ """I1 子集(python 侧):claim 200 + token 落盘 0600 + expiresAt 在场。
156
+
157
+ I1 的 app 沙箱断言(Documents/debug_control_plane/debug_auth_tokens.json
158
+ 存在/version==1/字节无明文)仅在驱动脚本执行(需 simctl 沙箱句柄)。
159
+ """
160
+ token, claim = claimed
161
+ assert token, "claim token 空"
162
+ store = tmp_path / "tokens.json"
163
+ data = json.loads(store.read_text(encoding="utf-8"))
164
+ row = data["tokens"]["ios-sim"]
165
+ assert row["token"] == token, "落盘 token 与 claim 不一致"
166
+ mode = stat.S_IMODE(store.stat().st_mode)
167
+ assert mode == 0o600, f"权限 {oct(mode)} != 0600"
168
+ assert row["tokenId"] and row["expiresAt"], f"metadata 未并入: {row!r}"
169
+
170
+
171
+ def test_i4_ttl_window(claimed: tuple[str, dict[str, Any]]) -> None:
172
+ """I4 子集(TTL 窗口):claim expiresAt - now ∈ [604790, 604810]s。
173
+
174
+ I4 的「篡改 app 沙箱 + 冷重启 → 401」部分仅在驱动脚本执行。
175
+ """
176
+ token, claim = claimed
177
+ exp = datetime.fromisoformat(claim["expiresAt"].replace("Z", "+00:00"))
178
+ delta = (exp - datetime.now(timezone.utc)).total_seconds()
179
+ assert 604790 <= delta <= 604810, f"expiresAt-now={delta:.0f}s 超出窗口"
180
+
181
+
182
+ def test_i5_no_token_401(endpoint_or_skip: str) -> None:
183
+ """I5:无 Authorization → 敏感路由 401。"""
184
+ st, _ = _http_json(endpoint_or_skip, "POST", "/debug/secure-action",
185
+ body={"via": "r005-py"})
186
+ assert st == 401, f"no-token: {st}"
187
+
188
+
189
+ def test_i5_forged_401_invalid_token(endpoint_or_skip: str) -> None:
190
+ """I5:伪造 Bearer → 401 invalid_token。"""
191
+ st, body = _http_json(endpoint_or_skip, "POST", "/debug/secure-action",
192
+ body={"via": "r005-py"}, bearer="tok-forged-r005")
193
+ assert st == 401 and body.get("code") == "invalid_token", \
194
+ f"forged: {st} {body!r:.60}"
195
+
196
+
197
+ def test_i5_pending_claim_no_token_leak(endpoint_or_skip: str) -> None:
198
+ """I5:hold* 前缀保持 pending → claim 200 status=pending 且无 token。"""
199
+ nonce = f"r005-py-hold-{uuid.uuid4().hex[:8]}"
200
+ st, body = _http_json(endpoint_or_skip, "POST", "/auth/request",
201
+ {"clientLabel": "hold-r005-py", "clientNonce": nonce})
202
+ rid = body.get("requestId")
203
+ assert st in (200, 202) and rid, f"/auth/request: {st} {body!r:.60}"
204
+ st, body = _http_json(endpoint_or_skip, "POST", "/auth/claim",
205
+ {"requestId": rid, "clientNonce": nonce})
206
+ assert st == 200 and body.get("status") == "pending" and "token" not in body, \
207
+ f"pending claim: {st} {body!r:.60}"
208
+
209
+
210
+ def test_i5_bearer_multi_capability(endpoint_or_skip: str,
211
+ claimed: tuple[str, dict[str, Any]]) -> None:
212
+ """I5:真 token Bearer 走 /debug/echo(capability 语义回归)。"""
213
+ token, _ = claimed
214
+ st, body = _http_json(endpoint_or_skip, "POST", "/debug/echo",
215
+ body={"r005": "multi"}, bearer=token)
216
+ assert st == 200 and body.get("capability") == "debug.echo", \
217
+ f"echo: {st} {body!r:.60}"
@@ -1313,7 +1313,7 @@ class TestMakeApp:
1313
1313
  srv = assembled[0]
1314
1314
  app = srv._make_app() # noqa: SLF001
1315
1315
  assert app.name == "mcp-debug-bridge"
1316
- assert app.version == "0.4.0"
1316
+ assert app.version == "0.5.1"
1317
1317
 
1318
1318
  def test_make_app_registers_stub_provider(self, assembled):
1319
1319
  """★ BF008-010 (A 类): assembled fixture 注入的 _StubProvider 已注册到
@@ -0,0 +1,168 @@
1
+ """Unit tests for FileTokenProvider (R004-BF001).
2
+
3
+ Covers the 7 BF001-T scenarios from test §2:
4
+ 1. roundtrip-cross-instance: save → new instance get hits disk.
5
+ 2. 0600-permission: file mode == 0o600 after save.
6
+ 3. expired-past-future-Z-invalid: expiresAt four-state judgement.
7
+ 4. clear-deletes-row: clear_token rewrites file without the row.
8
+ 5. corrupt-fallback: invalid JSON → silent empty fallback.
9
+ 6. metadata-merge: metadata str values merged into the row.
10
+ 7. version-mismatch: version != 1 → silent empty fallback.
11
+
12
+ All tests use tmp_path — the real ~/.debug-control-plane/ is never touched.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import stat
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+
22
+ from debug_control_plane.mcp_plane.token_provider import FileTokenProvider
23
+
24
+
25
+ def _provider(tmp_path: Path) -> FileTokenProvider:
26
+ return FileTokenProvider(path=tmp_path / "tokens.json")
27
+
28
+
29
+ def _write_raw(tmp_path: Path, payload: dict) -> Path:
30
+ p = tmp_path / "tokens.json"
31
+ p.write_text(json.dumps(payload), encoding="utf-8")
32
+ return p
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # 1. roundtrip-cross-instance
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def test_roundtrip_cross_instance(tmp_path: Path) -> None:
40
+ p1 = _provider(tmp_path)
41
+ p1.save_token(
42
+ "device-1",
43
+ "dcp_tok_1",
44
+ {"tokenId": "tid-1", "expiresAt": "2099-01-01T00:00:00+00:00"},
45
+ )
46
+ # A new instance must see the token (persistence, not memory).
47
+ p2 = _provider(tmp_path)
48
+ assert p2.get_token("device-1") == "dcp_tok_1"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 2. 0600-permission
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def test_file_mode_0600(tmp_path: Path) -> None:
56
+ provider = _provider(tmp_path)
57
+ provider.save_token("device-1", "dcp_tok_1", {"tokenId": "tid-1"})
58
+ mode = stat.S_IMODE(provider._path.stat().st_mode) # noqa: SLF001
59
+ assert mode == 0o600
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 3. expired four states
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def test_expired_past_returns_none(tmp_path: Path) -> None:
67
+ past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
68
+ provider = _provider(tmp_path)
69
+ provider.save_token("d", "t", {"expiresAt": past})
70
+ assert provider.get_token("d") is None
71
+
72
+
73
+ def test_not_expired_future(tmp_path: Path) -> None:
74
+ future = (datetime.now(timezone.utc) + timedelta(days=6)).isoformat()
75
+ provider = _provider(tmp_path)
76
+ provider.save_token("d", "t", {"expiresAt": future})
77
+ assert provider.get_token("d") == "t"
78
+
79
+
80
+ def test_not_expired_z_suffix(tmp_path: Path) -> None:
81
+ future = (
82
+ datetime.now(timezone.utc) + timedelta(days=6)
83
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+ provider = _provider(tmp_path)
85
+ provider.save_token("d", "t", {"expiresAt": future})
86
+ assert provider.get_token("d") == "t"
87
+
88
+
89
+ def test_invalid_expiresat_treated_as_not_expired(tmp_path: Path) -> None:
90
+ provider = _provider(tmp_path)
91
+ provider.save_token("d", "t", {"expiresAt": "not-a-date"})
92
+ assert provider.get_token("d") == "t"
93
+
94
+
95
+ def test_naive_expiresat_treated_as_not_expired(tmp_path: Path) -> None:
96
+ provider = _provider(tmp_path)
97
+ provider.save_token("d", "t", {"expiresAt": "2099-01-01T00:00:00"})
98
+ assert provider.get_token("d") == "t"
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # 4. clear-deletes-row
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def test_clear_deletes_row_on_disk(tmp_path: Path) -> None:
106
+ provider = _provider(tmp_path)
107
+ provider.save_token("d1", "t1", {"tokenId": "tid-1"})
108
+ provider.save_token("d2", "t2", {"tokenId": "tid-2"})
109
+ provider.clear_token("d1", reason="token_expired")
110
+ assert provider.get_token("d1") is None
111
+ assert provider.get_token("d2") == "t2"
112
+ # A fresh instance sees the same on-disk state.
113
+ assert _provider(tmp_path).get_token("d1") is None
114
+
115
+
116
+ def test_clear_missing_device_is_noop(tmp_path: Path) -> None:
117
+ provider = _provider(tmp_path)
118
+ provider.clear_token("ghost", reason="token_expired") # must not raise
119
+ assert provider.get_token("ghost") is None
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # 5. corrupt-fallback
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def test_corrupt_file_silent_fallback(tmp_path: Path) -> None:
127
+ p = tmp_path / "tokens.json"
128
+ p.write_text("{not valid json", encoding="utf-8")
129
+ provider = FileTokenProvider(path=p)
130
+ assert provider.get_token("d") is None # no raise, empty
131
+ # After a save the file becomes valid again (self-heal).
132
+ provider.save_token("d", "t", {"tokenId": "tid"})
133
+ assert json.loads(p.read_text(encoding="utf-8"))["version"] == 1
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # 6. metadata-merge
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def test_metadata_merged_into_row(tmp_path: Path) -> None:
141
+ provider = _provider(tmp_path)
142
+ provider.save_token("d", "t", {"tokenId": "tid-1", "expiresAt": "x", "count": 3})
143
+ row = json.loads((tmp_path / "tokens.json").read_text(encoding="utf-8"))[
144
+ "tokens"
145
+ ]["d"]
146
+ assert row["token"] == "t"
147
+ assert row["tokenId"] == "tid-1"
148
+ assert row["expiresAt"] == "x"
149
+ # Non-str metadata values are stringified (schema keeps str values only).
150
+ assert isinstance(row["count"], str)
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # 7. version-mismatch
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def test_version_mismatch_fallback(tmp_path: Path) -> None:
158
+ _write_raw(
159
+ tmp_path,
160
+ {"version": 2, "tokens": {"d": {"token": "t", "tokenId": "tid"}}},
161
+ )
162
+ provider = _provider(tmp_path)
163
+ assert provider.get_token("d") is None
164
+ # Save under v1 schema after the fallback.
165
+ provider.save_token("d", "t2", {})
166
+ data = json.loads((tmp_path / "tokens.json").read_text(encoding="utf-8"))
167
+ assert data["version"] == 1
168
+ assert data["tokens"]["d"]["token"] == "t2"