feat: add authenticated HWLAB web probe scripts
This commit is contained in:
+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