diff --git a/.agents/skills/unidesk-devlevel/SKILL.md b/.agents/skills/unidesk-devlevel/SKILL.md index b7f78c3c..16c445a8 100644 --- a/.agents/skills/unidesk-devlevel/SKILL.md +++ b/.agents/skills/unidesk-devlevel/SKILL.md @@ -47,6 +47,11 @@ description: >- - Web 使用 `$unidesk-webdev` 的 `web-probe` custom/local 入口访问 native Web。 - 微服务项目只启动当前微服务的前端、API、Worker及必要依赖。 - 前端、API 和 Worker可以分别启动、查看日志、重启和停止。 +- 对用户披露的 native API/Web 调试入口必须是 owning YAML 声明的公网 `IP:port`: + - `0.0.0.0` 只表示进程 bind; + - `127.0.0.1` 只用于本机 probe 或进程间代理; + - bind/probe 地址不得作为 L1 用户入口返回; + - 公网 IP、API port 和 Web port 缺失时必须明确失败,不得回退到 localhost 或代码默认值。 - 适合前后端联调、异步作业、Workflow、网络接口和页面交互的快速开发。 ## L2 Development diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 0d9002ea..22ad092f 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -111,6 +111,22 @@ lanes: browserCommand: npx playwright install chromium stateDir: /tmp/hwlab-v03-source-workspace-bootstrap timeoutSeconds: 1800 + nativeDevelopment: + workbench: + publicHostRef: config/unidesk-host-k8s.yaml#runtime.publicHost + stateDir: .state/workbench-native/services + api: + bindHost: 0.0.0.0 + probeHost: 127.0.0.1 + port: 6677 + worker: + bindHost: 127.0.0.1 + probeHost: 127.0.0.1 + healthPort: 6678 + web: + bindHost: 0.0.0.0 + probeHost: 127.0.0.1 + port: 5173 sourceAuthority: extends: templates.hwlabV03.sourceAuthorityGiteaSnapshot giteaMirrorRepoKey: hwlab-nc01-v03 diff --git a/scripts/src/hwlab-native-development.test.ts b/scripts/src/hwlab-native-development.test.ts new file mode 100644 index 00000000..b3eecb5e --- /dev/null +++ b/scripts/src/hwlab-native-development.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; + +import { hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp } from "./hwlab-native-development"; +import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; + +test("L1 Workbench exposure uses YAML public IP and separate bind/probe addresses", () => { + const native = hwlabRuntimeLaneSpecForNode("v03", "NC01").nativeDevelopment?.workbench; + expect(native).toBeDefined(); + const env = hwlabNativeDevelopmentEnvironment(native!); + expect(env.WORKBENCH_NATIVE_SERVICE_STATE_DIR).toBe(native!.stateDir); + expect(env.WORKBENCH_API_BIND_HOST).toBe(native!.api.bindHost); + expect(env.WORKBENCH_API_PROBE_HOST).toBe(native!.api.probeHost); + expect(env.WORKBENCH_API_PUBLIC_HOST).toBe(native!.publicHost); + expect(env.WORKBENCH_WEB_PUBLIC_HOST).toBe(native!.publicHost); + expect(hwlabNativeDevelopmentHelp()).toMatchObject({ ok: true, command: "hwlab nodes native-development workbench" }); +}); diff --git a/scripts/src/hwlab-native-development.ts b/scripts/src/hwlab-native-development.ts new file mode 100644 index 00000000..0b3ce5f5 --- /dev/null +++ b/scripts/src/hwlab-native-development.ts @@ -0,0 +1,94 @@ +import { runCommand } from "./command"; +import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeNativeDevelopmentSpec } from "./hwlab-node-lanes"; + +type Service = "api" | "worker" | "web"; +type Action = "start" | "stop" | "restart" | "status" | "logs"; + +export function hwlabNativeDevelopmentHelp(): Record { + return { + ok: true, + command: "hwlab nodes native-development workbench", + usage: [ + "bun scripts/cli.ts hwlab nodes native-development workbench api|worker|web start|stop|restart|status|logs --node --lane ", + ], + contract: "L1 API/Web 用户入口只输出 owning YAML 解析出的公网 IP 和 port;bind/probe 地址不作为用户入口。", + configPath: hwlabRuntimeLaneConfigPath(), + }; +} + +export function runHwlabNativeDevelopmentCommand(args: string[]): Record { + if (args.length === 0 || args.includes("--help") || args.includes("-h")) return hwlabNativeDevelopmentHelp(); + const [application, serviceRaw, actionRaw] = args; + if (application !== "workbench") throw new Error("native-development currently supports workbench"); + const service = parseService(serviceRaw); + const action = parseAction(actionRaw); + const node = requiredOption(args, "--node"); + const lane = requiredOption(args, "--lane"); + if (!isHwlabRuntimeLane(lane)) throw new Error(`--lane must be declared in ${hwlabRuntimeLaneConfigPath()}`); + const spec = hwlabRuntimeLaneSpecForNode(lane, node); + const native = spec.nativeDevelopment?.workbench; + if (native === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.workbench is required`); + const env = hwlabNativeDevelopmentEnvironment(native); + const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "workbench", "service", service, action]; + const result = runCommand(command, spec.workspace, { timeoutMs: 15_000, env: { ...process.env, ...env } }); + const payload = parsePayload(result.stdout); + const publicEndpoint = service === "api" + ? { ip: native.publicHost, port: native.api.port, url: `http://${native.publicHost}:${native.api.port}` } + : service === "web" + ? { ip: native.publicHost, port: native.web.port, url: `http://${native.publicHost}:${native.web.port}/workbench` } + : null; + return { + ok: result.exitCode === 0 && payload.ok !== false, + command: `hwlab nodes native-development workbench ${service} ${action}`, + node, + lane, + service, + action, + configSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.workbench`, + publicHostSource: native.publicHostRef, + userEndpoint: publicEndpoint, + serviceResult: payload, + ...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_service_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }), + }; +} + +export function hwlabNativeDevelopmentEnvironment(native: HwlabRuntimeNativeDevelopmentSpec["workbench"]): NodeJS.ProcessEnv { + return { + WORKBENCH_NATIVE_SERVICE_STATE_DIR: native.stateDir, + WORKBENCH_API_BIND_HOST: native.api.bindHost, + WORKBENCH_API_PROBE_HOST: native.api.probeHost, + WORKBENCH_API_PUBLIC_HOST: native.publicHost, + WORKBENCH_API_PORT: String(native.api.port), + WORKBENCH_WORKER_BIND_HOST: native.worker.bindHost, + WORKBENCH_WORKER_PROBE_HOST: native.worker.probeHost, + WORKBENCH_WORKER_HEALTH_PORT: String(native.worker.healthPort), + WORKBENCH_WEB_BIND_HOST: native.web.bindHost, + WORKBENCH_WEB_PROBE_HOST: native.web.probeHost, + WORKBENCH_WEB_PUBLIC_HOST: native.publicHost, + WORKBENCH_WEB_PORT: String(native.web.port), + WORKBENCH_NATIVE_API_URL: `http://${native.api.probeHost}:${native.api.port}`, + }; +} + +function parsePayload(stdout: string): Record { + const parsed = JSON.parse(stdout) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("HWLAB Workbench CLI returned invalid JSON"); + return parsed as Record; +} + +function parseService(value: string | undefined): Service { + if (value === "api" || value === "worker" || value === "web") return value; + throw new Error("native-development workbench service must be api, worker, or web"); +} + +function parseAction(value: string | undefined): Action { + if (value === "start" || value === "stop" || value === "restart" || value === "status" || value === "logs") return value; + throw new Error("native-development workbench action must be start, stop, restart, status, or logs"); +} + +function requiredOption(args: string[], option: string): string { + const index = args.indexOf(option); + const value = index < 0 ? undefined : args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); + return value; +} diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 9682f0df..54d5ddc2 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -49,6 +49,7 @@ export function hwlabNodeHelp(scope: "legacy-cicd" | "platform-maintenance" | nu "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 native-development workbench web status --node --lane ", "bun scripts/cli.ts hwlab nodes secret status --node --lane --name ", "bun scripts/cli.ts hwlab nodes test-accounts status --node --lane ", "bun scripts/cli.ts hwlab nodes observability performance-summary --node --lane ", @@ -62,6 +63,7 @@ export function hwlabNodeHelp(scope: "legacy-cicd" | "platform-maintenance" | nu "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.", + "native-development": "按 owning YAML 独立管理 L1 API、Worker 与 Web,并为 API/Web 披露公网 IP 和 port。", secret: "Inspect YAML-declared runtime Secrets without printing secret values; writes are documented only by platform-maintenance help.", "test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.", observability: "Read runtime metrics and authenticated Web Performance summaries.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index cff48eae..ba66df49 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -4,10 +4,12 @@ // SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // Responsibility: YAML source-of-truth parsing for HWLAB node/lane Workbench observability. import { readFileSync } from "node:fs"; +import { isIP } from "node:net"; import { rootPath } from "./config"; import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH } from "./hwlab-node-control-plane-model"; import { parseWebProbeConsoleVerificationProfiles, type HwlabRuntimeWebProbeConsoleVerificationProfileSpec } from "./hwlab-node-web-probe-console-profile"; import { materializeYamlComposition } from "./yaml-composition"; +import { resolveConfigRefString } from "./ops/config-refs"; export type HwlabRuntimeLane = string; @@ -660,6 +662,17 @@ export interface HwlabRuntimeSourceWorkspaceHostDependenciesSpec { }; } +export interface HwlabRuntimeNativeDevelopmentSpec { + readonly workbench: { + readonly publicHostRef: string; + readonly publicHost: string; + readonly stateDir: string; + readonly api: { readonly bindHost: string; readonly probeHost: string; readonly port: number }; + readonly worker: { readonly bindHost: string; readonly probeHost: string; readonly healthPort: number }; + readonly web: { readonly bindHost: string; readonly probeHost: string; readonly port: number }; + }; +} + export interface HwlabRuntimePipelineProvenanceSpec { readonly configRef: string; readonly renderer: "hwlab-runtime-lane"; @@ -711,6 +724,7 @@ export interface HwlabRuntimeLaneSpec { readonly codeAgentProvider?: HwlabRuntimeCodeAgentProviderSpec; readonly codeAgentRuntime?: HwlabRuntimeCodeAgentRuntimeSpec; readonly sourceWorkspace?: HwlabRuntimeSourceWorkspaceSpec; + readonly nativeDevelopment?: HwlabRuntimeNativeDevelopmentSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly runtimeStore?: HwlabRuntimeStoreSpec; readonly webProbe?: HwlabRuntimeWebProbeSpec; @@ -777,6 +791,7 @@ interface HwlabLaneConfig { readonly codeAgentProvider?: HwlabRuntimeCodeAgentProviderSpec; readonly codeAgentRuntime?: HwlabRuntimeCodeAgentRuntimeSpec; readonly sourceWorkspace?: HwlabRuntimeSourceWorkspaceSpec; + readonly nativeDevelopment?: HwlabRuntimeNativeDevelopmentSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly runtimeStore?: HwlabRuntimeStoreSpec; readonly webProbe?: HwlabRuntimeWebProbeSpec; @@ -1036,6 +1051,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa codeAgentProvider: codeAgentProviderConfig(raw.codeAgentProvider, `lanes.${id}.codeAgentProvider`), codeAgentRuntime: codeAgentRuntimeConfig(raw.codeAgentRuntime, `lanes.${id}.codeAgentRuntime`), sourceWorkspace: sourceWorkspaceConfig(raw.sourceWorkspace, `lanes.${id}.sourceWorkspace`), + nativeDevelopment: nativeDevelopmentConfig(raw.nativeDevelopment, `lanes.${id}.nativeDevelopment`), externalPostgres: externalPostgresConfig(raw.externalPostgres, `lanes.${id}.externalPostgres`), runtimeStore: runtimeStoreConfig(raw.runtimeStore, `lanes.${id}.runtimeStore`), webProbe: webProbeConfig(raw.webProbe, `lanes.${id}.webProbe`), @@ -1074,6 +1090,7 @@ function laneTargetConfig( sourceAuthority: mergeOptionalRecord(baseRaw.sourceAuthority, targetRaw.sourceAuthority), sourceSnapshot: mergeOptionalRecord(baseRaw.sourceSnapshot, targetRaw.sourceSnapshot), sourceWorkspace: mergeOptionalRecord(baseRaw.sourceWorkspace, targetRaw.sourceWorkspace), + nativeDevelopment: mergeOptionalRecord(baseRaw.nativeDevelopment, targetRaw.nativeDevelopment), externalPostgres: mergeOptionalRecord(baseRaw.externalPostgres, targetRaw.externalPostgres), runtimeStore: mergeOptionalRecord(baseRaw.runtimeStore, targetRaw.runtimeStore), webProbe: mergeOptionalRecord(baseRaw.webProbe, targetRaw.webProbe), @@ -1394,6 +1411,28 @@ function sourceWorkspaceConfig(value: unknown, path: string): HwlabRuntimeSource }; } +function nativeDevelopmentConfig(value: unknown, path: string): HwlabRuntimeNativeDevelopmentSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + const workbench = asRecord(raw.workbench, `${path}.workbench`); + const publicHostRef = stringField(workbench, "publicHostRef", `${path}.workbench`); + const publicHost = resolveConfigRefString(publicHostRef, `${path}.workbench.publicHostRef`); + if (isIP(publicHost) === 0) throw new Error(`${path}.workbench.publicHostRef must resolve to a public IP address`); + const api = asRecord(workbench.api, `${path}.workbench.api`); + const worker = asRecord(workbench.worker, `${path}.workbench.worker`); + const web = asRecord(workbench.web, `${path}.workbench.web`); + return { + workbench: { + publicHostRef, + publicHost, + stateDir: relativeWorkspacePathField(stringField(workbench, "stateDir", `${path}.workbench`), `${path}.workbench.stateDir`), + api: { bindHost: stringField(api, "bindHost", `${path}.workbench.api`), probeHost: stringField(api, "probeHost", `${path}.workbench.api`), port: numberField(api, "port", `${path}.workbench.api`) }, + worker: { bindHost: stringField(worker, "bindHost", `${path}.workbench.worker`), probeHost: stringField(worker, "probeHost", `${path}.workbench.worker`), healthPort: numberField(worker, "healthPort", `${path}.workbench.worker`) }, + web: { bindHost: stringField(web, "bindHost", `${path}.workbench.web`), probeHost: stringField(web, "probeHost", `${path}.workbench.web`), port: numberField(web, "port", `${path}.workbench.web`) }, + }, + }; +} + function sourceWorkspaceHostDependenciesConfig(value: unknown, path: string): HwlabRuntimeSourceWorkspaceHostDependenciesSpec | undefined { if (value === undefined) return undefined; const raw = asRecord(value, path); @@ -2465,6 +2504,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { ...(config.codeAgentProvider === undefined ? {} : { codeAgentProvider: config.codeAgentProvider }), ...(config.codeAgentRuntime === undefined ? {} : { codeAgentRuntime: config.codeAgentRuntime }), ...(config.sourceWorkspace === undefined ? {} : { sourceWorkspace: config.sourceWorkspace }), + ...(config.nativeDevelopment === undefined ? {} : { nativeDevelopment: config.nativeDevelopment }), ...(config.externalPostgres === undefined ? {} : { externalPostgres: config.externalPostgres }), ...(config.runtimeStore === undefined ? {} : { runtimeStore: config.runtimeStore }), ...(config.webProbe === undefined ? {} : { webProbe: config.webProbe }), diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 431e4b66..3c8a5638 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -47,6 +47,7 @@ import { legacyHwlabNodeWebProbeUnsupported } from "../web-probe"; import { startNodeDelegatedJob } from "./web-probe"; import { assertKnownOptions } from "./web-probe-observe"; import { hwlabNodeDeliveryMutationDecision } from "./delivery-authority"; +import { runHwlabNativeDevelopmentCommand } from "../hwlab-native-development"; export { hwlabNodeHelp, hwlabNodeWebProbeHelp, hwlabNodeObservabilityHelp } from "../hwlab-node-help"; @@ -569,6 +570,9 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom if (domain === "fake-model-provider") { return runHwlabFakeModelProviderCommand(_config, args.slice(1)); } + if (domain === "native-development") { + return runHwlabNativeDevelopmentCommand(args.slice(1)); + } if (domain === "web-probe") { return legacyHwlabNodeWebProbeUnsupported(args.slice(1)); } @@ -584,7 +588,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 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" }; + return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes native-development, 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);