feat: add local hardware approval leases
This commit is contained in:
+133
-4
@@ -10,6 +10,20 @@ function loadConversationId() {
|
|||||||
return conversationId;
|
return conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadHardwareClientId() {
|
||||||
|
const key = "cognitive-agent.hardware-client-id";
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
|
||||||
|
const clientId = `hwc-${crypto.randomUUID()}`;
|
||||||
|
localStorage.setItem(key, clientId);
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultHardwareClientLabel(clientId) {
|
||||||
|
return localStorage.getItem("cognitive-agent.hardware-client-label")
|
||||||
|
|| `测试终端-${clientId.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
executionMode: "dry_run",
|
executionMode: "dry_run",
|
||||||
@@ -23,6 +37,9 @@ const state = {
|
|||||||
seatFeedbackSource: "simulated",
|
seatFeedbackSource: "simulated",
|
||||||
availableSeatAxes: null,
|
availableSeatAxes: null,
|
||||||
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
|
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
|
||||||
|
hardwareClientId: loadHardwareClientId(),
|
||||||
|
hardwareAccess: null,
|
||||||
|
hardwareAccessPoller: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
@@ -2509,6 +2526,89 @@ function showToast(message) {
|
|||||||
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
|
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocalDecisionHost() {
|
||||||
|
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHardwareAccess(status) {
|
||||||
|
state.hardwareAccess = status;
|
||||||
|
const statusNode = $("#hardwareAccessStatus");
|
||||||
|
const requestButton = $("#requestHardwareAccess");
|
||||||
|
const releaseButton = $("#releaseHardwareAccess");
|
||||||
|
if (!statusNode || !requestButton || !releaseButton) return;
|
||||||
|
const accessState = status?.state || "none";
|
||||||
|
statusNode.dataset.state = accessState;
|
||||||
|
if (accessState === "approved") {
|
||||||
|
statusNode.textContent = `本地已同意。剩余空闲时间 ${status.idle_remaining_seconds ?? 180} 秒;每次真实执行后重新计时。`;
|
||||||
|
requestButton.hidden = true;
|
||||||
|
releaseButton.hidden = false;
|
||||||
|
} else if (accessState === "pending") {
|
||||||
|
statusNode.textContent = `正在等待本地审批,当前排队第 ${status.queue_position || 1} 位;未获批前不会进入 CAN 链路。`;
|
||||||
|
requestButton.textContent = "更新申请";
|
||||||
|
requestButton.hidden = false;
|
||||||
|
releaseButton.hidden = false;
|
||||||
|
} else if (accessState === "expired") {
|
||||||
|
statusNode.textContent = "因 3 分钟未执行测试,使用权已自动停止;再次使用请重新申请。";
|
||||||
|
requestButton.textContent = "重新申请";
|
||||||
|
requestButton.hidden = false;
|
||||||
|
releaseButton.hidden = true;
|
||||||
|
} else {
|
||||||
|
statusNode.textContent = "尚未申请;真实台架指令不会进入 CAN 链路。";
|
||||||
|
requestButton.textContent = "向本地申请";
|
||||||
|
requestButton.hidden = false;
|
||||||
|
releaseButton.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHardwareAccessStatus({ silent = false } = {}) {
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ client_id: state.hardwareClientId });
|
||||||
|
const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
|
||||||
|
if (response.status === 401) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||||
|
renderHardwareAccess(payload);
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) showToast(`使用权状态读取失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestHardwareAccess() {
|
||||||
|
const input = $("#hardwareClientLabel");
|
||||||
|
const label = input.value.trim() || defaultHardwareClientLabel(state.hardwareClientId);
|
||||||
|
input.value = label;
|
||||||
|
localStorage.setItem("cognitive-agent.hardware-client-label", label);
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("/api/hardware/access/request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_id: state.hardwareClientId, label }),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||||
|
renderHardwareAccess(payload);
|
||||||
|
showToast(payload.state === "approved" ? "当前使用权仍然有效" : "申请已发送到台架本机,请等待审批");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`申请失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseHardwareAccess() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("/api/hardware/access/release", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_id: state.hardwareClientId }),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||||
|
renderHardwareAccess(payload);
|
||||||
|
showToast("已停止使用真实台架;再次使用需要重新申请");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`停止使用失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectProvider(provider) {
|
function selectProvider(provider) {
|
||||||
state.provider = provider;
|
state.provider = provider;
|
||||||
$$(".segment[data-provider]").forEach((button) => {
|
$$(".segment[data-provider]").forEach((button) => {
|
||||||
@@ -2551,6 +2651,7 @@ function selectExecutionMode(mode) {
|
|||||||
const simulatedHardware = mode === "simulated_hardware";
|
const simulatedHardware = mode === "simulated_hardware";
|
||||||
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
|
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
|
||||||
$("#approvalField").hidden = !hardware || !approvalRequired;
|
$("#approvalField").hidden = !hardware || !approvalRequired;
|
||||||
|
$("#hardwareAccessField").hidden = !hardware;
|
||||||
$("#simulationFaultField").hidden = !simulatedHardware;
|
$("#simulationFaultField").hidden = !simulatedHardware;
|
||||||
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
|
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
|
||||||
$("#executionBadge").innerHTML = hardware
|
$("#executionBadge").innerHTML = hardware
|
||||||
@@ -2791,10 +2892,13 @@ async function loadRuntime() {
|
|||||||
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
|
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
|
||||||
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
|
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
|
||||||
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
|
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
|
||||||
? " · 单请求独占"
|
? " · 动作防冲突"
|
||||||
|
: "";
|
||||||
|
const accessText = hardware.access_policy === "local_approval_idle_lease"
|
||||||
|
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
|
||||||
: "";
|
: "";
|
||||||
$("#hardwareNote").textContent = hardware.configured
|
$("#hardwareNote").textContent = hardware.configured
|
||||||
? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${concurrencyText}`
|
? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${accessText}${concurrencyText}`
|
||||||
: `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`;
|
: `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`;
|
||||||
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
|
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
|
||||||
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
|
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
|
||||||
@@ -2805,7 +2909,7 @@ async function loadRuntime() {
|
|||||||
? '<i data-lucide="shield-check"></i>硬件已配置'
|
? '<i data-lucide="shield-check"></i>硬件已配置'
|
||||||
: '<i data-lucide="shield-off"></i>硬件锁定';
|
: '<i data-lucide="shield-off"></i>硬件锁定';
|
||||||
$("#hardwareCoverage").textContent = hardware.configured
|
$("#hardwareCoverage").textContent = hardware.configured
|
||||||
? `可选 · ${approvalText}${concurrencyText}`
|
? `可选 · ${approvalText}${accessText}${concurrencyText}`
|
||||||
: "已阻断";
|
: "已阻断";
|
||||||
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
|
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
|
||||||
const modeAvailable = state.executionMode === "hardware"
|
const modeAvailable = state.executionMode === "hardware"
|
||||||
@@ -2828,6 +2932,7 @@ async function synchronizePhysicalSeatState() {
|
|||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||||||
|
hardware_client_id: state.hardwareClientId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
|
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
|
||||||
@@ -3647,6 +3752,14 @@ async function submitRequest(event) {
|
|||||||
showToast("请输入车舱控制请求");
|
showToast("请输入车舱控制请求");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.executionMode === "hardware"
|
||||||
|
&& !isLocalDecisionHost()
|
||||||
|
&& state.hardwareAccess?.state !== "approved") {
|
||||||
|
showToast(state.hardwareAccess?.state === "pending"
|
||||||
|
? "申请仍在等待本地审批"
|
||||||
|
: "请先申请并获得本地真实台架使用权");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const button = $("#runButton");
|
const button = $("#runButton");
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.classList.add("loading");
|
button.classList.add("loading");
|
||||||
@@ -3674,6 +3787,9 @@ async function submitRequest(event) {
|
|||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||||
execution_mode: state.executionMode,
|
execution_mode: state.executionMode,
|
||||||
|
...(state.executionMode === "hardware"
|
||||||
|
? { hardware_client_id: state.hardwareClientId }
|
||||||
|
: {}),
|
||||||
...(state.executionMode === "simulated_hardware"
|
...(state.executionMode === "simulated_hardware"
|
||||||
? { simulation_fault: $("#simulationFault").value }
|
? { simulation_fault: $("#simulationFault").value }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -3711,7 +3827,12 @@ async function submitRequest(event) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.clearInterval(state.progressTimer);
|
window.clearInterval(state.progressTimer);
|
||||||
resetPipeline();
|
resetPipeline();
|
||||||
if (error.code === "hardware_busy") {
|
if (error.code === "hardware_access_required") {
|
||||||
|
setRunStatus("failed", "ACCESS REQUIRED");
|
||||||
|
$("#resultTitle").textContent = "真实台架使用权无效";
|
||||||
|
await loadHardwareAccessStatus({ silent: true });
|
||||||
|
showToast(error.message);
|
||||||
|
} else if (error.code === "hardware_busy") {
|
||||||
setRunStatus("failed", "BENCH BUSY");
|
setRunStatus("failed", "BENCH BUSY");
|
||||||
$("#resultTitle").textContent = "真实台架正在使用";
|
$("#resultTitle").textContent = "真实台架正在使用";
|
||||||
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
|
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
|
||||||
@@ -3833,6 +3954,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
refreshIcons();
|
refreshIcons();
|
||||||
initSeatTwin();
|
initSeatTwin();
|
||||||
$("#requestForm").addEventListener("submit", submitRequest);
|
$("#requestForm").addEventListener("submit", submitRequest);
|
||||||
|
$("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId);
|
||||||
|
$("#requestHardwareAccess").addEventListener("click", requestHardwareAccess);
|
||||||
|
$("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess);
|
||||||
$("#utterance").addEventListener("keydown", (event) => {
|
$("#utterance").addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -3867,6 +3991,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
||||||
await loadAuthStatus();
|
await loadAuthStatus();
|
||||||
await loadRuntime();
|
await loadRuntime();
|
||||||
|
await loadHardwareAccessStatus({ silent: true });
|
||||||
|
state.hardwareAccessPoller = window.setInterval(
|
||||||
|
() => loadHardwareAccessStatus({ silent: true }),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
await synchronizePhysicalSeatState();
|
await synchronizePhysicalSeatState();
|
||||||
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
||||||
$("#requestForm").requestSubmit();
|
$("#requestForm").requestSubmit();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
:root { color-scheme: dark; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: #070b12; color: #edf4ff; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, #17294b 0, transparent 35%), #070b12; }
|
||||||
|
.shell { width: min(1040px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 80px; }
|
||||||
|
header, .section-title, .policy, .request, .lease-details { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
|
header { margin-bottom: 34px; }
|
||||||
|
h1, h2, p { margin: 0; }
|
||||||
|
h1 { font-size: clamp(30px, 5vw, 48px); letter-spacing: -.04em; }
|
||||||
|
h2 { font-size: 22px; }
|
||||||
|
.eyebrow { color: #65a8ff; font-size: 12px; letter-spacing: .16em; font-weight: 800; margin-bottom: 8px; }
|
||||||
|
.lead, small { color: #8d9caf; }
|
||||||
|
.lead { margin-top: 12px; }
|
||||||
|
a, button { font: inherit; }
|
||||||
|
a { color: #cfe4ff; text-decoration: none; border: 1px solid #2b3a50; border-radius: 10px; padding: 10px 14px; }
|
||||||
|
.policy, .card { background: rgba(15, 23, 36, .88); border: 1px solid #26354a; border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.22); }
|
||||||
|
.policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; }
|
||||||
|
.policy div { display: grid; gap: 3px; flex: 1; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: #41d390; box-shadow: 0 0 16px #41d390; }
|
||||||
|
#clock { color: #8d9caf; font-variant-numeric: tabular-nums; }
|
||||||
|
.card { padding: 24px; margin-top: 18px; }
|
||||||
|
.active-card { border-color: #315a86; }
|
||||||
|
.section-title { margin-bottom: 20px; }
|
||||||
|
.count { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: #1a2d48; color: #7fbbff; font-weight: 800; }
|
||||||
|
.empty { padding: 26px 4px; color: #74849a; text-align: center; }
|
||||||
|
.request-list { display: grid; gap: 12px; }
|
||||||
|
.request { padding: 16px; border-radius: 13px; border: 1px solid #26364d; background: #0b121e; }
|
||||||
|
.identity { display: grid; gap: 5px; min-width: 0; }
|
||||||
|
.identity strong { font-size: 17px; }
|
||||||
|
.identity code, .identity span { color: #8394aa; font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
button { color: #f4f8ff; background: #1577db; border: 0; border-radius: 9px; padding: 10px 14px; cursor: pointer; font-weight: 750; }
|
||||||
|
button:hover { filter: brightness(1.12); }
|
||||||
|
button:disabled { opacity: .45; cursor: wait; }
|
||||||
|
button.secondary { background: #243348; }
|
||||||
|
button.danger { background: #b83449; }
|
||||||
|
.lease-details { justify-content: flex-start; flex-wrap: wrap; padding: 18px; border-radius: 13px; background: #0b121e; border: 1px solid #284f78; }
|
||||||
|
.lease-details .timer { margin-left: auto; color: #4ce0a0; font-weight: 850; font-size: 22px; font-variant-numeric: tabular-nums; }
|
||||||
|
.message { margin-top: 18px; min-height: 24px; color: #7fbbff; }
|
||||||
|
@media (max-width: 680px) { .shell { width: min(100% - 24px, 1040px); padding-top: 26px; } header, .request, .lease-details { align-items: flex-start; flex-direction: column; } .actions { width: 100%; } .actions button { flex: 1; } .lease-details .timer { margin-left: 0; } }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>真实台架本地决策台</title>
|
||||||
|
<link rel="stylesheet" href="/hardware-control.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">LOCAL BENCH AUTHORITY</p>
|
||||||
|
<h1>真实台架本地决策台</h1>
|
||||||
|
<p class="lead">只有本机可以批准、拒绝或停止终端的真实台架使用权。</p>
|
||||||
|
</div>
|
||||||
|
<a href="/">返回控制台</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="policy">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div><strong>单终端租约</strong><small>获批终端可连续测试;最后一次真实执行后空闲 180 秒自动停止。</small></div>
|
||||||
|
<span id="clock">正在连接…</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card active-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<div><p class="eyebrow">ACTIVE LEASE</p><h2>当前使用者</h2></div>
|
||||||
|
<button id="revokeButton" class="danger" type="button" hidden>立即停止使用</button>
|
||||||
|
</div>
|
||||||
|
<div id="activeLease" class="empty">当前无人获批使用真实台架。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-title">
|
||||||
|
<div><p class="eyebrow">PENDING REQUESTS</p><h2>等待审批</h2></div>
|
||||||
|
<span id="pendingCount" class="count">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="pendingRequests" class="request-list empty">暂时没有申请。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="message" class="message" role="status"></p>
|
||||||
|
</main>
|
||||||
|
<script src="/hardware-control.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
const activeLease = document.querySelector("#activeLease");
|
||||||
|
const pendingRequests = document.querySelector("#pendingRequests");
|
||||||
|
const pendingCount = document.querySelector("#pendingCount");
|
||||||
|
const revokeButton = document.querySelector("#revokeButton");
|
||||||
|
const message = document.querySelector("#message");
|
||||||
|
let snapshot = null;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
const node = document.createElement("span");
|
||||||
|
node.textContent = String(value ?? "");
|
||||||
|
return node.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(value, error = false) {
|
||||||
|
message.textContent = value;
|
||||||
|
message.style.color = error ? "#ff8797" : "#7fbbff";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: body ? "POST" : "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
location.replace(`/login?next=${encodeURIComponent(location.pathname)}`);
|
||||||
|
throw new Error("请先登录本机服务");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
snapshot = data;
|
||||||
|
pendingCount.textContent = String(data.pending.length);
|
||||||
|
if (data.active) {
|
||||||
|
const lease = data.active;
|
||||||
|
activeLease.className = "lease-details";
|
||||||
|
activeLease.innerHTML = `
|
||||||
|
<div class="identity"><strong>${escapeHtml(lease.label)}</strong><code>${escapeHtml(lease.client_id)}</code><span>来源 ${escapeHtml(lease.client_address)} · 最近执行 ${escapeHtml(lease.last_activity_at)}</span></div>
|
||||||
|
<div class="timer">${Number(lease.idle_remaining_seconds)}s</div>`;
|
||||||
|
revokeButton.hidden = false;
|
||||||
|
revokeButton.dataset.clientId = lease.client_id;
|
||||||
|
} else {
|
||||||
|
activeLease.className = "empty";
|
||||||
|
activeLease.textContent = "当前无人获批使用真实台架。";
|
||||||
|
revokeButton.hidden = true;
|
||||||
|
}
|
||||||
|
if (!data.pending.length) {
|
||||||
|
pendingRequests.className = "request-list empty";
|
||||||
|
pendingRequests.textContent = "暂时没有申请。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingRequests.className = "request-list";
|
||||||
|
pendingRequests.innerHTML = data.pending.map((request) => `
|
||||||
|
<article class="request">
|
||||||
|
<div class="identity"><strong>${escapeHtml(request.label)}</strong><code>${escapeHtml(request.client_id)}</code><span>来源 ${escapeHtml(request.client_address)} · ${escapeHtml(request.requested_at)}</span></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" data-action="approve" data-client-id="${escapeHtml(request.client_id)}">同意</button>
|
||||||
|
<button type="button" class="secondary" data-action="deny" data-client-id="${escapeHtml(request.client_id)}">拒绝</button>
|
||||||
|
</div>
|
||||||
|
</article>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
render(await api("/api/local/hardware-access"));
|
||||||
|
document.querySelector("#clock").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRequests.addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("button[data-action]");
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const action = button.dataset.action;
|
||||||
|
render(await api(`/api/local/hardware-access/${action}`, { client_id: button.dataset.clientId }));
|
||||||
|
showMessage(action === "approve" ? "已授予该终端真实台架使用权。" : "已拒绝该终端的申请。");
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revokeButton.addEventListener("click", async () => {
|
||||||
|
revokeButton.disabled = true;
|
||||||
|
try {
|
||||||
|
render(await api("/api/local/hardware-access/revoke", { client_id: revokeButton.dataset.clientId }));
|
||||||
|
showMessage("已停止该终端的真实台架使用权;再次使用必须重新申请。");
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
} finally {
|
||||||
|
revokeButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 2000);
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||||
<script src="/network.js?v=android-pad-1" defer></script>
|
<script src="/network.js?v=android-pad-1" defer></script>
|
||||||
<script src="/app.js?v=seat-hil-8" defer></script>
|
<script src="/app.js?v=seat-hil-9" defer></script>
|
||||||
<script type="module" src="/gesture-control.js?v=android-pad-1"></script>
|
<script type="module" src="/gesture-control.js?v=android-pad-1"></script>
|
||||||
<script src="/pwa.js?v=android-pad-1" defer></script>
|
<script src="/pwa.js?v=android-pad-1" defer></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -106,6 +106,16 @@
|
|||||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group hardware-access-field" id="hardwareAccessField" hidden>
|
||||||
|
<label class="field-label" for="hardwareClientLabel">真实台架使用权</label>
|
||||||
|
<input id="hardwareClientLabel" maxlength="64" autocomplete="off" placeholder="终端名称,例如:张三-测试电脑" />
|
||||||
|
<div class="hardware-access-actions">
|
||||||
|
<button id="requestHardwareAccess" type="button">向本地申请</button>
|
||||||
|
<button id="releaseHardwareAccess" type="button" class="secondary" hidden>停止使用</button>
|
||||||
|
</div>
|
||||||
|
<p class="provider-note hardware-access-status" id="hardwareAccessStatus">尚未申请;本次真实执行不会进入 CAN 链路。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
||||||
<label class="field-label" for="simulationFault">故障注入场景</label>
|
<label class="field-label" for="simulationFault">故障注入场景</label>
|
||||||
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v8";
|
const CACHE_VERSION = "cognitive-agent-pad-v9";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -6,7 +6,7 @@ const APP_SHELL = [
|
|||||||
"/index.html",
|
"/index.html",
|
||||||
"/styles.css?v=android-pad-2",
|
"/styles.css?v=android-pad-2",
|
||||||
"/network.js?v=android-pad-1",
|
"/network.js?v=android-pad-1",
|
||||||
"/app.js?v=seat-hil-8",
|
"/app.js?v=seat-hil-9",
|
||||||
"/gesture-control.js?v=android-pad-1",
|
"/gesture-control.js?v=android-pad-1",
|
||||||
"/pwa.js?v=android-pad-1",
|
"/pwa.js?v=android-pad-1",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
|
|||||||
@@ -4357,6 +4357,54 @@ pre {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hardware-access-field {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(38, 102, 159, 0.24);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(38, 102, 159, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-access-field input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-access-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-access-actions button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
background: var(--blue);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-access-actions button.secondary {
|
||||||
|
color: var(--ink);
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-access-status[data-state="approved"] { color: var(--teal); font-weight: 750; }
|
||||||
|
.hardware-access-status[data-state="pending"] { color: #9a6800; font-weight: 750; }
|
||||||
|
.hardware-access-status[data-state="expired"] { color: var(--danger); font-weight: 750; }
|
||||||
|
|
||||||
/* Android PAD, touch and installable-app refinements ---------------------- */
|
/* Android PAD, touch and installable-app refinements ---------------------- */
|
||||||
.execution-mode-options {
|
.execution-mode-options {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S=120
|
|||||||
|
|
||||||
`COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED` 默认为 `true`,生产和公网部署应保持默认值。本地受控台架调试可显式设为 `false`,此时前端隐藏令牌输入且服务端不再逐请求校验令牌;硬件启用、固件、适配器、副驾目标、物理反馈和故障 STOP_ALL 等其他门禁保持不变。
|
`COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED` 默认为 `true`,生产和公网部署应保持默认值。本地受控台架调试可显式设为 `false`,此时前端隐藏令牌输入且服务端不再逐请求校验令牌;硬件启用、固件、适配器、副驾目标、物理反馈和故障 STOP_ALL 等其他门禁保持不变。
|
||||||
|
|
||||||
|
## 公网多人使用与本地审批租约
|
||||||
|
|
||||||
|
公网用户选择“真实台架”后,浏览器会创建一个保存在本机 `localStorage` 的随机终端 ID。用户填写可识别的终端名称并点击“向本地申请”;申请只进入等待队列,未获批、已拒绝或已过期的执行请求会在打开 CAN 前返回 `hardware_access_required`。
|
||||||
|
|
||||||
|
台架操作员在台架电脑打开 `http://127.0.0.1:8000/hardware-control`,可查看所有等待终端并执行:
|
||||||
|
|
||||||
|
- 同意其中一台终端;若已有使用者,新的同意会撤销旧租约。
|
||||||
|
- 拒绝等待中的申请。
|
||||||
|
- 停止当前终端的使用权;后续请求立即失效。
|
||||||
|
|
||||||
|
审批页面和 `/api/local/hardware-access*` 只接受无代理头的本机回环请求。通过公网域名、Caddy 或 SSH 反向隧道访问会返回 `403 local_access_required`,即使已经登录也不能远程审批。
|
||||||
|
|
||||||
|
同一时间只有一台终端持有租约。获批后可连续执行多项真实台架测试,每次真正取得台架执行锁时刷新活跃时间;动作正在执行时不会因超时被中途撤权。最后一次动作完成后空闲 180 秒,租约自动停止,再次使用必须重新申请。用户也可点击“停止使用”主动释放。进程重启会清空全部申请和租约,以安全关闭状态重新开始。租约负责“谁可以用”,进程级单动作互斥继续负责防止同一获批终端的并发 CAN 指令互相覆盖。
|
||||||
|
|
||||||
`COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED` 在真实硬件执行启用时默认为 `true`,上面的显式配置便于台架审计;如需禁止自动覆盖控制器固件,可明确设置为 `false`。此默认值不会影响 `dry_run`。
|
`COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED` 在真实硬件执行启用时默认为 `true`,上面的显式配置便于台架审计;如需禁止自动覆盖控制器固件,可明确设置为 `false`。此默认值不会影响 `dry_run`。
|
||||||
|
|
||||||
当前台架只公布并接受副驾目标 `passenger/front_right`。没有座位词的座椅指令默认副驾;显式主驾、主副驾或前排范围请求会在打开 CAN 前拒绝,不能静默重定向。热管理 CAN 中仍出现的 `Driver_Seat_*` 是板端既有协议字段名,不代表应用层可以控制主驾。
|
当前台架只公布并接受副驾目标 `passenger/front_right`。没有座位词的座椅指令默认副驾;显式主驾、主副驾或前排范围请求会在打开 CAN 前拒绝,不能静默重定向。热管理 CAN 中仍出现的 `Driver_Seat_*` 是板端既有协议字段名,不代表应用层可以控制主驾。
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
|||||||
PHYSICAL_BENCH_COORDINATOR,
|
PHYSICAL_BENCH_COORDINATOR,
|
||||||
SeatHardwareSettings,
|
SeatHardwareSettings,
|
||||||
)
|
)
|
||||||
|
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||||
|
PHYSICAL_BENCH_ACCESS,
|
||||||
|
)
|
||||||
from cognitive_agent.runtime.contracts import ContractValidator # noqa: E402
|
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_settings = SeatHardwareSettings.from_env(repository_root)
|
||||||
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
||||||
|
self.hardware_access = PHYSICAL_BENCH_ACCESS
|
||||||
# Shared by the short-lived hardware executor instances. A process must
|
# Shared by the short-lived hardware executor instances. A process must
|
||||||
# establish a fresh physical four-axis reference before trusting stored
|
# establish a fresh physical four-axis reference before trusting stored
|
||||||
# Hall counts, and a firmware replacement invalidates it again.
|
# Hall counts, and a firmware replacement invalidates it again.
|
||||||
@@ -181,6 +185,7 @@ class SeatDemoService:
|
|||||||
default_vehicle = self.vehicle_profiles.default()
|
default_vehicle = self.vehicle_profiles.default()
|
||||||
hardware_info = self.hardware_settings.public_info()
|
hardware_info = self.hardware_settings.public_info()
|
||||||
hardware_info.update(self.hardware_execution.public_info())
|
hardware_info.update(self.hardware_execution.public_info())
|
||||||
|
hardware_info.update(self.hardware_access.public_info())
|
||||||
return {
|
return {
|
||||||
"service": "cabin-cognitive-orchestrator",
|
"service": "cabin-cognitive-orchestrator",
|
||||||
"domain": "cabin",
|
"domain": "cabin",
|
||||||
@@ -247,11 +252,19 @@ class SeatDemoService:
|
|||||||
self,
|
self,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
|
*,
|
||||||
|
hardware_client_id: str | None = None,
|
||||||
|
hardware_access_bypass: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Read the bench only when no physical execution currently owns it."""
|
"""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():
|
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(
|
def _synchronize_hardware_state_once(
|
||||||
self,
|
self,
|
||||||
@@ -322,6 +335,9 @@ class SeatDemoService:
|
|||||||
approval_token: str | None = None,
|
approval_token: str | None = None,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
simulation_fault: str = "normal",
|
simulation_fault: str = "normal",
|
||||||
|
*,
|
||||||
|
hardware_client_id: str | None = None,
|
||||||
|
hardware_access_bypass: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if execution_mode != "hardware":
|
if execution_mode != "hardware":
|
||||||
return self._process_once(
|
return self._process_once(
|
||||||
@@ -333,16 +349,29 @@ class SeatDemoService:
|
|||||||
vehicle_id,
|
vehicle_id,
|
||||||
simulation_fault,
|
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():
|
with self.hardware_execution.reserve():
|
||||||
return self._process_once(
|
with self.hardware_access.use(authorized_client_id):
|
||||||
utterance,
|
return self._process_once(
|
||||||
provider,
|
utterance,
|
||||||
conversation_id,
|
provider,
|
||||||
execution_mode,
|
conversation_id,
|
||||||
approval_token,
|
execution_mode,
|
||||||
vehicle_id,
|
approval_token,
|
||||||
simulation_fault,
|
vehicle_id,
|
||||||
)
|
simulation_fault,
|
||||||
|
)
|
||||||
|
|
||||||
def _process_once(
|
def _process_once(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from .hardware import (
|
|||||||
PHYSICAL_BENCH_COORDINATOR,
|
PHYSICAL_BENCH_COORDINATOR,
|
||||||
SeatHardwareSettings,
|
SeatHardwareSettings,
|
||||||
)
|
)
|
||||||
|
from .access import (
|
||||||
|
HardwareAccessController,
|
||||||
|
HardwareAccessRequiredError,
|
||||||
|
PHYSICAL_BENCH_ACCESS,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CanBus",
|
"CanBus",
|
||||||
@@ -16,10 +21,13 @@ __all__ = [
|
|||||||
"CanGatewayError",
|
"CanGatewayError",
|
||||||
"CanMatrix",
|
"CanMatrix",
|
||||||
"HardwareBusyError",
|
"HardwareBusyError",
|
||||||
|
"HardwareAccessController",
|
||||||
|
"HardwareAccessRequiredError",
|
||||||
"HardwareExecutionCoordinator",
|
"HardwareExecutionCoordinator",
|
||||||
"HardwareSafetyError",
|
"HardwareSafetyError",
|
||||||
"MatrixError",
|
"MatrixError",
|
||||||
"PHYSICAL_BENCH_COORDINATOR",
|
"PHYSICAL_BENCH_COORDINATOR",
|
||||||
|
"PHYSICAL_BENCH_ACCESS",
|
||||||
"PythonCanBus",
|
"PythonCanBus",
|
||||||
"SeatHardwareSettings",
|
"SeatHardwareSettings",
|
||||||
"ZlgCanBridgeBus",
|
"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()
|
||||||
@@ -32,6 +32,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
|||||||
HardwareBusyError,
|
HardwareBusyError,
|
||||||
HardwareSafetyError,
|
HardwareSafetyError,
|
||||||
)
|
)
|
||||||
|
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||||
|
HardwareAccessRequiredError,
|
||||||
|
)
|
||||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +99,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if not self._authorize(path):
|
if not self._authorize(path):
|
||||||
return
|
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":
|
if path == "/api/health":
|
||||||
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
||||||
return
|
return
|
||||||
@@ -106,13 +138,25 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed_url.query)
|
query = parse_qs(parsed_url.query)
|
||||||
conversation_id = str(query.get("conversation_id", [""])[0])
|
conversation_id = str(query.get("conversation_id", [""])[0])
|
||||||
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
||||||
|
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
|
||||||
try:
|
try:
|
||||||
self._json(
|
self._json(
|
||||||
self.service.synchronize_hardware_state(
|
self.service.synchronize_hardware_state(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
vehicle_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:
|
except HardwareBusyError as exc:
|
||||||
self._json(
|
self._json(
|
||||||
{
|
{
|
||||||
@@ -159,6 +203,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if not self._authorize(path):
|
if not self._authorize(path):
|
||||||
return
|
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":
|
if path != "/api/requests":
|
||||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
@@ -178,12 +231,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if self.auth.config.enabled:
|
if self.auth.config.enabled:
|
||||||
self.auth.api_requests.record(username)
|
self.auth.api_requests.record(username)
|
||||||
content_length = int(self.headers.get("Content-Length", "0"))
|
payload = self._read_json_body()
|
||||||
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(
|
result = self.service.process(
|
||||||
str(payload.get("utterance", "")),
|
str(payload.get("utterance", "")),
|
||||||
str(payload.get("provider", "auto")),
|
str(payload.get("provider", "auto")),
|
||||||
@@ -200,8 +248,23 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
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)
|
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:
|
except HardwareBusyError as exc:
|
||||||
self._json(
|
self._json(
|
||||||
{
|
{
|
||||||
@@ -238,6 +301,53 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
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:
|
def _authorize(self, path: str) -> bool:
|
||||||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||||||
return True
|
return True
|
||||||
@@ -363,6 +473,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
return address
|
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:
|
def _is_https(self) -> bool:
|
||||||
if not self.auth.config.trust_proxy_headers:
|
if not self.auth.config.trust_proxy_headers:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from unittest.mock import patch
|
|||||||
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
|
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
|
||||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
|
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
|
||||||
|
from cognitive_agent.infrastructure.can.access import HardwareAccessController
|
||||||
from cognitive_agent.server import SeatDemoHandler
|
from cognitive_agent.server import SeatDemoHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
cls.temporary_directory.cleanup()
|
cls.temporary_directory.cleanup()
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
self.original_hardware_access = self.service.hardware_access
|
||||||
|
self.service.hardware_access = HardwareAccessController()
|
||||||
self.cookie_jar = CookieJar()
|
self.cookie_jar = CookieJar()
|
||||||
self.opener = urllib.request.build_opener(
|
self.opener = urllib.request.build_opener(
|
||||||
urllib.request.HTTPCookieProcessor(self.cookie_jar)
|
urllib.request.HTTPCookieProcessor(self.cookie_jar)
|
||||||
@@ -87,6 +90,9 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
payload = json.loads(response.read().decode("utf-8"))
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
self.assertTrue(payload["authenticated"])
|
self.assertTrue(payload["authenticated"])
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.service.hardware_access = self.original_hardware_access
|
||||||
|
|
||||||
def test_authentication_protects_console_and_api(self) -> None:
|
def test_authentication_protects_console_and_api(self) -> None:
|
||||||
unauthenticated = urllib.request.build_opener()
|
unauthenticated = urllib.request.build_opener()
|
||||||
with unauthenticated.open(f"{self.base_url}/", timeout=3) as response:
|
with unauthenticated.open(f"{self.base_url}/", timeout=3) as response:
|
||||||
@@ -105,7 +111,7 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||||
html = response.read().decode("utf-8")
|
html = response.read().decode("utf-8")
|
||||||
with self.opener.open(f"{self.base_url}/app.js?v=seat-hil-8", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/app.js?v=seat-hil-9", timeout=3) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
||||||
runtime = json.loads(response.read().decode("utf-8"))
|
runtime = json.loads(response.read().decode("utf-8"))
|
||||||
@@ -115,7 +121,9 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
|
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
|
||||||
self.assertIn("整车孪生界面", html)
|
self.assertIn("整车孪生界面", html)
|
||||||
self.assertIn("透明车体 · 实体设备", html)
|
self.assertIn("透明车体 · 实体设备", html)
|
||||||
self.assertIn('src="/app.js?v=seat-hil-8"', html)
|
self.assertIn('src="/app.js?v=seat-hil-9"', html)
|
||||||
|
self.assertIn('id="hardwareAccessField"', html)
|
||||||
|
self.assertIn("hardware_access_required", app_script)
|
||||||
self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script)
|
self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"requestTimeoutForExecutionMode(state.executionMode)", app_script
|
"requestTimeoutForExecutionMode(state.executionMode)", app_script
|
||||||
@@ -151,6 +159,13 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
runtime["hardware"]["concurrency_policy"],
|
runtime["hardware"]["concurrency_policy"],
|
||||||
"single_flight_fail_fast",
|
"single_flight_fail_fast",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
runtime["hardware"]["access_policy"],
|
||||||
|
"local_approval_idle_lease",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
runtime["hardware"]["access_inactivity_timeout_seconds"], 180
|
||||||
|
)
|
||||||
self.assertFalse(runtime["hardware"]["busy"])
|
self.assertFalse(runtime["hardware"]["busy"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
runtime["hardware"]["supported_seat_instances"], ["passenger"]
|
runtime["hardware"]["supported_seat_instances"], ["passenger"]
|
||||||
@@ -370,6 +385,8 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
synchronize.assert_called_once_with(
|
synchronize.assert_called_once_with(
|
||||||
"conv-e2e-physical-state",
|
"conv-e2e-physical-state",
|
||||||
"denza_n7",
|
"denza_n7",
|
||||||
|
hardware_client_id=None,
|
||||||
|
hardware_access_bypass=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_gpt_http_failure_falls_back_without_returning_502(self) -> None:
|
def test_gpt_http_failure_falls_back_without_returning_502(self) -> None:
|
||||||
@@ -524,6 +541,109 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotIn("must-not-be-persisted", artifact_text)
|
self.assertNotIn("must-not-be-persisted", artifact_text)
|
||||||
|
|
||||||
|
def test_local_decision_point_selects_one_public_terminal_and_can_stop_it(self) -> None:
|
||||||
|
client_a = "hwc-e2e-client-a1"
|
||||||
|
client_b = "hwc-e2e-client-b2"
|
||||||
|
|
||||||
|
def post(path: str, payload: dict, *, public: bool = False):
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if public:
|
||||||
|
headers["X-Forwarded-For"] = "203.0.113.25"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
method="POST",
|
||||||
|
headers=headers,
|
||||||
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||||
|
)
|
||||||
|
return self.opener.open(request, timeout=3)
|
||||||
|
|
||||||
|
with post(
|
||||||
|
"/api/hardware/access/request",
|
||||||
|
{"client_id": client_a, "label": "用户电脑 A"},
|
||||||
|
public=True,
|
||||||
|
) as response:
|
||||||
|
first = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(response.status, 202)
|
||||||
|
with post(
|
||||||
|
"/api/hardware/access/request",
|
||||||
|
{"client_id": client_b, "label": "用户电脑 B"},
|
||||||
|
public=True,
|
||||||
|
) as response:
|
||||||
|
second = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(first["queue_position"], 1)
|
||||||
|
self.assertEqual(second["queue_position"], 2)
|
||||||
|
|
||||||
|
with self.opener.open(
|
||||||
|
f"{self.base_url}/api/local/hardware-access", timeout=3
|
||||||
|
) as response:
|
||||||
|
local_snapshot = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(len(local_snapshot["pending"]), 2)
|
||||||
|
|
||||||
|
with post(
|
||||||
|
"/api/local/hardware-access/approve", {"client_id": client_b}
|
||||||
|
) as response:
|
||||||
|
approved = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(approved["active"]["client_id"], client_b)
|
||||||
|
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as remote_admin:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}/api/local/hardware-access",
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.25"},
|
||||||
|
)
|
||||||
|
self.opener.open(request, timeout=3)
|
||||||
|
self.assertEqual(remote_admin.exception.code, 403)
|
||||||
|
|
||||||
|
def hardware_request(client_id: str, conversation: str):
|
||||||
|
return post(
|
||||||
|
"/api/requests",
|
||||||
|
{
|
||||||
|
"utterance": "关闭副驾座椅加热",
|
||||||
|
"provider": "local",
|
||||||
|
"conversation_id": conversation,
|
||||||
|
"execution_mode": "hardware",
|
||||||
|
"hardware_client_id": client_id,
|
||||||
|
},
|
||||||
|
public=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_result = {"status": "succeeded", "request_id": "req-approved-client"}
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_process_once", return_value=fake_result
|
||||||
|
) as process_once:
|
||||||
|
with hardware_request(client_b, "conv-approved-client") as response:
|
||||||
|
self.assertEqual(response.status, 201)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as unapproved:
|
||||||
|
hardware_request(client_a, "conv-unapproved-client")
|
||||||
|
denied_payload = json.loads(unapproved.exception.read().decode("utf-8"))
|
||||||
|
self.assertEqual(unapproved.exception.code, 403)
|
||||||
|
self.assertEqual(denied_payload["error"], "hardware_access_required")
|
||||||
|
self.assertEqual(denied_payload["access_state"], "pending")
|
||||||
|
self.assertEqual(process_once.call_count, 1)
|
||||||
|
|
||||||
|
with post(
|
||||||
|
"/api/hardware/access/release",
|
||||||
|
{"client_id": client_b},
|
||||||
|
public=True,
|
||||||
|
) as response:
|
||||||
|
released = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(released["state"], "none")
|
||||||
|
self.assertIsNone(self.service.hardware_access.admin_snapshot()["active"])
|
||||||
|
|
||||||
|
def test_local_hardware_decision_page_is_not_exposed_through_proxy(self) -> None:
|
||||||
|
with self.opener.open(
|
||||||
|
f"{self.base_url}/hardware-control", timeout=3
|
||||||
|
) as response:
|
||||||
|
html = response.read().decode("utf-8")
|
||||||
|
self.assertIn("真实台架本地决策台", html)
|
||||||
|
|
||||||
|
proxied = urllib.request.Request(
|
||||||
|
f"{self.base_url}/hardware-control",
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.25"},
|
||||||
|
)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||||
|
self.opener.open(proxied, timeout=3)
|
||||||
|
self.assertEqual(caught.exception.code, 403)
|
||||||
|
|
||||||
def test_overlapping_hardware_request_returns_conflict_without_entering_can(self) -> None:
|
def test_overlapping_hardware_request_returns_conflict_without_entering_can(self) -> None:
|
||||||
entered = threading.Event()
|
entered = threading.Event()
|
||||||
release = threading.Event()
|
release = threading.Event()
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cognitive_agent.infrastructure.can.access import (
|
||||||
|
HardwareAccessController,
|
||||||
|
HardwareAccessRequiredError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_A = "hwc-aaaaaaaa-1111"
|
||||||
|
CLIENT_B = "hwc-bbbbbbbb-2222"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.value = 100.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.value += seconds
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareAccessControllerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.clock = FakeClock()
|
||||||
|
self.controller = HardwareAccessController(clock=self.clock)
|
||||||
|
|
||||||
|
def test_local_admin_can_choose_one_of_multiple_pending_terminals(self) -> None:
|
||||||
|
first = self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
second = self.controller.request_access(CLIENT_B, "电脑 B", "203.0.113.11")
|
||||||
|
|
||||||
|
self.assertEqual(first["queue_position"], 1)
|
||||||
|
self.assertEqual(second["queue_position"], 2)
|
||||||
|
snapshot = self.controller.approve(CLIENT_B)
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["active"]["client_id"], CLIENT_B)
|
||||||
|
self.assertEqual([item["client_id"] for item in snapshot["pending"]], [CLIENT_A])
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_B)["state"], "approved")
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_A)["state"], "pending")
|
||||||
|
|
||||||
|
def test_execution_refreshes_lease_and_idle_180_seconds_expires_it(self) -> None:
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.approve(CLIENT_A)
|
||||||
|
self.clock.advance(179)
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_A)["state"], "approved")
|
||||||
|
|
||||||
|
with self.controller.use(CLIENT_A):
|
||||||
|
self.clock.advance(5)
|
||||||
|
|
||||||
|
self.clock.advance(179)
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_A)["state"], "approved")
|
||||||
|
self.clock.advance(1)
|
||||||
|
expired = self.controller.status(CLIENT_A)
|
||||||
|
self.assertEqual(expired["state"], "expired")
|
||||||
|
self.assertIsNone(self.controller.admin_snapshot()["active"])
|
||||||
|
with self.assertRaises(HardwareAccessRequiredError) as caught:
|
||||||
|
with self.controller.use(CLIENT_A):
|
||||||
|
pass
|
||||||
|
self.assertEqual(caught.exception.state, "expired")
|
||||||
|
|
||||||
|
def test_long_running_execution_is_not_revoked_while_it_is_active(self) -> None:
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.approve(CLIENT_A)
|
||||||
|
|
||||||
|
with self.controller.use(CLIENT_A):
|
||||||
|
self.clock.advance(600)
|
||||||
|
status = self.controller.status(CLIENT_A)
|
||||||
|
self.assertEqual(status["state"], "approved")
|
||||||
|
self.assertTrue(status["active_execution"])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.controller.status(CLIENT_A)["idle_remaining_seconds"], 180
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_release_requires_a_new_request(self) -> None:
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.approve(CLIENT_A)
|
||||||
|
self.assertEqual(self.controller.release(CLIENT_A)["state"], "none")
|
||||||
|
with self.assertRaises(HardwareAccessRequiredError):
|
||||||
|
with self.controller.use(CLIENT_A):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_approving_another_terminal_revokes_previous_lease(self) -> None:
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.approve(CLIENT_A)
|
||||||
|
self.controller.request_access(CLIENT_B, "电脑 B", "203.0.113.11")
|
||||||
|
snapshot = self.controller.approve(CLIENT_B)
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["active"]["client_id"], CLIENT_B)
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_A)["state"], "none")
|
||||||
|
with self.assertRaises(HardwareAccessRequiredError):
|
||||||
|
with self.controller.use(CLIENT_A):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_local_admin_can_deny_pending_and_revoke_active(self) -> None:
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.deny(CLIENT_A)
|
||||||
|
self.assertEqual(self.controller.status(CLIENT_A)["state"], "none")
|
||||||
|
|
||||||
|
self.controller.request_access(CLIENT_A, "电脑 A", "203.0.113.10")
|
||||||
|
self.controller.approve(CLIENT_A)
|
||||||
|
snapshot = self.controller.revoke(CLIENT_A)
|
||||||
|
self.assertIsNone(snapshot["active"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user