diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 52feebd5..492fe82e 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -772,7 +772,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: |- @@ -788,6 +788,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: @@ -813,6 +825,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" diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index 259d6816..b7e5d754 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -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":