diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index b4ed7ddb..c928c989 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -13,7 +13,8 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - Web-probe 正式 CLI 入口是 `bun scripts/cli.ts web-probe ...`;旧 `hwlab nodes web-probe` 已移除,只能按 CLI 提示迁移,不要在 issue 或长期文档中继续记录旧入口。 - 涉及 Web 哨兵、`web-probe sentinel`、`monitor.pikapython.com`、定期/周期巡检或新建巡检时,必须同时加载 `$unidesk-monitor`。 - 真实用户入口验证优先;源码检查、构建通过或截图局部正常不能替代原入口验收。 -- HWLAB Cloud Web/Workbench 的 closeout web-probe 必须打已经通过 CI/CD 部署的 node/lane 公网 origin,例如 NC01/v03 的 `https://hwlab.pikapython.com`;本地 fake-server、localhost、dist 静态服务和临时 `--url` 只能作为开发 preflight 证据,不能替代 PR 合并、部署后的公网 web-probe 验收。 +- 调试 HWLAB Cloud Web/Workbench 业务或功能 bug 时,使用 YAML 声明的 `--origin internal`;验收 public exposure、DNS、FRP、Caddy 或公网用户入口时,显式使用 `--origin public`。 +- `--url` 只是 custom/local 一次性逃生口,与 `--origin` 互斥;禁止通过手写 URL 或 IP 选择内网/公网运行面。本地 fake-server、localhost、dist 静态服务和 custom URL 只能作为开发 preflight 证据。 - 禁止在本地或 master server 直接跑 `vue-tsc` / 前端全量 typecheck 作为默认验证;本地只做语法级检查和真实入口复测,完整类型检查交给 CI、PipelineRun 或明确指定的受控构建运行面。 - 桌面版 Web 截图、视觉复测和 web-probe 采样默认使用 `1920x1080` 视口;只有用户、issue 或 SPEC 明确指定其他尺寸时才覆盖,并在证据中写明覆盖原因。 - Web probe 命令和历史判定口径见 [references/web-probe.md](references/web-probe.md);Playwright/fake-server 复现见 [references/playwright.md](references/playwright.md)。 @@ -23,7 +24,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - 用户要求“离线调查”时,web-probe 只读取已有 observer/sentinel artifact、`observe collect` 和 `observe analyze` 报告;不得新开 `run/script/observe` 或触发新的 dashboard 验证。若 collect/analyze 难以定位 run、trace、sample、root-cause signal 或输出不可界定,先改进 analyzer/CLI 证据面,再继续业务结论。 - 用户要求“用 web-probe 做 smoke”时,`web-probe` 是取证工具,smoke 目标以用户/issue 指定的业务入口为准;例如目标是 Workbench 时,必须用 `web-probe run` 或 `web-probe observe start` + `observe command --type sendPrompt` 验证 Workbench 页面、会话、消息终态和 `observe analyze` 结果,不能改成只验证 web-probe 工具自身。只有用户明确说“验证 web-probe 自身/基础设施”时,才把 screenshot/script/observe 作为工具自检 smoke。 -- HWLAB Web 改动的最终验收顺序是:先完成 PR/CI/CD/GitOps/Argo/runtime `/health` closeout,再对 YAML 选中的公网 origin 跑 web-probe;若公网 web-probe 发现缺路由、认证、代理、资源或 DOM 问题,应继续修复业务或运行面,不得回退到本地 fake-server 通过作为结论。 +- HWLAB Web 业务/功能改动可先在已部署的 node/lane 上用 `--origin internal` 完成快速复测;涉及 public exposure 或公网用户入口的 closeout 再用 `--origin public` 独立验收。两者均必须由 owning YAML 解析,不得用临时 URL/IP 切换运行面。 - web-probe 发现 Workbench 请求风暴、浏览器卡死、内存上涨、submit/command 失败或投影缺失时,修复目标默认是 Workbench runtime/projection/read model 或业务代码;不得为了通过 smoke 去降低 Playwright/Chromium 能力、减少采样、自动刷新页面、关闭 freeze/memory 检测或把 blocker 降级。探针侧只能补证据、root cause 可见性和 YAML policy 接入。 - 交互式线上验收优先使用 `web-probe observe start`、`observe command`、`observe collect` 和 `observe analyze`;`web-probe script` 只作为一次性探测逃生口。重复出现的高频动作(折叠侧栏、关闭报告、切换全屏、导出低码率截图、采集布局指标)必须沉淀为 observe command 或 analyzer 能力。 - 截图取证必须优先配合页面的 RESTful 深链:截图命令应直接打开能恢复目标 source/file/task/report/session/run 和聚焦区域的 URL,再等待稳定 DOM 合约后截图。若目标页面缺少必要深链能力,不要先用脆弱点击/滚动流程硬截;应先增强前端深链与刷新恢复能力,再把截图能力接回 web-probe/observe/sentinel 命令。 @@ -33,6 +34,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - `observe collect --view turn-summary --command-id ` 必须按 commandId 精确返回单轮;不存在时保持结构化 `not-found`,禁止回退全量轮次。输出中 observer control 完成、turn submission 与异步 turn terminal 是三个独立证据字段,control completed 不等于业务 turn terminal。 - `workbench-triad` 的本地 `network.jsonl` 没有匹配响应时,结论必须写作 `artifact evidence missing`,并明确这不能证明业务 API 未发生;将 control artifact 中的 OTel trace reference 作为独立证据引用,再走 `$unidesk-otel` 受控诊断核验完整业务链路。 - `web-probe script` 结果首行固定返回 `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT `,同时在结构化结果首字段返回 `commandPromotionHint`;一次性脚本在重复前必须沉淀为 repo-owned typed command。内置 generated command(例如 `web-probe opencode-smoke`)会明确返回可直接复用且不附加临时脚本 warning,禁止复制其生成脚本另起一次性入口。 +- 沉淀后的 typed command 必须复用 observer start 时选中的 semantic origin;不得在 command 内嵌 URL/IP,也不得为 internal/public 各复制一份 command。 - Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary`;`performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。 - Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible`、`reportFullscreenVisible`、报告 deep link 和截图 SHA。 - MDTODO → Workbench launch 验收必须引用 `launchWorkbenchFromMdtodo` 的 command result,确认文件/任务选择、Workbench session、launch/chat 状态和 OTel trace header;不要只凭页面最终停在 Workbench URL 判断通过。 @@ -42,7 +44,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H ## 高频工作流 -1. 先确认目标:repo、分支/lane、node、用户入口 URL、预期页面或 workflow。 +1. 先确认目标:repo、分支/lane、node、semantic origin(internal/public)、预期页面或 workflow。 2. 读取对应 SPEC 或 issue,明确验收口径;Workbench 和 Performance 页不要只凭局部 DOM 判断。 3. 用受控 CLI 或 Playwright wrapper 复现:优先 `bun scripts/cli.ts web-probe ...`,必要时用 `bun scripts/playwright-cli.ts ...`。 4. 修改前端代码后执行最小相关验证,再用同一原入口复测。 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 60fa479c..ce769336 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -533,9 +533,18 @@ templates: hwlabV03: webProbeWorkbench: browserProxyMode: auto - defaultOrigin: - mode: public - baseUrl: https://hwlab.pikapython.com + defaultOrigin: internal + origins: + internal: + mode: k8s-service-cluster-ip + serviceName: hwlab-cloud-web + namespace: "hwlab-${lane}" + port: 8080 + scheme: http + browserProxyMode: direct + public: + mode: public + browserProxyMode: auto authLogin: maxAttempts: 6 requestTimeoutMs: 30000 diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index e1fa2863..8ce9b22d 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -49,16 +49,16 @@ export function hwlabNodeWebProbeHelp(): Record { return { ok: true, command: "web-probe", - description: "Run target node/lane HWLAB Cloud Web probes with YAML-selected origin and one-shot bootstrap Web credentials.", + description: "Run target node/lane HWLAB Cloud Web probes with YAML-selected semantic internal/public origins and one-shot bootstrap Web credentials.", examples: [ - "bun scripts/cli.ts web-probe run --node --lane --wait-messages-ms 1000", - "bun scripts/cli.ts web-probe run --node --lane --fresh-session --message 'ping'", - "bun scripts/cli.ts web-probe opencode-smoke --node --lane --message 'hi'", - "bun scripts/cli.ts web-probe script --node --lane --script-file .state/probes/workbench.mjs", - "bun scripts/cli.ts web-probe screenshot --node --lane --url https://monitor.pikapython.com --viewport 1440x900", - "bun scripts/cli.ts web-probe screenshot --node --lane --url https://monitor.pikapython.com --viewport 390x844 --name monitor-mobile.png", - "bun scripts/cli.ts web-probe observe start --node --lane --target-path /workbench --sample-interval-ms 5000", - "bun scripts/cli.ts web-probe observe start --node --lane --target-path /projects/mdtodo --sample-interval-ms 5000", + "bun scripts/cli.ts web-probe run --node --lane --origin internal --wait-messages-ms 1000", + "bun scripts/cli.ts web-probe run --node --lane --origin public --fresh-session --message 'public exposure closeout'", + "bun scripts/cli.ts web-probe opencode-smoke --node --lane --origin internal --message 'hi'", + "bun scripts/cli.ts web-probe script --node --lane --origin internal --script-file .state/probes/workbench.mjs", + "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", + "bun scripts/cli.ts web-probe screenshot --node --lane --origin public --path /workbench --viewport 390x844 --name workbench-public-mobile.png", + "bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000", + "bun scripts/cli.ts web-probe observe start --node --lane --origin public --target-path /projects/mdtodo --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 '' --second-text ''", "bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx", @@ -95,18 +95,21 @@ export function hwlabNodeWebProbeHelp(): Record { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", "opencode-smoke": "Run the repo-owned OpenCode iframe/direct-host composer smoke and require DOM assistant text plus EventSource update/finish/idle evidence.", script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.", - screenshot: "Capture a no-auth or public page through the selected node/lane remote browser and download PNG artifacts to the caller /tmp by default.", + screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.", observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.", sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated JD01 formal closeout uses platform-infra pipelines-as-code, while publish-current is debug/recovery only.", }, notes: [ - "Default URL, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.", + "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.", "`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 ` 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.", "observe is passive by default; user actions must be explicit observe command entries in control.jsonl.", "observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.", "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", + "Typed observe commands inherit the semantic origin selected by observe start and must not embed a replacement URL or IP.", "validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", "performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 3b3328e6..e6364cb0 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -127,19 +127,28 @@ export interface HwlabRuntimeWebProbeServiceOriginSpec { readonly namespace: string; readonly port: number; readonly scheme: "http" | "https"; + readonly browserProxyMode?: "auto" | "direct"; } export interface HwlabRuntimeWebProbePublicOriginSpec { readonly mode: "public"; readonly baseUrl?: string; + readonly browserProxyMode?: "auto" | "direct"; } export type HwlabRuntimeWebProbeOriginSpec = HwlabRuntimeWebProbeServiceOriginSpec | HwlabRuntimeWebProbePublicOriginSpec; +export type HwlabRuntimeWebProbeOriginName = "internal" | "public"; + +export interface HwlabRuntimeWebProbeOriginsSpec { + readonly internal: HwlabRuntimeWebProbeServiceOriginSpec; + readonly public: HwlabRuntimeWebProbePublicOriginSpec; +} export interface HwlabRuntimeWebProbeSpec { readonly browserProxyMode?: "auto" | "direct"; readonly playwrightBrowsersPath?: string; - readonly defaultOrigin?: HwlabRuntimeWebProbeOriginSpec; + readonly defaultOrigin?: HwlabRuntimeWebProbeOriginName; + readonly origins?: HwlabRuntimeWebProbeOriginsSpec; readonly authLogin?: HwlabRuntimeWebProbeAuthLoginSpec; readonly alertThresholds?: HwlabRuntimeWebProbeAlertThresholdsSpec; readonly browserFreezePolicy?: HwlabRuntimeWebProbeBrowserFreezePolicySpec; @@ -1267,10 +1276,23 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec browserProxyMode = rawBrowserProxyMode; } const playwrightBrowsersPath = optionalStringField(raw, "playwrightBrowsersPath", path); + const defaultOriginRaw = optionalStringField(raw, "defaultOrigin", path); + let defaultOrigin: HwlabRuntimeWebProbeOriginName | undefined; + if (defaultOriginRaw !== undefined) { + if (defaultOriginRaw !== "internal" && defaultOriginRaw !== "public") { + throw new Error(`${path}.defaultOrigin must be internal or public`); + } + defaultOrigin = defaultOriginRaw; + } + const origins = raw.origins === undefined ? undefined : webProbeOriginsConfig(raw.origins, `${path}.origins`); + if (defaultOrigin !== undefined && origins === undefined) { + throw new Error(`${path}.origins must declare internal and public when defaultOrigin is set`); + } return { ...(browserProxyMode === undefined ? {} : { browserProxyMode }), ...(playwrightBrowsersPath === undefined ? {} : { playwrightBrowsersPath }), - ...(raw.defaultOrigin === undefined ? {} : { defaultOrigin: webProbeOriginConfig(raw.defaultOrigin, `${path}.defaultOrigin`) }), + ...(defaultOrigin === undefined ? {} : { defaultOrigin }), + ...(origins === undefined ? {} : { origins }), ...(raw.authLogin === undefined ? {} : { authLogin: webProbeAuthLoginConfig(raw.authLogin, `${path}.authLogin`) }), ...(raw.alertThresholds === undefined ? {} : { alertThresholds: webProbeAlertThresholdsConfig(raw.alertThresholds, `${path}.alertThresholds`) }), ...(raw.browserFreezePolicy === undefined ? {} : { browserFreezePolicy: webProbeBrowserFreezePolicyConfig(raw.browserFreezePolicy, `${path}.browserFreezePolicy`) }), @@ -1279,6 +1301,18 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec }; } +function webProbeOriginsConfig(value: unknown, path: string): HwlabRuntimeWebProbeOriginsSpec { + const raw = asRecord(value, path); + for (const key of Object.keys(raw)) { + if (key !== "internal" && key !== "public") throw new Error(`${path}.${key} is not a supported web-probe origin`); + } + const internal = webProbeOriginConfig(raw.internal, `${path}.internal`); + const publicOrigin = webProbeOriginConfig(raw.public, `${path}.public`); + if (internal.mode !== "k8s-service-cluster-ip") throw new Error(`${path}.internal.mode must be k8s-service-cluster-ip`); + if (publicOrigin.mode !== "public") throw new Error(`${path}.public.mode must be public`); + return { internal, public: publicOrigin }; +} + function webProbeRealtimeFanoutProfilesConfig(value: unknown, path: string): Readonly> { const raw = asRecord(value, path); const entries = Object.entries(raw).map(([id, item]) => { @@ -1510,10 +1544,19 @@ function webProbeAlertThresholdsConfig(value: unknown, path: string): HwlabRunti function webProbeOriginConfig(value: unknown, path: string): HwlabRuntimeWebProbeOriginSpec { const raw = asRecord(value, path); const mode = stringField(raw, "mode", path); + const browserProxyModeRaw = optionalStringField(raw, "browserProxyMode", path); + let browserProxyMode: "auto" | "direct" | undefined; + if (browserProxyModeRaw !== undefined) { + if (browserProxyModeRaw !== "auto" && browserProxyModeRaw !== "direct") { + throw new Error(`${path}.browserProxyMode must be auto or direct`); + } + browserProxyMode = browserProxyModeRaw; + } if (mode === "public") { return { mode, ...(raw.baseUrl === undefined ? {} : { baseUrl: stringField(raw, "baseUrl", path).replace(/\/+$/u, "") }), + ...(browserProxyMode === undefined ? {} : { browserProxyMode }), }; } if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be public or k8s-service-cluster-ip`); @@ -1525,6 +1568,7 @@ function webProbeOriginConfig(value: unknown, path: string): HwlabRuntimeWebProb namespace: stringField(raw, "namespace", path), port: numberField(raw, "port", path), scheme, + ...(browserProxyMode === undefined ? {} : { browserProxyMode }), }; } diff --git a/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts index ec9eb4f9..d6671143 100644 --- a/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts @@ -51,6 +51,7 @@ async function writeManifest(extra = {}) { pid: process.pid, stateDir, baseUrl, + origin, targetPath, network: publicNetwork(playwrightProxy), navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true }, diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 771ca555..327c2397 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -34,6 +34,14 @@ const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERV const maxRunMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, 0); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto"); +const origin = { + name: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_NAME || "custom", + mode: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_MODE || "custom-url", + configPath: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_CONFIG_PATH || null, + browserProxyMode, + browserProxyModeSource: process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE_SOURCE || "default", + valuesRedacted: true, +}; const webPerformanceRequestBodyMaxBytes = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, 65536, 1024, 1048576); const authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20); const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000); diff --git a/scripts/src/hwlab-node-web-observe-wrapper.ts b/scripts/src/hwlab-node-web-observe-wrapper.ts index 96039ced..90fb9bd8 100644 --- a/scripts/src/hwlab-node-web-observe-wrapper.ts +++ b/scripts/src/hwlab-node-web-observe-wrapper.ts @@ -12,6 +12,11 @@ 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 originConfigPath: string | null; + readonly browserProxyMode: "auto" | "direct" | null; + readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default" | null; readonly targetPath: string | null; readonly commandType: string | null; readonly commandTimeoutSeconds: number | null; @@ -32,6 +37,11 @@ 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 originConfigPath: string | null; + readonly browserProxyMode: "auto" | "direct"; + readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default"; readonly targetPath: string; readonly stateDir: string | null; readonly commandType: string | null; @@ -109,6 +119,14 @@ export function buildWebObserveWrapperContract(input: WebObserveWrapperInput): R }, effectiveParameters: { url: input.url, + origin: { + name: input.originName, + mode: input.originMode, + configPath: input.originConfigPath, + browserProxyMode: input.browserProxyMode, + browserProxyModeSource: input.browserProxyModeSource, + valuesRedacted: true, + }, targetPath: input.targetPath, commandType: input.commandType, commandTimeoutSeconds: input.commandTimeoutSeconds, @@ -175,6 +193,11 @@ export function buildWebObserveWrapperForObserveOptions( jobId: overrides.jobId ?? options.jobId ?? id, stateDir: overrides.stateDir ?? options.stateDir, url: options.url, + originName: options.originName, + originMode: options.originMode, + originConfigPath: options.originConfigPath, + browserProxyMode: options.browserProxyMode, + browserProxyModeSource: options.browserProxyModeSource, targetPath: options.targetPath, commandType: overrides.commandType ?? options.commandType, commandTimeoutSeconds: options.commandTimeoutSeconds, @@ -212,6 +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); parts.push("--target-path", input.targetPath); } if (input.action === "command" && input.commandType !== null) { diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index a3bc6ca8..0fd9bb7a 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -14,7 +14,7 @@ import { runCommand, type CommandResult } from "../command"; import { startJob } from "../jobs"; import { classifySshTcpPoolFailure } from "../ssh"; import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane"; -import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; +import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeOriginName, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source"; import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source"; import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source"; @@ -55,8 +55,17 @@ 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 interface NodeWebProbeRunOptions { +export interface WebProbeOriginSelection { + readonly originName: WebProbeOriginName; + readonly originMode: "k8s-service-cluster-ip" | "public" | "custom-url"; + readonly originConfigPath: string | null; + readonly browserProxyMode: WebProbeBrowserProxyMode; + readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default"; +} + +export interface NodeWebProbeRunOptions extends WebProbeOriginSelection { action: "run"; node: string; lane: string; @@ -76,7 +85,7 @@ export interface NodeWebProbeRunOptions { commandTimeoutUserProvided: boolean; } -export interface NodeWebProbeScriptOptions { +export interface NodeWebProbeScriptOptions extends WebProbeOriginSelection { action: "script"; node: string; lane: string; @@ -98,7 +107,7 @@ export interface NodeWebProbeScriptOptions { }; } -export interface NodeWebProbeScreenshotOptions { +export interface NodeWebProbeScreenshotOptions extends WebProbeOriginSelection { action: "screenshot"; node: string; lane: string; @@ -164,7 +173,7 @@ export type NodeWebProbeObserveCommandType = | "mark" | "stop"; -export interface NodeWebProbeObserveOptions { +export interface NodeWebProbeObserveOptions extends WebProbeOriginSelection { action: "observe"; observeAction: NodeWebProbeObserveAction; id: string | null; @@ -258,6 +267,11 @@ export interface WebObserveIndexEntry { workspace: string; stateDir: string; url: string; + originName: WebProbeOriginName | null; + originMode: WebProbeOriginSelection["originMode"] | null; + originConfigPath: string | null; + browserProxyMode: WebProbeBrowserProxyMode | null; + browserProxyModeSource: WebProbeOriginSelection["browserProxyModeSource"] | null; targetPath: string; status: string | null; pid: number | null; diff --git a/scripts/src/hwlab-node/plan.ts b/scripts/src/hwlab-node/plan.ts index 8e3a1bbe..2565ea31 100644 --- a/scripts/src/hwlab-node/plan.ts +++ b/scripts/src/hwlab-node/plan.ts @@ -135,6 +135,7 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record { 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, + 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, targetPath: typeof recordValue.targetPath === "string" ? recordValue.targetPath : "", status: typeof recordValue.status === "string" ? recordValue.status : null, pid: typeof recordValue.pid === "number" ? recordValue.pid : null, @@ -1079,6 +1084,11 @@ export function discoverWebObserveIndexEntryOnTarget(id: string, node: string, l " id: jobId,", " stateDir,", " url: typeof manifest.baseUrl === 'string' ? manifest.baseUrl : '',", + " originName: typeof manifest.origin?.name === 'string' ? manifest.origin.name : null,", + " originMode: typeof manifest.origin?.mode === 'string' ? manifest.origin.mode : null,", + " originConfigPath: typeof manifest.origin?.configPath === 'string' ? manifest.origin.configPath : null,", + " browserProxyMode: typeof manifest.origin?.browserProxyMode === 'string' ? manifest.origin.browserProxyMode : null,", + " browserProxyModeSource: typeof manifest.origin?.browserProxyModeSource === 'string' ? manifest.origin.browserProxyModeSource : null,", " targetPath: typeof manifest.targetPath === 'string' ? manifest.targetPath : '',", " status: typeof manifest.status === 'string' ? manifest.status : null,", " pid,", @@ -1111,6 +1121,11 @@ 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, + 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, targetPath: typeof entry.targetPath === "string" ? entry.targetPath : "", status: typeof entry.status === "string" ? entry.status : null, pid: typeof entry.pid === "number" ? entry.pid : null, @@ -1161,6 +1176,7 @@ export function webObserveIdFromStatus(status: Record | null, o export function webObserveIndexEntryFromOptions(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, id: string, status: Record): WebObserveIndexEntry { const manifest = record(status.manifest); + const manifestOrigin = record(manifest.origin); const heartbeat = record(status.heartbeat); return { id, @@ -1169,6 +1185,11 @@ export function webObserveIndexEntryFromOptions(options: NodeWebProbeObserveOpti workspace: spec.workspace, stateDir: options.stateDir ?? "", url: typeof manifest.baseUrl === "string" ? manifest.baseUrl : options.url, + originName: webObserveOriginName(manifestOrigin.name) ?? options.originName, + originMode: webObserveOriginMode(manifestOrigin.mode) ?? options.originMode, + originConfigPath: typeof manifestOrigin.configPath === "string" ? manifestOrigin.configPath : options.originConfigPath, + browserProxyMode: webObserveBrowserProxyMode(manifestOrigin.browserProxyMode) ?? options.browserProxyMode, + browserProxyModeSource: webObserveBrowserProxyModeSource(manifestOrigin.browserProxyModeSource) ?? options.browserProxyModeSource, targetPath: typeof manifest.targetPath === "string" ? manifest.targetPath : options.targetPath, status: typeof manifest.status === "string" ? manifest.status : null, pid: typeof status.pid === "number" ? status.pid : null, @@ -1177,6 +1198,22 @@ export function webObserveIndexEntryFromOptions(options: NodeWebProbeObserveOpti }; } +function webObserveOriginName(value: unknown): WebObserveIndexEntry["originName"] { + return value === "internal" || value === "public" || value === "custom" ? value : null; +} + +function webObserveOriginMode(value: unknown): WebObserveIndexEntry["originMode"] { + return value === "k8s-service-cluster-ip" || value === "public" || value === "custom-url" ? value : null; +} + +function webObserveBrowserProxyMode(value: unknown): WebObserveIndexEntry["browserProxyMode"] { + return value === "auto" || value === "direct" ? value : null; +} + +function webObserveBrowserProxyModeSource(value: unknown): WebObserveIndexEntry["browserProxyModeSource"] { + return value === "cli" || value === "yaml-origin" || value === "yaml-web-probe" || value === "default" ? value : null; +} + export function webObserveCommandLabel(action: NodeWebProbeObserveAction, options: Pick): string { const id = webObserveIdFromOptions(options); return id === null diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index d0a44e39..e56fbf4d 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -35,6 +35,7 @@ import { isSafeWebProbeScriptArtifactPath, isSafeWebProbeScriptReportPath, isSaf import { compactCommandResultRedacted, nullableRecord, redactKnownSecrets, shellQuote } from "./utils"; import { renderWebProbeScriptResult } from "./web-observe-render"; import { nodeWebProbeHostProxyEnv } from "./web-probe-observe"; +import { webProbeOriginSummary } from "./web-probe-origin"; export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string, commandId: string | null = null): string { return `node -e ${shellQuote(` @@ -493,7 +494,7 @@ export function runNodeWebProbeScript( material: BootstrapAdminPasswordMaterial, credential: Record, ): Record { - const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`; + const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)}`; const commandGovernance = webProbeScriptCommandGovernance(options); const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath); @@ -595,6 +596,7 @@ export function runNodeWebProbeScript( lane: options.lane, workspace: spec.workspace, url: options.url, + origin: webProbeOriginSummary(options), network: webProbeProxy.summary, credential, scriptSource: options.scriptSource, @@ -641,7 +643,7 @@ export function webProbeScriptCommandGovernance(options: NodeWebProbeScriptOptio export function webProbeScriptCommandPromotionHint(options: NodeWebProbeScriptOptions): Record { const repoOwnedTypedCommand = options.suppressAdHocWarning === true; - const currentCommand = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`; + const currentCommand = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)}`; return { schemaVersion: "unidesk.web-probe.command-promotion.v1", code: "prefer_repo_owned_typed_command", @@ -680,7 +682,7 @@ export function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions return [ "Before rerunning this script, promote the workflow to a repo-owned typed `web-probe` command so auth, artifacts, output bounds and evidence contracts remain reusable.", "Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows; use `observe collect/analyze` for repeated evidence reads.", - `For this target, start with: bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`, + `For this target, start with: bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)} --target-path /projects/mdtodo`, ]; } @@ -688,12 +690,16 @@ export function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptio if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands; return { typedCommandCatalog: "bun scripts/cli.ts web-probe --help", - startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`, + startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)} --target-path /projects/mdtodo`, mdtodoSummary: "bun scripts/cli.ts web-probe observe collect --view project-mdtodo-summary", analyze: "bun scripts/cli.ts web-probe observe analyze ", }; } +function webProbeSemanticOriginArgs(options: Pick): string { + return options.originName === "internal" || options.originName === "public" ? ` --origin ${options.originName}` : ""; +} + export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string { const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64"); const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64"); diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 6c492ffe..9614b8d4 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -455,6 +455,10 @@ export function runNodeWebProbeObserveStart( `PLAYWRIGHT_BROWSERS_PATH=${shellQuote(spec.webProbe.playwrightBrowsersPath)}`, ]), `HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`, + `UNIDESK_WEB_OBSERVE_ORIGIN_NAME=${shellQuote(options.originName)}`, + `UNIDESK_WEB_OBSERVE_ORIGIN_MODE=${shellQuote(options.originMode)}`, + `UNIDESK_WEB_OBSERVE_ORIGIN_CONFIG_PATH=${shellQuote(options.originConfigPath ?? "")}`, + `UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE_SOURCE=${shellQuote(options.browserProxyModeSource)}`, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`, `UNIDESK_WEB_OBSERVE_STATE_DIR=${shellQuote(stateDir)}`, @@ -529,6 +533,11 @@ export function runNodeWebProbeObserveStart( workspace: spec.workspace, stateDir, url: options.url, + originName: options.originName, + originMode: options.originMode, + originConfigPath: options.originConfigPath, + browserProxyMode: options.browserProxyMode, + browserProxyModeSource: options.browserProxyModeSource, targetPath: options.targetPath, status: "running", pid: typeof started?.pid === "number" ? started.pid : null, @@ -539,11 +548,19 @@ export function runNodeWebProbeObserveStart( return renderWebObserveStartResult({ ok: effectiveOk, status: effectiveOk ? "started" : "blocked", - command: `web-probe observe start --node ${options.node} --lane ${options.lane}`, + command: `web-probe observe start --node ${options.node} --lane ${options.lane}${options.originName === "custom" ? "" : ` --origin ${options.originName}`}`, node: options.node, lane: options.lane, workspace: spec.workspace, url: options.url, + origin: { + name: options.originName, + mode: options.originMode, + configPath: options.originConfigPath, + browserProxyMode: options.browserProxyMode, + browserProxyModeSource: options.browserProxyModeSource, + valuesRedacted: true, + }, network: webProbeProxy.summary, alertThresholds, browserFreezePolicy, diff --git a/scripts/src/hwlab-node/web-probe-observe-options.test.ts b/scripts/src/hwlab-node/web-probe-observe-options.test.ts index 71a6a034..35bf713d 100644 --- a/scripts/src/hwlab-node/web-probe-observe-options.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-options.test.ts @@ -57,3 +57,78 @@ test("validateRealtimeFanout rejects profiles absent from the owning YAML", () = /profile is not declared by the owning YAML/u, ); }); + +test("observe start selects the YAML public origin semantically", () => { + const options = parseNodeWebProbeObserveOptions( + ["start", "--origin", "public"], + "NC01", + "v03", + spec, + null, + null, + ); + + assert.equal(options.originName, "public"); + assert.equal(options.originMode, "public"); + assert.equal(options.url, spec.publicWebUrl); + assert.equal(options.browserProxyMode, "auto"); + assert.equal(options.browserProxyModeSource, "yaml-origin"); +}); + +test("observe start refuses URL-based internal/public selection ambiguity", () => { + assert.throws( + () => parseNodeWebProbeObserveOptions( + ["start", "--origin", "public", "--url", "http://127.0.0.1:4173"], + "NC01", + "v03", + spec, + null, + null, + ), + /accepts --origin or --url, not both/u, + ); +}); + +test("observe command inherits the indexed origin and rejects replacement origins", () => { + const indexed = { + id: "webobs-fixture", + node: "NC01", + lane: "v03", + workspace: spec.workspace, + stateDir: ".state/web-observe/NC01/v03/2026/07/10/20260710T000000Z_workbench_webobs-fixture", + url: "http://10.43.99.7:8080", + originName: "internal" as const, + originMode: "k8s-service-cluster-ip" as const, + originConfigPath: "config/hwlab-node-lanes.yaml#webProbe.origins.internal", + browserProxyMode: "direct" as const, + browserProxyModeSource: "yaml-origin" as const, + targetPath: "/workbench", + status: "running", + pid: 42, + startedAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:01.000Z", + }; + const inherited = parseNodeWebProbeObserveOptions( + ["command", "--type", "mark", "--label", "checkpoint"], + "NC01", + "v03", + spec, + "webobs-fixture", + indexed, + ); + assert.equal(inherited.originName, "internal"); + assert.equal(inherited.url, indexed.url); + assert.equal(inherited.browserProxyMode, "direct"); + + assert.throws( + () => parseNodeWebProbeObserveOptions( + ["command", "--origin", "public", "--type", "mark", "--label", "checkpoint"], + "NC01", + "v03", + spec, + "webobs-fixture", + indexed, + ), + /later actions inherit the observer origin/u, + ); +}); diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 7ef0deae..c44e145e 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -37,6 +37,7 @@ import { transPath } from "./runtime-common"; import { assertLane, assertNodeId, compactCommandResult, compactCommandResultRedacted, compactCommandResultWithStdoutTail, nullableRecord, optionValue, parseJsonObject, positiveIntegerOption, record, requiredOption, shellQuote } from "./utils"; import { runNodeWebProbeObserveCommand, runNodeWebProbeObserveGc, runNodeWebProbeObserveStart, runNodeWebProbeObserveStatus } from "./web-probe-observe-actions"; import { runNodeWebProbeObserveCollect } from "./web-probe-observe-collect"; +import { parseWebProbeOriginName, resolveNodeWebProbeCliOrigin, resolveNodeWebProbeOrigin, webProbeOriginSummary } from "./web-probe-origin"; import { nodeWebObserveResolveStateDirShell, recoverWebObserveAnalyzeTurnDetails, renderWebObserveStartResult, renderWebProbeRunResult, upsertWebObserveIndexEntry, webObserveCommandLabel, webObserveIdFromOptions, webObserveIdFromStatus, webObserveIndexEntryFromOptions, webObserveNextCommands, withWebObserveAnalyzeRendered, withWebObserveShortcuts } from "./web-observe-render"; import { commandSummaryForOutput, isSafeWebObserveArchivePrefix, isSafeWebObserveCollectFile, isSafeWebObserveFindingId, isSafeWebObserveJobId, isSafeWebObserveStateDir, isSafeWebObserveTraceId, nodeWebObserveForceStopNodeScript, nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell, runNodeWebProbeScript, safeWebObserveSegment, safeWebObserveTargetSegment } from "./web-observe-scripts"; import { displayRepoPath, readBootstrapAdminPasswordMaterial, sleepSync } from "./web-probe"; @@ -387,6 +388,7 @@ export function parseNodeWebProbeObserveOptions( assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", + "--origin", "--url", "--target-path", "--viewport", @@ -491,6 +493,25 @@ export function parseNodeWebProbeObserveOptions( if (observeActionRaw !== "start" && observeActionRaw !== "gc" && stateDir === null && jobId === null) { throw new Error("web-probe observe status|command|stop|collect|analyze requires --state-dir or --job-id"); } + const requestedOrigin = optionValue(args, "--origin"); + const customUrl = optionValue(args, "--url"); + const browserProxyModeRaw = optionValue(args, "--browser-proxy-mode"); + if (observeActionRaw !== "start" && (requestedOrigin !== undefined || customUrl !== undefined || browserProxyModeRaw !== undefined)) { + throw new Error("web-probe observe --origin/--url/--browser-proxy-mode is only valid for start; later actions inherit the observer origin"); + } + const requestedBrowserProxyMode = browserProxyModeRaw === undefined ? undefined : parseWebProbeBrowserProxyMode(browserProxyModeRaw); + const selectedOrigin = observeActionRaw === "start" + ? resolveNodeWebProbeCliOrigin(spec, requestedOrigin, customUrl, requestedBrowserProxyMode) + : { + originName: indexed?.originName ?? "custom" as const, + originMode: indexed?.originMode ?? "custom-url" as const, + originConfigPath: indexed?.originConfigPath ?? null, + url: indexed?.url || spec.publicWebUrl, + browserProxyMode: requestedBrowserProxyMode ?? indexed?.browserProxyMode ?? spec.webProbe?.browserProxyMode ?? "auto" as const, + browserProxyModeSource: requestedBrowserProxyMode === undefined + ? indexed?.browserProxyModeSource ?? (spec.webProbe?.browserProxyMode === undefined ? "default" as const : "yaml-web-probe" as const) + : "cli" as const, + }; const confirm = args.includes("--confirm"); const dryRun = args.includes("--dry-run") || !confirm; if (confirm && args.includes("--dry-run")) throw new Error("web-probe observe gc accepts only one of --confirm or --dry-run"); @@ -585,10 +606,14 @@ export function parseNodeWebProbeObserveOptions( id: observeId ?? jobId, node, lane, - url: optionValue(args, "--url") ?? (observeActionRaw === "start" ? nodeWebProbeDefaultUrl(spec) : indexed?.url ?? spec.publicWebUrl), + url: selectedOrigin.url, + originName: selectedOrigin.originName, + originMode: selectedOrigin.originMode, + originConfigPath: selectedOrigin.originConfigPath, targetPath: optionValue(args, "--target-path") ?? "/workbench", viewport: optionValue(args, "--viewport") ?? "1440x900", - browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), + browserProxyMode: selectedOrigin.browserProxyMode, + browserProxyModeSource: selectedOrigin.browserProxyModeSource, sampleIntervalMs: positiveIntegerOption(args, "--sample-interval-ms", 5000, 600000), screenshotIntervalMs: positiveIntegerOption(args, "--screenshot-interval-ms", 300000, 86_400_000), observerRefreshIntervalMs: positiveIntegerOption(args, "--observer-refresh-interval-ms", 180000, 86_400_000), @@ -715,10 +740,8 @@ export function parseWebProbeBrowserProxyMode(value: string | undefined): WebPro } export function nodeWebProbeDefaultUrl(spec: HwlabRuntimeLaneSpec): string { - const origin = spec.webProbe?.defaultOrigin; - if (origin === undefined || origin.mode === "public") return origin?.baseUrl ?? spec.publicWebUrl; - const clusterIp = resolveKubernetesServiceClusterIp(spec, origin.namespace, origin.serviceName, new Map()); - return `${origin.scheme}://${clusterIp}:${origin.port}`; + const origin = parseWebProbeOriginName(spec.webProbe?.defaultOrigin ?? "public"); + return resolveNodeWebProbeOrigin(spec, origin).url; } export function nodeWebProbeAutoCommandTimeoutSeconds(input: { @@ -790,6 +813,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record 55) return runNodeWebProbeAsync(options, spec, secretSpec, material, credential); const probeArgs = nodeWebProbeRunArgs(options, "run"); - const webProbeProxy = nodeWebProbeHostProxyEnv(spec); + const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const script = [ "set -eu", `${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`, @@ -821,6 +845,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record, ): Record { const startArgs = nodeWebProbeRunArgs(options, "start"); - const webProbeProxy = nodeWebProbeHostProxyEnv(spec); + const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const startScript = [ "set -eu", `${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`, @@ -1156,6 +1182,7 @@ export function runNodeWebProbeAsync( lane: options.lane, workspace: spec.workspace, url: options.url, + origin: webProbeOriginSummary(options), network: webProbeProxy.summary, credential, mode: "async-start", @@ -1194,6 +1221,7 @@ export function runNodeWebProbeAsync( lane: options.lane, workspace: spec.workspace, url: options.url, + origin: webProbeOriginSummary(options), network: webProbeProxy.summary, credential, mode: "async", diff --git a/scripts/src/hwlab-node/web-probe-origin.test.ts b/scripts/src/hwlab-node/web-probe-origin.test.ts new file mode 100644 index 00000000..53f74fe0 --- /dev/null +++ b/scripts/src/hwlab-node/web-probe-origin.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; +import { parseWebProbeOriginName, resolveNodeWebProbeCliOrigin, resolveNodeWebProbeOrigin } from "./web-probe-origin"; + +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); +}); + +test("NC01 v03 YAML declares internal debugging and public closeout origins", () => { + assert.equal(spec.webProbe?.defaultOrigin, "internal"); + assert.deepEqual(spec.webProbe?.origins?.internal, { + mode: "k8s-service-cluster-ip", + serviceName: "hwlab-cloud-web", + namespace: "hwlab-v03", + port: 8080, + scheme: "http", + browserProxyMode: "direct", + }); + assert.deepEqual(spec.webProbe?.origins?.public, { + mode: "public", + browserProxyMode: "auto", + }); +}); + +test("public semantic origin reuses the lane public Web URL", () => { + const origin = resolveNodeWebProbeOrigin(spec, "public"); + assert.equal(origin.originName, "public"); + assert.equal(origin.originMode, "public"); + assert.equal(origin.url, spec.publicWebUrl); + assert.equal(origin.browserProxyMode, "auto"); + assert.match(origin.originConfigPath ?? "", /webProbe\.origins\.public/u); +}); + +test("internal semantic origin resolves the current Service address without YAML IPs", () => { + const calls: Array<{ namespace: string; serviceName: string }> = []; + const origin = resolveNodeWebProbeOrigin(spec, "internal", (_spec, namespace, serviceName) => { + calls.push({ namespace, serviceName }); + return "10.43.99.7"; + }); + + assert.deepEqual(calls, [{ namespace: "hwlab-v03", serviceName: "hwlab-cloud-web" }]); + assert.equal(origin.originName, "internal"); + assert.equal(origin.originMode, "k8s-service-cluster-ip"); + assert.equal(origin.url, "http://10.43.99.7:8080"); + assert.equal(origin.browserProxyMode, "direct"); + assert.equal(origin.browserProxyModeSource, "yaml-origin"); +}); + +test("--origin and custom --url are mutually exclusive", () => { + assert.throws( + () => resolveNodeWebProbeCliOrigin(spec, "public", "http://127.0.0.1:4173", undefined), + /--origin.*--url|--url.*--origin/u, + ); +}); + +test("custom/local --url remains an explicit one-shot escape hatch", () => { + const origin = resolveNodeWebProbeCliOrigin(spec, undefined, "http://127.0.0.1:4173", undefined); + assert.equal(origin.originName, "custom"); + assert.equal(origin.originMode, "custom-url"); + assert.equal(origin.originConfigPath, null); + assert.equal(origin.url, "http://127.0.0.1:4173"); +}); diff --git a/scripts/src/hwlab-node/web-probe-origin.ts b/scripts/src/hwlab-node/web-probe-origin.ts new file mode 100644 index 00000000..34a33a05 --- /dev/null +++ b/scripts/src/hwlab-node/web-probe-origin.ts @@ -0,0 +1,122 @@ +// 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 type { WebProbeBrowserProxyMode, WebProbeOriginSelection } from "./entry"; +import { resolveKubernetesServiceClusterIp } from "./web-probe-observe-action-config"; + +export interface ResolvedNodeWebProbeOrigin extends WebProbeOriginSelection { + readonly url: string; +} + +export function webProbeOriginSummary(origin: WebProbeOriginSelection): Record { + return { + name: origin.originName, + mode: origin.originMode, + configPath: origin.originConfigPath, + browserProxyMode: origin.browserProxyMode, + browserProxyModeSource: origin.browserProxyModeSource, + valuesRedacted: true, + }; +} + +type ServiceClusterIpResolver = ( + spec: HwlabRuntimeLaneSpec, + namespace: string, + serviceName: string, + serviceCache: Map, +) => 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 resolveNodeWebProbeOrigin( + spec: HwlabRuntimeLaneSpec, + name: HwlabRuntimeWebProbeOriginName, + serviceResolver: ServiceClusterIpResolver = resolveKubernetesServiceClusterIp, +): ResolvedNodeWebProbeOrigin { + 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`); + const browserProxy = resolvedOriginBrowserProxyMode(spec, origin.browserProxyMode); + if (name === "public") { + if (origin.mode !== "public") throw new Error(`${configPath}.mode must be public`); + return { + originName: name, + originMode: origin.mode, + originConfigPath: configPath, + url: normalizeHttpOrigin(origin.baseUrl ?? spec.publicWebUrl, configPath), + ...browserProxy, + }; + } + if (origin.mode !== "k8s-service-cluster-ip") throw new Error(`${configPath}.mode must be k8s-service-cluster-ip`); + const namespace = materializeOriginTemplate(origin.namespace, spec); + const serviceName = materializeOriginTemplate(origin.serviceName, spec); + const clusterIp = serviceResolver(spec, namespace, serviceName, new Map()); + return { + originName: name, + originMode: origin.mode, + originConfigPath: configPath, + url: `${origin.scheme}://${clusterIp}:${origin.port}`, + ...browserProxy, + }; +} + +function materializeOriginTemplate(value: string, spec: HwlabRuntimeLaneSpec): string { + return value + .replace(/\$\{lane\}/gu, spec.lane) + .replace(/\$\{NODE\}/gu, spec.nodeId) + .replace(/\$\{nodeLower\}/gu, spec.nodeId.toLowerCase()); +} + +export function resolveNodeWebProbeCliOrigin( + spec: HwlabRuntimeLaneSpec, + requestedOrigin: string | undefined, + customUrl: string | undefined, + 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"); + } + const selected = customUrl === undefined + ? resolveNodeWebProbeOrigin(spec, parseWebProbeOriginName(requestedOrigin ?? spec.webProbe?.defaultOrigin ?? "public")) + : resolveCustomWebProbeOrigin(spec, customUrl); + if (requestedBrowserProxyMode === undefined) return selected; + return { ...selected, browserProxyMode: requestedBrowserProxyMode, browserProxyModeSource: "cli" }; +} + +function resolveCustomWebProbeOrigin(spec: HwlabRuntimeLaneSpec, value: string): ResolvedNodeWebProbeOrigin { + const browserProxyMode = spec.webProbe?.browserProxyMode ?? "auto"; + return { + originName: "custom", + originMode: "custom-url", + originConfigPath: null, + url: normalizeHttpOrigin(value, "web-probe --url"), + browserProxyMode, + browserProxyModeSource: spec.webProbe?.browserProxyMode === undefined ? "default" : "yaml-web-probe", + }; +} + +function resolvedOriginBrowserProxyMode( + spec: HwlabRuntimeLaneSpec, + originMode: WebProbeBrowserProxyMode | undefined, +): Pick { + if (originMode !== undefined) return { browserProxyMode: originMode, browserProxyModeSource: "yaml-origin" }; + if (spec.webProbe?.browserProxyMode !== undefined) { + return { browserProxyMode: spec.webProbe.browserProxyMode, browserProxyModeSource: "yaml-web-probe" }; + } + return { browserProxyMode: "auto", browserProxyModeSource: "default" }; +} + +function normalizeHttpOrigin(value: string, source: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch (error) { + throw new Error(`${source} must be an absolute HTTP(S) origin: ${error instanceof Error ? error.message : String(error)}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${source} must use http or https`); + if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error(`${source} must not contain credentials, query, or fragment`); + return parsed.toString().replace(/\/$/u, ""); +} diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index b104e74e..d44b5197 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -36,7 +36,8 @@ import { nodeRuntimePipelineFailureSummary, nodeRuntimePipelineRunDiagnostics } import { compactRuntimeCommand, runNodeHostScript, runNodeHostScriptAsync } from "./runtime-common"; import { assertLane, assertNodeId, keyValueLinesFromText, numericField, optionValue, optionalStringValue, positiveIntegerOption, positiveIntegerValue, record, requiredOption, shellQuote, statusText, stringValue, stripOptions } from "./utils"; import { discoverWebObserveIndexEntry, readWebObserveIndexEntry } from "./web-observe-render"; -import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, nodeWebProbeDefaultUrl, normalizeNodeWebProbeObserveArgs, parseNodeWebProbeObserveOptions, parseNodeWebProbeSentinelOptions, parseWebProbeBrowserProxyMode } from "./web-probe-observe"; +import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, normalizeNodeWebProbeObserveArgs, parseNodeWebProbeObserveOptions, parseNodeWebProbeSentinelOptions, parseWebProbeBrowserProxyMode } from "./web-probe-observe"; +import { resolveNodeWebProbeCliOrigin } from "./web-probe-origin"; import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes"; import { resolveEgressProxySourceRef } from "../egress-proxy-sources"; @@ -2023,7 +2024,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType { + const browserProxyModeRaw = optionValue(args, "--browser-proxy-mode"); + const browserProxyMode = browserProxyModeRaw === undefined ? undefined : parseWebProbeBrowserProxyMode(browserProxyModeRaw); + return resolveNodeWebProbeCliOrigin(spec, optionValue(args, "--origin"), optionValue(args, "--url"), browserProxyMode); + }; if (actionRaw === "screenshot") { assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", + "--origin", "--url", "--path", "--viewport", + "--browser-proxy-mode", "--local-dir", "--name", "--timeout-ms", @@ -2075,6 +2083,7 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { const url = optionValue(args, "--url"); const targetPath = optionValue(args, "--path") ?? null; if (url !== undefined && targetPath !== null) throw new Error("web-probe screenshot accepts --url or --path, not both"); + const selectedOrigin = resolveOrigin(); const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000); const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000); const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.ceil(waitTimeoutMs / 1000) + 45, 900); @@ -2082,7 +2091,12 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { action: "screenshot", node, lane, - url: url ?? resolveWebProbeScreenshotUrl(spec, targetPath ?? "/"), + url: url ?? resolveWebProbeScreenshotUrl(selectedOrigin.url, targetPath ?? "/"), + originName: selectedOrigin.originName, + originMode: selectedOrigin.originMode, + originConfigPath: selectedOrigin.originConfigPath, + browserProxyMode: selectedOrigin.browserProxyMode, + browserProxyModeSource: selectedOrigin.browserProxyModeSource, path: targetPath, viewport: parseWebProbeScreenshotViewport(optionValue(args, "--viewport") ?? "1440x900"), localDir: optionValue(args, "--local-dir") ?? "/tmp", @@ -2100,6 +2114,7 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", + "--origin", "--url", "--timeout-ms", "--viewport", @@ -2115,14 +2130,19 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { if (scriptText.trim().length === 0) throw new Error("web-probe script received an empty script"); const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000); const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.max(60, Math.ceil(timeoutMs / 1000) + 30), 3600); + const selectedOrigin = resolveOrigin(); return { action: "script", node, lane, - url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), + url: selectedOrigin.url, + originName: selectedOrigin.originName, + originMode: selectedOrigin.originMode, + originConfigPath: selectedOrigin.originConfigPath, timeoutMs, viewport: optionValue(args, "--viewport") ?? "1440x900", - browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), + browserProxyMode: selectedOrigin.browserProxyMode, + browserProxyModeSource: selectedOrigin.browserProxyModeSource, commandTimeoutSeconds, scriptText, scriptSource: { @@ -2137,6 +2157,7 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", + "--origin", "--url", "--path", "--message", @@ -2163,15 +2184,20 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { expectText, responseTimeoutMs, }); - const commandLabel = `web-probe opencode-smoke --node ${node} --lane ${lane}`; + const selectedOrigin = resolveOrigin(); + const commandLabel = `web-probe opencode-smoke --node ${node} --lane ${lane}${selectedOrigin.originName === "custom" ? "" : ` --origin ${selectedOrigin.originName}`}`; return { action: "script", node, lane, - url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), + url: selectedOrigin.url, + originName: selectedOrigin.originName, + originMode: selectedOrigin.originMode, + originConfigPath: selectedOrigin.originConfigPath, timeoutMs, viewport: optionValue(args, "--viewport") ?? "1440x900", - browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), + browserProxyMode: selectedOrigin.browserProxyMode, + browserProxyModeSource: selectedOrigin.browserProxyModeSource, commandTimeoutSeconds, scriptText, commandLabel, @@ -2194,7 +2220,9 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", + "--origin", "--url", + "--browser-proxy-mode", "--timeout-ms", "--wait-after-submit-ms", "--wait-messages-ms", @@ -2233,11 +2261,17 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { const commandTimeoutSeconds = commandTimeoutUserProvided ? Math.max(positiveIntegerOption(args, "--command-timeout-seconds", commandTimeoutAutoSeconds, 3600), commandTimeoutAutoSeconds) : commandTimeoutAutoSeconds; + const selectedOrigin = resolveOrigin(); return { action: "run", node, lane, - url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), + url: selectedOrigin.url, + originName: selectedOrigin.originName, + originMode: selectedOrigin.originMode, + originConfigPath: selectedOrigin.originConfigPath, + browserProxyMode: selectedOrigin.browserProxyMode, + browserProxyModeSource: selectedOrigin.browserProxyModeSource, timeoutMs, waitAfterSubmitMs, waitMessagesMs, @@ -2433,8 +2467,7 @@ function normalizeButtonText(value) { return patched; } -function resolveWebProbeScreenshotUrl(spec: HwlabRuntimeLaneSpec, targetPath: string): string { - const origin = nodeWebProbeDefaultUrl(spec); +function resolveWebProbeScreenshotUrl(origin: string, targetPath: string): string { try { return new URL(targetPath || "/", origin).toString(); } catch {