From f9a1869a92d359884993c6e365ea1af38c2d4de6 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Tue, 11 Aug 2026 10:22:42 +0800 Subject: [PATCH] fix(runtime): isolate Fitbit candidate port --- monitor/runtime_env.py | 15 +++++++++++++++ monitor/server.py | 9 +++++++-- plugin.py | 16 ++++++++++++++-- tests/test_plugin.py | 12 ++++++++++-- tests/test_runtime_env.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 monitor/runtime_env.py create mode 100644 tests/test_runtime_env.py diff --git a/monitor/runtime_env.py b/monitor/runtime_env.py new file mode 100644 index 0000000..62e8c29 --- /dev/null +++ b/monitor/runtime_env.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os + + +def resolve_server_port(configured_port: int) -> int: + """Return the candidate-isolated monitor port when the runtime provides one.""" + + raw_port = os.environ.get("FITBIT_MONITOR_PORT") + if raw_port is None: + return configured_port + port = int(raw_port) + if not 1 <= port <= 65535: + raise ValueError("FITBIT_MONITOR_PORT 必须在 1..65535") + return port diff --git a/monitor/server.py b/monitor/server.py index 01bfe23..3b98c49 100644 --- a/monitor/server.py +++ b/monitor/server.py @@ -15,6 +15,8 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import uvicorn + +from runtime_env import resolve_server_port import sleep_model import retrain_guard import build_sleep_diff_report @@ -465,8 +467,11 @@ def _load_runtime_config() -> dict: SERVER_HOST = str( _get_cfg(CONFIG, ("server", "host"), DEFAULT_CONFIG["server"]["host"]) ) -SERVER_PORT = _as_int( - _get_cfg(CONFIG, ("server", "port"), DEFAULT_CONFIG["server"]["port"]), 18765 +SERVER_PORT = resolve_server_port( + _as_int( + _get_cfg(CONFIG, ("server", "port"), DEFAULT_CONFIG["server"]["port"]), + 18765, + ) ) SERVER_LOG_LEVEL = str( _get_cfg(CONFIG, ("server", "log_level"), DEFAULT_CONFIG["server"]["log_level"]) diff --git a/plugin.py b/plugin.py index 6ef06a9..7c9fcfe 100644 --- a/plugin.py +++ b/plugin.py @@ -199,7 +199,7 @@ class FitbitConfig(BaseModel): class FitbitPlugin(Plugin): api_version = 2 name = "fitbit" - version = "1.4.1" + version = "1.4.2" desc = "Fitbit health monitor and sleep model" ConfigModel = FitbitConfig @@ -220,7 +220,18 @@ def dashboard_module(cls) -> str: @classmethod def mcp_servers(cls) -> list[McpServerSpec]: - return [McpServerSpec(name="fitbit", command=("python", "run_mcp.py"))] + return [ + McpServerSpec( + name="fitbit", + command=("python", "run_mcp.py"), + candidate_read_only_tools=( + "get_proactive_events", + "get_sleep_context", + "fitbit_health_snapshot", + "fitbit_sleep_report", + ), + ) + ] @classmethod def managed_services(cls) -> list[ManagedServiceSpec]: @@ -231,6 +242,7 @@ def managed_services(cls) -> list[ManagedServiceSpec]: cwd="monitor", readiness_url="http://127.0.0.1:18765/api/data", startup_timeout_seconds=15, + validation_port_env="FITBIT_MONITOR_PORT", ) ] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 3828656..49ec5c9 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,12 +10,20 @@ def test_declares_mcp_and_both_proactive_channels() -> None: plugin = FitbitPlugin() plugin.context = type("Context", (), {"config": FitbitConfig()})() - assert plugin.version == "1.4.1" - assert [server.name for server in plugin.mcp_servers()] == ["fitbit"] + assert plugin.version == "1.4.2" + servers = plugin.mcp_servers() + assert [server.name for server in servers] == ["fitbit"] + assert servers[0].candidate_read_only_tools == ( + "get_proactive_events", + "get_sleep_context", + "fitbit_health_snapshot", + "fitbit_sleep_report", + ) services = plugin.managed_services() assert [(service.id, service.cwd) for service in services] == [ ("monitor", "monitor") ] + assert services[0].validation_port_env == "FITBIT_MONITOR_PORT" sources = plugin.proactive_sources() assert [source.id for source in sources] == ["health_alerts", "sleep_context"] assert [source.channels for source in sources] == [("alert",), ("context",)] diff --git a/tests/test_runtime_env.py b/tests/test_runtime_env.py new file mode 100644 index 0000000..f0e04d8 --- /dev/null +++ b/tests/test_runtime_env.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +from monitor.runtime_env import resolve_server_port + + +def test_server_port_uses_config_without_runtime_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FITBIT_MONITOR_PORT", raising=False) + + assert resolve_server_port(18765) == 18765 + + +def test_server_port_uses_candidate_isolation_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FITBIT_MONITOR_PORT", "28765") + + assert resolve_server_port(18765) == 28765 + + +@pytest.mark.parametrize("value", ["0", "65536", "invalid"]) +def test_server_port_rejects_invalid_runtime_override( + monkeypatch: pytest.MonkeyPatch, + value: str, +) -> None: + monkeypatch.setenv("FITBIT_MONITOR_PORT", value) + + with pytest.raises(ValueError): + resolve_server_port(18765)