From 4b39c9ce415ed4c0b237a7a5210636990f1b3911 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 07:34:31 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20WebProbe=20?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=90=AF=E5=8A=A8=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/hwlab-node-lanes.yaml | 6 + scripts/src/hwlab-node-lanes.ts | 17 +++ .../hwlab-node-web-probe-memory-guard.test.ts | 107 +++++++++++++++- .../src/hwlab-node-web-probe-memory-guard.ts | 121 ++++++++++++++++++ .../src/hwlab-node-web-probe-runner-source.ts | 54 ++++++-- scripts/src/hwlab-node-web-sentinel-p5.ts | 8 +- scripts/src/hwlab-node/web-observe-scripts.ts | 12 +- scripts/src/hwlab-node/web-probe-observe.ts | 15 ++- scripts/src/hwlab-node/web-probe.ts | 2 +- 9 files changed, 322 insertions(+), 20 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 43c7b97e..69f10dee 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -588,6 +588,12 @@ templates: maxProcessReadConcurrency: 32 maxRealtimeTotalEntries: 512 maxRealtimeTotalBodyBytes: 4194304 + browserEvidence: + screenshotMode: viewport + maxNetworkRequestEntries: 200 + maxNetworkRequestBytes: 262144 + maxNetworkFailureEntries: 100 + maxNetworkFailureBytes: 131072 origins: internal: mode: k8s-service-cluster-ip diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 64d2d2a0..4331e231 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -203,6 +203,15 @@ export interface HwlabRuntimeWebProbeObserverLifecycleSpec { export interface HwlabRuntimeWebProbeResourcePolicySpec { readonly memoryStartGuard: HwlabRuntimeWebProbeMemoryStartGuardSpec; readonly observerLifecycle: HwlabRuntimeWebProbeObserverLifecycleSpec; + readonly browserEvidence: HwlabRuntimeWebProbeBrowserEvidenceSpec; +} + +export interface HwlabRuntimeWebProbeBrowserEvidenceSpec { + readonly screenshotMode: "viewport"; + readonly maxNetworkRequestEntries: number; + readonly maxNetworkRequestBytes: number; + readonly maxNetworkFailureEntries: number; + readonly maxNetworkFailureBytes: number; } export interface HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec { @@ -1482,6 +1491,7 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim const raw = asRecord(value, path); const memory = asRecord(raw.memoryStartGuard, `${path}.memoryStartGuard`); const lifecycle = asRecord(raw.observerLifecycle, `${path}.observerLifecycle`); + const browserEvidence = asRecord(raw.browserEvidence, `${path}.browserEvidence`); return { memoryStartGuard: { metric: enumStringField(memory, "metric", `${path}.memoryStartGuard`, ["MemAvailable"] as const), @@ -1522,6 +1532,13 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim maxRealtimeTotalEntries: boundedIntegerField(lifecycle, "maxRealtimeTotalEntries", `${path}.observerLifecycle`, 1, 100_000), maxRealtimeTotalBodyBytes: boundedIntegerField(lifecycle, "maxRealtimeTotalBodyBytes", `${path}.observerLifecycle`, 1024, 64 * 1024 * 1024), }, + browserEvidence: { + screenshotMode: enumStringField(browserEvidence, "screenshotMode", `${path}.browserEvidence`, ["viewport"] as const), + maxNetworkRequestEntries: boundedIntegerField(browserEvidence, "maxNetworkRequestEntries", `${path}.browserEvidence`, 1, 100_000), + maxNetworkRequestBytes: boundedIntegerField(browserEvidence, "maxNetworkRequestBytes", `${path}.browserEvidence`, 1024, 64 * 1024 * 1024), + maxNetworkFailureEntries: boundedIntegerField(browserEvidence, "maxNetworkFailureEntries", `${path}.browserEvidence`, 1, 100_000), + maxNetworkFailureBytes: boundedIntegerField(browserEvidence, "maxNetworkFailureBytes", `${path}.browserEvidence`, 1024, 64 * 1024 * 1024), + }, }; } diff --git a/scripts/src/hwlab-node-web-probe-memory-guard.test.ts b/scripts/src/hwlab-node-web-probe-memory-guard.test.ts index 44c99c7b..8a98cbc3 100644 --- a/scripts/src/hwlab-node-web-probe-memory-guard.test.ts +++ b/scripts/src/hwlab-node-web-probe-memory-guard.test.ts @@ -5,7 +5,8 @@ import { test } from "bun:test"; import type { CommandResult } from "./command"; import { rootPath } from "./config"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; -import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard, webProbeMemoryStartGuardConfigRef } from "./hwlab-node-web-probe-memory-guard"; +import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell, webProbeMemoryStartGuardConfigRef } from "./hwlab-node-web-probe-memory-guard"; +import { nodeWebProbeScriptRunnerSource } from "./hwlab-node-web-probe-runner-source"; const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); const thresholdBytes = 4 * 1024 * 1024 * 1024; @@ -23,6 +24,15 @@ function memoryPayload(availableBytes: number, extra: Record = }; } +function exportedFunctionSource(source: string, name: string): string { + const start = source.indexOf(`export function ${name}(`); + assert.ok(start >= 0, `missing exported function ${name}`); + const nextExport = source.indexOf("\nexport function ", start + 1); + const nextFunction = source.indexOf("\nfunction ", start + 1); + const next = [nextExport, nextFunction].filter((value) => value >= 0).sort((left, right) => left - right)[0] ?? -1; + return source.slice(start, next < 0 ? source.length : next); +} + test("WebProbe memory threshold is read from the single owning YAML policy", () => { const policy = spec.webProbe?.resourcePolicy?.memoryStartGuard; assert.equal(policy?.thresholdBytes, thresholdBytes); @@ -34,6 +44,21 @@ test("WebProbe memory threshold is read from the single owning YAML policy", () assert.match(webProbeMemoryStartGuardConfigRef, /resourcePolicy\.memoryStartGuard$/u); }); +test("browser evidence limits and viewport screenshots are read from owning YAML", () => { + const policy = spec.webProbe?.resourcePolicy?.browserEvidence; + assert.deepEqual(policy, { + screenshotMode: "viewport", + maxNetworkRequestEntries: 200, + maxNetworkRequestBytes: 262144, + maxNetworkFailureEntries: 100, + maxNetworkFailureBytes: 131072, + }); + const assignments = webProbeBrowserEvidenceEnvAssignments(spec).join(" "); + assert.match(assignments, /UNIDESK_WEB_PROBE_SCREENSHOT_MODE='viewport'/u); + assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES='200'/u); + assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES='131072'/u); +}); + test("manual start is blocked at exactly 4 GiB and requires controlled GC then MemAvailable recheck", () => { const guard = evaluateWebProbeMemoryGuard(spec, "manual-start", memoryPayload(thresholdBytes)); const memory = guard.memory as Record; @@ -141,3 +166,83 @@ test("manual and sentinel gates return before any observer launch path", () => { const plannedStart = serviceSource.indexOf('"bun", "scripts/cli.ts", "web-probe", "observe", "start"'); assert.match(serviceSource.slice(plannedStart, plannedStart + 800), /--sentinel-cadence/u); }); + +test("all browser-launch entries use the shared preflight and final launch guard", () => { + const probeSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8"); + const scriptSource = readFileSync(rootPath("scripts/src/hwlab-node/web-observe-scripts.ts"), "utf8"); + const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8"); + const parserSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8"); + + const run = exportedFunctionSource(probeSource, "runNodeWebProbe"); + const screenshot = exportedFunctionSource(probeSource, "runNodeWebProbeScreenshot"); + const asyncRun = exportedFunctionSource(probeSource, "runNodeWebProbeAsync"); + const script = exportedFunctionSource(scriptSource, "runNodeWebProbeScript"); + const scriptShell = exportedFunctionSource(scriptSource, "nodeWebProbeScriptRemoteShell"); + const dashboard = exportedFunctionSource(sentinelSource, "probeSentinelDashboardBrowser"); + const parser = exportedFunctionSource(parserSource, "parseNodeWebProbeOptions"); + + assert.doesNotMatch(probeSource, /runNodeWebProbeMemoryGuard/u); + assert.match(run, /if \(options\.action === "script"\) return runNodeWebProbeScript/u); + assert.match(run, /if \(options\.commandTimeoutSeconds > 55\) return runNodeWebProbeAsync/u); + assert.match(run, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"/u); + assert.match(screenshot, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start", webProbeScreenshotRemoteScript/u); + assert.match(asyncRun, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"[\s\S]+"structured-output"\)/u); + assert.match(script, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+nodeWebProbeScriptRemoteShell/u); + assert.match(scriptShell, /webProbeGuardedLaunchShell\(spec, "manual-start"/u); + assert.match(parser, /if \(actionRaw === "opencode-smoke"\)[\s\S]+action: "script"/u); + assert.match(dashboard, /runWebProbeMemoryGuard\(state\.spec, "sentinel-cadence"\)[\s\S]+webProbeGuardedLaunchShell\(state\.spec, "sentinel-cadence"/u); + assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_TRIGGER=.*options\.action === "trigger"/u); + assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_CAPTURE=.*options\.action === "screenshot"/u); +}); + +test("read-only and control-plane entries stay outside browser launch guard", () => { + const dispatchSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8"); + const dispatchStart = dispatchSource.indexOf("export function runNodeWebProbe("); + const dispatchEnd = dispatchSource.indexOf("export function runNodeWebProbeScreenshot", dispatchStart); + const dispatch = dispatchSource.slice(dispatchStart, dispatchEnd); + const readOnlyReturn = dispatch.indexOf('options.action === "observe" && options.observeAction !== "start"'); + const firstGuard = dispatch.indexOf("runWebProbeMemoryGuard"); + assert.ok(readOnlyReturn >= 0 && firstGuard > readOnlyReturn); + assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "sentinel"/u); + assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "screenshot"/u); + + const sentinelDispatch = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-cicd.ts"), "utf8"); + const sentinel = exportedFunctionSource(sentinelDispatch, "runWebProbeSentinelCommand"); + assert.doesNotMatch(sentinel, /runWebProbeMemoryGuard|webProbeGuardedLaunchShell/u); + assert.match(sentinel, /options\.kind === "dashboard"/u); +}); + +test("generated runner bounds network evidence and defaults screenshots to viewport", () => { + const source = nodeWebProbeScriptRunnerSource(); + assert.match(source, /maxNetworkRequestEntries/u); + assert.match(source, /maxNetworkRequestBytes/u); + assert.match(source, /maxNetworkFailureEntries/u); + assert.match(source, /maxNetworkFailureBytes/u); + assert.match(source, /apiRequestEvidenceDropped/u); + assert.match(source, /apiFailureEvidenceDropped/u); + assert.doesNotMatch(source, /fullPage: true/u); + assert.equal((source.match(/fullPage: screenshotMode !== "viewport"/gu) ?? []).length, 2); + + const parserSource = exportedFunctionSource(readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8"), "parseNodeWebProbeOptions"); + assert.match(parserSource, /fullPage: args\.includes\("--full-page"\) && !args\.includes\("--no-full-page"\)/u); + const screenshotSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8"); + assert.match(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE === "1"/u); + assert.doesNotMatch(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0"/u); + const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8"); + assert.match(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE === "1"/u); + assert.doesNotMatch(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"/u); +}); + +test("final launch guard rechecks MemAvailable under the configured lock", () => { + const sync = webProbeGuardedLaunchShell(spec, "manual-start", "node runner.mjs"); + const async = webProbeGuardedLaunchShell(spec, "manual-start", "node helper.mjs start", "structured-output"); + assert.match(sync, /flock -w '15'/u); + assert.match(sync, /MemAvailable:/u); + assert.match(sync, /mem_available_bytes.*-le '4294967296'/u); + assert.match(sync, /setsid sh -c/u); + assert.match(sync, /kill -TERM -- "-\$browser_job_pid"/u); + assert.match(sync, /kill -KILL -- "-\$browser_job_pid"/u); + assert.match(async, /browserCreated === true/u); + assert.match(async, /data\.workerPid, data\.jobPid, data\.pid/u); + assert.match(async, /\/proc\/.*\/cmdline/u); +}); diff --git a/scripts/src/hwlab-node-web-probe-memory-guard.ts b/scripts/src/hwlab-node-web-probe-memory-guard.ts index 64d854f3..dc658055 100644 --- a/scripts/src/hwlab-node-web-probe-memory-guard.ts +++ b/scripts/src/hwlab-node-web-probe-memory-guard.ts @@ -24,6 +24,127 @@ function memoryPolicy(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeMemorySta return spec.webProbe?.resourcePolicy?.memoryStartGuard ?? null; } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export function webProbeBrowserEvidenceEnvAssignments(spec: HwlabRuntimeLaneSpec): string[] { + const policy = spec.webProbe?.resourcePolicy?.browserEvidence; + if (policy === undefined) throw new Error("web-probe browser evidence policy is missing from the owning YAML"); + return [ + `UNIDESK_WEB_PROBE_SCREENSHOT_MODE=${shellQuote(policy.screenshotMode)}`, + `UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES=${shellQuote(String(policy.maxNetworkRequestEntries))}`, + `UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES=${shellQuote(String(policy.maxNetworkRequestBytes))}`, + `UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES=${shellQuote(String(policy.maxNetworkFailureEntries))}`, + `UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES=${shellQuote(String(policy.maxNetworkFailureBytes))}`, + ]; +} + +export function webProbeGuardedLaunchShell( + spec: HwlabRuntimeLaneSpec, + mode: WebProbeMemoryGuardMode, + command: string, + readiness: "child-process" | "structured-output" = "child-process", +): string { + const policy = memoryPolicy(spec); + if (policy === null) throw new Error("web-probe memory start policy is missing from the owning YAML"); + const comparator = mode === "manual-start" ? "-le" : "-lt"; + const common = [ + `exec 9>${shellQuote(policy.launchLockPath)}`, + `flock -w ${shellQuote(String(policy.launchLockTimeoutSeconds))} 9`, + "mem_available_kib=$(awk '$1 == \"MemAvailable:\" { print $2; exit }' /proc/meminfo)", + "case \"$mem_available_kib\" in ''|*[!0-9]*) printf '%s\\n' 'web-probe final memory guard could not read MemAvailable' >&2; exit 42;; esac", + "mem_available_bytes=$((mem_available_kib * 1024))", + `if [ "$mem_available_bytes" ${comparator} ${shellQuote(String(policy.thresholdBytes))} ]; then printf '%s\\n' 'web-probe final memory guard blocked browser launch' >&2; exit 42; fi`, + ]; + if (readiness === "structured-output") return [ + ...common, + "launch_output=$(mktemp)", + "launch_error=$(mktemp)", + "trap 'rm -f \"$launch_output\" \"$launch_error\"' EXIT", + `if ! ${command} >"$launch_output" 2>"$launch_error"; then cat "$launch_output"; cat "$launch_error" >&2; exit 43; fi`, + "cat \"$launch_output\"", + "cat \"$launch_error\" >&2", + `node - "$launch_output" ${shellQuote(String(policy.launchReadyTimeoutSeconds * 1000))} ${shellQuote(String(policy.launchReadyPollMilliseconds))} <<'UNIDESK_WEB_PROBE_STRUCTURED_READINESS'`, + "const fs = require('fs');", + "const text = fs.readFileSync(process.argv[2], 'utf8').trim();", + "const timeoutMs = Number(process.argv[3]);", + "const pollMs = Number(process.argv[4]);", + "let value = null; try { value = JSON.parse(text); } catch {}", + "const data = value && typeof value === 'object' && value.data && typeof value.data === 'object' ? value.data : value;", + "if (!data || typeof data !== 'object' || !data.jobId) process.exit(43);", + "if (data.browserCreated === true && Number.isInteger(data.browserPid) && data.browserPid > 0) process.exit(0);", + "const rootPid = [data.browserPid, data.workerPid, data.jobPid, data.pid].find((item) => Number.isInteger(item) && item > 0);", + "if (!rootPid) process.exit(43);", + "const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));", + "const browserDescendant = () => {", + " const rows = [];", + " for (const name of fs.readdirSync('/proc')) {", + " if (!/^[0-9]+$/.test(name)) continue;", + " try {", + " const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');", + " const close = stat.lastIndexOf(')');", + " const fields = stat.slice(close + 2).split(' ');", + " const ppid = Number(fields[1]);", + " const command = fs.readFileSync('/proc/' + name + '/cmdline', 'utf8').replace(/\\0/g, ' ');", + " rows.push({ pid: Number(name), ppid, command });", + " } catch {}", + " }", + " const descendants = new Set([rootPid]);", + " let changed = true;", + " while (changed) { changed = false; for (const row of rows) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { descendants.add(row.pid); changed = true; } }", + " return rows.some((row) => descendants.has(row.pid) && /(chromium|chrome|playwright)/i.test(row.command));", + "};", + "(async () => {", + " const deadline = Date.now() + timeoutMs;", + " while (Date.now() <= deadline) { if (browserDescendant()) process.exit(0); await sleep(pollMs); }", + " process.exit(43);", + "})().catch(() => process.exit(43));", + "UNIDESK_WEB_PROBE_STRUCTURED_READINESS", + "flock -u 9", + ].join("\n"); + return [ + ...common, + `setsid sh -c ${shellQuote(command)} &`, + "browser_job_pid=$!", + "browser_ready=false", + `browser_ready_deadline=$(( $(date +%s) + ${policy.launchReadyTimeoutSeconds} ))`, + "while kill -0 \"$browser_job_pid\" 2>/dev/null; do", + " if node - \"$browser_job_pid\" <<'UNIDESK_WEB_PROBE_CHILD_READINESS'", + "const fs = require('fs');", + "const rootPid = Number(process.argv[2]);", + "const rows = [];", + "for (const name of fs.readdirSync('/proc')) {", + " if (!/^[0-9]+$/.test(name)) continue;", + " try {", + " const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');", + " const close = stat.lastIndexOf(')');", + " const fields = stat.slice(close + 2).split(' ');", + " rows.push({ pid: Number(name), ppid: Number(fields[1]), command: fs.readFileSync('/proc/' + name + '/cmdline', 'utf8').replace(/\\0/g, ' ') });", + " } catch {}", + "}", + "const descendants = new Set([rootPid]);", + "let changed = true;", + "while (changed) { changed = false; for (const row of rows) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { descendants.add(row.pid); changed = true; } }", + "process.exit(rows.some((row) => descendants.has(row.pid) && /(chromium|chrome|playwright)/i.test(row.command)) ? 0 : 1);", + "UNIDESK_WEB_PROBE_CHILD_READINESS", + " then browser_ready=true; break; fi", + " if [ \"$(date +%s)\" -ge \"$browser_ready_deadline\" ]; then break; fi", + ` sleep ${Math.max(0.05, policy.launchReadyPollMilliseconds / 1000)}`, + "done", + "flock -u 9", + "if [ \"$browser_ready\" != true ]; then", + " kill -TERM -- \"-$browser_job_pid\" 2>/dev/null || true", + " sleep 1", + " if kill -0 \"$browser_job_pid\" 2>/dev/null; then kill -KILL -- \"-$browser_job_pid\" 2>/dev/null || true; fi", + " wait \"$browser_job_pid\" 2>/dev/null || true", + " printf '%s\\n' 'web-probe browser launch readiness was not confirmed' >&2", + " exit 43", + "fi", + "wait \"$browser_job_pid\"", + ].join("\n"); +} + function humanBytes(value: number | null): string | null { if (value === null || !Number.isFinite(value)) return null; const units = ["B", "KiB", "MiB", "GiB", "TiB"]; diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index bff5110c..16a10475 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -20,6 +20,11 @@ const viewport = parseViewport(process.env.UNIDESK_WEB_PROBE_VIEWPORT || "1440x9 const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE || "auto"); const playwrightProxy = proxyConfigFromEnv(baseUrl); const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy); +const screenshotMode = requiredEnum(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_MODE, ["viewport"]); +const maxNetworkRequestEntries = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES, "UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES"); +const maxNetworkRequestBytes = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES, "UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES"); +const maxNetworkFailureEntries = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES, "UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES"); +const maxNetworkFailureBytes = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES, "UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES"); const artifactRecords = []; const readinessRecords = []; const stepRecords = []; @@ -1314,36 +1319,54 @@ function createNetworkTracker(targetPage, apiPattern) { const started = Date.now(); const requests = []; const failures = []; + let requestBytes = 0; + let requestDropped = 0; + let failureBytes = 0; + let failureDropped = 0; const matches = createApiMatcher(apiPattern); + const appendBounded = (items, item, kind) => { + const bytes = Buffer.byteLength(JSON.stringify(item), "utf8"); + const entryLimit = kind === "request" ? maxNetworkRequestEntries : maxNetworkFailureEntries; + const byteLimit = kind === "request" ? maxNetworkRequestBytes : maxNetworkFailureBytes; + const usedBytes = kind === "request" ? requestBytes : failureBytes; + if (items.length >= entryLimit || usedBytes + bytes > byteLimit) { + if (kind === "request") requestDropped += 1; + else failureDropped += 1; + return; + } + items.push(item); + if (kind === "request") requestBytes += bytes; + else failureBytes += bytes; + }; const onRequest = (request) => { if (!matches(request.url())) return; - requests.push({ + appendBounded(requests, { method: request.method(), path: urlPath(request.url()), atMs: Date.now() - started, - }); + }, "request"); }; const onResponse = (response) => { if (!matches(response.url())) return; if (response.status() >= 400) { - failures.push({ + appendBounded(failures, { type: "response", status: response.status(), statusText: response.statusText(), path: urlPath(response.url()), atMs: Date.now() - started, - }); + }, "failure"); } }; const onRequestFailed = (request) => { if (!matches(request.url())) return; - failures.push({ + appendBounded(failures, { type: "requestfailed", method: request.method(), path: urlPath(request.url()), failureText: request.failure()?.errorText ?? null, atMs: Date.now() - started, - }); + }, "failure"); }; targetPage.on("request", onRequest); targetPage.on("response", onResponse); @@ -1356,6 +1379,10 @@ function createNetworkTracker(targetPage, apiPattern) { apiFailureCount: failures.length, apiRequests: requests.slice(0, 5), apiFailures: failures.slice(0, 5), + apiRequestEvidenceBytes: requestBytes, + apiRequestEvidenceDropped: requestDropped, + apiFailureEvidenceBytes: failureBytes, + apiFailureEvidenceDropped: failureDropped, }), dispose: () => { targetPage.off("request", onRequest); @@ -1476,7 +1503,7 @@ async function captureFailureScreenshot(name = "failure.png") { async function screenshotPage(targetPage, name = "screenshot.png", options = {}) { const file = artifactPath(name); - await targetPage.screenshot({ path: file, fullPage: true, ...options }); + await targetPage.screenshot({ path: file, fullPage: screenshotMode !== "viewport", ...options }); return recordArtifact(file, "screenshot"); } @@ -1910,6 +1937,17 @@ function positiveInteger(raw, fallback) { return value; } +function requiredPositiveInteger(raw, name) { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error("missing or invalid " + name); + return value; +} + +function requiredEnum(raw, allowed) { + if (!allowed.includes(raw)) throw new Error("missing or invalid screenshot mode"); + return raw; +} + function artifactPath(name) { const safe = String(name || "artifact") .replace(/[^A-Za-z0-9._-]/gu, "_") @@ -1920,7 +1958,7 @@ function artifactPath(name) { async function screenshot(name = "screenshot.png", options = {}) { const file = artifactPath(name); - await page.screenshot({ path: file, fullPage: true, ...options }); + await page.screenshot({ path: file, fullPage: screenshotMode !== "viewport", ...options }); return recordArtifact(file, "screenshot"); } diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index 8c8c3280..e0651305 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -9,6 +9,7 @@ import { startJob } from "./jobs"; import type { RenderedCliResult } from "./output"; import { runWebProbeRemoteArtifactJob } from "./web-probe-remote-artifact"; import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref"; +import { runWebProbeMemoryGuard, webProbeGuardedLaunchShell } from "./hwlab-node-web-probe-memory-guard"; import type { SentinelCicdState, WebProbeSentinelOptions } from "./hwlab-node-web-sentinel-cicd"; import { clipTail, @@ -1144,11 +1145,13 @@ export function runSentinelDashboard(state: SentinelCicdState, options: Extract< } export function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extract): Record { + const memoryGuard = runWebProbeMemoryGuard(state.spec, "sentinel-cadence"); + if (memoryGuard.status !== "allowed") return memoryGuard; const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, ""); const dashboardUrl = sentinelDashboardUrl(publicBaseUrl, options.runId, options.errorId); const [widthRaw, heightRaw] = options.viewport.split("x"); const screenshotName = options.action === "screenshot" ? dashboardScreenshotName(options, state) : ""; - const script = [ + const browserScript = [ "set -eu", `export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(dashboardUrl)}`, `export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR"/${shellQuote(screenshotName)}`, @@ -1178,6 +1181,7 @@ export function probeSentinelDashboardBrowser(state: SentinelCicdState, options: "WEB_PROBE_SENTINEL_DASHBOARD_JS", "node \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"", ].join("\n"); + const script = webProbeGuardedLaunchShell(state.spec, "sentinel-cadence", browserScript); const route = `${state.spec.nodeId}:${state.spec.workspace}`; const job = runWebProbeRemoteArtifactJob({ route, @@ -1263,7 +1267,7 @@ const triggerManual = process.env.UNIDESK_SENTINEL_DASHBOARD_TRIGGER === "1"; const width = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_WIDTH || 1440); const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900); const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000); -const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"; +const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE === "1"; const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || ""; const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || ""; const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/"; diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 961b6129..6bcda0ea 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -25,6 +25,7 @@ import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapp import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd"; import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help"; import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary"; +import { runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell } from "../hwlab-node-web-probe-memory-guard"; import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql"; import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport"; import type { RenderedCliResult } from "../output"; @@ -537,7 +538,9 @@ export function runNodeWebProbeScript( 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); + const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start"); + if (memoryGuard.status !== "allowed") return memoryGuard; + const script = nodeWebProbeScriptRemoteShell(options, spec, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const commandTimedOut = result.timedOut || result.exitCode === 124; const runPaths = webProbeScriptRunPathsFromStderr(result.stderr); @@ -740,7 +743,7 @@ function webProbeSemanticOriginArgs(options: Pick 55) return runNodeWebProbeAsync(options, spec, secretSpec, material, credential); + const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start"); + if (memoryGuard.status !== "allowed") return memoryGuard; const probeArgs = nodeWebProbeRunArgs(options, "run"); 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(" ")}`, + webProbeGuardedLaunchShell(spec, "manual-start", `${[...webProbeProxy.envAssignments, ...webProbeBrowserEvidenceEnvAssignments(spec), `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`), ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const probe = compactWebProbeResult(parseJsonObject(result.stdout)); @@ -932,8 +935,10 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record { + const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start"); + if (memoryGuard.status !== "allowed") return memoryGuard; const route = `${options.node}:${spec.workspace}`; - const script = webProbeScreenshotRemoteScript(options); + const script = webProbeGuardedLaunchShell(spec, "manual-start", webProbeScreenshotRemoteScript(options)); const job = runWebProbeRemoteArtifactJob({ route, localDir: options.localDir, @@ -1095,7 +1100,7 @@ const width = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH || 1440); const height = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT || 900); const timeout = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS || 30000); const waitUntil = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL || "networkidle"; -const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0"; +const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE === "1"; const selector = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR || ""; const executablePath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH || ""; @@ -1234,9 +1239,11 @@ export function runNodeWebProbeAsync( ): Record { const startArgs = nodeWebProbeRunArgs(options, "start"); const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); + const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start"); + if (memoryGuard.status !== "allowed") return memoryGuard; 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(" ")}`, + webProbeGuardedLaunchShell(spec, "manual-start", `${[...webProbeProxy.envAssignments, ...webProbeBrowserEvidenceEnvAssignments(spec), `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`, "structured-output"), ].join("\n"); const startResult = runTransWorkspaceStdinScript(options.node, spec.workspace, startScript, 55); const start = parseJsonObject(startResult.stdout); diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 74e3411b..605bb694 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -2130,7 +2130,7 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { name: parseWebProbeScreenshotName(optionValue(args, "--name") ?? `web-probe-${node.toLowerCase()}-${lane}.png`), timeoutMs, waitUntil: parseWebProbeScreenshotWaitUntil(optionValue(args, "--wait-until") ?? "networkidle"), - fullPage: !args.includes("--no-full-page"), + fullPage: args.includes("--full-page") && !args.includes("--no-full-page"), selector: optionValue(args, "--selector") ?? null, keepRemote: args.includes("--keep-remote"), waitTimeoutMs,