feat: add authenticated HWLAB web probe scripts

This commit is contained in:
Codex
2026-06-16 00:44:11 +00:00
parent c552e2e2bc
commit 06c813f35e
4 changed files with 397 additions and 18 deletions
+14
View File
@@ -165,6 +165,20 @@ PW
`playwright` 读取 stdin heredoc,在目标 host/workload 上注入临时 `playwright-cli` wrapper,并把远端 run 目录中的截图/PDF 回传到本机 `--local-dir`,默认 `/tmp`。多步登录、创建 session、发送消息和 trace 截图应写在同一个 heredoc 中,并显式等待登录 API response 与稳定 selector;不要只靠宽泛 input selector 或页面标题判断登录成功。
HWLAB node/lane 的 authenticated Cloud Web Playwright 验收优先使用受控入口,而不是手写 secret 读取和登录脚本:
```bash
bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'
export default async ({ page, goto, screenshot }) => {
await page.route('**/v1/agent/conversations**', route => setTimeout(() => route.continue(), 3000));
await goto('/workbench');
return { finalUrl: page.url(), screenshot: await screenshot('workbench.png') };
};
JS
```
这个入口由 UniDesk CLI 从 YAML 声明的 bootstrap admin sourceRef 读取凭据、调用同源 `/auth/login` 建立 `hwlab_session`、向自定义脚本传入已认证的 `browser/context/page/baseUrl` 和 artifact helper,并且输出只披露 sourceRef、fingerprint、cookie presence、artifact path/hash 和脚本 JSON 结果。只有非 HWLAB、无需 node/lane sourceRef,或确实需要 `--local-dir` 自动下载截图/PDF 时,才优先使用上面的 `trans <provider> playwright`
### kubectl / logsk3s 诊断)
```bash
+1 -1
View File
@@ -22,7 +22,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 `config/hwlab-node-lanes.yaml``bootstrapAdmin` 声明与受控 `hwlab nodes secret status|ensure --node <node> --lane v03 --name hwlab-v03-bootstrap-admin` CLI。明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` sourceRef 文件;CLI 在本地把明文转换为 HWLAB 兼容 password hash,只向运行面同步 `password-hash`,并在输出中只披露 sourceRef、sourceKey、target Secret/key、presence、byte count、fingerprint、mutation 与后续命令。`secret ensure --force` 只用于明确需要按 YAML sourceRef 重灌 bootstrap admin hash 并重启 Cloud API 的受控恢复场景,默认 ensure 不做强制重灌;不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node <node> --lane <lane> <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。
`hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml``plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。
+2 -2
View File
@@ -74,9 +74,9 @@ Code Agent trace/result 展示类问题的 typed CLI 关闭证据以 `hwlab-cli
### Web Live DOM Probe 验收
`scripts/web-live-dom-probe.mjs` 是 Cloud Web 原入口的 DOM 级等价验收 helper。它必须在 issue/CLI 选中的 node/lane workspace 上运行,并打到同一 public origin;例如 D601 `v0.3` 使用目标 workspace 的脚本和 `https://hwlab.pikapython.com`,不能在 master server 本地跑浏览器 smoke,也不能用旧端口或其他 lane fallback。跨 node/lane 的日常指挥验收优先使用 UniDesk `bun scripts/cli.ts hwlab nodes web-probe run --node <node> --lane <lane>`,由该入口解析 workspace、public origin 和 Web 登录 sourceRef,再把凭据作为一次性 stdin/env 注入目标 helper。只修改该 helper 时属于无服务交付,按目标 HWLAB repo `AGENTS.md` 选择直接提交或 PR,关闭证据写明 `rollout=not-applicable`
`scripts/web-live-dom-probe.mjs` 是 Cloud Web 原入口的 DOM 级等价验收 helper。它必须在 issue/CLI 选中的 node/lane workspace 上运行,并打到同一 public origin;例如 D601 `v0.3` 使用目标 workspace 的脚本和 `https://hwlab.pikapython.com`,不能在 master server 本地跑浏览器 smoke,也不能用旧端口或其他 lane fallback。跨 node/lane 的日常指挥验收优先使用 UniDesk `bun scripts/cli.ts hwlab nodes web-probe run --node <node> --lane <lane>`,由该入口解析 workspace、public origin 和 Web 登录 sourceRef,再把凭据作为一次性 stdin/env 注入目标 helper。需要自定义 Playwright route/intercept、延迟 API、读取 in-flight DOM 或生成专项 artifact 时,使用 `bun scripts/cli.ts hwlab nodes web-probe script --node <node> --lane <lane> <<'JS' ... JS`;该入口由 UniDesk 先通过同源 `/auth/login` 建立 `hwlab_session`,脚本只接收已认证的 `browser/context/page/baseUrl` 和 artifact helper。只修改该 helper 时属于无服务交付,按目标 HWLAB repo `AGENTS.md` 选择直接提交或 PR,关闭证据写明 `rollout=not-applicable`
Web 登录凭据必须从目标 node/lane 的受控 source 解析并作为一次性进程环境注入,例如先用 `bun scripts/cli.ts hwlab nodes secret status|ensure --node <node> --lane <lane> --name hwlab-v03-bootstrap-admin` 确认 bootstrap admin sourceRef,再运行受控 `hwlab nodes web-probe run` 或在目标 workspace 显式注入 `HWLAB_WEB_PASS`。目标 host 没有 owner-only source 文件时,直接运行 helper 应快速返回 `web_login_secret_missing`;不要依赖脚本历史默认密码,不要把凭据复制到目标 host、shell 启动文件、issue、日志或 Git 文档。若 sourceRef/fingerprint 已确认但 public `/auth/login` 仍不接受 source 密码,应先区分 API rollout、user-billing 回退和用户表状态;只有明确需要按 YAML source 重灌 bootstrap admin hash 时,才使用受控 `secret ensure --confirm --force`
Web 登录凭据必须从目标 node/lane 的受控 source 解析并作为一次性进程环境注入,例如先用 `bun scripts/cli.ts hwlab nodes secret status|ensure --node <node> --lane <lane> --name hwlab-v03-bootstrap-admin` 确认 bootstrap admin sourceRef,再运行受控 `hwlab nodes web-probe run` / `hwlab nodes web-probe script`。目标 host 没有 owner-only source 文件时,受控入口应快速返回 `web_login_secret_missing`;不要依赖脚本历史默认密码,不要把凭据复制到目标 host、shell 启动文件、issue、日志或 Git 文档。若 sourceRef/fingerprint 已确认但 public `/auth/login` 仍不接受 source 密码,应先区分 API rollout、user-billing 回退和用户表状态;只有明确需要按 YAML source 重灌 bootstrap admin hash 时,才使用受控 `secret ensure --confirm --force`
排查 probe 登录误报时,优先看 JSON 里的 `actions``dom.authState``finalUrl``failureDom``dom.requiredSelectors`。新版登录页 fallback 必须先等待真实登录 surface(`#workspace`、legacy id 或 `.login-card input`)再判断 input count;提交前还要确认表单值已经落到 DOM,例如 `actions.login.valuesReady=true`。只在 `authState=login` 的瞬间立即 `count()`,或在 Vue 尚未更新 input value 时 submit,都可能把前端填表时序误判成凭据错误。关闭 Workbench 登录/DOM helper 问题时,证据至少包含原命令、目标 URL/lane、登录 `selectorMode``valuesReady``finalUrl``workspace`/`commandInput` 等关键 selector 结果。
+380 -15
View File
@@ -14,7 +14,7 @@ type SecretPreset = "openfga" | "master-server-admin-api-key" | "bootstrap-admin
type DelegatedNodeDomain = "control-plane" | "git-mirror";
type NodeRuntimeRenderLocation = "node-host" | "local";
interface NodeWebProbeOptions {
interface NodeWebProbeRunOptions {
action: "run";
node: string;
lane: string;
@@ -30,6 +30,25 @@ interface NodeWebProbeOptions {
commandTimeoutSeconds: number;
}
interface NodeWebProbeScriptOptions {
action: "script";
node: string;
lane: string;
url: string;
timeoutMs: number;
viewport: string;
commandTimeoutSeconds: number;
scriptText: string;
scriptSource: {
kind: "stdin" | "file";
path: string | null;
byteCount: number;
sha256: string;
};
}
type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions;
interface NodeRuntimeRenderResult {
readonly result: CommandResult;
readonly renderDir: string;
@@ -233,11 +252,16 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
return {
ok: true,
command: "hwlab nodes web-probe",
description: "Run the target node/lane HWLAB Cloud Web DOM probe with Web login credentials resolved from YAML-declared bootstrap admin sourceRef and injected only as one-shot stdin/env.",
description: "Run target node/lane HWLAB Cloud Web DOM probes with Web login credentials resolved from YAML-declared bootstrap admin sourceRef and injected only as one-shot stdin/env.",
examples: [
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000",
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'",
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ page, goto, screenshot }) => {\n await page.route('**/v1/agent/conversations**', route => setTimeout(() => route.continue(), 3000));\n await goto('/workbench');\n await screenshot('workbench.png');\n return { finalUrl: page.url() };\n};\nJS",
],
actions: {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page helpers and must not handle secrets itself.",
},
};
}
@@ -3490,13 +3514,46 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof par
function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run") throw new Error("web-probe usage: run --node NODE --lane vNN [--url URL] [--wait-messages-ms N]");
if (actionRaw !== "run" && actionRaw !== "script") throw new Error("web-probe usage: run|script --node NODE --lane vNN [--url URL]");
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--url",
"--timeout-ms",
"--viewport",
"--script-file",
"--command-timeout-seconds",
]), new Set([]));
const scriptFile = optionValue(args, "--script-file");
if (scriptFile === undefined && process.stdin.isTTY) {
throw new Error("web-probe script requires a stdin heredoc or --script-file <path>");
}
const scriptText = scriptFile === undefined ? readFileSync(0, "utf8") : readFileSync(scriptFile, "utf8");
if (scriptText.trim().length === 0) throw new Error("web-probe script received an empty script");
return {
action: "script",
node,
lane,
url: optionValue(args, "--url") ?? spec.publicWebUrl,
timeoutMs: positiveIntegerOption(args, "--timeout-ms", 30000, 60000),
viewport: optionValue(args, "--viewport") ?? "1440x900",
commandTimeoutSeconds: positiveIntegerOption(args, "--command-timeout-seconds", 60, 60),
scriptText,
scriptSource: {
kind: scriptFile === undefined ? "stdin" : "file",
path: scriptFile ?? null,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
@@ -3548,22 +3605,12 @@ function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown>
const spec = hwlabRuntimeLaneSpecForNode(lane, options.node);
const secretSpec = runtimeSecretSpec({ node: options.node, lane });
const material = readBootstrapAdminPasswordMaterial(secretSpec);
const credential = {
username: secretSpec.bootstrapAdminUsername,
sourceRef: material.sourceRef,
sourceKey: material.sourceKey,
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
sourcePresent: material.sourcePresent,
sourceFingerprint: material.sourceFingerprint,
injectedVia: material.ok ? "stdin-env" : null,
valuesRedacted: true,
error: material.error,
};
const credential = webProbeCredential(secretSpec, material);
if (!material.ok || material.password === null) {
return {
ok: false,
status: "blocked",
command: `hwlab nodes web-probe run --node ${options.node} --lane ${options.lane}`,
command: `hwlab nodes web-probe ${options.action} --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
@@ -3573,6 +3620,7 @@ function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown>
next: { secretStatus: `bun scripts/cli.ts hwlab nodes secret status --node ${options.node} --lane ${options.lane} --name ${secretSpec.bootstrapAdminSecret}` },
};
}
if (options.action === "script") return runNodeWebProbeScript(options, spec, secretSpec, material, credential);
const probeArgs = [
"node",
"scripts/web-live-dom-probe.mjs",
@@ -3609,6 +3657,307 @@ function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown>
};
}
function webProbeCredential(secretSpec: RuntimeSecretSpec, material: BootstrapAdminPasswordMaterial): Record<string, unknown> {
return {
username: secretSpec.bootstrapAdminUsername,
sourceRef: material.sourceRef,
sourceKey: material.sourceKey,
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
sourcePresent: material.sourcePresent,
sourceFingerprint: material.sourceFingerprint,
injectedVia: material.ok ? "stdin-env" : null,
valuesRedacted: true,
error: material.error,
};
}
function runNodeWebProbeScript(
options: NodeWebProbeScriptOptions,
spec: HwlabRuntimeLaneSpec,
secretSpec: RuntimeSecretSpec,
material: BootstrapAdminPasswordMaterial,
credential: Record<string, unknown>,
): Record<string, unknown> {
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.password ?? "");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const report = compactWebProbeScriptResult(parseJsonObject(result.stdout));
const passed = result.exitCode === 0 && report?.ok === true;
return {
ok: passed,
status: passed ? "pass" : "blocked",
command: `hwlab nodes web-probe script --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
url: options.url,
credential,
scriptSource: options.scriptSource,
probe: report,
result: compactCommandResultRedacted(result, [material.password ?? ""]),
valuesRedacted: true,
};
}
function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, password: string): string {
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
return [
"set -eu",
"run_root=.state/web-probe-script",
"mkdir -p \"$run_root\"",
"run_dir=$(mktemp -d \"$run_root/run.XXXXXX\")",
"chmod 700 \"$run_dir\"",
"user_script=\"$run_dir/user-script.mjs\"",
"runner=\"$run_dir/runner.mjs\"",
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$user_script" ${shellQuote(userScriptB64)}`,
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$runner" ${shellQuote(runnerB64)}`,
[
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_PASS=${shellQuote(password)}`,
"UNIDESK_WEB_PROBE_RUN_DIR=\"$run_dir\"",
"UNIDESK_WEB_PROBE_USER_SCRIPT=\"$user_script\"",
`UNIDESK_WEB_PROBE_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`UNIDESK_WEB_PROBE_VIEWPORT=${shellQuote(options.viewport)}`,
"node \"$runner\"",
].join(" "),
].join("\n");
}
function nodeWebProbeScriptRunnerSource(): string {
return String.raw`#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const startedAt = new Date().toISOString();
const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL);
const username = process.env.HWLAB_WEB_USER || "admin";
const password = process.env.HWLAB_WEB_PASS || "";
const runDir = path.resolve(process.env.UNIDESK_WEB_PROBE_RUN_DIR || ".state/web-probe-script/run-manual");
const userScript = path.resolve(process.env.UNIDESK_WEB_PROBE_USER_SCRIPT || path.join(runDir, "user-script.mjs"));
const timeoutMs = positiveInteger(process.env.UNIDESK_WEB_PROBE_TIMEOUT_MS, 30000);
const viewport = parseViewport(process.env.UNIDESK_WEB_PROBE_VIEWPORT || "1440x900");
const artifactRecords = [];
let browser;
let context;
let page;
let auth = null;
let cookieSecrets = [];
try {
if (!password) throw new Error("missing HWLAB_WEB_PASS");
await mkdir(runDir, { recursive: true, mode: 0o700 });
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
const { chromium } = await launcher.importPlaywright();
browser = await launcher.launchChromium(chromium);
context = await browser.newContext({ viewport });
auth = await authenticate(context);
if (!auth.ok) throw new Error("auth-login-failed");
page = await context.newPage();
const mod = await import(pathToFileURL(userScript).href + "?t=" + Date.now());
const fn = typeof mod.default === "function" ? mod.default : typeof mod.run === "function" ? mod.run : typeof mod.probe === "function" ? mod.probe : null;
if (fn === null) throw new Error("custom script must export default, run, or probe function");
const scriptResult = await fn({
browser,
context,
page,
baseUrl,
runDir,
auth: publicAuth(auth),
artifactPath,
screenshot,
jsonArtifact,
sha256File,
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
goto: async (target = "/workbench", options = {}) => {
const url = new URL(target, baseUrl).toString();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs, ...options });
return page.url();
},
request: context.request,
});
const safeResult = sanitize(scriptResult);
const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false);
await emit({
ok: scriptOk,
status: scriptOk ? "pass" : "blocked",
command: "web-probe-script",
generatedAt: new Date().toISOString(),
startedAt,
baseUrl,
finalUrl: page.url(),
auth: publicAuth(auth),
script: { ok: scriptOk, result: safeResult },
artifacts: { runDir, items: artifactRecords },
safety: { valuesRedacted: true, secretValuesPrinted: false }
});
process.exitCode = scriptOk ? 0 : 2;
} catch (error) {
await emit({
ok: false,
status: "blocked",
command: "web-probe-script",
generatedAt: new Date().toISOString(),
startedAt,
baseUrl,
finalUrl: page ? page.url() : null,
auth: auth === null ? null : publicAuth(auth),
error: sanitize(error instanceof Error ? error.message : String(error)),
artifacts: { runDir, items: artifactRecords },
safety: { valuesRedacted: true, secretValuesPrinted: false }
});
process.exitCode = 2;
} finally {
if (browser) await browser.close().catch(() => {});
}
async function authenticate(browserContext) {
const loginUrl = new URL("/auth/login", baseUrl).toString();
const response = await browserContext.request.post(loginUrl, {
data: { username, password },
timeout: timeoutMs,
});
const cookies = await browserContext.cookies(baseUrl);
cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean);
const cookieNames = cookies.map((cookie) => cookie.name).sort();
const cookiePresent = cookieNames.includes("hwlab_session");
return {
ok: response.ok() && cookiePresent,
loginUrl: new URL("/auth/login", baseUrl).pathname,
status: response.status(),
statusText: response.statusText(),
cookiePresent,
cookieNames,
valuesRedacted: true,
};
}
function publicAuth(value) {
return {
ok: value.ok,
loginPath: value.loginUrl,
status: value.status,
statusText: value.statusText,
cookiePresent: value.cookiePresent,
cookieNames: value.cookieNames,
username,
valuesRedacted: true,
};
}
function normalizeBaseUrl(raw) {
if (!raw) throw new Error("missing HWLAB_WEB_BASE_URL");
const parsed = new URL(raw);
parsed.hash = "";
parsed.search = "";
return parsed.toString().replace(/\/+$/u, "/");
}
function parseViewport(raw) {
const match = String(raw).match(/^([0-9]+)x([0-9]+)$/u);
if (!match) throw new Error("viewport must look like 1440x900");
return { width: Number(match[1]), height: Number(match[2]) };
}
function positiveInteger(raw, fallback) {
const value = Number(raw || fallback);
if (!Number.isInteger(value) || value <= 0) return fallback;
return value;
}
function artifactPath(name) {
const safe = String(name || "artifact")
.replace(/[^A-Za-z0-9._-]/gu, "_")
.replace(/^\.+/u, "_")
.slice(0, 120) || "artifact";
return path.join(runDir, safe);
}
async function screenshot(name = "screenshot.png", options = {}) {
const file = artifactPath(name);
await page.screenshot({ path: file, fullPage: true, ...options });
return recordArtifact(file, "screenshot");
}
async function jsonArtifact(name, value) {
const file = artifactPath(name);
await writeFile(file, JSON.stringify(sanitize(value), null, 2) + "\n", "utf8");
return recordArtifact(file, "json");
}
async function recordArtifact(file, kind) {
const fileStat = await stat(file);
const item = {
kind,
path: file,
byteCount: fileStat.size,
sha256: await sha256File(file),
};
artifactRecords.push(item);
return item;
}
async function sha256File(file) {
const buffer = await readFile(file);
return "sha256:" + createHash("sha256").update(buffer).digest("hex");
}
function sanitize(value, depth = 0, seen = new WeakSet()) {
if (value === null || value === undefined) return value ?? null;
if (typeof value === "string") return redactString(value).slice(0, 6000);
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "bigint") return String(value);
if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]";
if (depth > 8) return "[max-depth]";
if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitize(item, depth + 1, seen));
if (typeof value === "object") {
if (seen.has(value)) return "[circular]";
seen.add(value);
const out = {};
for (const [key, nested] of Object.entries(value).slice(0, 200)) {
if (/password|passwd|secret|token|authorization|cookie|api[_-]?key/iu.test(key) && typeof nested === "string") {
out[key] = "<redacted>";
} else {
out[key] = sanitize(nested, depth + 1, seen);
}
}
return out;
}
return String(value);
}
function redactString(text) {
let next = String(text);
for (const secret of [password, ...cookieSecrets].filter(Boolean)) {
next = next.split(secret).join("<redacted>");
}
return next;
}
async function emit(payload) {
process.stdout.write(JSON.stringify(sanitize(payload), null, 2) + "\n");
}
`;
}
function compactWebProbeScriptResult(report: Record<string, unknown> | null): Record<string, unknown> | null {
if (report === null) return null;
return {
ok: report.ok === true,
status: typeof report.status === "string" ? report.status : null,
baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null,
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null,
auth: record(report.auth),
script: record(report.script),
artifacts: record(report.artifacts),
error: typeof report.error === "string" ? report.error : null,
safety: record(report.safety),
};
}
function parseSecretOptions(args: string[]): NodeSecretOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "cleanup-owned-postgres" && actionRaw !== "cleanup-obsolete") {
@@ -6420,6 +6769,22 @@ function compactCommandResult(result: CommandResult): Record<string, unknown> {
};
}
function compactCommandResultRedacted(result: CommandResult, secrets: string[]): Record<string, unknown> {
const compact = compactCommandResult(result);
if (typeof compact.stderr === "string" && compact.stderr.length > 0) {
compact.stderr = redactKnownSecrets(compact.stderr, secrets);
}
return compact;
}
function redactKnownSecrets(text: string, secrets: string[]): string {
let next = text;
for (const secret of secrets.filter((item) => item.length > 0)) {
next = next.split(secret).join("<redacted>");
}
return next;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;