feat: add seat firmware workbench and OTA delivery
This commit is contained in:
@@ -75,6 +75,65 @@ async function main() {
|
||||
await page.locator('[data-execution-mode="dry_run"]').click();
|
||||
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
|
||||
|
||||
async function submitDraft(text) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
const body = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text;
|
||||
assert.equal(response.status(), 200, `${text}: ${body}`);
|
||||
const events = body.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
|
||||
assert.ok(payload, `Missing draft for ${text}: ${body}`);
|
||||
assert.equal(payload.status, "succeeded");
|
||||
assert.equal(payload.execution_mode, "dry_run");
|
||||
const draft = payload.mode_profile_draft;
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
await card.waitFor({ state: "visible" });
|
||||
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
|
||||
return { payload, draft, card, request: posted.filter((item) => item.pathname === "/api/assistant/stream").at(-1).payload };
|
||||
}
|
||||
|
||||
// The original failing two-turn request must revise the server-owned
|
||||
// draft, not attempt immediate hardware execution or replace its seats.
|
||||
const recline = await submitDraft("构建一个躺平模式");
|
||||
const originalActions = recline.payload.actions;
|
||||
await recline.card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-before.png") });
|
||||
const expanded = await submitDraft("在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作");
|
||||
assert.deepEqual(expanded.request.mode_profile_draft_context, {
|
||||
name: recline.draft.name,
|
||||
source_request_id: recline.draft.source_request_id,
|
||||
vehicle_id: recline.draft.vehicle_id,
|
||||
});
|
||||
assert.notEqual(expanded.draft.source_request_id, recline.draft.source_request_id);
|
||||
assert.equal(expanded.draft.draft_revision, 2);
|
||||
const actionKey = (action) => `${action.capability}:${action.target?.instance || action.target?.zone || ""}`;
|
||||
const expandedActions = new Map(expanded.payload.actions.map((action) => [actionKey(action), action]));
|
||||
for (const original of originalActions) {
|
||||
assert.deepEqual(expandedActions.get(actionKey(original))?.parameters, original.parameters,
|
||||
"adding devices must preserve all existing action values");
|
||||
}
|
||||
for (const capability of ["climate.set_temperature", "interior_light.set_reading_power", "window.set_position"]) {
|
||||
assert.ok(expanded.payload.actions.some((action) => action.capability === capability), capability);
|
||||
}
|
||||
assert.ok(expanded.payload.actions.some((action) => action.domain === "ambient_light"));
|
||||
assert.ok(expanded.payload.actions.every((action) => action.domain !== "seat"
|
||||
|| (action.target?.instance || action.target?.zone) === "passenger"));
|
||||
assert.ok(expanded.draft.operation_count > recline.draft.operation_count);
|
||||
assert.equal(await recline.card.getAttribute("data-superseded"), "true");
|
||||
assert.equal(await recline.card.locator(".assistant-mode-draft-save").isDisabled(), true);
|
||||
assert.equal(posted.filter((item) => item.pathname === "/api/mode-profiles").length, 0);
|
||||
await expanded.card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-after.png") });
|
||||
const temperatureEdit = await submitDraft("把躺平模式的空调温度调整到26度");
|
||||
assert.equal(temperatureEdit.request.mode_profile_draft_context.source_request_id, expanded.draft.source_request_id);
|
||||
assert.equal(temperatureEdit.draft.draft_revision, 3);
|
||||
assert.equal(temperatureEdit.draft.operation_count, expanded.draft.operation_count);
|
||||
assert.ok(temperatureEdit.payload.actions.some((action) => action.capability === "climate.set_temperature"
|
||||
&& action.parameters.temperature_c === 26));
|
||||
|
||||
// Exercise the same real assistant endpoint and rendered cards for both
|
||||
// implicit passenger selection and an explicit driver request. These
|
||||
// previews stay unsaved, so the timeline save/trial tests remain isolated.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.firmware_ota_server import FirmwareOtaHandler, FirmwareOtaRepository
|
||||
|
||||
|
||||
class TestFirmwareOtaServer:
|
||||
def test_latest_download_head_and_resume_are_read_only(self, tmp_path: Path) -> None:
|
||||
release_id = "fw-rel-123456789abc"
|
||||
filename = "PassengerSeat_test.zip"
|
||||
package = (b"vehicle-firmware-package" * 128) + b"end"
|
||||
release_root = tmp_path / "releases" / release_id
|
||||
release_root.mkdir(parents=True)
|
||||
package_path = release_root / filename
|
||||
package_path.write_bytes(package)
|
||||
manifest = {
|
||||
"schema": "n7-ota-release/v1",
|
||||
"release_id": release_id,
|
||||
"firmware_filename": filename,
|
||||
"size_bytes": len(package),
|
||||
"sha256": hashlib.sha256(package).hexdigest(),
|
||||
"capabilities": ["时间控制", "座椅加热"],
|
||||
"published_at": "2026-09-16T13:17:17+08:00",
|
||||
"status": "published",
|
||||
"download_url": "http://stale-address.invalid/download",
|
||||
}
|
||||
(release_root / "release.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "latest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
older_release_id = "fw-rel-000000000001"
|
||||
older_root = tmp_path / "releases" / older_release_id
|
||||
older_root.mkdir(parents=True)
|
||||
older_filename = "PassengerSeat_older.zip"
|
||||
older_package = b"older-firmware-package"
|
||||
(older_root / older_filename).write_bytes(older_package)
|
||||
older_manifest = {
|
||||
**manifest,
|
||||
"release_id": older_release_id,
|
||||
"firmware_filename": older_filename,
|
||||
"size_bytes": len(older_package),
|
||||
"sha256": hashlib.sha256(older_package).hexdigest(),
|
||||
"published_at": "2026-09-15T13:17:17+08:00",
|
||||
}
|
||||
(older_root / "release.json").write_text(
|
||||
json.dumps(older_manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
class Handler(FirmwareOtaHandler):
|
||||
repository = FirmwareOtaRepository(
|
||||
tmp_path, "https://n7-ota.example.ts.net/api/firmware-workbench/releases"
|
||||
)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/api/firmware-workbench/releases/latest", timeout=3
|
||||
) as response:
|
||||
latest = json.loads(response.read().decode("utf-8"))
|
||||
assert latest["download_url"] == (
|
||||
f"https://n7-ota.example.ts.net/api/firmware-workbench/releases/"
|
||||
f"{release_id}/download"
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/api/firmware-workbench/releases", timeout=3
|
||||
) as response:
|
||||
releases = json.loads(response.read().decode("utf-8"))
|
||||
assert releases["schema"] == "n7-ota-release-list/v1"
|
||||
assert releases["count"] == 2
|
||||
assert releases["latest_release_id"] == release_id
|
||||
assert [item["release_id"] for item in releases["releases"]] == [
|
||||
release_id,
|
||||
older_release_id,
|
||||
]
|
||||
assert releases["releases"][1]["download_url"].endswith(
|
||||
f"/{older_release_id}/download"
|
||||
)
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
headers={"Range": "bytes=8-31"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
assert response.status == 206
|
||||
assert response.headers["Content-Range"] == f"bytes 8-31/{len(package)}"
|
||||
assert response.headers["X-Firmware-SHA256"] == manifest["sha256"]
|
||||
assert response.read() == package[8:32]
|
||||
|
||||
head = urllib.request.Request(
|
||||
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
method="HEAD",
|
||||
)
|
||||
with urllib.request.urlopen(head, timeout=3) as response:
|
||||
assert response.status == 200
|
||||
assert int(response.headers["Content-Length"]) == len(package)
|
||||
assert response.read() == b""
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestFirmwareWorkbenchApi:
|
||||
@classmethod
|
||||
def setup_class(cls) -> None:
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temporary_directory.name)
|
||||
image = root / "Seat_Controller_demo.hex"
|
||||
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
||||
|
||||
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
|
||||
log("info", f"IAR task {task_id}: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
cls.workbench = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=root / "workbench",
|
||||
ota_root=root / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
cls.original_workbench = SeatDemoHandler.firmware_workbench
|
||||
cls.original_auth = SeatDemoHandler.auth
|
||||
SeatDemoHandler.firmware_workbench = cls.workbench
|
||||
SeatDemoHandler.auth = AuthRuntime(
|
||||
AuthConfig(
|
||||
enabled=False,
|
||||
username="",
|
||||
password_hash="",
|
||||
session_secret=b"t" * 64,
|
||||
session_ttl_seconds=3600,
|
||||
login_attempts=5,
|
||||
login_window_seconds=300,
|
||||
requests_per_minute=120,
|
||||
trust_proxy_headers=False,
|
||||
)
|
||||
)
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2)
|
||||
cls.workbench.close()
|
||||
SeatDemoHandler.firmware_workbench = cls.original_workbench
|
||||
SeatDemoHandler.auth = cls.original_auth
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
@classmethod
|
||||
def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]:
|
||||
request = urllib.request.Request(
|
||||
f"{cls.base_url}{path}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(payload, ensure_ascii=False).encode(),
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read().decode())
|
||||
|
||||
def test_real_http_task_publish_detail_and_download_flow(self) -> None:
|
||||
instruction = "增加位置控制、座椅通风和座椅加热功能"
|
||||
status, created = self.post(
|
||||
"/api/firmware-workbench/tasks", {"instruction": instruction}
|
||||
)
|
||||
assert status == 202
|
||||
task_id = str(created["task"]["id"])
|
||||
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5
|
||||
) as response:
|
||||
task = json.loads(response.read().decode())
|
||||
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
assert task["status"] == "success"
|
||||
assert task["message"] == "已发布,待车机端下载升级"
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5
|
||||
) as response:
|
||||
latest = json.loads(response.read().decode())
|
||||
assert latest["release_id"] == task["release_id"]
|
||||
assert latest["download_url"].endswith(f"/{task['release_id']}/download")
|
||||
|
||||
repeated_status, repeated = self.post(
|
||||
"/api/firmware-workbench/tasks", {"instruction": instruction}
|
||||
)
|
||||
assert repeated_status == 202
|
||||
assert repeated["task"]["id"] != task_id
|
||||
repeated_task_id = str(repeated["task"]["id"])
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5
|
||||
) as response:
|
||||
repeated_task = json.loads(response.read().decode())
|
||||
if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
assert repeated_task["status"] == "success"
|
||||
|
||||
artifact_id = task["artifact_id"]
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5
|
||||
) as response:
|
||||
artifact = json.loads(response.read().decode())
|
||||
assert artifact["status"] == "published"
|
||||
assert artifact["sha256"]
|
||||
|
||||
release_id = task["release_id"]
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
timeout=5,
|
||||
) as response:
|
||||
package = response.read()
|
||||
assert response.headers["Content-Type"] == "application/zip"
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert len(package) == artifact["size_bytes"]
|
||||
|
||||
delete_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
|
||||
method="DELETE",
|
||||
)
|
||||
with urllib.request.urlopen(delete_request, timeout=5) as response:
|
||||
deleted = json.loads(response.read().decode())
|
||||
assert deleted["deleted"] is True
|
||||
repeated_artifact_id = str(repeated_task["artifact_id"])
|
||||
repeated_delete_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}",
|
||||
method="DELETE",
|
||||
)
|
||||
with urllib.request.urlopen(repeated_delete_request, timeout=5) as response:
|
||||
assert json.loads(response.read().decode())["deleted"] is True
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/state", timeout=5
|
||||
) as response:
|
||||
state = json.loads(response.read().decode())
|
||||
assert [item["id"] for item in state["artifacts"]] == [
|
||||
"fw-art-time-only-baseline"
|
||||
]
|
||||
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/firmware-workbench.html", timeout=5
|
||||
) as response:
|
||||
page = response.read().decode()
|
||||
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
|
||||
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
|
||||
assert "座椅固件开发工作台" in page
|
||||
@@ -89,6 +89,13 @@ class TestMutationRequestSecurity:
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||
|
||||
def get(self, path: str) -> tuple[int, dict[str, object]]:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||
|
||||
def test_same_origin_json_assistant_request_is_accepted(self) -> None:
|
||||
status, body = self.post(
|
||||
"/api/assistant/stream",
|
||||
@@ -147,6 +154,29 @@ class TestMutationRequestSecurity:
|
||||
assert status == 200
|
||||
assert MutationSecurityHandler.last_hardware_bypass is False
|
||||
|
||||
def test_vehicle_ota_download_is_public_but_workbench_state_remains_private(self) -> None:
|
||||
MutationSecurityHandler.auth = _auth(enabled=True)
|
||||
try:
|
||||
download_status, download_body = self.get(
|
||||
"/api/firmware-workbench/releases/fw-rel-missing1234/download"
|
||||
)
|
||||
latest_status, latest_body = self.get(
|
||||
"/api/firmware-workbench/releases/latest"
|
||||
)
|
||||
state_status, state_body = self.get("/api/firmware-workbench/state")
|
||||
finally:
|
||||
MutationSecurityHandler.auth = _auth(enabled=False)
|
||||
|
||||
assert download_status == 404
|
||||
assert download_body["error"] == "not_found"
|
||||
assert latest_status in {200, 404}
|
||||
if latest_status == 404:
|
||||
assert latest_body["error"] == "not_found"
|
||||
else:
|
||||
assert latest_body["download_url"]
|
||||
assert state_status == 401
|
||||
assert state_body["error"] == "authentication_required"
|
||||
|
||||
def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None:
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.client_address = ("127.0.0.1", 12345)
|
||||
|
||||
@@ -734,7 +734,7 @@ operations:
|
||||
) as response:
|
||||
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-29", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-30", timeout=3
|
||||
) as response:
|
||||
assistant_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -796,8 +796,8 @@ operations:
|
||||
self.assertNotIn("滚动查看更多", html)
|
||||
self.assertIn('aria-label="发送消息"', html)
|
||||
self.assertIn('id="assistantTranscript"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-29"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-30"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-30"', html)
|
||||
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
|
||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
||||
self.assertNotIn('"admin"', console_script)
|
||||
@@ -1075,11 +1075,11 @@ operations:
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v283"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v284"', service_worker)
|
||||
self.assertIn('/console-v2.css?v=delivery-state-68', service_worker)
|
||||
self.assertIn('/console-v2.js?v=local-first-66', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-30', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-30', service_worker)
|
||||
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
|
||||
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-133"', service_worker)
|
||||
|
||||
Reference in New Issue
Block a user