1080 lines
43 KiB
Python
Executable File
1080 lines
43 KiB
Python
Executable File
"""Dependency-light HTTP server for the cabin orchestration demo."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hmac
|
|
import ipaddress
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import socket
|
|
from http.cookies import CookieError, SimpleCookie
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import ParseResult, parse_qs, quote, unquote, urlparse
|
|
|
|
from cognitive_agent.config_loader import load_local_env
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
# Repository-local launch configuration is deliberate and must not be shadowed
|
|
# by stale variables inherited from an IDE or an earlier model experiment.
|
|
load_local_env(REPOSITORY_ROOT / ".env", override=True)
|
|
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
|
|
|
|
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
|
|
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
|
|
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
|
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
|
ApiError as CalibrationApiError,
|
|
CalibrationService,
|
|
)
|
|
from cognitive_agent.application.self_extension import ( # noqa: E402
|
|
ExtensionIntegrationError,
|
|
GatewayError,
|
|
SelfExtensionService,
|
|
)
|
|
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
|
|
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
|
|
|
|
|
|
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
|
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
|
mimetypes.add_type("model/gltf-binary", ".glb")
|
|
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
|
|
PUBLIC_AUTH_PATHS = {
|
|
"/login",
|
|
"/login.html",
|
|
"/login.css",
|
|
"/login.js",
|
|
"/network.js",
|
|
"/api/auth/status",
|
|
"/api/auth/login",
|
|
}
|
|
|
|
|
|
def _lan_ipv4_address() -> str | None:
|
|
"""Return the IPv4 address selected by the host's default route."""
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
probe.connect(("192.0.2.1", 80))
|
|
address = str(probe.getsockname()[0])
|
|
return None if address.startswith("127.") else address
|
|
except OSError:
|
|
return None
|
|
finally:
|
|
probe.close()
|
|
|
|
|
|
class SeatDemoHandler(BaseHTTPRequestHandler):
|
|
service = SeatDemoService()
|
|
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
|
|
extension = SelfExtensionService()
|
|
calibration = CalibrationService(
|
|
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
|
|
repository_root=REPOSITORY_ROOT,
|
|
)
|
|
repair = SelfRepairService()
|
|
auth = AuthRuntime(AuthConfig.from_env())
|
|
server_version = "CognitiveAgentCabinDemo/2.0"
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
|
parsed_url = urlparse(self.path)
|
|
path = unquote(parsed_url.path)
|
|
if path == "/favicon.ico":
|
|
self.send_response(HTTPStatus.NO_CONTENT)
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
return
|
|
if path == "/api/auth/status":
|
|
username = self._session_username()
|
|
self._json(
|
|
{
|
|
"enabled": self.auth.config.enabled,
|
|
"authenticated": not self.auth.config.enabled or username is not None,
|
|
"username": username,
|
|
"session_ttl_seconds": self.auth.config.session_ttl_seconds,
|
|
"requests_per_minute": self.auth.config.requests_per_minute,
|
|
}
|
|
)
|
|
return
|
|
if path in {"/login", "/login.html"}:
|
|
if self.auth.config.enabled and self._session_username() is not None:
|
|
self._redirect("/")
|
|
return
|
|
self._serve_static_file("login.html")
|
|
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
|
|
if path == "/api/runtime":
|
|
self._json(self.service.runtime_info())
|
|
return
|
|
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
|
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_vehicle_state
|
|
if path == "/api/hardware/vehicle-state"
|
|
else 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(
|
|
{
|
|
"error": "hardware_busy",
|
|
"message": str(exc),
|
|
"retry_after_seconds": exc.retry_after_seconds,
|
|
},
|
|
HTTPStatus.CONFLICT,
|
|
{"Retry-After": str(exc.retry_after_seconds)},
|
|
)
|
|
except ValueError as exc:
|
|
self._json(
|
|
{"error": "invalid_request", "message": str(exc)},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - surface passive CAN availability
|
|
self.log_error("physical CAN state synchronization failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"error": "hardware_state_unavailable",
|
|
"message": str(exc),
|
|
},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
if path == "/api/gesture/runtime":
|
|
self._json(self.gesture.runtime_info())
|
|
return
|
|
if path == "/api/gesture/model":
|
|
self._serve_gesture_model()
|
|
return
|
|
if path == "/api/extension/health":
|
|
self._json(self.extension.health())
|
|
return
|
|
if path == "/api/extension/gscu/info":
|
|
self._handle_extension_gateway("info", {})
|
|
return
|
|
if path.startswith("/api/calibration/"):
|
|
if not self._require_calibration_admin(parsed_url):
|
|
return
|
|
if path == "/api/calibration/state":
|
|
self._json(self.calibration.snapshot())
|
|
return
|
|
if path == "/api/calibration/events":
|
|
self._serve_calibration_events()
|
|
return
|
|
if path == "/api/calibration/video/stream":
|
|
self._serve_calibration_video()
|
|
return
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
if path.startswith("/api/self-repair/"):
|
|
if not self._require_self_repair_admin(parsed_url):
|
|
return
|
|
if path == "/api/self-repair/state":
|
|
self._json(self.repair.snapshot())
|
|
return
|
|
if path == "/api/self-repair/health":
|
|
self._json(self.repair.health())
|
|
return
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
if path.startswith("/api/requests/"):
|
|
self._serve_request(path.removeprefix("/api/requests/"))
|
|
return
|
|
self._serve_static(path)
|
|
|
|
def do_POST(self) -> None: # noqa: N802 - stdlib handler API
|
|
path = unquote(urlparse(self.path).path)
|
|
if path == "/api/auth/login":
|
|
self._login()
|
|
return
|
|
if path == "/api/auth/logout":
|
|
self._logout()
|
|
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.startswith("/api/extension/"):
|
|
self._handle_extension_post(path)
|
|
return
|
|
if path.startswith("/api/calibration/"):
|
|
self._handle_calibration_post(path)
|
|
return
|
|
if path.startswith("/api/self-repair/"):
|
|
self._handle_self_repair_post(path)
|
|
return
|
|
if path != "/api/requests":
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
|
|
try:
|
|
username = self._session_username() or "local"
|
|
retry_after = self.auth.api_requests.retry_after(username)
|
|
if self.auth.config.enabled and retry_after:
|
|
self._json(
|
|
{
|
|
"error": "rate_limited",
|
|
"message": "请求过于频繁,请稍后再试。",
|
|
},
|
|
HTTPStatus.TOO_MANY_REQUESTS,
|
|
{"Retry-After": str(retry_after)},
|
|
)
|
|
return
|
|
if self.auth.config.enabled:
|
|
self.auth.api_requests.record(username)
|
|
payload = self._read_json_body()
|
|
result = self.service.process(
|
|
str(payload.get("utterance", "")),
|
|
str(payload.get("provider", "auto")),
|
|
str(payload["conversation_id"]) if payload.get("conversation_id") else None,
|
|
str(payload.get("execution_mode", "dry_run")),
|
|
# Hardware authorization is a server deployment policy. The
|
|
# browser no longer renders or transmits an approval secret.
|
|
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
|
vehicle_id=(
|
|
str(payload["vehicle_id"])
|
|
if payload.get("vehicle_id")
|
|
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(
|
|
{
|
|
"error": "hardware_busy",
|
|
"message": str(exc),
|
|
"retry_after_seconds": exc.retry_after_seconds,
|
|
},
|
|
HTTPStatus.CONFLICT,
|
|
{"Retry-After": str(exc.retry_after_seconds)},
|
|
)
|
|
except HardwareSafetyError as exc:
|
|
self._json(
|
|
{"error": "hardware_safety_gate", "message": str(exc)},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
|
|
except OpenAIIntentError as exc:
|
|
self.log_error("OpenAI intent request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"error": "openai_request_failed",
|
|
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
|
|
},
|
|
HTTPStatus.BAD_GATEWAY,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - keep the demo API responsive
|
|
self.log_error("request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"error": "internal_error",
|
|
"message": "请求处理失败,请查看服务端日志。",
|
|
},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
|
|
parsed_url = urlparse(self.path)
|
|
path = unquote(parsed_url.path)
|
|
if not self._authorize(path):
|
|
return
|
|
prefix = "/api/mode-profiles/"
|
|
if not path.startswith(prefix):
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
|
|
mode_id = path.removeprefix(prefix).strip()
|
|
vehicle_id = str(parse_qs(parsed_url.query).get("vehicle_id", [""])[0]) or None
|
|
try:
|
|
self._json(
|
|
self.service.delete_user_mode_profile(mode_id, vehicle_id),
|
|
HTTPStatus.OK,
|
|
)
|
|
except KeyError:
|
|
self._json(
|
|
{"error": "not_found", "message": "未找到可删除的用户自定义模式。"},
|
|
HTTPStatus.NOT_FOUND,
|
|
)
|
|
except PermissionError as exc:
|
|
self._json(
|
|
{"error": "preset_mode_protected", "message": str(exc)},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
except ValueError as exc:
|
|
self._json(
|
|
{"error": "invalid_request", "message": str(exc)},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
|
|
def _handle_extension_post(self, path: str) -> None:
|
|
action_map = {
|
|
"/api/extension/gscu/learn": "learn",
|
|
"/api/extension/gscu/cancel": "cancel",
|
|
"/api/extension/gscu/emit": "emit",
|
|
"/api/extension/gscu/read": "read",
|
|
"/api/extension/gscu/erase": "erase",
|
|
}
|
|
try:
|
|
payload = self._read_json_body(max_bytes=256 * 1024)
|
|
action = action_map.get(path)
|
|
if action:
|
|
self._handle_extension_gateway(action, payload)
|
|
return
|
|
if path == "/api/extension/assistant/plan":
|
|
self._json(self.extension.plan(payload))
|
|
return
|
|
if path == "/api/extension/vehicle/execute":
|
|
result = self.extension.execute_vehicle(
|
|
payload,
|
|
self.service,
|
|
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
|
)
|
|
self._json(result)
|
|
return
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
except GatewayError as exc:
|
|
self._json(
|
|
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
except ExtensionIntegrationError as exc:
|
|
self._json(
|
|
{"success": False, "error": "extension_unavailable", "message": str(exc)},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
except HardwareAccessRequiredError as exc:
|
|
self._json(
|
|
{
|
|
"success": False,
|
|
"error": "hardware_access_required",
|
|
"message": str(exc),
|
|
"access_state": exc.state,
|
|
},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
except HardwareBusyError as exc:
|
|
self._json(
|
|
{
|
|
"success": False,
|
|
"error": "hardware_busy",
|
|
"message": str(exc),
|
|
"retry_after_seconds": exc.retry_after_seconds,
|
|
},
|
|
HTTPStatus.CONFLICT,
|
|
{"Retry-After": str(exc.retry_after_seconds)},
|
|
)
|
|
except HardwareSafetyError as exc:
|
|
self._json(
|
|
{"success": False, "error": "hardware_safety_gate", "message": str(exc)},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
except OpenAIIntentError as exc:
|
|
self.log_error("extension vehicle intent request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"success": False,
|
|
"error": "openai_request_failed",
|
|
"message": "车端 GPT 意图解析失败,请检查模型配置。",
|
|
},
|
|
HTTPStatus.BAD_GATEWAY,
|
|
)
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
self._json(
|
|
{"success": False, "error": "invalid_request", "message": str(exc)},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - keep extension APIs responsive
|
|
self.log_error("self-extension request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"success": False,
|
|
"error": "internal_error",
|
|
"message": "自扩展服务处理失败,请查看服务端日志。",
|
|
},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
def _handle_calibration_post(self, path: str) -> None:
|
|
if not self._require_calibration_admin():
|
|
return
|
|
try:
|
|
payload = self._read_json_body(max_bytes=256 * 1024)
|
|
action_map = {
|
|
"/api/calibration/can/connect": lambda: self.calibration.connect_can(
|
|
dict(payload.get("can", payload))
|
|
),
|
|
"/api/calibration/start": lambda: self.calibration.calibrate(
|
|
None if payload.get("axis", "all") == "all" else int(payload["axis"])
|
|
),
|
|
"/api/calibration/move": lambda: self.calibration.move(
|
|
int(payload["axis"]), int(payload["permille"])
|
|
),
|
|
"/api/calibration/seek": lambda: self.calibration.seek(
|
|
int(payload["axis"]), bool(payload.get("far"))
|
|
),
|
|
"/api/calibration/stop": self.calibration.stop,
|
|
"/api/calibration/settings": lambda: self.calibration.update_settings(
|
|
dict(payload)
|
|
),
|
|
"/api/calibration/mqtt/connect": self.calibration.connect_mqtt,
|
|
"/api/calibration/mqtt/disconnect": self.calibration.disconnect_mqtt,
|
|
"/api/calibration/mqtt/test": self.calibration.mqtt_test,
|
|
"/api/calibration/video/start": self.calibration.start_video,
|
|
"/api/calibration/video/stop": self.calibration.stop_video,
|
|
}
|
|
action = action_map.get(path)
|
|
if action is None:
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
|
|
requested_mode = str(
|
|
dict(payload.get("can", {})).get(
|
|
"mode", self.calibration.snapshot()["transport"]["mode"]
|
|
)
|
|
)
|
|
motion_paths = {
|
|
"/api/calibration/can/connect",
|
|
"/api/calibration/start",
|
|
"/api/calibration/move",
|
|
"/api/calibration/seek",
|
|
"/api/calibration/stop",
|
|
}
|
|
if path in motion_paths and requested_mode == "hardware":
|
|
if not self._require_local_decision_point():
|
|
return
|
|
|
|
self._json({"ok": True, "state": action()})
|
|
except CalibrationApiError as exc:
|
|
self._json(
|
|
{"ok": False, "error": "calibration_error", "message": str(exc)},
|
|
HTTPStatus(exc.status),
|
|
)
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
self._json(
|
|
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - keep calibration APIs responsive
|
|
self.log_error("self-calibration request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"ok": False,
|
|
"error": "internal_error",
|
|
"message": "自标定服务处理失败,请查看服务端日志。",
|
|
},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
def _handle_self_repair_post(self, path: str) -> None:
|
|
if not self._require_self_repair_admin():
|
|
return
|
|
try:
|
|
payload = self._read_json_body(max_bytes=256 * 1024)
|
|
physical_paths = {
|
|
"/api/self-repair/hardware/start",
|
|
"/api/self-repair/hardware/stop",
|
|
"/api/self-repair/emergency-stop",
|
|
"/api/self-repair/firmware/build-flash",
|
|
"/api/self-repair/open-log",
|
|
}
|
|
if (
|
|
path in physical_paths
|
|
or (
|
|
path in {
|
|
"/api/self-repair/healing/start",
|
|
"/api/self-repair/healing/reset",
|
|
}
|
|
and str(payload.get("execution_mode", "shadow")) == "bench"
|
|
)
|
|
) and not self._require_local_decision_point():
|
|
return
|
|
|
|
if path == "/api/self-repair/healing/start":
|
|
self._json({"ok": True, "healing": self.repair.start_healing(payload)})
|
|
return
|
|
if path == "/api/self-repair/healing/run-all":
|
|
self._json({"ok": True, "batch": self.repair.start_batch(payload)})
|
|
return
|
|
if path == "/api/self-repair/healing/reset":
|
|
self._json({"ok": True, **self.repair.reset_healing(payload)})
|
|
return
|
|
if path == "/api/self-repair/hardware/start":
|
|
self._json({"ok": True, "session": self.repair.start_hardware()})
|
|
return
|
|
if path == "/api/self-repair/hardware/stop":
|
|
self._json({"ok": True, "session": self.repair.stop_hardware()})
|
|
return
|
|
if path == "/api/self-repair/hardware/self-test":
|
|
self._json(
|
|
{"ok": True, "self_test": self.repair.run_hardware_self_test()}
|
|
)
|
|
return
|
|
if path == "/api/self-repair/emergency-stop":
|
|
self._json({"ok": True, "request": self.repair.emergency_stop()})
|
|
return
|
|
if path == "/api/self-repair/snapshot":
|
|
self._json({"ok": True, "path": str(self.repair.save_snapshot())})
|
|
return
|
|
if path == "/api/self-repair/firmware/build-flash":
|
|
self._json(
|
|
{
|
|
"ok": True,
|
|
"firmware": self.repair.build_and_flash_firmware(payload),
|
|
}
|
|
)
|
|
return
|
|
if path == "/api/self-repair/open-log":
|
|
opened = self.repair.open_log_dir()
|
|
self._json({"ok": opened})
|
|
return
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
self._json(
|
|
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - keep self-repair API responsive
|
|
self.log_error("self-repair request failed: %s", exc)
|
|
self._json(
|
|
{
|
|
"ok": False,
|
|
"error": "internal_error",
|
|
"message": "自修复服务处理失败,请查看服务端日志。",
|
|
},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
def _require_self_repair_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
|
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
|
if not role and parsed_url is not None:
|
|
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
|
if hmac.compare_digest(role, "admin"):
|
|
return True
|
|
self._json(
|
|
{"error": "admin_required", "message": "自修复仅对 admin 开放。"},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
return False
|
|
|
|
def _require_calibration_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
|
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
|
if not role and parsed_url is not None:
|
|
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
|
if hmac.compare_digest(role, "admin"):
|
|
return True
|
|
self._json(
|
|
{
|
|
"error": "admin_required",
|
|
"message": "自标定仅对 admin 开放。",
|
|
},
|
|
HTTPStatus.FORBIDDEN,
|
|
)
|
|
return False
|
|
|
|
def _serve_calibration_events(self) -> None:
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "keep-alive")
|
|
self.send_header("X-Accel-Buffering", "no")
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
version = -1
|
|
try:
|
|
while True:
|
|
state = self.calibration.wait_for_version(version, 12.0)
|
|
version = state["version"]
|
|
data = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
|
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
pass
|
|
|
|
def _serve_calibration_video(self) -> None:
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=n7frame")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Connection", "close")
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
sequence = -1
|
|
try:
|
|
while True:
|
|
sequence, frame = self.calibration.video.wait_frame(sequence)
|
|
if frame is None:
|
|
continue
|
|
header = (
|
|
b"--n7frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
|
+ str(len(frame)).encode("ascii")
|
|
+ b"\r\n\r\n"
|
|
)
|
|
self.wfile.write(header + frame + b"\r\n")
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
pass
|
|
|
|
def _handle_extension_gateway(self, action: str, payload: dict[str, object]) -> None:
|
|
try:
|
|
self._json(self.extension.execute_gateway(action, dict(payload)))
|
|
except GatewayError as exc:
|
|
self._json(
|
|
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
|
|
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
|
|
if self._session_username() is not None:
|
|
return True
|
|
if path.startswith("/api/"):
|
|
self._json(
|
|
{
|
|
"error": "authentication_required",
|
|
"message": "请登录后访问。",
|
|
},
|
|
HTTPStatus.UNAUTHORIZED,
|
|
)
|
|
else:
|
|
requested = path if path.startswith("/") else "/"
|
|
self._redirect(f"/login?next={quote(requested, safe='/')}")
|
|
return False
|
|
|
|
def _login(self) -> None:
|
|
if not self.auth.config.enabled:
|
|
self._json({"enabled": False, "authenticated": True})
|
|
return
|
|
client_key = self._client_key()
|
|
retry_after = self.auth.login_failures.retry_after(client_key)
|
|
if retry_after:
|
|
self._json(
|
|
{
|
|
"error": "login_rate_limited",
|
|
"message": "登录尝试过多,请稍后再试。",
|
|
},
|
|
HTTPStatus.TOO_MANY_REQUESTS,
|
|
{"Retry-After": str(retry_after)},
|
|
)
|
|
return
|
|
try:
|
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
if content_length <= 0 or content_length > 4096:
|
|
raise ValueError("invalid login body")
|
|
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("invalid login body")
|
|
username = str(payload.get("username", ""))[:128]
|
|
password = str(payload.get("password", ""))[:1024]
|
|
except (ValueError, json.JSONDecodeError):
|
|
self._json(
|
|
{"error": "invalid_request", "message": "登录请求格式无效。"},
|
|
HTTPStatus.BAD_REQUEST,
|
|
)
|
|
return
|
|
|
|
password_valid = verify_password(password, self.auth.config.password_hash)
|
|
username_valid = hmac.compare_digest(
|
|
username.encode("utf-8"),
|
|
self.auth.config.username.encode("utf-8"),
|
|
)
|
|
if not (username_valid and password_valid):
|
|
self.auth.login_failures.record(client_key)
|
|
self._json(
|
|
{"error": "invalid_credentials", "message": "用户名或密码错误。"},
|
|
HTTPStatus.UNAUTHORIZED,
|
|
)
|
|
return
|
|
|
|
self.auth.login_failures.clear(client_key)
|
|
token = self.auth.sessions.issue(self.auth.config.username)
|
|
attributes = [
|
|
f"{self.auth.config.cookie_name}={token}",
|
|
"Path=/",
|
|
"HttpOnly",
|
|
"SameSite=Strict",
|
|
f"Max-Age={self.auth.config.session_ttl_seconds}",
|
|
]
|
|
if self._is_https():
|
|
attributes.append("Secure")
|
|
self._json(
|
|
{
|
|
"authenticated": True,
|
|
"username": self.auth.config.username,
|
|
},
|
|
headers={"Set-Cookie": "; ".join(attributes)},
|
|
)
|
|
|
|
def _logout(self) -> None:
|
|
attributes = [
|
|
f"{self.auth.config.cookie_name}=",
|
|
"Path=/",
|
|
"HttpOnly",
|
|
"SameSite=Strict",
|
|
"Max-Age=0",
|
|
]
|
|
if self._is_https():
|
|
attributes.append("Secure")
|
|
self._json(
|
|
{"authenticated": False},
|
|
headers={"Set-Cookie": "; ".join(attributes)},
|
|
)
|
|
|
|
def _session_username(self) -> str | None:
|
|
if not self.auth.config.enabled:
|
|
return None
|
|
cookie_header = self.headers.get("Cookie", "")
|
|
if not cookie_header or len(cookie_header) > 4096:
|
|
return None
|
|
try:
|
|
cookies = SimpleCookie()
|
|
cookies.load(cookie_header)
|
|
morsel = cookies.get(self.auth.config.cookie_name)
|
|
return self.auth.sessions.verify(morsel.value if morsel else "")
|
|
except (CookieError, ValueError):
|
|
return None
|
|
|
|
def _client_key(self) -> str:
|
|
address = self.client_address[0]
|
|
direct_address = ipaddress.ip_address(address)
|
|
if self.auth.config.trust_proxy_headers and direct_address.is_loopback:
|
|
forwarded = (
|
|
self.headers.get("CF-Connecting-IP")
|
|
or self.headers.get("X-Forwarded-For", "").split(",", 1)[0]
|
|
).strip()
|
|
try:
|
|
address = str(ipaddress.ip_address(forwarded))
|
|
except ValueError:
|
|
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
|
|
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
|
|
return True
|
|
return '"scheme":"https"' in self.headers.get("CF-Visitor", "").replace(" ", "")
|
|
|
|
def _serve_request(self, request_id: str) -> None:
|
|
if not REQUEST_ID_PATTERN.fullmatch(request_id):
|
|
self._json({"error": "invalid_request_id"}, HTTPStatus.BAD_REQUEST)
|
|
return
|
|
result_path = self.service.artifact_root / request_id / "result.json"
|
|
if not result_path.is_file():
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
self._json(json.loads(result_path.read_text(encoding="utf-8")))
|
|
|
|
def _serve_static(self, path: str) -> None:
|
|
relative_path = "index.html" if path in {"", "/"} else path.lstrip("/")
|
|
self._serve_static_file(relative_path)
|
|
|
|
def _serve_static_file(self, relative_path: str) -> None:
|
|
candidate = (STATIC_ROOT / relative_path).resolve()
|
|
try:
|
|
candidate.relative_to(STATIC_ROOT.resolve())
|
|
except ValueError:
|
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
if not candidate.is_file():
|
|
candidate = STATIC_ROOT / "index.html"
|
|
content_type, _ = mimetypes.guess_type(candidate.name)
|
|
content = candidate.read_bytes()
|
|
self.send_response(HTTPStatus.OK)
|
|
resolved_content_type = content_type or "application/octet-stream"
|
|
if resolved_content_type.startswith("text/") or resolved_content_type in {
|
|
"application/javascript",
|
|
"application/json",
|
|
"application/manifest+json",
|
|
"image/svg+xml",
|
|
}:
|
|
resolved_content_type = f"{resolved_content_type}; charset=utf-8"
|
|
self.send_header(
|
|
"Content-Type",
|
|
resolved_content_type,
|
|
)
|
|
self.send_header("Content-Length", str(len(content)))
|
|
cache_control = "no-cache"
|
|
if relative_path.startswith(("vendor/", "assets/", "icons/")):
|
|
cache_control = "public, max-age=31536000, immutable"
|
|
elif candidate.name in {"index.html", "login.html", "service-worker.js"}:
|
|
cache_control = "no-cache"
|
|
self.send_header("Cache-Control", cache_control)
|
|
if candidate.name == "service-worker.js":
|
|
self.send_header("Service-Worker-Allowed", "/")
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
def _serve_gesture_model(self) -> None:
|
|
path = self.gesture.model_path
|
|
if not path.is_file():
|
|
self._json(
|
|
{"error": "gesture_model_unavailable", "message": "手势模型文件不可用。"},
|
|
HTTPStatus.NOT_FOUND,
|
|
)
|
|
return
|
|
content = path.read_bytes()
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", "private, max-age=3600")
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
def _json(
|
|
self,
|
|
payload: object,
|
|
status: HTTPStatus = HTTPStatus.OK,
|
|
headers: dict[str, str] | None = None,
|
|
) -> None:
|
|
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for name, value in (headers or {}).items():
|
|
self.send_header(name, value)
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
def _redirect(self, location: str) -> None:
|
|
self.send_response(HTTPStatus.SEE_OTHER)
|
|
self.send_header("Location", location)
|
|
self.send_header("Cache-Control", "no-store")
|
|
self._send_security_headers()
|
|
self.end_headers()
|
|
|
|
def _send_security_headers(self) -> None:
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header(
|
|
"Permissions-Policy",
|
|
"camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
|
|
)
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; "
|
|
"script-src 'self' 'wasm-unsafe-eval'; "
|
|
"style-src 'self'; "
|
|
"img-src 'self' data: blob:; "
|
|
"connect-src 'self' blob:; "
|
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
)
|
|
if self._is_https():
|
|
self.send_header(
|
|
"Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains",
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Run the cabin orchestration demo")
|
|
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
|
|
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
|
args = parser.parse_args()
|
|
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
|
SeatDemoHandler.extension.start()
|
|
SeatDemoHandler.calibration.start()
|
|
SeatDemoHandler.repair.start()
|
|
if args.host in {"0.0.0.0", "::"}:
|
|
lan_address = _lan_ipv4_address()
|
|
print(f"Cabin orchestration demo (local): http://127.0.0.1:{args.port}", flush=True)
|
|
if lan_address:
|
|
print(
|
|
f"Cabin orchestration demo (LAN): http://{lan_address}:{args.port}",
|
|
flush=True,
|
|
)
|
|
print(
|
|
"LAN mode: devices on reachable networks can use the GPT-backed API.",
|
|
flush=True,
|
|
)
|
|
else:
|
|
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
|
|
hardware = SeatDemoHandler.service.hardware_settings
|
|
print(
|
|
"Execution modes: dry_run + simulated_hardware "
|
|
f"({'enabled' if SeatDemoHandler.service.simulated_hardware_enabled else 'locked'}) "
|
|
"+ hardware "
|
|
f"({'configured' if hardware.configured else 'locked'})",
|
|
flush=True,
|
|
)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
SeatDemoHandler.extension.close()
|
|
SeatDemoHandler.calibration.close()
|
|
SeatDemoHandler.repair.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|