497 lines
14 KiB
Python
497 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import queue
|
|
from collections.abc import Callable, Mapping
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.application.codex_assistant import (
|
|
ActiveCodexTurn,
|
|
CodexAssistantService,
|
|
)
|
|
from cognitive_agent.application.seat_service import SeatDemoService
|
|
from cognitive_agent.infrastructure.codex import vehicle_mcp_server
|
|
|
|
|
|
def _complete_turn(turn: ActiveCodexTurn, answer: str = "已安全处理。") -> None:
|
|
item_id = f"answer-{turn.turn_id}"
|
|
turn.events.put(
|
|
{
|
|
"method": "item/started",
|
|
"params": {
|
|
"item": {
|
|
"id": item_id,
|
|
"type": "agentMessage",
|
|
"phase": "final_answer",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
turn.events.put(
|
|
{
|
|
"method": "item/agentMessage/delta",
|
|
"params": {"itemId": item_id, "delta": answer},
|
|
}
|
|
)
|
|
turn.events.put(
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"item": {
|
|
"id": item_id,
|
|
"type": "agentMessage",
|
|
"phase": "final_answer",
|
|
"text": answer,
|
|
}
|
|
},
|
|
}
|
|
)
|
|
turn.events.put(
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {"turn": {"id": turn.turn_id, "status": "completed"}},
|
|
}
|
|
)
|
|
|
|
|
|
class _SecurityProbeClient:
|
|
available = True
|
|
status = "ready"
|
|
|
|
def __init__(self) -> None:
|
|
self.on_turn: Callable[[str, ActiveCodexTurn], None] | None = None
|
|
self.request_token = ""
|
|
|
|
def start(self, _callback_url: str, _callback_token: str) -> None:
|
|
return None
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
def start_thread(
|
|
self,
|
|
*,
|
|
developer_instructions: str,
|
|
ephemeral: bool = True,
|
|
) -> str:
|
|
assert developer_instructions
|
|
assert ephemeral is True
|
|
return "thread-security-probe"
|
|
|
|
def start_turn(
|
|
self,
|
|
thread_id: str,
|
|
text: str,
|
|
request_token: str,
|
|
*,
|
|
application_context: Mapping[str, object] | None = None,
|
|
) -> ActiveCodexTurn:
|
|
del text, application_context
|
|
self.request_token = request_token
|
|
turn = ActiveCodexTurn(thread_id, "turn-security-probe", queue.Queue())
|
|
if self.on_turn is not None:
|
|
self.on_turn(request_token, turn)
|
|
_complete_turn(turn)
|
|
return turn
|
|
|
|
def finish_turn(self, _turn: ActiveCodexTurn) -> None:
|
|
return None
|
|
|
|
def interrupt(self, _turn: ActiveCodexTurn) -> None:
|
|
return None
|
|
|
|
def unsubscribe_thread(self, _thread_id: str) -> None:
|
|
return None
|
|
|
|
|
|
def _stream(
|
|
assistant: CodexAssistantService,
|
|
message: str,
|
|
*,
|
|
execute_control: Callable[..., dict[str, object]] | None = None,
|
|
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
|
events: list[dict[str, object]] = []
|
|
result = assistant.stream(
|
|
message,
|
|
conversation_id="conv-security-boundary",
|
|
principal="security-probe",
|
|
history=[],
|
|
emit=lambda event: events.append(event),
|
|
execute_control=execute_control
|
|
or (lambda *_args: pytest.fail("unexpected control execution")),
|
|
capability_snapshot={},
|
|
capability_context={},
|
|
control_terms=("空调",),
|
|
query_capabilities=lambda _snapshot, _query: {},
|
|
)
|
|
return result, events
|
|
|
|
|
|
def _plan(parameter_value: object) -> 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": parameter_value}],
|
|
"reason": "用户明确指定透光率。",
|
|
}
|
|
],
|
|
"ambiguities": [],
|
|
"decision_summary": ["设置天幕透光率。"],
|
|
"confidence": 0.99,
|
|
}
|
|
|
|
|
|
def _seat_motion_plan(
|
|
capability: str,
|
|
operation: str,
|
|
parameters: Mapping[str, object],
|
|
*,
|
|
duration_ms: int | None = None,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"detected_domains": ["seat"],
|
|
"requests": [
|
|
{
|
|
"domain": "seat",
|
|
"capability": capability,
|
|
"target_scope": "passenger",
|
|
"operation": operation,
|
|
"duration_ms": duration_ms,
|
|
"parameters": [
|
|
{"name": name, "value": value} for name, value in parameters.items()
|
|
],
|
|
"reason": "仅规范化用户明确指定的座椅运动。",
|
|
}
|
|
],
|
|
"ambiguities": [],
|
|
"decision_summary": ["执行用户明确指定的座椅运动。"],
|
|
"confidence": 0.99,
|
|
}
|
|
|
|
|
|
def _prime_axis(
|
|
service: SeatDemoService,
|
|
conversation_id: str,
|
|
axis: str,
|
|
position_pct: int,
|
|
) -> None:
|
|
service.conversations.apply_successful_actions(
|
|
conversation_id,
|
|
"test setup",
|
|
[
|
|
{
|
|
"action_type": "position",
|
|
"target": "passenger",
|
|
"axis": axis,
|
|
"position_pct": position_pct,
|
|
}
|
|
],
|
|
["test setup"],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("non_finite", [float("nan"), float("inf"), float("-inf")])
|
|
def test_mcp_rejects_non_finite_plan_parameter_before_callback(
|
|
non_finite: float,
|
|
) -> None:
|
|
callback_called = False
|
|
|
|
def callback(
|
|
_tool: str,
|
|
_arguments: Mapping[str, object],
|
|
) -> Mapping[str, object]:
|
|
nonlocal callback_called
|
|
callback_called = True
|
|
return {"status": "succeeded"}
|
|
|
|
response = vehicle_mcp_server.handle_message(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "execute_cockpit_request",
|
|
"arguments": {
|
|
"request_token": "request-token-123",
|
|
"plan": _plan(non_finite),
|
|
},
|
|
},
|
|
},
|
|
callback,
|
|
)
|
|
|
|
assert response is not None
|
|
assert response["error"] == {
|
|
"code": -32602,
|
|
"message": "Invalid assistant plan",
|
|
}
|
|
assert callback_called is False
|
|
|
|
|
|
def test_request_token_is_not_exposed_across_mcp_progress_events() -> None:
|
|
client = _SecurityProbeClient()
|
|
assistant = CodexAssistantService(client)
|
|
|
|
def expose_split_token(request_token: str, turn: ActiveCodexTurn) -> None:
|
|
split = len(request_token) // 2
|
|
for part in (request_token[:split], request_token[split:]):
|
|
turn.events.put(
|
|
{
|
|
"method": "item/mcpToolCall/progress",
|
|
"params": {"message": part},
|
|
}
|
|
)
|
|
|
|
client.on_turn = expose_split_token
|
|
_result, events = _stream(assistant, "解释一下空调的工作原理")
|
|
|
|
serialized = json.dumps(events, ensure_ascii=False)
|
|
split = len(client.request_token) // 2
|
|
assert client.request_token[:split] not in serialized
|
|
assert client.request_token[split:] not in serialized
|
|
|
|
|
|
def test_model_controlled_summary_cannot_echo_request_token_to_browser() -> None:
|
|
client = _SecurityProbeClient()
|
|
assistant = CodexAssistantService(client)
|
|
|
|
def execute_tool(request_token: str, _turn: ActiveCodexTurn) -> None:
|
|
assistant.execute_cockpit_request(request_token)
|
|
|
|
def execute_control(
|
|
instruction: str,
|
|
_progress: Callable[[dict[str, object]], None],
|
|
) -> dict[str, object]:
|
|
assert instruction == "打开空调"
|
|
return {
|
|
"status": "succeeded",
|
|
"execution_mode": "dry_run",
|
|
"decision_summary": [f"internal={client.request_token}"],
|
|
"intent_ir": {"spec": {"actions": []}},
|
|
}
|
|
|
|
client.on_turn = execute_tool
|
|
_result, events = _stream(
|
|
assistant,
|
|
"打开空调",
|
|
execute_control=execute_control,
|
|
)
|
|
|
|
assert client.request_token not in json.dumps(events, ensure_ascii=False)
|
|
|
|
|
|
def test_safe_vehicle_result_filters_sensitive_nested_action_parameters() -> None:
|
|
safe = CodexAssistantService._safe_vehicle_result(
|
|
{
|
|
"status": "succeeded",
|
|
"execution_mode": "dry_run",
|
|
"intent_ir": {
|
|
"spec": {
|
|
"actions": [
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_power",
|
|
"target": {"zone": "vehicle"},
|
|
"parameters": {
|
|
"state": "on",
|
|
"raw_can": "41 01 02 03 04 05 06 07",
|
|
"approval_token": "approval-secret",
|
|
"internal_token": "internal-secret",
|
|
},
|
|
}
|
|
]
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert safe["actions"][0]["parameters"]["state"] == "on"
|
|
serialized = json.dumps(safe, ensure_ascii=False)
|
|
assert "raw_can" not in serialized
|
|
assert "approval-secret" not in serialized
|
|
assert "internal-secret" not in serialized
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"text",
|
|
"capability",
|
|
"axis",
|
|
"correct_operation",
|
|
"reversed_operation",
|
|
"parameters",
|
|
"duration_ms",
|
|
"initial_position",
|
|
),
|
|
[
|
|
(
|
|
"副驾座椅向前移动20毫米",
|
|
"seat.set_longitudinal_position",
|
|
"longitudinal",
|
|
"decrease",
|
|
"increase",
|
|
{"distance_mm": 20},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"副驾座椅向后移动20毫米",
|
|
"seat.set_longitudinal_position",
|
|
"longitudinal",
|
|
"increase",
|
|
"decrease",
|
|
{"distance_mm": 20},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"把副驾坐垫前缘抬高2秒",
|
|
"seat.set_cushion_tilt",
|
|
"cushion_tilt",
|
|
"decrease",
|
|
"increase",
|
|
{},
|
|
2_000,
|
|
50,
|
|
),
|
|
(
|
|
"把副驾坐垫前缘降低2秒",
|
|
"seat.set_cushion_tilt",
|
|
"cushion_tilt",
|
|
"increase",
|
|
"decrease",
|
|
{},
|
|
2_000,
|
|
50,
|
|
),
|
|
(
|
|
"副驾靠背向后放倒10度",
|
|
"seat.set_backrest_recline",
|
|
"backrest_recline",
|
|
"increase",
|
|
"decrease",
|
|
{"angle_delta_deg": 10},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"副驾靠背向前调直10度",
|
|
"seat.set_backrest_recline",
|
|
"backrest_recline",
|
|
"decrease",
|
|
"increase",
|
|
{"angle_delta_deg": 10},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"请使副驾腿托角度向上调整10度",
|
|
"seat.set_legrest_height",
|
|
"legrest_height",
|
|
"increase",
|
|
"decrease",
|
|
{"angle_delta_deg": 10},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"请使副驾腿托角度向下调整10度",
|
|
"seat.set_legrest_height",
|
|
"legrest_height",
|
|
"decrease",
|
|
"increase",
|
|
{"angle_delta_deg": 10},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"请使副驾腿托伸长到65%",
|
|
"seat.set_legrest_extension",
|
|
"legrest_extension",
|
|
"increase",
|
|
"decrease",
|
|
{"position_pct": 65},
|
|
None,
|
|
50,
|
|
),
|
|
(
|
|
"请使副驾腿托缩回到35%",
|
|
"seat.set_legrest_extension",
|
|
"legrest_extension",
|
|
"decrease",
|
|
"increase",
|
|
{"position_pct": 35},
|
|
None,
|
|
50,
|
|
),
|
|
],
|
|
)
|
|
def test_each_seat_axis_binds_physical_words_to_its_normalized_direction(
|
|
tmp_path,
|
|
text: str,
|
|
capability: str,
|
|
axis: str,
|
|
correct_operation: str,
|
|
reversed_operation: str,
|
|
parameters: Mapping[str, object],
|
|
duration_ms: int | None,
|
|
initial_position: int,
|
|
) -> None:
|
|
service = SeatDemoService()
|
|
service.artifact_root = tmp_path
|
|
slug = capability.rsplit(".", 1)[-1].replace("_", "-")
|
|
correct_conversation = f"conv-direction-correct-{slug}"
|
|
reversed_conversation = f"conv-direction-reversed-{slug}"
|
|
_prime_axis(service, correct_conversation, axis, initial_position)
|
|
_prime_axis(service, reversed_conversation, axis, initial_position)
|
|
|
|
try:
|
|
reversed_result = service.preview_native_can(
|
|
text,
|
|
provider="local",
|
|
execution_mode="dry_run",
|
|
conversation_id=reversed_conversation,
|
|
assistant_plan=_seat_motion_plan(
|
|
capability,
|
|
reversed_operation,
|
|
parameters,
|
|
duration_ms=duration_ms,
|
|
),
|
|
)
|
|
correct_result = service.preview_native_can(
|
|
text,
|
|
provider="local",
|
|
execution_mode="dry_run",
|
|
conversation_id=correct_conversation,
|
|
assistant_plan=_seat_motion_plan(
|
|
capability,
|
|
correct_operation,
|
|
parameters,
|
|
duration_ms=duration_ms,
|
|
),
|
|
)
|
|
finally:
|
|
service.close()
|
|
|
|
assert reversed_result["status"] == "no_action"
|
|
assert any("安全授权已过滤" in summary for summary in reversed_result["decision_summary"])
|
|
assert correct_result["status"] == "succeeded"
|
|
assert correct_result["provider_used"] == "local_rules+codex_app_server"
|
|
actions = correct_result["intent_ir"]["spec"]["actions"]
|
|
assert len(actions) == 1
|
|
action = actions[0]
|
|
assert action["capability"] == capability
|
|
assert action["resolution"]["direction"] == correct_operation
|
|
final_position = action["parameters"]["position_pct"]
|
|
if correct_operation == "increase":
|
|
assert final_position > initial_position
|
|
else:
|
|
assert final_position < initial_position
|