init
This commit is contained in:
Executable
+444
@@ -0,0 +1,444 @@
|
||||
"""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 quote, unquote, urlparse
|
||||
|
||||
from cognitive_agent.config_loader import load_local_env
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
load_local_env(REPOSITORY_ROOT / ".env")
|
||||
load_local_env(REPOSITORY_ROOT / ".env.auth")
|
||||
|
||||
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.infrastructure.can.hardware import HardwareSafetyError # noqa: E402
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||||
|
||||
|
||||
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
|
||||
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
|
||||
PUBLIC_AUTH_PATHS = {
|
||||
"/login",
|
||||
"/login.html",
|
||||
"/login.css",
|
||||
"/login.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)
|
||||
auth = AuthRuntime(AuthConfig.from_env())
|
||||
server_version = "CognitiveAgentCabinDemo/2.0"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
||||
path = unquote(urlparse(self.path).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 == "/api/health":
|
||||
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
||||
return
|
||||
if path == "/api/runtime":
|
||||
self._json(self.service.runtime_info())
|
||||
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.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 != "/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)
|
||||
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")
|
||||
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")),
|
||||
str(payload["approval_token"])
|
||||
if payload.get("approval_token")
|
||||
else None,
|
||||
)
|
||||
self._json(result, HTTPStatus.CREATED)
|
||||
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 _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_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)
|
||||
self.send_header("Content-Type", f"{content_type or 'application/octet-stream'}; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
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' https://unpkg.com https://cdn.jsdelivr.net; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
||||
"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)
|
||||
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 + hardware "
|
||||
f"({'configured' if hardware.configured else 'locked'})",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user