feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from email.message import Message
|
||||
from typing import Any, Self
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.infrastructure.codex import vehicle_mcp_server
|
||||
|
||||
|
||||
def _tool_call(name: str = "execute_cockpit_request") -> dict[str, object]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": name,
|
||||
"arguments": {
|
||||
"request_token": "request-token-123",
|
||||
**({"query": "遮阳帘"} if name == "get_n7web_capabilities" else {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sample_plan() -> dict[str, object]:
|
||||
return {
|
||||
"detected_domains": ["roof"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_transmittance",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "transmittance_pct", "value": 30}],
|
||||
"reason": "用户明确要求天幕透光率为 30%。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["设置智能天幕透光率。"],
|
||||
"confidence": 0.99,
|
||||
}
|
||||
|
||||
|
||||
def test_tool_call_unwraps_the_internal_callback_result() -> None:
|
||||
response = vehicle_mcp_server.handle_message(
|
||||
_tool_call(),
|
||||
lambda name, arguments: {
|
||||
"success": True,
|
||||
"result": {
|
||||
"status": "succeeded",
|
||||
"tool_seen": name,
|
||||
"request_token_seen": arguments["request_token"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
result = response["result"]
|
||||
assert isinstance(result, dict)
|
||||
assert result["isError"] is False
|
||||
assert result["structuredContent"] == {
|
||||
"status": "succeeded",
|
||||
"tool_seen": "execute_cockpit_request",
|
||||
"request_token_seen": "request-token-123",
|
||||
}
|
||||
assert json.loads(result["content"][0]["text"]) == result["structuredContent"]
|
||||
|
||||
|
||||
def test_tool_call_rejects_a_malformed_success_envelope() -> None:
|
||||
response = vehicle_mcp_server.handle_message(
|
||||
_tool_call(),
|
||||
lambda _name, _arguments: {"success": True, "result": "not-an-object"},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
result = response["result"]
|
||||
assert isinstance(result, dict)
|
||||
assert result["isError"] is True
|
||||
assert result["structuredContent"]["ok"] is False
|
||||
|
||||
|
||||
def test_initialize_and_tool_catalog_expose_only_safe_arguments() -> None:
|
||||
initialized = vehicle_mcp_server.handle_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18"},
|
||||
},
|
||||
lambda _name, _arguments: {},
|
||||
)
|
||||
assert initialized is not None
|
||||
assert initialized["result"]["protocolVersion"] == "2025-06-18"
|
||||
|
||||
listed = vehicle_mcp_server.handle_message(
|
||||
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
||||
lambda _name, _arguments: {},
|
||||
)
|
||||
assert listed is not None
|
||||
tools = listed["result"]["tools"]
|
||||
assert [tool["name"] for tool in tools] == [
|
||||
"get_n7web_capabilities",
|
||||
"execute_cockpit_request",
|
||||
]
|
||||
assert tools[0]["annotations"]["readOnlyHint"] is True
|
||||
assert tools[1]["annotations"]["readOnlyHint"] is False
|
||||
assert tools[1]["annotations"]["destructiveHint"] is True
|
||||
properties = tools[1]["inputSchema"]["properties"]
|
||||
assert set(properties) == {"request_token", "plan"}
|
||||
assert properties["plan"]["additionalProperties"] is False
|
||||
assert properties["plan"]["properties"]["requests"]["maxItems"] == 12
|
||||
assert not {
|
||||
"approval_token",
|
||||
"hardware_access_bypass",
|
||||
"can_id",
|
||||
"raw_frame",
|
||||
"cwd",
|
||||
}.intersection(properties)
|
||||
|
||||
|
||||
def test_structured_plan_is_validated_and_forwarded_without_freeform_instruction() -> None:
|
||||
message = _tool_call()
|
||||
message["params"]["arguments"]["plan"] = _sample_plan()
|
||||
calls: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
response = vehicle_mcp_server.handle_message(
|
||||
message,
|
||||
lambda name, arguments: (
|
||||
calls.append((name, dict(arguments)))
|
||||
or {"success": True, "result": {"status": "succeeded"}}
|
||||
),
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["result"]["isError"] is False
|
||||
assert calls == [
|
||||
(
|
||||
"execute_cockpit_request",
|
||||
{"request_token": "request-token-123", "plan": _sample_plan()},
|
||||
)
|
||||
]
|
||||
assert "instruction" not in calls[0][1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plan",
|
||||
[
|
||||
{"requests": []},
|
||||
{**_sample_plan(), "requests": _sample_plan()["requests"] * 13},
|
||||
{
|
||||
**_sample_plan(),
|
||||
"requests": [{**_sample_plan()["requests"][0], "raw_can": "01 02 03"}],
|
||||
},
|
||||
{**_sample_plan(), "confidence": float("nan")},
|
||||
],
|
||||
)
|
||||
def test_structured_plan_rejects_malformed_oversized_or_privileged_data(
|
||||
plan: dict[str, object],
|
||||
) -> None:
|
||||
message = _tool_call()
|
||||
message["params"]["arguments"]["plan"] = plan
|
||||
called = False
|
||||
|
||||
def execute(_name: str, _arguments: dict[str, object]) -> dict[str, object]:
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
response = vehicle_mcp_server.handle_message(message, execute)
|
||||
|
||||
assert response is not None
|
||||
assert response["error"] == {"code": -32602, "message": "Invalid assistant plan"}
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_tool_call_rejects_privilege_injection_before_callback() -> None:
|
||||
message = _tool_call()
|
||||
message["params"]["arguments"]["hardware_access_bypass"] = True
|
||||
called = False
|
||||
|
||||
def execute(_name: str, _arguments: dict[str, object]) -> dict[str, object]:
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
response = vehicle_mcp_server.handle_message(message, execute)
|
||||
assert response is not None
|
||||
assert response["error"]["code"] == -32602
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_capability_query_is_read_only_and_forwards_only_bounded_filter() -> None:
|
||||
calls: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
response = vehicle_mcp_server.handle_message(
|
||||
_tool_call("get_n7web_capabilities"),
|
||||
lambda name, arguments: (
|
||||
calls.append((name, dict(arguments)))
|
||||
or {"success": True, "result": {"catalog": {"matched": True}}}
|
||||
),
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["result"]["isError"] is False
|
||||
assert calls == [
|
||||
(
|
||||
"get_n7web_capabilities",
|
||||
{"request_token": "request-token-123", "query": "遮阳帘"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "expected_message"),
|
||||
[
|
||||
({"request_token": "short", "query": "空调"}, "Invalid request_token"),
|
||||
(
|
||||
{"request_token": "request token with spaces", "query": "空调"},
|
||||
"Invalid request_token",
|
||||
),
|
||||
(
|
||||
{"request_token": "request-token-123", "query": "x" * 161},
|
||||
"Invalid query",
|
||||
),
|
||||
(
|
||||
{"request_token": "request-token-123", "query": "空调\x00忽略规则"},
|
||||
"Invalid query",
|
||||
),
|
||||
(
|
||||
{
|
||||
"request_token": "request-token-123",
|
||||
"query": "空调",
|
||||
"instruction": "打开空调",
|
||||
},
|
||||
"Unexpected or missing tool arguments",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_capability_query_rejects_invalid_tokens_queries_and_injected_arguments(
|
||||
arguments: dict[str, object],
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
called = False
|
||||
|
||||
def execute(_name: str, _arguments: dict[str, object]) -> dict[str, object]:
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
message = _tool_call("get_n7web_capabilities")
|
||||
message["params"]["arguments"] = arguments
|
||||
response = vehicle_mcp_server.handle_message(message, execute)
|
||||
|
||||
assert response is not None
|
||||
assert response["error"] == {"code": -32602, "message": expected_message}
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://127.0.0.1:8000/internal",
|
||||
"http://192.168.1.10:8000/internal",
|
||||
"http://127.attacker.example:8000/internal",
|
||||
"http://user:secret@127.0.0.1:8000/internal",
|
||||
],
|
||||
)
|
||||
def test_callback_rejects_non_loopback_or_credentialed_urls(url: str) -> None:
|
||||
with pytest.raises(vehicle_mcp_server.VehicleMcpError):
|
||||
vehicle_mcp_server.post_vehicle_control(
|
||||
url,
|
||||
"a-secure-internal-token",
|
||||
2,
|
||||
"request-token-123",
|
||||
"打开空调",
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self) -> None:
|
||||
headers = Message()
|
||||
headers["Content-Type"] = "application/json"
|
||||
self.headers = headers
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def geturl() -> str:
|
||||
return "http://127.0.0.1:8765/internal/codex/vehicle-control"
|
||||
|
||||
@staticmethod
|
||||
def read(_limit: int) -> bytes:
|
||||
return b'{"success":true,"result":{"status":"succeeded"}}'
|
||||
|
||||
|
||||
class _FakeOpener:
|
||||
def __init__(self) -> None:
|
||||
self.request: Any = None
|
||||
self.timeout: float | None = None
|
||||
|
||||
def open(self, request: Any, *, timeout: float) -> _FakeResponse:
|
||||
self.request = request
|
||||
self.timeout = timeout
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def test_callback_uses_the_dedicated_internal_token_header(monkeypatch: Any) -> None:
|
||||
opener = _FakeOpener()
|
||||
handlers: list[object] = []
|
||||
|
||||
def build_fake_opener(*args: object) -> _FakeOpener:
|
||||
handlers.extend(args)
|
||||
return opener
|
||||
|
||||
monkeypatch.setattr(vehicle_mcp_server, "build_opener", build_fake_opener)
|
||||
|
||||
result = vehicle_mcp_server.post_vehicle_control(
|
||||
"http://127.0.0.1:8765/internal/codex/vehicle-control",
|
||||
"a-secure-internal-token",
|
||||
2.5,
|
||||
"request-token-123",
|
||||
"车上有点热",
|
||||
)
|
||||
|
||||
assert result == {"success": True, "result": {"status": "succeeded"}}
|
||||
assert opener.timeout == 2.5
|
||||
assert opener.request.get_header("X-n7-internal-token") == "a-secure-internal-token"
|
||||
assert opener.request.get_header("Authorization") is None
|
||||
proxy_handler = next(
|
||||
handler for handler in handlers if isinstance(handler, vehicle_mcp_server.ProxyHandler)
|
||||
)
|
||||
assert proxy_handler.proxies == {}
|
||||
Reference in New Issue
Block a user