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
+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;