feat: add authenticated HWLAB web probe scripts
This commit is contained in:
@@ -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 / logs(k3s 诊断)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 当成输入基础镜像。
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user