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
+133 -4
View File
@@ -10,6 +10,20 @@ function loadConversationId() {
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 = {
provider: "openai",
executionMode: "dry_run",
@@ -23,6 +37,9 @@ const state = {
seatFeedbackSource: "simulated",
availableSeatAxes: null,
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
hardwareClientId: loadHardwareClientId(),
hardwareAccess: null,
hardwareAccessPoller: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -2509,6 +2526,89 @@ function showToast(message) {
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) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
@@ -2551,6 +2651,7 @@ function selectExecutionMode(mode) {
const simulatedHardware = mode === "simulated_hardware";
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
$("#approvalField").hidden = !hardware || !approvalRequired;
$("#hardwareAccessField").hidden = !hardware;
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
@@ -2791,10 +2892,13 @@ async function loadRuntime() {
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
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
? `${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 ? "和令牌" : ""}配置`;
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
@@ -2805,7 +2909,7 @@ async function loadRuntime() {
? '<i data-lucide="shield-check"></i>硬件已配置'
: '<i data-lucide="shield-off"></i>硬件锁定';
$("#hardwareCoverage").textContent = hardware.configured
? `可选 · ${approvalText}${concurrencyText}`
? `可选 · ${approvalText}${accessText}${concurrencyText}`
: "已阻断";
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
const modeAvailable = state.executionMode === "hardware"
@@ -2828,6 +2932,7 @@ async function synchronizePhysicalSeatState() {
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
hardware_client_id: state.hardwareClientId,
});
try {
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
@@ -3647,6 +3752,14 @@ async function submitRequest(event) {
showToast("请输入车舱控制请求");
return;
}
if (state.executionMode === "hardware"
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
showToast(state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权");
return;
}
const button = $("#runButton");
button.disabled = true;
button.classList.add("loading");
@@ -3674,6 +3787,9 @@ async function submitRequest(event) {
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
...(state.executionMode === "hardware"
? { hardware_client_id: state.hardwareClientId }
: {}),
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
@@ -3711,7 +3827,12 @@ async function submitRequest(event) {
} catch (error) {
window.clearInterval(state.progressTimer);
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");
$("#resultTitle").textContent = "真实台架正在使用";
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
@@ -3833,6 +3954,9 @@ document.addEventListener("DOMContentLoaded", async () => {
refreshIcons();
initSeatTwin();
$("#requestForm").addEventListener("submit", submitRequest);
$("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId);
$("#requestHardwareAccess").addEventListener("click", requestHardwareAccess);
$("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess);
$("#utterance").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
@@ -3867,6 +3991,11 @@ document.addEventListener("DOMContentLoaded", async () => {
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
await loadAuthStatus();
await loadRuntime();
await loadHardwareAccessStatus({ silent: true });
state.hardwareAccessPoller = window.setInterval(
() => loadHardwareAccessStatus({ silent: true }),
3000,
);
await synchronizePhysicalSeatState();
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
+39
View File
@@ -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; } }
+46
View File
@@ -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>
+104
View File
@@ -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);
+11 -1
View File
@@ -15,7 +15,7 @@
<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="/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 src="/pwa.js?v=android-pad-1" defer></script>
</head>
@@ -106,6 +106,16 @@
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</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>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
+2 -2
View File
@@ -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 RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,7 +6,7 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=android-pad-2",
"/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",
"/pwa.js?v=android-pad-1",
"/manifest.webmanifest",
+48
View File
@@ -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 ---------------------- */
.execution-mode-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
+14
View File
@@ -69,6 +69,20 @@ COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S=120
`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`。
当前台架只公布并接受副驾目标 `passenger/front_right`。没有座位词的座椅指令默认副驾;显式主驾、主副驾或前排范围请求会在打开 CAN 前拒绝,不能静默重定向。热管理 CAN 中仍出现的 `Driver_Seat_*` 是板端既有协议字段名,不代表应用层可以控制主驾。
@@ -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():
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,6 +349,7 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
)
if hardware_access_bypass:
with self.hardware_execution.reserve():
return self._process_once(
utterance,
@@ -343,6 +360,18 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
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
+122 -2
View File
@@ -15,6 +15,7 @@ from unittest.mock import patch
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
from cognitive_agent.infrastructure.can.access import HardwareAccessController
from cognitive_agent.server import SeatDemoHandler
@@ -71,6 +72,8 @@ class SeatWebApiTests(unittest.TestCase):
cls.temporary_directory.cleanup()
def setUp(self) -> None:
self.original_hardware_access = self.service.hardware_access
self.service.hardware_access = HardwareAccessController()
self.cookie_jar = CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cookie_jar)
@@ -87,6 +90,9 @@ class SeatWebApiTests(unittest.TestCase):
payload = json.loads(response.read().decode("utf-8"))
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:
unauthenticated = urllib.request.build_opener()
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:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
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")
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
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("整车孪生界面", 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(
"requestTimeoutForExecutionMode(state.executionMode)", app_script
@@ -151,6 +159,13 @@ class SeatWebApiTests(unittest.TestCase):
runtime["hardware"]["concurrency_policy"],
"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.assertEqual(
runtime["hardware"]["supported_seat_instances"], ["passenger"]
@@ -370,6 +385,8 @@ class SeatWebApiTests(unittest.TestCase):
synchronize.assert_called_once_with(
"conv-e2e-physical-state",
"denza_n7",
hardware_client_id=None,
hardware_access_bypass=True,
)
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)
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:
entered = 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()