feat: add local hardware approval leases

This commit is contained in:
vee
2026-08-03 10:20:55 +08:00
parent 85b4740d29
commit a952f37ceb
14 changed files with 1089 additions and 25 deletions
+39 -10
View File
@@ -64,6 +64,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
PHYSICAL_BENCH_COORDINATOR,
SeatHardwareSettings,
)
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
PHYSICAL_BENCH_ACCESS,
)
from cognitive_agent.runtime.contracts import ContractValidator # noqa: E402
@@ -140,6 +143,7 @@ class SeatDemoService:
)
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
self.hardware_access = PHYSICAL_BENCH_ACCESS
# Shared by the short-lived hardware executor instances. A process must
# establish a fresh physical four-axis reference before trusting stored
# Hall counts, and a firmware replacement invalidates it again.
@@ -181,6 +185,7 @@ class SeatDemoService:
default_vehicle = self.vehicle_profiles.default()
hardware_info = self.hardware_settings.public_info()
hardware_info.update(self.hardware_execution.public_info())
hardware_info.update(self.hardware_access.public_info())
return {
"service": "cabin-cognitive-orchestrator",
"domain": "cabin",
@@ -247,11 +252,19 @@ class SeatDemoService:
self,
conversation_id: str,
vehicle_id: str | None = None,
*,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
"""Read the bench only when no physical execution currently owns it."""
if hardware_access_bypass:
with self.hardware_execution.reserve():
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
with self.hardware_access.use(authorized_client_id):
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
def _synchronize_hardware_state_once(
self,
@@ -322,6 +335,9 @@ class SeatDemoService:
approval_token: str | None = None,
vehicle_id: str | None = None,
simulation_fault: str = "normal",
*,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
if execution_mode != "hardware":
return self._process_once(
@@ -333,16 +349,29 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
)
if hardware_access_bypass:
with self.hardware_execution.reserve():
return self._process_once(
utterance,
provider,
conversation_id,
execution_mode,
approval_token,
vehicle_id,
simulation_fault,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
return self._process_once(
utterance,
provider,
conversation_id,
execution_mode,
approval_token,
vehicle_id,
simulation_fault,
)
with self.hardware_access.use(authorized_client_id):
return self._process_once(
utterance,
provider,
conversation_id,
execution_mode,
approval_token,
vehicle_id,
simulation_fault,
)
def _process_once(
self,
@@ -9,6 +9,11 @@ from .hardware import (
PHYSICAL_BENCH_COORDINATOR,
SeatHardwareSettings,
)
from .access import (
HardwareAccessController,
HardwareAccessRequiredError,
PHYSICAL_BENCH_ACCESS,
)
__all__ = [
"CanBus",
@@ -16,10 +21,13 @@ __all__ = [
"CanGatewayError",
"CanMatrix",
"HardwareBusyError",
"HardwareAccessController",
"HardwareAccessRequiredError",
"HardwareExecutionCoordinator",
"HardwareSafetyError",
"MatrixError",
"PHYSICAL_BENCH_COORDINATOR",
"PHYSICAL_BENCH_ACCESS",
"PythonCanBus",
"SeatHardwareSettings",
"ZlgCanBridgeBus",
@@ -0,0 +1,268 @@
"""Local approval leases for access to the single physical seat bench."""
from __future__ import annotations
import re
import threading
import time
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import Any, Callable, Iterator
HARDWARE_CLIENT_ID_PATTERN = re.compile(r"^hwc-[a-z0-9-]{8,80}$")
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class HardwareAccessRequiredError(PermissionError):
"""Raised before CAN is opened when a terminal has no active lease."""
def __init__(self, state: str = "none") -> None:
self.state = state
messages = {
"pending": "真实台架使用申请正在等待本地审批,本次请求未进入 CAN 链路。",
"expired": "真实台架使用权因空闲超过 3 分钟已自动停止,请重新申请。",
"none": "当前终端尚未获得真实台架使用权,请先向本地发送申请。",
}
super().__init__(messages.get(state, messages["none"]))
class HardwareAccessController:
"""Manage one locally-approved terminal and a bounded pending queue."""
def __init__(
self,
inactivity_timeout_seconds: int = 180,
max_pending: int = 32,
*,
clock: Callable[[], float] = time.monotonic,
timestamp: Callable[[], str] = _utc_now,
) -> None:
if inactivity_timeout_seconds <= 0:
raise ValueError("inactivity_timeout_seconds must be positive")
if max_pending <= 0:
raise ValueError("max_pending must be positive")
self.inactivity_timeout_seconds = inactivity_timeout_seconds
self.max_pending = max_pending
self._clock = clock
self._timestamp = timestamp
self._lock = threading.RLock()
self._pending: dict[str, dict[str, Any]] = {}
self._active: dict[str, Any] | None = None
self._expired_clients: set[str] = set()
@staticmethod
def validate_client_id(client_id: str | None) -> str:
value = (client_id or "").strip().lower()
if not HARDWARE_CLIENT_ID_PATTERN.fullmatch(value):
raise ValueError(
"hardware_client_id must start with hwc- and contain 8-80 lowercase letters, numbers, or hyphens"
)
return value
@staticmethod
def clean_label(label: str | None, client_id: str) -> str:
value = " ".join((label or "").strip().split())
if any(ord(character) < 32 for character in value):
raise ValueError("terminal label must not contain control characters")
return value[:64] or f"终端-{client_id[-6:]}"
def request_access(
self,
client_id: str | None,
label: str | None,
client_address: str,
) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
clean_label = self.clean_label(label, client_id)
with self._lock:
self._expire_locked()
self._expired_clients.discard(client_id)
if self._active and self._active["client_id"] == client_id:
return self._public_status_locked(client_id)
existing = self._pending.get(client_id)
if existing:
existing.update(label=clean_label, client_address=client_address)
else:
if len(self._pending) >= self.max_pending:
raise ValueError("hardware access request queue is full")
self._pending[client_id] = {
"client_id": client_id,
"label": clean_label,
"client_address": client_address,
"requested_at": self._timestamp(),
}
return self._public_status_locked(client_id)
def status(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
return self._public_status_locked(client_id)
def admin_snapshot(self) -> dict[str, Any]:
with self._lock:
self._expire_locked()
active = None
if self._active:
active = dict(self._active)
active.update(self._lease_timing_locked())
active.pop("last_activity_monotonic", None)
active.pop("active_executions", None)
return {
"policy": "local_approval_idle_lease",
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
"active": active,
"pending": [dict(record) for record in self._pending.values()],
}
def approve(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
request = self._pending.pop(client_id, None)
if request is None:
if self._active and self._active["client_id"] == client_id:
return self.admin_snapshot()
raise ValueError("hardware access request is no longer pending")
now = self._clock()
self._active = {
**request,
"approved_at": self._timestamp(),
"last_activity_at": self._timestamp(),
"last_activity_monotonic": now,
"active_executions": 0,
}
self._expired_clients.discard(client_id)
return self.admin_snapshot()
def deny(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._pending.pop(client_id, None)
self._expired_clients.discard(client_id)
return self.admin_snapshot()
def release(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
self._pending.pop(client_id, None)
if self._active and self._active["client_id"] == client_id:
self._active = None
self._expired_clients.discard(client_id)
return self._public_status_locked(client_id)
def revoke(self, client_id: str | None = None) -> dict[str, Any]:
with self._lock:
self._expire_locked()
if client_id:
client_id = self.validate_client_id(client_id)
if self._active and self._active["client_id"] != client_id:
raise ValueError("the selected terminal does not own the hardware lease")
self._active = None
return self.admin_snapshot()
@contextmanager
def use(self, client_id: str | None) -> Iterator[None]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
if not self._active or self._active["client_id"] != client_id:
raise HardwareAccessRequiredError(self._state_locked(client_id))
self._active["active_executions"] += 1
self._touch_locked()
try:
yield
finally:
with self._lock:
if self._active and self._active["client_id"] == client_id:
self._active["active_executions"] = max(
0, int(self._active["active_executions"]) - 1
)
self._touch_locked()
def ensure_authorized(self, client_id: str | None) -> str:
"""Validate a lease without refreshing it for a failed/busy request."""
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
if not self._active or self._active["client_id"] != client_id:
raise HardwareAccessRequiredError(self._state_locked(client_id))
return client_id
def public_info(self) -> dict[str, Any]:
with self._lock:
self._expire_locked()
return {
"access_policy": "local_approval_idle_lease",
"access_inactivity_timeout_seconds": self.inactivity_timeout_seconds,
"access_pending_count": len(self._pending),
"access_lease_active": self._active is not None,
}
def _touch_locked(self) -> None:
if self._active:
self._active["last_activity_monotonic"] = self._clock()
self._active["last_activity_at"] = self._timestamp()
def _expire_locked(self) -> None:
if not self._active:
return
if int(self._active.get("active_executions", 0)) > 0:
return
idle = self._clock() - float(self._active["last_activity_monotonic"])
if idle < self.inactivity_timeout_seconds:
return
self._expired_clients.add(str(self._active["client_id"]))
self._active = None
def _state_locked(self, client_id: str) -> str:
if self._active and self._active["client_id"] == client_id:
return "approved"
if client_id in self._pending:
return "pending"
if client_id in self._expired_clients:
return "expired"
return "none"
def _lease_timing_locked(self) -> dict[str, Any]:
if not self._active:
return {}
if int(self._active.get("active_executions", 0)) > 0:
return {
"idle_seconds": 0,
"idle_remaining_seconds": self.inactivity_timeout_seconds,
"active_execution": True,
}
idle = max(0.0, self._clock() - float(self._active["last_activity_monotonic"]))
return {
"idle_seconds": int(idle),
"idle_remaining_seconds": max(
0, self.inactivity_timeout_seconds - int(idle)
),
}
def _public_status_locked(self, client_id: str) -> dict[str, Any]:
state = self._state_locked(client_id)
result: dict[str, Any] = {
"state": state,
"client_id": client_id,
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
}
if state == "approved":
result.update(self._lease_timing_locked())
result["approved_at"] = self._active["approved_at"]
result["last_activity_at"] = self._active["last_activity_at"]
elif state == "pending":
pending_ids = list(self._pending)
result["queue_position"] = pending_ids.index(client_id) + 1
result["requested_at"] = self._pending[client_id]["requested_at"]
return result
PHYSICAL_BENCH_ACCESS = HardwareAccessController()
+145 -6
View File
@@ -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