diff --git a/.agents/skills/unidesk-hwpod-ops/SKILL.md b/.agents/skills/unidesk-hwpod-ops/SKILL.md index c274a3a2..7f8ceb3d 100644 --- a/.agents/skills/unidesk-hwpod-ops/SKILL.md +++ b/.agents/skills/unidesk-hwpod-ops/SKILL.md @@ -86,6 +86,10 @@ trans :win ps 'Get-ScheduledTask -ErrorAction SilentlyContinue | Where - 变更前要求预检解析 YAML 节点配置; - 证明节点的 `trans` 路由可达; - 命令族不存在时先实现它。 + - 云端认证与桌面部署分阶段执行: + - `--component cloud-auth` 先同步节点认证 Secret; + - HWLAB 发布完成后使用 `--component desktop` 部署桌面节点; + - `--component all` 只用于云端已引用该 Secret 的稳定运行面。 4. 由受控 CLI 完成部署: - 渲染并校验配置; - 获取已发布 Python 文件并校验 SHA; diff --git a/config/hwlab-hwpod-nodes.yaml b/config/hwlab-hwpod-nodes.yaml new file mode 100644 index 00000000..cedd1798 --- /dev/null +++ b/config/hwlab-hwpod-nodes.yaml @@ -0,0 +1,67 @@ +version: 1 +kind: HwlabHwpodNodeDeploymentConfig +metadata: + name: hwlab-hwpod-nodes + owner: unidesk + spec: PJ2026-010104 + implementationRef: draft-2026-07-10-g14-wsl-python-hwpod-node + +cloud: + controlNode: NC01 + lane: v03 + kubeRoute: NC01:k3s + namespace: hwlab-v03 + publicOrigin: https://hwlab.pikapython.com + nodeOpsUrl: https://hwlab.pikapython.com/v1/hwpod-node-ops + nodeAuth: + sourceRef: hwlab/nc01-v03-hwpod-node.env + sourceKey: HWLAB_HWPOD_NODE_WS_TOKEN + generateIfMissing: true + secretName: hwlab-v03-hwpod-node-auth + targetKey: token + opsAuth: + sourceRef: hwlab/nc01-v03-admin.env + sourceKey: HWLAB_API_KEY + +targets: + G14-WSL: + enabled: true + lane: v03 + providerId: G14-WSL + windowsRoute: G14-WSL:win + nodeId: node-g14-wsl-constar + hwpodId: constar-workspace + interactiveUser: lyon + python: + probeLauncherPath: "C:\\Windows\\py.exe" + guiLauncherPath: "C:\\Windows\\pyw.exe" + launcherArgs: + - "-3.11" + expectedVersionPrefix: "3.11.9" + runtime: + root: "C:\\Users\\lyon\\hwlab-node" + scriptPath: "C:\\Users\\lyon\\hwlab-node\\hwlab-node.py" + configPath: "C:\\Users\\lyon\\.hwlab\\config.json" + credentialPath: "C:\\Users\\lyon\\.hwlab\\hwpod-node.token" + logPath: "C:\\Users\\lyon\\.hwlab\\logs\\hwlab-node.log" + artifact: + metadataUrl: https://hwlab.pikapython.com/v1/hwlab-node/update + channel: stable + platform: windows + workspace: + allowedRoots: + - "D:\\Work\\CONSTAR_workspace" + defaultRoot: "D:\\Work\\CONSTAR_workspace" + desktopConfig: + serverUrl: https://hwlab.pikapython.com + autoConnect: true + requireCredential: true + updateEnabled: true + updateAutoApplyWhenIdle: false + startup: + mode: hkcu-run + runKeyName: hwlab-node-g14-wsl + initialLaunch: interactive-process + processPattern: hwlab-node.py + projectManagementSourceRef: config/hwlab-project-management/constar-workspace-mdtodo.yaml#projectManagement.sources[0] + diff --git a/config/hwlab-project-management/constar-workspace-mdtodo.yaml b/config/hwlab-project-management/constar-workspace-mdtodo.yaml new file mode 100644 index 00000000..ec684a48 --- /dev/null +++ b/config/hwlab-project-management/constar-workspace-mdtodo.yaml @@ -0,0 +1,36 @@ +version: 1 +kind: HwlabProjectManagementSourceConfig +metadata: + name: constar-workspace-mdtodo + owner: unidesk + spec: PJ2026-010404 + implementationRef: draft-2026-07-10-g14-wsl-python-hwpod-node + +projectManagement: + sources: + - 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 + focusFiles: + - 20260419_频率判断.md + - 20260609_频率判断_用户反馈.md + - 20260625_71_FILTER.md + capabilities: + probe: true + list: true + read: true + write: false + reindex: true + launchWorkbench: true + hwpodNodeOpsUrlConfigRef: config/hwlab-hwpod-nodes.yaml#cloud.nodeOpsUrl + hwpodNodeOpsApiKeyConfigRef: config/hwlab-hwpod-nodes.yaml#cloud.opsAuth + redaction: + rawMarkdown: true + hostPath: true + valuesRedacted: true diff --git a/project-management/PJ2026-01/specs/PJ2026-010103-hwpod-service.md b/project-management/PJ2026-01/specs/PJ2026-010103-hwpod-service.md index 679cd961..4071cc92 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010103-hwpod-service.md +++ b/project-management/PJ2026-01/specs/PJ2026-010103-hwpod-service.md @@ -9,6 +9,36 @@ ## 正文 +## 实现引用 + +- 当前实现引用: + - `draft-2026-07-10-g14-wsl-python-hwpod-node`。 + +## Windows Python 节点注册与路由 + +- 注册模型: + - HWPOD 服务支持多个主动出站 Windows Python 节点; + - WebSocket 注册表以唯一 nodeId 路由; + - 新连接接管同一 nodeId 时,旧连接必须断开并暴露单活切换; + - 未注册节点不得接收节点操作。 +- 认证模型: + - cloud-api 必须从 SecretRef 读取节点 WebSocket 凭据; + - 节点必须在握手时携带对应凭据; + - 凭据缺失或不匹配必须返回 `hwpod_node_ws_token_invalid` 或等价结构化阻塞; + - 不得保留匿名生产注册或直连地址兜底。 +- 路由与能力: + - 调度必须按 plan.nodeId 选择注册连接; + - 云端必须使用节点实际注册的 capabilities; + - 静态 supportedOps 与节点实际上报不一致时必须暴露 capability mismatch; + - direct URL、SSH reader 和服务端本地挂载不得替代节点注册表。 +- 可观测性: + - 状态必须显示 nodeId、版本、平台、能力、最近心跳和诊断摘要; + - 输出不得包含 token、Authorization 或可复用凭据; + - 离线、身份不匹配、认证失败、能力不匹配和操作失败必须分别分类。 +- 配置归属: + - Secret 名称、sourceRef、targetKey、节点身份和路由事实以 owning YAML 为准; + - 规格不保存具体 Secret 值、地址、超时或重连参数。 + ## PJ2026-010103 HWPOD服务需求规格 ## 1. 文档控制 diff --git a/project-management/PJ2026-01/specs/PJ2026-010104-ai-gateway.md b/project-management/PJ2026-01/specs/PJ2026-010104-ai-gateway.md index 4814c3a1..fadc4664 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010104-ai-gateway.md +++ b/project-management/PJ2026-01/specs/PJ2026-010104-ai-gateway.md @@ -19,7 +19,7 @@ | 短名 | AI网关 | | 层级 | L2 课题 | | 状态 | 已生效 | -| 实现引用版本 | draft-2026-06-25-p0-web-caserun-e2e | +| 实现引用版本 | draft-2026-06-25-p0-web-caserun-e2e; draft-2026-07-10-g14-wsl-python-hwpod-node | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 上级规格 | [PJ2026-0101 硬件池](PJ2026-0101-hardware-pool.md) | | 规格治理索引 | [规格治理](spec-governance.md) | @@ -140,3 +140,41 @@ AI网关应回传原始硬件事实,使板内协议响应、板外 ioProbe 读 原始硬件事实必须区分板内 echo、板外真实读数和节点适配器状态。网关不能把 TCP 端口可达、进程在线或命令已发出等中间状态当作硬件动作成功。 Python UI node 本地日志应与云端诊断保持可关联。节点在处理 CaseRun 操作时应保留 requestId、runId 或等价 correlation 摘要,并把可脱敏的关键错误上报给 HWPOD 服务,使 Cloud Web、web-probe 和 issue evidence 能看到同一失败原因,而不是只能在 Windows 控制台或本地 GUI 中观察。 + +### 6.5 HWPOD-GW-REQ-005 Windows Python 图形节点受控部署 + +- 主责模块: + - PJ2026-01010401 节点健康; + - PJ2026-01010402 适配器执行。 +- 关联模块: + - [PJ2026-010103 HWPOD服务](PJ2026-010103-hwpod-service.md); + - [PJ2026-010404 Project Management](PJ2026-010404-project-management.md)。 +- 部署入口: + - 必须使用 UniDesk YAML-first 受控 CLI; + - 只支持 owning YAML 已声明且 `trans` 路由可解析、可访问的节点; + - CLI 可以在内部使用 `trans` 作为传输层; + - 操作者不得用手写 PowerShell、cmd 或远端脚本形成部署真相。 +- Windows 运行时: + - 必须使用 owning YAML 声明的交互用户; + - 必须优先使用该用户 Windows 会话内的原生 `python.exe`; + - WSL Python、SSH 辅助解释器、Bun 运行器和非交互 Windows 服务不得作为完成态; + - GUI 和托盘必须属于同一交互登录会话。 +- 节点身份与认证: + - nodeId 必须全局唯一; + - 节点凭据只通过 YAML `sourceRef`/`sourceKey` 和目标 credentialFile 下发; + - WebSocket 握手必须携带节点凭据; + - UI、日志、CLI 和 issue 只披露凭据是否存在及指纹。 +- 工作区策略: + - owning YAML 必须声明允许的 Windows 工作区根; + - 节点必须执行规范路径包含性检查; + - 节点必须拒绝根目录之外的绝对路径、`..`、符号链接/联接点逃逸和跨根操作; + - `node.inventory` 必须返回脱敏的允许根与实际能力摘要。 +- 启动与单活: + - 登录自启动策略由 owning YAML 声明; + - 受控 CLI 必须区分“已安装”“界面可见”“已注册”; + - 相同 nodeId 或 HWPOD 资源出现第二运行器时必须阻塞; + - 旧 Bun 运行器只能作为漂移证据,不得参与完成态。 +- 配置归属: + - nodeId、入口、pythonPath、运行目录、允许根、更新策略和启动策略以 owning YAML 为准; + - `%USERPROFILE%\.hwlab\config.json` 仅是 CLI 渲染的桌面副本; + - 规格不保存具体路径、地址、token、重连次数或时间参数。 diff --git a/project-management/PJ2026-01/specs/PJ2026-010404-project-management.md b/project-management/PJ2026-01/specs/PJ2026-010404-project-management.md index 7cf75a43..56626328 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010404-project-management.md +++ b/project-management/PJ2026-01/specs/PJ2026-010404-project-management.md @@ -19,7 +19,7 @@ | 短名 | 项目管理 | | 层级 | L2 课题 | | 状态 | 已生效 | -| 实现引用版本 | draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source; draft-2026-06-26-p1-mdtodo-web-operable; draft-2026-06-26-p0-mdtodo-web-rewrite | +| 实现引用版本 | draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source; draft-2026-06-26-p1-mdtodo-web-operable; draft-2026-06-26-p0-mdtodo-web-rewrite; draft-2026-07-10-g14-wsl-python-hwpod-node | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 上级规格 | [PJ2026-0104 客户端](PJ2026-0104-client.md) | | 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-0102 Agent编排](PJ2026-0102-agent-orchestration.md)、[PJ2026-0105 用户管理](PJ2026-0105-user-management.md)、[PJ2026-0106 平台运维](PJ2026-0106-platform-ops.md) | @@ -264,6 +264,32 @@ sequenceDiagram HWPOD-bound source 只能访问配置过的 workspace 相对根,例如 `docs/MDTODO/`。路径归一化必须拒绝 `..`、绝对路径、Secret 文件、未声明扩展名和非 allowlist 根;Web 不得展示任意文件浏览器,也不得直接调用 HWPOD node 私有接口。 +### 6.10 HWPOD-bound MDTODO 多桌面节点来源 + +- 来源声明: + - owning YAML 必须声明 sourceId、projectId、hwpodId 和 nodeId; + - owning YAML 必须声明 Windows workspaceRootRef 和相对 mdtodoRootRef; + - source、HWPOD 资源、节点和工作区根必须交叉一致; + - WSL `/mnt//...` 路径不得替代 Windows Python 节点的 workspaceRootRef。 +- 读取路径: + - Project Management 只调用公共 HWPOD workspace ops; + - 服务端不得直接读取 Windows 文件系统; + - SSH、SMB、本地挂载和私有调试接口不得作为兜底; + - Markdown 仍是任务真相源,数据库只保存可重建投影。 +- 能力与安全: + - 只读来源只声明 probe、list、read 和 reindex; + - 未声明写能力时,Web 不得显示可写状态; + - 节点必须在工作区允许列表内完成规范路径包含性检查; + - 越界路径必须作为 workspace-policy 阻塞返回。 +- 原入口验收: + - 完成来源保存、探测、重建索引和文件/任务可见; + - 记录 sourceId、nodeId、文档/任务数量、指纹和 requestId/traceId; + - 默认不输出 Markdown 正文; + - 直接 `trans cat` 只能作为诊断证据,不能作为完成证据。 +- 配置归属: + - 节点、路径、能力、最大文件数和焦点文件以 owning YAML 为准; + - 规格不保存具体节点值、工作区路径或数量参数。 + ### 5.6 web-probe 交互式 command 控制流 ```mermaid diff --git a/scripts/src/hwlab-hwpod-node.ts b/scripts/src/hwlab-hwpod-node.ts new file mode 100644 index 00000000..4ec96907 --- /dev/null +++ b/scripts/src/hwlab-hwpod-node.ts @@ -0,0 +1,712 @@ +// 规格:PJ2026-010104 draft-2026-07-10-g14-wsl-python-hwpod-node。 +// 职责:通过 YAML-first 管理 trans 可达的 Windows Python HWPOD 节点部署和状态。 +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { repoRoot, rootPath } from "./config"; +import { runCommand, type CommandResult } from "./command"; + +type HwpodNodeAction = "plan" | "status" | "apply"; +type HwpodNodeComponent = "all" | "cloud-auth" | "desktop"; + +interface HwpodNodeOptions { + action: HwpodNodeAction; + component: HwpodNodeComponent; + node: string; + lane: string; + confirm: boolean; + dryRun: boolean; +} + +interface SecretSpec { + sourceRef: string; + sourceKey: string; + generateIfMissing: boolean; + secretName: string; + targetKey: string; +} + +interface SourceSpec { + sourceId: string; + projectId: string; + hwpodId: string; + nodeId: string; + workspaceRootRef: string; + mdtodoRootRef: string; + capabilities: Record; +} + +interface HwpodNodeSpec { + configPath: string; + node: string; + lane: string; + providerId: string; + windowsRoute: string; + nodeId: string; + hwpodId: string; + interactiveUser: string; + publicOrigin: string; + nodeOpsUrl: string; + cloudKubeRoute: string; + cloudNamespace: string; + cloudSecret: SecretSpec; + opsAuth: { sourceRef: string; sourceKey: string }; + python: { + probeLauncherPath: string; + guiLauncherPath: string; + launcherArgs: string[]; + expectedVersionPrefix: string; + }; + runtime: { + root: string; + scriptPath: string; + configPath: string; + credentialPath: string; + logPath: string; + }; + artifact: { metadataUrl: string; channel: string; platform: string }; + workspace: { allowedRoots: string[]; defaultRoot: string }; + desktopConfig: { + serverUrl: string; + autoConnect: boolean; + requireCredential: boolean; + updateEnabled: boolean; + updateAutoApplyWhenIdle: boolean; + }; + startup: { mode: string; runKeyName: string; initialLaunch: string; processPattern: string }; + sourceRef: string; + source: SourceSpec; +} + +interface ArtifactBundle { + latestVersion: string; + downloadUrl: string; + sha256: string; + content: Buffer; +} + +const CONFIG_PATH = "config/hwlab-hwpod-nodes.yaml"; +const SECRET_ROOT = rootPath(".state", "secrets"); + +export function hwlabHwpodNodeHelp(): Record { + return { + ok: true, + command: "hwlab nodes hwpod-node", + description: "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。", + configPath: CONFIG_PATH, + actions: { + plan: "校验 YAML、SecretRef、发布文件元数据和 Windows 路由,不修改运行面。", + status: "查询 Windows 文件、解释器、交互会话、启动项、进程和云端节点操作状态。", + apply: "显式确认后同步云端认证 Secret 或部署 Windows Python 图形节点。", + }, + components: { + "cloud-auth": "生成缺失的 YAML 声明凭据,并同步 cloud-api 使用的 Kubernetes Secret。", + desktop: "校验发布文件 SHA 后部署桌面节点、配置、凭据和 HKCU Run 启动项。", + all: "按 cloud-auth、desktop 顺序执行。", + }, + examples: [ + "bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03", + "bun scripts/cli.ts hwlab nodes hwpod-node status --node G14-WSL --lane v03", + "bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component cloud-auth --confirm", + "bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component desktop --confirm", + ], + valuesRedacted: true, + }; +} + +export async function runHwlabHwpodNodeCommand(args: string[]): Promise> { + if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabHwpodNodeHelp(); + const options = parseOptions(args); + const spec = readSpec(options.node, options.lane); + const artifact = await readArtifact(spec, false); + const secretBefore = readSecretMaterial(spec.cloudSecret); + const remoteBefore = remoteStatus(spec); + const graph = configGraph(spec, secretBefore, artifact, remoteBefore); + if (options.action === "plan") return resultEnvelope(options, spec, graph, null, null, null); + const cloudBefore = await cloudProbe(spec); + if (options.action === "status") return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, null); + if (options.dryRun) return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, { status: "dry-run", component: options.component }); + if (!options.confirm) throw new Error("hwpod-node apply 必须显式使用 --confirm 或 --dry-run"); + let cloudApply: Record | null = null; + let desktopApply: Record | null = null; + if (options.component === "all" || options.component === "cloud-auth") cloudApply = applyCloudAuth(spec); + if (options.component === "all" || options.component === "desktop") { + const secret = readSecretMaterial(spec.cloudSecret); + if (!secret.present || secret.value === null) throw new Error(`${spec.cloudSecret.sourceRef}.${spec.cloudSecret.sourceKey} 缺失;先应用 cloud-auth`); + const fullArtifact = await readArtifact(spec, true); + desktopApply = applyDesktop(spec, fullArtifact, secret.value); + } + await delay(2500); + const remoteAfter = remoteStatus(spec); + const cloudAfter = await cloudProbe(spec); + const applyResult = { status: "applied", component: options.component, cloudAuth: cloudApply, desktop: desktopApply, valuesRedacted: true }; + return resultEnvelope(options, spec, configGraph(spec, readSecretMaterial(spec.cloudSecret), artifact, remoteAfter), remoteAfter.payload, cloudAfter, applyResult); +} + +function parseOptions(args: string[]): HwpodNodeOptions { + const action = args[0]; + if (action !== "plan" && action !== "status" && action !== "apply") { + throw new Error("hwpod-node 用法:plan|status|apply --node NODE --lane LANE [--component all|cloud-auth|desktop] [--confirm|--dry-run]"); + } + const knownValue = new Set(["--node", "--lane", "--component"]); + const knownFlag = new Set(["--confirm", "--dry-run", "--full"]); + for (let index = 1; index < args.length; index += 1) { + const item = args[index]; + if (knownValue.has(item)) { + index += 1; + if (args[index] === undefined) throw new Error(`${item} 缺少值`); + continue; + } + if (!knownFlag.has(item)) throw new Error(`hwpod-node 不支持参数 ${item}`); + } + const node = option(args, "--node"); + const lane = option(args, "--lane"); + const componentRaw = option(args, "--component", "all"); + if (componentRaw !== "all" && componentRaw !== "cloud-auth" && componentRaw !== "desktop") throw new Error(`--component 不支持 ${componentRaw}`); + const confirm = args.includes("--confirm"); + const dryRun = args.includes("--dry-run"); + if (confirm && dryRun) throw new Error("--confirm 与 --dry-run 不能同时使用"); + if (action !== "apply" && (confirm || dryRun)) throw new Error(`${action} 是只读动作,不接受 --confirm 或 --dry-run`); + return { action, component: componentRaw, node, lane, confirm, dryRun }; +} + +function option(args: string[], name: string, fallback?: string): string { + const index = args.indexOf(name); + if (index < 0) { + if (fallback !== undefined) return fallback; + throw new Error(`缺少 ${name}`); + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少值`); + return value; +} + +function readSpec(node: string, lane: string): HwpodNodeSpec { + const config = yamlFile(CONFIG_PATH); + const cloud = record(config.cloud, `${CONFIG_PATH}#cloud`); + const targets = record(config.targets, `${CONFIG_PATH}#targets`); + const target = record(targets[node], `${CONFIG_PATH}#targets.${node}`); + if (target.enabled !== true) throw new Error(`${CONFIG_PATH}#targets.${node}.enabled 必须为 true`); + const targetLane = text(target.lane, `targets.${node}.lane`); + if (targetLane !== lane) throw new Error(`目标 ${node} 声明 lane=${targetLane},不是 ${lane}`); + const python = record(target.python, `targets.${node}.python`); + const runtime = record(target.runtime, `targets.${node}.runtime`); + const artifact = record(target.artifact, `targets.${node}.artifact`); + const workspace = record(target.workspace, `targets.${node}.workspace`); + const desktopConfig = record(target.desktopConfig, `targets.${node}.desktopConfig`); + const startup = record(target.startup, `targets.${node}.startup`); + const nodeAuth = record(cloud.nodeAuth, "cloud.nodeAuth"); + const opsAuth = record(cloud.opsAuth, "cloud.opsAuth"); + const sourceRef = text(target.projectManagementSourceRef, `targets.${node}.projectManagementSourceRef`); + const source = readSource(sourceRef); + const allowedRoots = texts(workspace.allowedRoots, `targets.${node}.workspace.allowedRoots`); + if (allowedRoots.length === 0) throw new Error(`targets.${node}.workspace.allowedRoots 不能为空`); + const spec: HwpodNodeSpec = { + configPath: CONFIG_PATH, + node, + lane, + providerId: text(target.providerId, `targets.${node}.providerId`), + windowsRoute: text(target.windowsRoute, `targets.${node}.windowsRoute`), + nodeId: text(target.nodeId, `targets.${node}.nodeId`), + hwpodId: text(target.hwpodId, `targets.${node}.hwpodId`), + interactiveUser: text(target.interactiveUser, `targets.${node}.interactiveUser`), + publicOrigin: text(cloud.publicOrigin, "cloud.publicOrigin"), + nodeOpsUrl: text(cloud.nodeOpsUrl, "cloud.nodeOpsUrl"), + cloudKubeRoute: text(cloud.kubeRoute, "cloud.kubeRoute"), + cloudNamespace: text(cloud.namespace, "cloud.namespace"), + cloudSecret: { + sourceRef: text(nodeAuth.sourceRef, "cloud.nodeAuth.sourceRef"), + sourceKey: text(nodeAuth.sourceKey, "cloud.nodeAuth.sourceKey"), + generateIfMissing: bool(nodeAuth.generateIfMissing, "cloud.nodeAuth.generateIfMissing"), + secretName: text(nodeAuth.secretName, "cloud.nodeAuth.secretName"), + targetKey: text(nodeAuth.targetKey, "cloud.nodeAuth.targetKey"), + }, + opsAuth: { sourceRef: text(opsAuth.sourceRef, "cloud.opsAuth.sourceRef"), sourceKey: text(opsAuth.sourceKey, "cloud.opsAuth.sourceKey") }, + python: { + probeLauncherPath: text(python.probeLauncherPath, `targets.${node}.python.probeLauncherPath`), + guiLauncherPath: text(python.guiLauncherPath, `targets.${node}.python.guiLauncherPath`), + launcherArgs: texts(python.launcherArgs, `targets.${node}.python.launcherArgs`), + expectedVersionPrefix: text(python.expectedVersionPrefix, `targets.${node}.python.expectedVersionPrefix`), + }, + runtime: { + root: text(runtime.root, `targets.${node}.runtime.root`), + scriptPath: text(runtime.scriptPath, `targets.${node}.runtime.scriptPath`), + configPath: text(runtime.configPath, `targets.${node}.runtime.configPath`), + credentialPath: text(runtime.credentialPath, `targets.${node}.runtime.credentialPath`), + logPath: text(runtime.logPath, `targets.${node}.runtime.logPath`), + }, + artifact: { + metadataUrl: text(artifact.metadataUrl, `targets.${node}.artifact.metadataUrl`), + channel: text(artifact.channel, `targets.${node}.artifact.channel`), + platform: text(artifact.platform, `targets.${node}.artifact.platform`), + }, + workspace: { allowedRoots, defaultRoot: text(workspace.defaultRoot, `targets.${node}.workspace.defaultRoot`) }, + desktopConfig: { + serverUrl: text(desktopConfig.serverUrl, `targets.${node}.desktopConfig.serverUrl`), + autoConnect: bool(desktopConfig.autoConnect, `targets.${node}.desktopConfig.autoConnect`), + requireCredential: bool(desktopConfig.requireCredential, `targets.${node}.desktopConfig.requireCredential`), + updateEnabled: bool(desktopConfig.updateEnabled, `targets.${node}.desktopConfig.updateEnabled`), + updateAutoApplyWhenIdle: bool(desktopConfig.updateAutoApplyWhenIdle, `targets.${node}.desktopConfig.updateAutoApplyWhenIdle`), + }, + startup: { + mode: text(startup.mode, `targets.${node}.startup.mode`), + runKeyName: text(startup.runKeyName, `targets.${node}.startup.runKeyName`), + initialLaunch: text(startup.initialLaunch, `targets.${node}.startup.initialLaunch`), + processPattern: text(startup.processPattern, `targets.${node}.startup.processPattern`), + }, + sourceRef, + source, + }; + validateCrossRefs(spec); + return spec; +} + +function readSource(ref: string): SourceSpec { + const parsed = configRef(ref); + const doc = yamlFile(parsed.file); + const value = pathValue(doc, parsed.path); + const source = record(value, ref); + const capabilitiesRaw = record(source.capabilities, `${ref}.capabilities`); + return { + sourceId: text(source.sourceId, `${ref}.sourceId`), + projectId: text(source.projectId, `${ref}.projectId`), + hwpodId: text(source.hwpodId, `${ref}.hwpodId`), + nodeId: text(source.nodeId, `${ref}.nodeId`), + workspaceRootRef: text(source.workspaceRootRef, `${ref}.workspaceRootRef`), + mdtodoRootRef: text(source.mdtodoRootRef, `${ref}.mdtodoRootRef`), + capabilities: Object.fromEntries(Object.entries(capabilitiesRaw).map(([key, value]) => [key, bool(value, `${ref}.capabilities.${key}`)])), + }; +} + +function validateCrossRefs(spec: HwpodNodeSpec): void { + const conflicts: string[] = []; + if (spec.source.nodeId !== spec.nodeId) conflicts.push(`source.nodeId=${spec.source.nodeId} != target.nodeId=${spec.nodeId}`); + if (spec.source.hwpodId !== spec.hwpodId) conflicts.push(`source.hwpodId=${spec.source.hwpodId} != target.hwpodId=${spec.hwpodId}`); + if (!spec.workspace.allowedRoots.includes(spec.workspace.defaultRoot)) conflicts.push("workspace.defaultRoot 不在 allowedRoots 中"); + if (spec.source.workspaceRootRef !== spec.workspace.defaultRoot) conflicts.push("source.workspaceRootRef 与 workspace.defaultRoot 不一致"); + if (spec.startup.mode !== "hkcu-run") conflicts.push(`startup.mode 仅支持 hkcu-run,实际为 ${spec.startup.mode}`); + if (!spec.windowsRoute.endsWith(":win")) conflicts.push("windowsRoute 必须指向 :win plane"); + if (conflicts.length > 0) throw new Error(`HWPOD 节点配置冲突:${conflicts.join(";")}`); +} + +function configGraph(spec: HwpodNodeSpec, secret: ReturnType, artifact: ArtifactBundle, remote: ReturnType): Record { + return { + ok: remote.reachable && artifact.sha256.length > 0 && (secret.present || spec.cloudSecret.generateIfMissing), + configPath: spec.configPath, + sourceRef: spec.sourceRef, + target: { + node: spec.node, + lane: spec.lane, + providerId: spec.providerId, + windowsRoute: spec.windowsRoute, + nodeId: spec.nodeId, + hwpodId: spec.hwpodId, + interactiveUser: spec.interactiveUser, + workspaceRoots: spec.workspace.allowedRoots, + mdtodoRootRef: spec.source.mdtodoRootRef, + }, + cloudAuth: { + sourceRef: spec.cloudSecret.sourceRef, + sourceKey: spec.cloudSecret.sourceKey, + sourcePresent: secret.present, + fingerprint: secret.fingerprint, + secretName: spec.cloudSecret.secretName, + targetKey: spec.cloudSecret.targetKey, + generateIfMissing: spec.cloudSecret.generateIfMissing, + valuesRedacted: true, + }, + artifact: { latestVersion: artifact.latestVersion, downloadUrl: artifact.downloadUrl, sha256: artifact.sha256, bytes: artifact.content.length }, + route: { reachable: remote.reachable, exitCode: remote.result.exitCode, timedOut: remote.result.timedOut }, + valuesRedacted: true, + }; +} + +function resultEnvelope( + options: HwpodNodeOptions, + spec: HwpodNodeSpec, + graph: Record, + remote: Record | null, + cloud: Record | null, + apply: Record | null, +): Record { + const remoteReady = remote?.installed === true && remote?.processReady === true && remote?.interactiveProcessReady === true; + const cloudReady = cloud?.ok === true; + const ok = options.action === "plan" ? graph.ok === true : options.action === "status" ? remoteReady && cloudReady : apply?.status === "applied" && (options.component === "cloud-auth" || (remoteReady && cloudReady)); + return { + ok, + command: `hwlab nodes hwpod-node ${options.action} --node ${spec.node} --lane ${spec.lane}${options.action === "apply" ? ` --component ${options.component}${options.confirm ? " --confirm" : " --dry-run"}` : ""}`, + status: ok ? options.action === "apply" ? "applied" : "ready" : "blocked", + graph, + remote, + cloud, + apply, + next: { + plan: `bun scripts/cli.ts hwlab nodes hwpod-node plan --node ${spec.node} --lane ${spec.lane}`, + status: `bun scripts/cli.ts hwlab nodes hwpod-node status --node ${spec.node} --lane ${spec.lane}`, + cloudAuth: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component cloud-auth --confirm`, + desktop: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component desktop --confirm`, + }, + valuesRedacted: true, + }; +} + +async function readArtifact(spec: HwpodNodeSpec, includeContent: boolean): Promise { + const metadataUrl = new URL(spec.artifact.metadataUrl); + metadataUrl.searchParams.set("platform", spec.artifact.platform); + metadataUrl.searchParams.set("channel", spec.artifact.channel); + metadataUrl.searchParams.set("current", "0.0.0"); + const metadataResponse = await fetch(metadataUrl, { signal: AbortSignal.timeout(15000) }); + if (!metadataResponse.ok) throw new Error(`HWPOD 节点更新元数据返回 HTTP ${metadataResponse.status}`); + const metadata = record(await metadataResponse.json(), "HWPOD 节点更新元数据"); + const downloadUrl = text(metadata.downloadUrl, "更新元数据.downloadUrl"); + const sha256 = text(metadata.sha256, "更新元数据.sha256").toLowerCase(); + const latestVersion = text(metadata.latestVersion, "更新元数据.latestVersion"); + const download = new URL(downloadUrl); + if (download.hostname !== new URL(spec.publicOrigin).hostname || !download.pathname.endsWith(".py")) throw new Error("更新元数据下载地址不属于 YAML 声明的公共入口或不是 .py 文件"); + if (!includeContent) return { latestVersion, downloadUrl, sha256, content: Buffer.alloc(0) }; + const response = await fetch(download, { signal: AbortSignal.timeout(30000) }); + if (!response.ok) throw new Error(`HWPOD 节点文件下载返回 HTTP ${response.status}`); + const content = Buffer.from(await response.arrayBuffer()); + const actual = createHash("sha256").update(content).digest("hex"); + if (actual !== sha256) throw new Error(`HWPOD 节点文件 SHA 不匹配:expected=${sha256} actual=${actual}`); + return { latestVersion, downloadUrl, sha256, content }; +} + +function readSecretMaterial(spec: { sourceRef: string; sourceKey: string }): { present: boolean; value: string | null; fingerprint: string | null; sourcePath: string } { + const sourcePath = secretPath(spec.sourceRef); + if (!existsSync(sourcePath)) return { present: false, value: null, fingerprint: null, sourcePath }; + const values = parseEnv(readFileSync(sourcePath, "utf8")); + const value = values[spec.sourceKey] ?? null; + return { present: value !== null && value.length > 0, value, fingerprint: value ? sha256(value) : null, sourcePath }; +} + +function ensureSecretMaterial(spec: SecretSpec): { value: string; fingerprint: string; generated: boolean; sourcePath: string } { + const current = readSecretMaterial(spec); + if (current.present && current.value) return { value: current.value, fingerprint: current.fingerprint ?? sha256(current.value), generated: false, sourcePath: current.sourcePath }; + if (!spec.generateIfMissing) throw new Error(`${spec.sourceRef}.${spec.sourceKey} 缺失且 generateIfMissing=false`); + const value = randomBytes(32).toString("base64url"); + const sourcePath = secretPath(spec.sourceRef); + mkdirSync(dirname(sourcePath), { recursive: true }); + const existing = existsSync(sourcePath) ? parseEnv(readFileSync(sourcePath, "utf8")) : {}; + existing[spec.sourceKey] = value; + writeFileSync(sourcePath, `${Object.entries(existing).map(([key, item]) => `${key}=${item}`).join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(sourcePath, 0o600); + return { value, fingerprint: sha256(value), generated: true, sourcePath }; +} + +function applyCloudAuth(spec: HwpodNodeSpec): Record { + const material = ensureSecretMaterial(spec.cloudSecret); + const manifest = JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + metadata: { name: spec.cloudSecret.secretName, namespace: spec.cloudNamespace, labels: { "app.kubernetes.io/managed-by": "unidesk", "hwlab.pikastech.local/config": "hwpod-node-auth" } }, + type: "Opaque", + data: { [spec.cloudSecret.targetKey]: Buffer.from(material.value, "utf8").toString("base64") }, + }); + const result = runCommand(["trans", spec.cloudKubeRoute, "kubectl", "-n", spec.cloudNamespace, "apply", "-f", "-"], repoRoot, { input: manifest, timeoutMs: 55000 }); + if (!commandSucceeded(result)) throw new Error(`同步 HWPOD 节点认证 Secret 失败:${compactFailure(result)}`); + return { + ok: true, + status: "applied", + generated: material.generated, + sourceRef: spec.cloudSecret.sourceRef, + sourceKey: spec.cloudSecret.sourceKey, + fingerprint: material.fingerprint, + secretName: spec.cloudSecret.secretName, + targetKey: spec.cloudSecret.targetKey, + valuesRedacted: true, + }; +} + +function applyDesktop(spec: HwpodNodeSpec, artifact: ArtifactBundle, credential: string): Record { + const config = { + schemaVersion: 1, + serverUrl: spec.desktopConfig.serverUrl, + nodeId: spec.nodeId, + autoConnect: spec.desktopConfig.autoConnect, + requireCredential: spec.desktopConfig.requireCredential, + credentialFile: spec.runtime.credentialPath, + allowedWorkspaceRoots: spec.workspace.allowedRoots, + defaultWorkspaceRoot: spec.workspace.defaultRoot, + update: { + enabled: spec.desktopConfig.updateEnabled, + autoApplyWhenIdle: spec.desktopConfig.updateAutoApplyWhenIdle, + channel: spec.artifact.channel, + metadataUrl: spec.artifact.metadataUrl, + downloadHostAllowlist: [new URL(spec.publicOrigin).hostname], + }, + }; + const payload = { + interactiveUser: spec.interactiveUser, + runtimeRoot: spec.runtime.root, + scriptPath: spec.runtime.scriptPath, + configPath: spec.runtime.configPath, + credentialPath: spec.runtime.credentialPath, + probeLauncherPath: spec.python.probeLauncherPath, + guiLauncherPath: spec.python.guiLauncherPath, + launcherArgs: spec.python.launcherArgs, + expectedVersionPrefix: spec.python.expectedVersionPrefix, + runKeyName: spec.startup.runKeyName, + artifactBase64: artifact.content.toString("base64"), + artifactSha256: artifact.sha256, + configBase64: Buffer.from(`${JSON.stringify(config, null, 2)}\n`, "utf8").toString("base64"), + configSha256: sha256Hex(`${JSON.stringify(config, null, 2)}\n`), + credentialBase64: Buffer.from(`${credential}\n`, "utf8").toString("base64"), + }; + const result = runPowerShell(spec.windowsRoute, desktopApplyScript(payload), 55000); + if (!commandSucceeded(result)) throw new Error(`部署 Windows HWPOD 节点失败:${compactFailure(result)}`); + const parsed = parseJsonRecord(result.stdout); + if (parsed?.ok !== true) throw new Error(`Windows HWPOD 节点部署未就绪:${JSON.stringify(parsed ?? { stdout: result.stdout.slice(-600) })}`); + return { ...parsed, artifactVersion: artifact.latestVersion, artifactSha256: artifact.sha256, valuesRedacted: true }; +} + +function remoteStatus(spec: HwpodNodeSpec): { reachable: boolean; payload: Record | null; result: CommandResult } { + const payload = { + interactiveUser: spec.interactiveUser, + runtimeRoot: spec.runtime.root, + scriptPath: spec.runtime.scriptPath, + configPath: spec.runtime.configPath, + credentialPath: spec.runtime.credentialPath, + logPath: spec.runtime.logPath, + probeLauncherPath: spec.python.probeLauncherPath, + launcherArgs: spec.python.launcherArgs, + expectedVersionPrefix: spec.python.expectedVersionPrefix, + runKeyName: spec.startup.runKeyName, + }; + const result = runPowerShell(spec.windowsRoute, desktopStatusScript(payload), 45000); + const reachable = commandSucceeded(result); + return { reachable, payload: reachable ? parseJsonRecord(result.stdout) : null, result }; +} + +async function cloudProbe(spec: HwpodNodeSpec): Promise> { + const auth = readSecretMaterial(spec.opsAuth); + if (!auth.present || !auth.value) return { ok: false, status: "blocked", blocker: "ops-auth-source-missing", sourceRef: spec.opsAuth.sourceRef, valuesRedacted: true }; + const body = { + contractVersion: "hwpod-node-ops-v1", + planId: `hwpod_status_${Date.now()}`, + hwpodId: spec.hwpodId, + nodeId: spec.nodeId, + intent: "node.version", + ops: [{ opId: "op_status", op: "node.version", args: {} }], + }; + try { + const response = await fetch(spec.nodeOpsUrl, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${auth.value}`, "x-source-service-id": "unidesk-hwpod-node-cli" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + const value = record(await response.json(), "node-ops status response"); + const result = Array.isArray(value.results) ? record(value.results[0], "node-ops result") : {}; + return { + ok: response.ok && value.ok === true && result.ok === true, + httpStatus: response.status, + status: value.status ?? null, + nodeId: spec.nodeId, + nodeVersion: record(result.output ?? {}, "node-ops output").version ?? null, + blocker: result.blocker ?? value.blocker ?? value.error ?? null, + requestId: record(value.requestMeta ?? {}, "requestMeta").requestId ?? null, + valuesRedacted: true, + }; + } catch (error) { + return { ok: false, status: "blocked", blocker: error instanceof Error ? error.message : String(error), valuesRedacted: true }; + } +} + +function desktopStatusScript(payload: Record): string { + return powerShellPayload(payload, ` +$ErrorActionPreference = 'Stop' +$expectedUser = [string]$payload.interactiveUser +$scriptPath = [string]$payload.scriptPath +$configPath = [string]$payload.configPath +$credentialPath = [string]$payload.credentialPath +$logPath = [string]$payload.logPath +$probe = [string]$payload.probeLauncherPath +$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ }) +$versionOut = @(& $probe @launcherArgs -c "import sys;print(sys.version);print(sys.executable)" 2>&1) +$versionExit = $LASTEXITCODE +$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) }) +$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique) +$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId }) +$runValue = (Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -ErrorAction SilentlyContinue).([string]$payload.runKeyName) +function HashOrNull([string]$path) { if (Test-Path $path) { return 'sha256:' + (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant() }; return $null } +$result = [ordered]@{ + ok = $true + status = 'observed' + user = $env:USERNAME + expectedUser = $expectedUser + userMatches = [bool]($env:USERNAME -ieq $expectedUser) + pythonReady = [bool]((Test-Path $probe) -and ($versionExit -eq 0) -and (($versionOut -join "\`n") -like "*$($payload.expectedVersionPrefix)*")) + pythonSummary = (($versionOut | Select-Object -First 2) -join ' | ') + installed = [bool]((Test-Path $scriptPath) -and (Test-Path $configPath) -and (Test-Path $credentialPath)) + scriptSha256 = HashOrNull $scriptPath + configSha256 = HashOrNull $configPath + credentialPresent = [bool](Test-Path $credentialPath) + credentialFingerprint = HashOrNull $credentialPath + runKeyReady = [bool](-not [string]::IsNullOrWhiteSpace([string]$runValue)) + processCount = [int]$processes.Count + processReady = [bool]($processes.Count -eq 1) + interactiveProcessCount = [int]$interactive.Count + interactiveProcessReady = [bool]($interactive.Count -eq 1) + processIds = @($processes | ForEach-Object { [int]$_.ProcessId }) + explorerSessions = @($explorerSessions) + logPresent = [bool](Test-Path $logPath) + valuesRedacted = $true +} +$result | ConvertTo-Json -Compress -Depth 6 +`); +} + +function desktopApplyScript(payload: Record): string { + return powerShellPayload(payload, ` +$ErrorActionPreference = 'Stop' +if ($env:USERNAME -ine [string]$payload.interactiveUser) { throw "interactive user mismatch expected=$($payload.interactiveUser) actual=$env:USERNAME" } +$root = [string]$payload.runtimeRoot +$scriptPath = [string]$payload.scriptPath +$configPath = [string]$payload.configPath +$credentialPath = [string]$payload.credentialPath +$probe = [string]$payload.probeLauncherPath +$gui = [string]$payload.guiLauncherPath +$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ }) +if (-not (Test-Path $probe)) { throw "Python probe launcher missing: $probe" } +if (-not (Test-Path $gui)) { throw "Python GUI launcher missing: $gui" } +$versionOut = @(& $probe @launcherArgs -c "import sys;import tkinter;print(sys.version);print(sys.executable);print(tkinter.TkVersion)" 2>&1) +if ($LASTEXITCODE -ne 0 -or (($versionOut -join "\`n") -notlike "*$($payload.expectedVersionPrefix)*")) { throw "Python version mismatch: $($versionOut -join ' | ')" } +New-Item -ItemType Directory -Force -Path $root, (Split-Path -Parent $configPath), (Split-Path -Parent $credentialPath) | Out-Null +$old = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) }) +foreach ($proc in $old) { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } +Start-Sleep -Milliseconds 500 +[IO.File]::WriteAllBytes($scriptPath, [Convert]::FromBase64String([string]$payload.artifactBase64)) +[IO.File]::WriteAllBytes($configPath, [Convert]::FromBase64String([string]$payload.configBase64)) +[IO.File]::WriteAllBytes($credentialPath, [Convert]::FromBase64String([string]$payload.credentialBase64)) +$artifactActual = (Get-FileHash -Algorithm SHA256 $scriptPath).Hash.ToLowerInvariant() +$configActual = (Get-FileHash -Algorithm SHA256 $configPath).Hash.ToLowerInvariant() +if ($artifactActual -ne [string]$payload.artifactSha256) { throw "artifact SHA mismatch" } +if ($configActual -ne [string]$payload.configSha256) { throw "config SHA mismatch" } +$null = & icacls $credentialPath /inheritance:r /grant:r "$env:USERNAME\`:(R,W)" 2>&1 +$argText = (($launcherArgs | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }) -join ' ') +$runCommand = '"' + $gui + '" ' + $argText + ' "' + $scriptPath + '"' +$null = New-Item -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Force +Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -Value $runCommand +Start-Process -FilePath $gui -ArgumentList @($launcherArgs + @($scriptPath)) -WorkingDirectory $root +Start-Sleep -Seconds 3 +$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) }) +$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique) +$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId }) +$result = [ordered]@{ + ok = [bool](($processes.Count -eq 1) -and ($interactive.Count -eq 1)) + status = $(if (($processes.Count -eq 1) -and ($interactive.Count -eq 1)) { 'applied' } else { 'installed-not-interactive' }) + pythonSummary = (($versionOut | Select-Object -First 3) -join ' | ') + scriptSha256 = 'sha256:' + $artifactActual + configSha256 = 'sha256:' + $configActual + credentialPresent = $true + processCount = [int]$processes.Count + interactiveProcessCount = [int]$interactive.Count + processIds = @($processes | ForEach-Object { [int]$_.ProcessId }) + runKeyReady = $true + stoppedProcessIds = @($old | ForEach-Object { [int]$_.ProcessId }) + valuesRedacted = $true +} +$result | ConvertTo-Json -Compress -Depth 6 +`); +} + +function powerShellPayload(payload: Record, body: string): string { + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + return `$payload = ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json)\n${body.trim()}\n`; +} + +function runPowerShell(route: string, script: string, timeoutMs: number): CommandResult { + return runCommand(["trans", route, "ps"], repoRoot, { input: script, timeoutMs }); +} + +function yamlFile(path: string): Record { + const absolute = rootPath(path); + if (!existsSync(absolute)) throw new Error(`${path} 不存在`); + return record(Bun.YAML.parse(readFileSync(absolute, "utf8")) as unknown, path); +} + +function configRef(ref: string): { file: string; path: string } { + const parts = ref.split("#"); + if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error(`配置引用必须使用 path.yaml#object.path:${ref}`); + if (isAbsolute(parts[0]) || parts[0].includes("..")) throw new Error(`配置引用文件必须是仓库内相对路径:${ref}`); + return { file: parts[0], path: parts[1] }; +} + +function pathValue(input: unknown, path: string): unknown { + let current = input; + for (const raw of path.replace(/\[(\d+)\]/gu, ".$1").split(".").filter(Boolean)) { + if (Array.isArray(current)) current = current[Number(raw)]; + else if (current && typeof current === "object") current = (current as Record)[raw]; + else return undefined; + } + return current; +} + +function record(value: unknown, path: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} 必须是对象`); + return value as Record; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`); + return value.trim(); +} + +function texts(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${path} 必须是非空字符串数组`); + return value.map((item) => String(item)); +} + +function bool(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} 必须是布尔值`); + return value; +} + +function secretPath(sourceRef: string): string { + if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`Secret sourceRef 必须是相对路径且不能包含 ..:${sourceRef}`); + return join(SECRET_ROOT, sourceRef); +} + +function parseEnv(content: string): Record { + const result: Record = {}; + for (const raw of content.split(/\r?\n/u)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const index = line.indexOf("="); + if (index <= 0) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim().replace(/^['"]|['"]$/gu, ""); + } + return result; +} + +function parseJsonRecord(textValue: string): Record | null { + const lines = textValue.trim().split(/\r?\n/u).filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + return record(JSON.parse(lines[index]) as unknown, "远端 JSON"); + } catch {} + } + return null; +} + +function compactFailure(result: CommandResult): string { + const reason = result.timedOut ? "command-timed-out" : "command-failed"; + return `${reason}; exit=${result.exitCode ?? "-"}; stderr=${result.stderr.trim().slice(-500)}`; +} + +function sha256(value: string | Buffer): string { + return `sha256:${sha256Hex(value)}`; +} + +function sha256Hex(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function commandSucceeded(result: CommandResult): boolean { + return result.exitCode === 0 && !result.timedOut; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index e1fa2863..e348ca87 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -19,6 +19,7 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes git-mirror status --node --lane ", "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node --lane --dry-run", + "bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03", "bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo", "bun scripts/cli.ts hwlab nodes secret status --node --lane --name ", "bun scripts/cli.ts hwlab nodes test-accounts status --node --lane ", @@ -29,6 +30,7 @@ export function hwlabNodeHelp(): Record { "control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.", "git-mirror": "Inspect or operate the selected node/lane source mirror.", "hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.", + "hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。", "fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.", secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.", "test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.", diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index a3bc6ca8..b81f6190 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -540,6 +540,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall"); return runHwlabNodeHwpodPreinstallCommand(args.slice(1)); } + if (domain === "hwpod-node") { + const { runHwlabHwpodNodeCommand } = await import("../hwlab-hwpod-node"); + return runHwlabHwpodNodeCommand(args.slice(1)); + } if (domain === "fake-model-provider") { return runHwlabFakeModelProviderCommand(_config, args.slice(1)); } @@ -555,7 +559,7 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom return runNodeDelegatedDomain(_config, domain, args.slice(1)); } if (domain !== "secret") { - return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" }; + return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes hwpod-node, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" }; } const options = parseSecretOptions(args.slice(1)); return runNodeSecret(options);