From 06c813f35ee46fb61db41ff6bcc3182136f9a978 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 00:44:11 +0000 Subject: [PATCH] feat: add authenticated HWLAB web probe scripts --- .agents/skills/unidesk-trans/SKILL.md | 14 + docs/reference/cli.md | 2 +- docs/reference/hwlab.md | 4 +- scripts/src/hwlab-node.ts | 395 +++++++++++++++++++++++++- 4 files changed, 397 insertions(+), 18 deletions(-) diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 0d666c7b..dd5266e7 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -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 playwright`。 + ### kubectl / logs(k3s 诊断) ```bash diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ed9f01d1..4473cbdf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 --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 --lane [--url ]` 是 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 --lane [--url ]` 是 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 --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 当成输入基础镜像。 diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index d7fa286a..9bb1633a 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -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 --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 --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 --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 --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 --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 结果。 diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index a10bb9c3..e3924ad2 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -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 { 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"); + } + 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 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 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 }; } +function webProbeCredential(secretSpec: RuntimeSecretSpec, material: BootstrapAdminPasswordMaterial): Record { + 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, +): Record { + 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] = ""; + } 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(""); + } + return next; +} + +async function emit(payload) { + process.stdout.write(JSON.stringify(sanitize(payload), null, 2) + "\n"); +} +`; +} + +function compactWebProbeScriptResult(report: Record | null): Record | 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 { }; } +function compactCommandResultRedacted(result: CommandResult, secrets: string[]): Record { + 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(""); + } + return next; +} + function parseJsonObject(text: string): Record | null { try { const parsed = JSON.parse(text) as unknown;