Merge pull request #2724 from pikasTech/fix/web-probe-native-observer
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

feat: 支持 L1 native Web observer
This commit is contained in:
Lyon
2026-07-21 11:33:09 +08:00
committed by GitHub
12 changed files with 70 additions and 33 deletions
+4
View File
@@ -29,6 +29,10 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web
- 禁止直接调用 `playwright``@playwright/cli``playwright-cli`、浏览器 MCP 或仓库外临时浏览器脚本;
- `web-probe` 内部可以使用浏览器引擎,但认证、目标解析、截图、脱敏、artifact 和报告必须由 `web-probe` 合同统一管理;
- 缺少所需动作时,先在 owning YAML 和 `web-probe` typed command 中补齐通用能力,不得绕过为第二操作面。
- L1 Workbench 长程 observer 使用 `web-probe observe start --origin native`
- 固定 HTTPS 入口只从 owning YAML 的 `nativeDevelopment.workbench.publicExposure.publicBaseUrl` 解析;
- 禁止使用部署态 `public` origin、自定义 URL 或 localhost 冒充 L1 observer
- 后续 command、status、collect、analyze 和 stop 继承启动时冻结的 native origin。
- Web-probe 正式 CLI 入口是 `bun scripts/cli.ts web-probe ...`;旧 `hwlab nodes web-probe` 已移除,只能按 CLI 提示迁移,不要在 issue 或长期文档中继续记录旧入口。
- `web-probe observe stop` 是 observer 生命周期清理入口:
- 不得依赖业务页面 readiness 才执行停止;
@@ -14,6 +14,7 @@
- 已报告的视觉层级问题使用 profile 的 `geometryChecks` 输出匹配数量、实际尺寸和 YAML 预算,截图与几何判定必须同时通过;
- 命令不接受 `--url``--origin`,避免绕过 owning YAML 选择其他运行面。
- L1 浏览器必须打开 owning YAML 解析出的固定 HTTPS origin`127.0.0.1``localhost`、IP、port 和临时 `--url` 只能作为本机 preflight,不能替代 L1 readiness。
- L1 Workbench 长程观察使用 `bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin native --target-path /workbench`native origin 只读取同一 owning YAML 的 Workbench `publicExposure.publicBaseUrl`,不得回退部署态 public origin。
- Cloud Console 页面矩阵:
- 使用 `bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal|public --profile <profile>`
- 配置真相位于 `config/hwlab-node-lanes.yaml#templates.*.webProbeWorkbench.consoleVerificationProfiles`
@@ -497,6 +497,8 @@ mock 数据应优先来自目标 node/lane 的真实受控样本,而不是从
Web 工作台应具备长程 web-probe observer 能力,用真实浏览器在目标 node/lane 的正式公开入口上持续观察同一页面,用于复现和分析长 Trace、session 切换、刷新恢复、滚动跟随、final response 闪烁和无用户新消息时状态变化等瞬态问题。该能力只提供观察和证据,不拥有 Workbench 业务状态、身份鉴权、session lifecycle、projection、监控指标或修复权。
长程 observer 必须显式选择运行面。L1 native 使用 `--origin native`,并且只从目标 node/lane owning YAML 的 `nativeDevelopment.workbench.publicExposure.publicBaseUrl` 解析固定 HTTPS 入口;L2/L3 继续使用部署态 `internal``public` semantic origin。CLI 不得用自定义 URL、部署态 public URL、localhost 或隐藏 fallback 冒充 L1 证据,后续 command、status、collect、analyze 和 stop 必须继承 observer 启动时冻结的运行面与 origin。
web-probe observer 必须保持纯客户端形态。它可以作为目标 host 上的长时间本地进程运行,但不得暴露入站 HTTP/WebSocket/gRPC API、监听控制端口、数据库 schema、消息队列消费者、Kubernetes 常驻服务、平台 daemon 或 Web 管理面。启动、停止、状态查看、命令投递和 artifact 回收只能通过受控 CLI、`trans` 短连接和目标 host 文件系统完成。任何需要长期保留的默认参数必须按 YAML-first 进入 UniDesk 自有配置;一次性控制命令使用命令文件,不得演变为新服务协议。
同一个 observer job 内必须只有一个页面权威。Playwright browser/context/page 由 observer 客户端进程持有;控制循环、采样循环、截图逻辑和网络事件监听共享同一个 `page` 对象。登录、打开 URL、发送 prompt、点击会话、截图和停止等控制动作以命令文件进入 `commands/pending`,处理结果追加到 `control.jsonl`,并带上命令 id、时间戳、操作者来源、执行前后 URL 和页面标识。若浏览器或页面崩溃后必须重建,observer 必须记录 oldPageId/newPageId、原因和连续性中断,不能把重建后的页面当作同一段无缝采样。
+7 -6
View File
@@ -86,7 +86,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
return {
ok: true,
command: "web-probe",
description: "Run target node/lane HWLAB Cloud Web probes with YAML-selected semantic internal/public origins and one-shot bootstrap Web credentials.",
description: "Run target node/lane HWLAB Cloud Web probes with YAML-selected semantic internal/public/native origins and one-shot bootstrap Web credentials.",
examples: [
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin internal --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --origin internal --message 'hi'",
@@ -102,6 +102,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe script --node <node> --lane <lane> --origin internal --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --origin internal --path /workbench --viewport 1440x900",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin internal --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin native --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx",
@@ -138,9 +139,9 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated consumers are delivered only by the automatic PR-merge chain.",
},
notes: [
"Named internal/public origins, default origin, per-origin browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.",
"Use --origin internal for business/feature bug debugging and --origin public for public exposure or public-entry closeout; both resolve only from the owning YAML.",
"--url is a mutually exclusive custom/local one-shot escape hatch; never pass a URL or IP to choose between internal and public origins.",
"Named internal/public origins, the L1 native Workbench origin, default origin, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml.",
"Use --origin native for L1 Workbench, --origin internal for deployed feature debugging, and --origin public for deployed public-entry closeout; all resolve only from the owning YAML.",
"--url is a mutually exclusive custom/local one-shot escape hatch; never pass a URL or IP to impersonate an internal, public, or native origin.",
"`web-probe script` is an ad-hoc exploration escape hatch; repeated/high-frequency workflows must become `web-probe observe command` types or repo-owned web-probe commands.",
"Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT <json>` guidance; repo-owned generated commands report reuse instead of ad-hoc promotion.",
"`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.",
@@ -177,7 +178,7 @@ export function hwlabNodeWebProbeScreenshotHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --url 'http://127.0.0.1:4173/workbench' --viewport 1440x900",
],
options: {
"--origin": "从 owning YAML 选择 internalpublic semantic origin;可与 --path 配合。",
"--origin": "从 owning YAML 选择 internalpublic 或 native semantic origin;可与 --path 配合。",
"--path": "附加到 semantic origin 的绝对路径;与 --url 互斥。",
"--url": "custom/local 一次性 URL,必须包含完整路径;与 --origin、--path 互斥。",
"--viewport": "截图视口,默认 1440x900。",
@@ -186,7 +187,7 @@ export function hwlabNodeWebProbeScreenshotHelp(): Record<string, unknown> {
},
notes: [
"使用 --url 时不要再传 --pathcustom/local URL 已包含完整路径。",
"internal/public 运行面必须通过 owning YAML 的 --origin 选择,禁止用手写 URL 或 IP 替代。",
"internal/public/native 运行面必须通过 owning YAML 的 --origin 选择,禁止用手写 URL 或 IP 替代。",
"该命令只采集证据,不修改浏览器能力、资源门禁或运行面。",
],
mutation: false,
@@ -12,8 +12,8 @@ export interface WebObserveWrapperInput {
readonly jobId: string | null;
readonly stateDir: string | null;
readonly url: string | null;
readonly originName: "internal" | "public" | "custom" | null;
readonly originMode: "k8s-service-cluster-ip" | "public" | "custom-url" | null;
readonly originName: "internal" | "public" | "native" | "custom" | null;
readonly originMode: "k8s-service-cluster-ip" | "public" | "native-public" | "custom-url" | null;
readonly originConfigPath: string | null;
readonly browserProxyMode: "auto" | "direct" | null;
readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default" | null;
@@ -37,8 +37,8 @@ export interface WebObserveWrapperOptionsLike {
readonly node: string;
readonly lane: string;
readonly url: string;
readonly originName: "internal" | "public" | "custom";
readonly originMode: "k8s-service-cluster-ip" | "public" | "custom-url";
readonly originName: "internal" | "public" | "native" | "custom";
readonly originMode: "k8s-service-cluster-ip" | "public" | "native-public" | "custom-url";
readonly originConfigPath: string | null;
readonly browserProxyMode: "auto" | "direct";
readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default";
@@ -235,7 +235,7 @@ function webObserveWrapperCommandShape(input: WebObserveWrapperInput): string {
parts.push("--state-dir", input.stateDir);
}
if (input.action === "start" && input.targetPath !== null) {
if (input.originName === "internal" || input.originName === "public") parts.push("--origin", input.originName);
if (input.originName === "internal" || input.originName === "public" || input.originName === "native") parts.push("--origin", input.originName);
parts.push("--target-path", input.targetPath);
}
if (input.action === "command" && input.commandType !== null) {
+2 -2
View File
@@ -58,11 +58,11 @@ export type SecretPreset = "openfga" | "master-server-admin-api-key" | "bootstra
export type NodeRuntimeRenderLocation = "node-host";
export type WebProbeBrowserProxyMode = "auto" | "direct";
export type WebProbeOriginName = HwlabRuntimeWebProbeOriginName | "custom";
export type WebProbeOriginName = HwlabRuntimeWebProbeOriginName | "native" | "custom";
export interface WebProbeOriginSelection {
readonly originName: WebProbeOriginName;
readonly originMode: "k8s-service-cluster-ip" | "public" | "custom-url";
readonly originMode: "k8s-service-cluster-ip" | "public" | "native-public" | "custom-url";
readonly originConfigPath: string | null;
readonly originSource?: {
readonly namespace: string;
+5 -5
View File
@@ -991,8 +991,8 @@ export function readWebObserveIndex(): Record<string, WebObserveIndexEntry> {
workspace,
stateDir,
url: typeof recordValue.url === "string" ? recordValue.url : "",
originName: recordValue.originName === "internal" || recordValue.originName === "public" || recordValue.originName === "custom" ? recordValue.originName : null,
originMode: recordValue.originMode === "k8s-service-cluster-ip" || recordValue.originMode === "public" || recordValue.originMode === "custom-url" ? recordValue.originMode : null,
originName: recordValue.originName === "internal" || recordValue.originName === "public" || recordValue.originName === "native" || recordValue.originName === "custom" ? recordValue.originName : null,
originMode: recordValue.originMode === "k8s-service-cluster-ip" || recordValue.originMode === "public" || recordValue.originMode === "native-public" || recordValue.originMode === "custom-url" ? recordValue.originMode : null,
originConfigPath: typeof recordValue.originConfigPath === "string" ? recordValue.originConfigPath : null,
browserProxyMode: recordValue.browserProxyMode === "auto" || recordValue.browserProxyMode === "direct" ? recordValue.browserProxyMode : null,
browserProxyModeSource: recordValue.browserProxyModeSource === "cli" || recordValue.browserProxyModeSource === "yaml-origin" || recordValue.browserProxyModeSource === "yaml-web-probe" || recordValue.browserProxyModeSource === "default" ? recordValue.browserProxyModeSource : null,
@@ -1121,8 +1121,8 @@ export function discoverWebObserveIndexEntryOnTarget(id: string, node: string, l
workspace: spec.workspace,
stateDir,
url: typeof entry.url === "string" ? entry.url : "",
originName: entry.originName === "internal" || entry.originName === "public" || entry.originName === "custom" ? entry.originName : null,
originMode: entry.originMode === "k8s-service-cluster-ip" || entry.originMode === "public" || entry.originMode === "custom-url" ? entry.originMode : null,
originName: entry.originName === "internal" || entry.originName === "public" || entry.originName === "native" || entry.originName === "custom" ? entry.originName : null,
originMode: entry.originMode === "k8s-service-cluster-ip" || entry.originMode === "public" || entry.originMode === "native-public" || entry.originMode === "custom-url" ? entry.originMode : null,
originConfigPath: typeof entry.originConfigPath === "string" ? entry.originConfigPath : null,
browserProxyMode: entry.browserProxyMode === "auto" || entry.browserProxyMode === "direct" ? entry.browserProxyMode : null,
browserProxyModeSource: entry.browserProxyModeSource === "cli" || entry.browserProxyModeSource === "yaml-origin" || entry.browserProxyModeSource === "yaml-web-probe" || entry.browserProxyModeSource === "default" ? entry.browserProxyModeSource : null,
@@ -1203,7 +1203,7 @@ function webObserveOriginName(value: unknown): WebObserveIndexEntry["originName"
}
function webObserveOriginMode(value: unknown): WebObserveIndexEntry["originMode"] {
return value === "k8s-service-cluster-ip" || value === "public" || value === "custom-url" ? value : null;
return value === "k8s-service-cluster-ip" || value === "public" || value === "native-public" || value === "custom-url" ? value : null;
}
function webObserveBrowserProxyMode(value: unknown): WebObserveIndexEntry["browserProxyMode"] {
@@ -906,7 +906,7 @@ export function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptio
}
function webProbeSemanticOriginArgs(options: Pick<NodeWebProbeScriptOptions, "originName">): string {
return options.originName === "internal" || options.originName === "public" ? ` --origin ${options.originName}` : "";
return options.originName === "internal" || options.originName === "public" || options.originName === "native" ? ` --origin ${options.originName}` : "";
}
export function nodeWebProbeScriptRemoteShell(
@@ -171,9 +171,25 @@ test("observe start selects the YAML public origin semantically", () => {
assert.equal(options.browserProxyModeSource, "yaml-origin");
});
test("observe start selects the YAML L1 native Workbench HTTPS origin", () => {
const options = parseNodeWebProbeObserveOptions(
["start", "--origin", "native"],
"NC01",
"v03",
spec,
null,
null,
);
assert.equal(options.originName, "native");
assert.equal(options.originMode, "native-public");
assert.equal(options.url, spec.nativeDevelopment?.workbench.publicExposure.publicBaseUrl);
assert.match(options.originConfigPath ?? "", /nativeDevelopment\.workbench\.publicExposure\.publicBaseUrl/u);
});
test("observe start inherits finite observer lifecycle limits from the owning YAML", () => {
const options = parseNodeWebProbeObserveOptions(
["start"],
["start", "--origin", "native"],
"NC01",
"v03",
spec,
@@ -216,7 +232,7 @@ test("observe start inherits finite observer lifecycle limits from the owning YA
test("sentinel observe start preserves the cadence comparator through the child CLI", () => {
const options = parseNodeWebProbeObserveOptions(
["start", "--sentinel-cadence"],
["start", "--origin", "native", "--sentinel-cadence"],
"NC01",
"v03",
spec,
@@ -228,11 +244,11 @@ test("sentinel observe start preserves the cadence comparator through the child
test("observe start cannot disable or exceed the YAML lifecycle caps", () => {
assert.throws(
() => parseNodeWebProbeObserveOptions(["start", "--max-run-seconds", "0"], "NC01", "v03", spec, null, null),
() => parseNodeWebProbeObserveOptions(["start", "--origin", "native", "--max-run-seconds", "0"], "NC01", "v03", spec, null, null),
/must be a positive integer/u,
);
assert.throws(
() => parseNodeWebProbeObserveOptions(["start", "--max-samples", "721"], "NC01", "v03", spec, null, null),
() => parseNodeWebProbeObserveOptions(["start", "--origin", "native", "--max-samples", "721"], "NC01", "v03", spec, null, null),
/exceeds the owning YAML observer lifecycle cap 720/u,
);
});
@@ -9,8 +9,9 @@ const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
test("web-probe origin names are semantic and bounded", () => {
assert.equal(parseWebProbeOriginName("internal"), "internal");
assert.equal(parseWebProbeOriginName("public"), "public");
assert.throws(() => parseWebProbeOriginName("external"), /internal or public/u);
assert.throws(() => parseWebProbeOriginName("https:\/\/hwlab.example.test"), /internal or public/u);
assert.equal(parseWebProbeOriginName("native"), "native");
assert.throws(() => parseWebProbeOriginName("external"), /internal, public, or native/u);
assert.throws(() => parseWebProbeOriginName("https:\/\/hwlab.example.test"), /internal, public, or native/u);
});
test("NC01 v03 YAML declares internal debugging and public closeout origins", () => {
+19 -7
View File
@@ -1,7 +1,7 @@
// Responsibility: resolve semantic YAML-owned HWLAB web-probe origins without CLI URL/IP selection.
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeOriginName } from "../hwlab-node-lanes";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { WebProbeBrowserProxyMode, WebProbeOriginSelection } from "./entry";
import type { WebProbeBrowserProxyMode, WebProbeOriginName, WebProbeOriginSelection } from "./entry";
import { resolveKubernetesServiceClusterIp } from "./web-probe-observe-action-config";
export interface ResolvedNodeWebProbeOrigin extends WebProbeOriginSelection {
@@ -27,16 +27,28 @@ type ServiceClusterIpResolver = (
serviceCache: Map<string, string>,
) => string;
export function parseWebProbeOriginName(value: string): HwlabRuntimeWebProbeOriginName {
if (value === "internal" || value === "public") return value;
throw new Error(`web-probe --origin must be internal or public, got ${value}`);
export function parseWebProbeOriginName(value: string): Exclude<WebProbeOriginName, "custom"> {
if (value === "internal" || value === "public" || value === "native") return value;
throw new Error(`web-probe --origin must be internal, public, or native, got ${value}`);
}
export function resolveNodeWebProbeOrigin(
spec: HwlabRuntimeLaneSpec,
name: HwlabRuntimeWebProbeOriginName,
name: Exclude<WebProbeOriginName, "custom">,
serviceResolver: ServiceClusterIpResolver = resolveKubernetesServiceClusterIp,
): ResolvedNodeWebProbeOrigin {
if (name === "native") {
const configPath = `${hwlabRuntimeLaneConfigPath()} node=${spec.nodeId} lane=${spec.lane}#nativeDevelopment.workbench.publicExposure.publicBaseUrl`;
const publicBaseUrl = spec.nativeDevelopment?.workbench.publicExposure.publicBaseUrl;
if (publicBaseUrl === undefined) throw new Error(`${configPath} must declare the L1 native Workbench HTTPS origin`);
return {
originName: name,
originMode: "native-public",
originConfigPath: configPath,
url: normalizeHttpOrigin(publicBaseUrl, configPath),
...resolvedOriginBrowserProxyMode(spec, undefined),
};
}
const origin = spec.webProbe?.origins?.[name];
const configPath = `${hwlabRuntimeLaneConfigPath()} node=${spec.nodeId} lane=${spec.lane}#webProbe.origins.${name}`;
if (origin === undefined) throw new Error(`${configPath} must declare the semantic ${name} web-probe origin`);
@@ -85,7 +97,7 @@ export function resolveNodeWebProbeCliOrigin(
requestedBrowserProxyMode: WebProbeBrowserProxyMode | undefined,
): ResolvedNodeWebProbeOrigin {
if (requestedOrigin !== undefined && customUrl !== undefined) {
throw new Error("web-probe accepts --origin or --url, not both; use --origin internal|public for HWLAB network selection");
throw new Error("web-probe accepts --origin or --url, not both; use --origin internal|public|native for HWLAB runtime selection");
}
const selected = customUrl === undefined
? resolveNodeWebProbeOrigin(spec, parseWebProbeOriginName(requestedOrigin ?? spec.webProbe?.defaultOrigin ?? "public"))
+1 -1
View File
@@ -2092,7 +2092,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify" && actionRaw !== "native-readiness") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify|native-readiness --node NODE --lane vNN [--origin internal|public]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify" && actionRaw !== "native-readiness") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify|native-readiness --node NODE --lane vNN [--origin internal|public|native]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));