feat: 支持 G14 Windows HWPOD 节点

增加节点凭据握手、工作区根目录约束和 CONSTAR MDTODO 数据源。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-10 13:03:24 +02:00
parent 3a87d3513a
commit c531507b02
2 changed files with 87 additions and 13 deletions
+14 -1
View File
@@ -770,7 +770,7 @@ lanes:
labels:
hwlab.pikastech.local/config-kind: project-management-bootstrap-sources
annotations:
hwlab.pikastech.local/source-ref: config/hwlab-project-management/constart-71freq-mdtodo.yaml#projectManagement.sources[0]
hwlab.pikastech.local/source-ref: config/hwlab-project-management/constart-71freq-mdtodo.yaml#projectManagement.sources[0],config/hwlab-project-management/constar-workspace-mdtodo.yaml#projectManagement.sources[0]
hwlab.pikastech.local/verification-issue: pikasTech/HWLAB#2183
data:
sources.json: |-
@@ -786,6 +786,18 @@ lanes:
"mdtodoRootRef": "docs/MDTODO",
"maxFiles": 300,
"capabilities": ["probe", "list", "read", "write", "reindex"]
},
{
"sourceId": "constar-workspace-mdtodo",
"sourceKind": "hwpod-workspace",
"displayName": "控之星工作区 MDTODO",
"projectId": "project_constar_workspace",
"hwpodId": "constar-workspace",
"nodeId": "node-g14-wsl-constar",
"workspaceRootRef": "D:\\Work\\CONSTAR_workspace",
"mdtodoRootRef": "docs/MDTODO",
"maxFiles": 300,
"capabilities": ["probe", "list", "read", "reindex", "launchWorkbench"]
}
]
services:
@@ -812,6 +824,7 @@ lanes:
HWLAB_WORKBENCH_RUNTIME_URL: http://hwlab-workbench-runtime.hwlab-v03.svc.cluster.local:6671
HWLAB_PROJECT_MANAGEMENT_URL: http://hwlab-project-management.hwlab-v03.svc.cluster.local:6672
HWLAB_HWPOD_SPEC_REGISTRY_DIRS: /etc/hwlab/hwpod-specs
HWLAB_HWPOD_NODE_WS_TOKEN: secretRef:hwlab-v03-hwpod-node-auth/token
HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2"
HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS: "250"
HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1"
+73 -12
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Single-file HWLAB node GUI for D601/HWPOD.
"""面向 Windows HWPOD 的单文件 HWLAB 图形节点。
The user-facing entrypoint intentionally accepts no command line arguments and
does not require HWLAB_* environment variables. All durable configuration lives
under ~/.hwlab/ and is managed by the settings dialog.
用户入口不接受命令行参数,也不依赖 HWLAB_* 环境变量。
持久配置统一存放在 ~/.hwlab/,并由设置界面或受控部署管理。
"""
from __future__ import annotations
@@ -51,7 +50,7 @@ except Exception: # pragma: no cover - import failure is reported in main.
messagebox = None
APP_VERSION = "0.1.1"
APP_VERSION = "0.1.2"
NODE_VERSION = f"{APP_VERSION}-python-gui"
CONTRACT_VERSION = "hwpod-node-ops-v1"
APP_NAME = "HWLAB Node"
@@ -67,6 +66,10 @@ DEFAULT_CONFIG = {
"serverUrl": "https://hwlab.pikapython.com",
"nodeId": "node-d601-f103-v2",
"autoConnect": False,
"requireCredential": False,
"credentialFile": str(CONFIG_DIR / "hwpod-node.token"),
"allowedWorkspaceRoots": [],
"defaultWorkspaceRoot": "",
"update": {
"enabled": True,
"autoApplyWhenIdle": False,
@@ -353,6 +356,31 @@ class NodeOpsExecutor:
"io.uart.read",
]
def allowed_workspace_roots(self) -> list[Path]:
values = self.config.get("allowedWorkspaceRoots")
if not isinstance(values, list):
return []
roots = []
for value in values:
if str(value).strip():
roots.append(Path(str(value)).expanduser().resolve(strict=False))
return roots
def _require_allowed_path(self, path: Path) -> Path:
resolved = path.expanduser().resolve(strict=False)
roots = self.allowed_workspace_roots()
if not roots:
return resolved
resolved_key = os.path.normcase(str(resolved))
for root in roots:
root_key = os.path.normcase(str(root))
try:
if os.path.commonpath([resolved_key, root_key]) == root_key:
return resolved
except ValueError:
continue
raise PermissionError("workspace path is outside allowedWorkspaceRoots")
def _node_mismatch(self, plan: dict, requested_node: str) -> dict:
blocker = {
"code": "hwpod_node_id_mismatch",
@@ -424,19 +452,33 @@ class NodeOpsExecutor:
return self._failed(op_id, name, output, "hwpod_node_command_failed", f"{name} node-side command exited with {output.get('exitCode')}")
def _workspace_root(self, args: dict) -> Path:
value = args.get("workspacePath") or args.get("workspaceRoot") or args.get("cwd") or os.getcwd()
return Path(str(value)).expanduser()
value = (
args.get("workspacePath")
or args.get("workspaceRoot")
or args.get("cwd")
or self.config.get("defaultWorkspaceRoot")
or os.getcwd()
)
return self._require_allowed_path(Path(str(value)))
def _resolve_workspace_path(self, args: dict, relative: object) -> Path:
text = str(relative or ".")
candidate = Path(text)
if candidate.is_absolute() or re.match(r"^[A-Za-z]:[\\/]", text):
return candidate
return self._workspace_root(args) / candidate
return self._require_allowed_path(candidate)
return self._require_allowed_path(self._workspace_root(args) / candidate)
def _node_inventory(self, args: dict) -> dict:
root = self._workspace_root(args)
return {"workspaceRoot": str(root), "workspaceExists": root.exists(), "workspaceIsDirectory": root.is_dir(), "platform": sys.platform, "hostname": socket.gethostname()}
return {
"workspaceRoot": str(root),
"workspaceExists": root.exists(),
"workspaceIsDirectory": root.is_dir(),
"allowedWorkspaceRootCount": len(self.allowed_workspace_roots()),
"capabilities": self.capabilities(),
"platform": sys.platform,
"hostname": socket.gethostname(),
}
def _node_diagnostics(self, args: dict) -> dict:
max_events = safe_int(args.get("maxEvents"), 50)
@@ -700,7 +742,7 @@ class WebSocketNodeClient:
self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
self._handshake(parsed, host, port)
self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "diagnostics": DIAGNOSTICS.summary()}})
self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "allowedWorkspaceRootCount": len(self.executor.allowed_workspace_roots()), "diagnostics": DIAGNOSTICS.summary()}})
last_heartbeat = 0.0
last_diagnostic_flush = 0.0
while not self.stop_event.is_set():
@@ -734,13 +776,16 @@ class WebSocketNodeClient:
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
credential = self._credential()
credential_header = f"X-HWPOD-Node-Token: {credential}\r\n" if credential else ""
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
"Sec-WebSocket-Version: 13\r\n"
f"{credential_header}\r\n"
)
assert self.sock is not None
self.sock.sendall(request.encode("ascii"))
@@ -755,6 +800,22 @@ class WebSocketNodeClient:
if b" 101 " not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(f"websocket upgrade failed: {response[:200]!r}")
def _credential(self) -> str:
path_value = str(self.config.get("credentialFile") or "").strip()
required = self.config.get("requireCredential") is True
if not path_value:
if required:
raise RuntimeError("credentialFile is required")
return ""
path = Path(path_value).expanduser()
try:
credential = path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
credential = ""
if required and not credential:
raise RuntimeError("HWPOD node credential is missing")
return credential
def _handle_message(self, text: str) -> None:
message = json.loads(text)
if message.get("type") == "ack" and message.get("requestId") == "register":