feat: add local hardware approval leases
This commit is contained in:
@@ -32,6 +32,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareBusyError,
|
||||
HardwareSafetyError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
HardwareAccessRequiredError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||||
|
||||
|
||||
@@ -96,6 +99,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if path in {"/hardware-control", "/hardware-control.html"}:
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._serve_static_file("hardware-control.html")
|
||||
return
|
||||
if path in {"/hardware-control.js", "/hardware-control.css"}:
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._serve_static_file(path.lstrip("/"))
|
||||
return
|
||||
if path == "/api/local/hardware-access":
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._json(self.service.hardware_access.admin_snapshot())
|
||||
return
|
||||
if path == "/api/hardware/access/status":
|
||||
query = parse_qs(parsed_url.query)
|
||||
try:
|
||||
self._json(
|
||||
self.service.hardware_access.status(
|
||||
str(query.get("client_id", [""])[0])
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
||||
return
|
||||
@@ -106,13 +138,25 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parsed_url.query)
|
||||
conversation_id = str(query.get("conversation_id", [""])[0])
|
||||
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
||||
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
|
||||
try:
|
||||
self._json(
|
||||
self.service.synchronize_hardware_state(
|
||||
conversation_id,
|
||||
vehicle_id,
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
)
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
@@ -159,6 +203,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if path in {
|
||||
"/api/hardware/access/request",
|
||||
"/api/hardware/access/release",
|
||||
"/api/local/hardware-access/approve",
|
||||
"/api/local/hardware-access/deny",
|
||||
"/api/local/hardware-access/revoke",
|
||||
}:
|
||||
self._handle_hardware_access_post(path)
|
||||
return
|
||||
if path != "/api/requests":
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
@@ -178,12 +231,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self.auth.config.enabled:
|
||||
self.auth.api_requests.record(username)
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > 32768:
|
||||
raise ValueError("request body must be between 1 and 32768 bytes")
|
||||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
payload = self._read_json_body()
|
||||
result = self.service.process(
|
||||
str(payload.get("utterance", "")),
|
||||
str(payload.get("provider", "auto")),
|
||||
@@ -200,8 +248,23 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
else None
|
||||
),
|
||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||
hardware_client_id=(
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
)
|
||||
self._json(result, HTTPStatus.CREATED)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
@@ -238,6 +301,53 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _handle_hardware_access_post(self, path: str) -> None:
|
||||
local_path = path.startswith("/api/local/")
|
||||
if local_path and not self._require_local_decision_point():
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=4096)
|
||||
client_id = str(payload.get("client_id", ""))
|
||||
if path == "/api/hardware/access/request":
|
||||
status = self.service.hardware_access.request_access(
|
||||
client_id,
|
||||
str(payload.get("label", "")),
|
||||
self._client_key(),
|
||||
)
|
||||
self._json(
|
||||
status,
|
||||
HTTPStatus.OK if status["state"] == "approved" else HTTPStatus.ACCEPTED,
|
||||
)
|
||||
return
|
||||
if path == "/api/hardware/access/release":
|
||||
self._json(self.service.hardware_access.release(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/approve":
|
||||
self._json(self.service.hardware_access.approve(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/deny":
|
||||
self._json(self.service.hardware_access.deny(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/revoke":
|
||||
self._json(
|
||||
self.service.hardware_access.revoke(client_id or None)
|
||||
)
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _read_json_body(self, max_bytes: int = 32768) -> dict[str, object]:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > max_bytes:
|
||||
raise ValueError(f"request body must be between 1 and {max_bytes} bytes")
|
||||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _authorize(self, path: str) -> bool:
|
||||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||||
return True
|
||||
@@ -363,6 +473,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
return address
|
||||
|
||||
def _is_unproxied_loopback_request(self) -> bool:
|
||||
"""Distinguish the local decision console from traffic via the tunnel."""
|
||||
|
||||
try:
|
||||
direct_address = ipaddress.ip_address(self.client_address[0])
|
||||
except ValueError:
|
||||
return False
|
||||
proxy_headers = (
|
||||
"Forwarded",
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"CF-Connecting-IP",
|
||||
)
|
||||
return direct_address.is_loopback and not any(
|
||||
self.headers.get(name, "").strip() for name in proxy_headers
|
||||
)
|
||||
|
||||
def _require_local_decision_point(self) -> bool:
|
||||
if self._is_unproxied_loopback_request():
|
||||
return True
|
||||
self._json(
|
||||
{
|
||||
"error": "local_access_required",
|
||||
"message": "真实台架审批与停止权限仅在台架本机开放。",
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
|
||||
def _is_https(self) -> bool:
|
||||
if not self.auth.config.trust_proxy_headers:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user