feat(web-probe): add semantic YAML origins

This commit is contained in:
Codex
2026-07-10 12:37:13 +02:00
parent 2d4c1a5ffa
commit 2c47fd6528
18 changed files with 545 additions and 48 deletions
+14 -11
View File
@@ -49,16 +49,16 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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 <node> --lane <lane> --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --fresh-session --message 'ping'",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --message 'hi'",
"bun scripts/cli.ts web-probe script --node <node> --lane <lane> --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --url https://monitor.pikapython.com --viewport 1440x900",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --url https://monitor.pikapython.com --viewport 390x844 --name monitor-mobile.png",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --target-path /projects/mdtodo --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin internal --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin public --fresh-session --message 'public exposure closeout'",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --origin internal --message 'hi'",
"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 screenshot --node <node> --lane <lane> --origin public --path /workbench --viewport 390x844 --name workbench-public-mobile.png",
"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 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 '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx",
@@ -95,18 +95,21 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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 <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.",
"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 <id> 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.",
+46 -2
View File
@@ -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<Record<string, HwlabRuntimeWebProbeRealtimeFanoutProfileSpec>> {
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 }),
};
}
@@ -51,6 +51,7 @@ async function writeManifest(extra = {}) {
pid: process.pid,
stateDir,
baseUrl,
origin,
targetPath,
network: publicNetwork(playwrightProxy),
navigation: { maxAttempts: navigationMaxAttempts, valuesRedacted: true },
@@ -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);
@@ -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) {
+19 -5
View File
@@ -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;
+1
View File
@@ -135,6 +135,7 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record<string,
browserProxyMode: spec.webProbe.browserProxyMode ?? null,
playwrightBrowsersPath: spec.webProbe.playwrightBrowsersPath ?? null,
defaultOrigin: spec.webProbe.defaultOrigin ?? null,
origins: spec.webProbe.origins ?? null,
},
bootstrapAdmin: spec.bootstrapAdmin === undefined ? null : {
username: spec.bootstrapAdmin.username,
@@ -131,9 +131,13 @@ test("web-probe command governance distinguishes ad-hoc scripts from repo-owned
node: "D601",
lane: "v03",
url: "https://hwlab.example.test",
originName: "public" as const,
originMode: "public" as const,
originConfigPath: "config/hwlab-node-lanes.yaml#webProbe.origins.public",
timeoutMs: 30000,
viewport: "1920x1080",
browserProxyMode: "auto" as const,
browserProxyModeSource: "yaml-origin" as const,
commandTimeoutSeconds: 60,
scriptText: "return { ok: true };",
scriptSource: {
@@ -991,6 +991,11 @@ 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,
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<string, unknown> | null, o
export function webObserveIndexEntryFromOptions(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, id: string, status: Record<string, unknown>): 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<NodeWebProbeObserveOptions, "id" | "jobId" | "node" | "lane">): string {
const id = webObserveIdFromOptions(options);
return id === null
+10 -4
View File
@@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown> {
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 <observerId> --view project-mdtodo-summary",
analyze: "bun scripts/cli.ts web-probe observe analyze <observerId>",
};
}
function webProbeSemanticOriginArgs(options: Pick<NodeWebProbeScriptOptions, "originName">): 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");
@@ -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,
@@ -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,
);
});
+36 -8
View File
@@ -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<string, un
lane: options.lane,
workspace: spec.workspace,
url: options.url,
origin: webProbeOriginSummary(options),
degradedReason: "web_login_secret_missing",
credential,
next: { secretStatus: `bun scripts/cli.ts hwlab nodes secret status --node ${options.node} --lane ${options.lane} --name ${secretSpec.bootstrapAdminSecret}` },
@@ -799,7 +823,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
if (options.action === "script") return runNodeWebProbeScript(options, spec, secretSpec, material, credential);
if (options.commandTimeoutSeconds > 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<string, un
lane: options.lane,
workspace: spec.workspace,
url: options.url,
origin: webProbeOriginSummary(options),
network: webProbeProxy.summary,
credential,
commandTimeout: {
@@ -884,6 +909,7 @@ export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions
workspace: spec.workspace,
route,
url: options.url,
origin: webProbeOriginSummary(options),
viewport: options.viewport,
localDir: options.localDir,
screenshot: compactScreenshot,
@@ -1139,7 +1165,7 @@ export function runNodeWebProbeAsync(
credential: Record<string, unknown>,
): Record<string, unknown> {
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",
@@ -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");
});
+122
View File
@@ -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<string, unknown> {
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, 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 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<ResolvedNodeWebProbeOrigin, "browserProxyMode" | "browserProxyModeSource"> {
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, "");
}
+44 -11
View File
@@ -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<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke --node NODE --lane vNN [--url URL]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke --node NODE --lane vNN [--origin internal|public]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
@@ -2053,13 +2054,20 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
const resolveOrigin = () => {
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 {