Files
pikasTech-unidesk/scripts/src/web-probe-external-form-runner-source.ts
T
pikastech c58e92c71b
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success
feat(web-probe): support switching to new form pages
2026-07-19 22:51:08 +02:00

421 lines
16 KiB
TypeScript

// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-18-p19-external-form-workflow.
// Responsibility: Persistent, redacted browser runner for declared external form workflows.
export function webProbeExternalFormRunnerSource(): string {
return String.raw`#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
const stateDir = path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_STATE_DIR"));
const workflowPath = path.join(stateDir, "workflow.json");
const secretPath = path.join(stateDir, "secrets.json");
const statusPath = path.join(stateDir, "status.json");
const launchReadyPath = path.join(stateDir, "launch-ready.json");
const startedAt = new Date().toISOString();
const workflow = JSON.parse(await readFile(workflowPath, "utf8"));
const secrets = JSON.parse(await readFile(secretPath, "utf8"));
await rm(secretPath, { force: true });
const secretValues = Object.values(secrets).filter((value) => typeof value === "string" && value.length > 0);
const completed = [];
const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 };
const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null };
const observedPages = new WeakSet();
let inspection = null;
let browser;
let context;
let page;
let browserPid = null;
let fixtureServer = null;
let effectiveOrigin = workflow.origin;
let stopping = false;
let currentAction = null;
process.on("SIGTERM", () => { stopping = true; });
process.on("SIGINT", () => { stopping = true; });
try {
await mkdir(path.join(stateDir, "artifacts"), { recursive: true, mode: 0o700 });
await writeFile(path.join(stateDir, "pid"), String(process.pid) + "\n", { mode: 0o600 });
await writeStatus("starting");
if (typeof workflow.fixtureHtml === "string") {
fixtureServer = await startFixtureServer(workflow.fixtureHtml);
effectiveOrigin = "http://127.0.0.1:" + fixtureServer.address().port;
}
const launcher = await import(pathToFileURL(path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_LAUNCHER_PATH"))).href);
const { chromium } = await launcher.importPlaywright();
const proxy = browserProxy();
browser = await launcher.launchChromium(chromium, {
env: browserProcessEnvWithoutProxy(),
...(proxy === null ? { args: ["--no-proxy-server"] } : { proxy }),
});
browserPid = await waitForBrowserPid();
context = await browser.newContext({ viewport: workflow.viewport, ...(proxy === null ? {} : { proxy }) });
page = await context.newPage();
attachPageSignals(page);
await writeStatus("running", { browserPid });
await writeJsonAtomic(launchReadyPath, {
ok: true,
status: "ready",
workflowId: workflow.workflowId,
pid: process.pid,
browserPid,
phase: "running",
observedAt: new Date().toISOString(),
valuesRedacted: true,
});
for (let index = 0; index < workflow.actions.length; index += 1) {
if (stopping) throw stableError("workflow-stopped", "workflow was stopped");
const action = workflow.actions[index];
currentAction = { index, id: action.id, type: action.type };
await writeStatus("running");
await executeAction(action);
completed.push(action.id);
}
currentAction = null;
await writeStatus("completed", { completedAt: new Date().toISOString() });
} catch (error) {
const code = error && typeof error === "object" && typeof error.code === "string" ? error.code : "external-form-runner-failed";
await writeJsonAtomic(launchReadyPath, {
ok: false,
status: "failed",
workflowId: workflow.workflowId,
pid: process.pid,
browserPid,
reason: code,
observedAt: new Date().toISOString(),
valuesRedacted: true,
}).catch(() => undefined);
await writeStatus(code === "workflow-stopped" ? "stopped" : "failed", {
error: { code, messageHash: shaText(redact(error instanceof Error ? error.message : String(error))) },
completedAt: new Date().toISOString(),
}).catch(() => undefined);
process.exitCode = code === "workflow-stopped" ? 0 : 2;
} finally {
await context?.close().catch(() => undefined);
await browser?.close().catch(() => undefined);
await closeFixtureServer();
await rm(path.join(stateDir, "uploads"), { recursive: true, force: true }).catch(() => undefined);
for (const key of Object.keys(secrets)) secrets[key] = "";
}
async function executeAction(action) {
if (action.type === "goto") {
const target = new URL(action.url, effectiveOrigin).toString();
assertAllowedUrl(target);
await page.goto(target, { waitUntil: action.waitUntil || "domcontentloaded", timeout: workflow.timeoutMs });
return;
}
if (action.type === "fill") {
await page.locator(action.selector).first().fill(inputValue(action), { timeout: workflow.timeoutMs });
return;
}
if (action.type === "typeText") {
await page.locator(action.selector).first().pressSequentially(inputValue(action), { timeout: workflow.timeoutMs });
return;
}
if (action.type === "select") {
const option = action.label ? { label: action.label } : inputValue(action);
await page.locator(action.selector).first().selectOption(option, { timeout: workflow.timeoutMs });
return;
}
if (action.type === "check") {
const locator = page.locator(action.selector).first();
if (action.checked === false) await locator.uncheck({ timeout: workflow.timeoutMs });
else await locator.check({ timeout: workflow.timeoutMs });
return;
}
if (action.type === "upload") {
await page.locator(action.selector).first().setInputFiles(action.path, { timeout: workflow.timeoutMs });
return;
}
if (action.type === "click" || action.type === "submit") {
const locator = page.locator(action.selector).first();
if (action.type === "click" && await isSubmitControl(locator)) {
throw stableError("external-form-submit-action-required", "submit controls require an explicit submit action");
}
await locator.click({ timeout: workflow.timeoutMs });
return;
}
if (action.type === "press") {
await page.locator(action.selector).first().press(action.key, { timeout: workflow.timeoutMs });
return;
}
if (action.type === "waitFor") {
await page.locator(action.selector).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || workflow.timeoutMs });
return;
}
if (action.type === "switchPage") {
const previousPage = page;
const deadline = Date.now() + (action.timeoutMs || workflow.timeoutMs);
let nextPage = null;
while (Date.now() < deadline) {
const candidates = context.pages().filter((candidate) => candidate !== previousPage && !candidate.isClosed());
if (candidates.length > 0) {
nextPage = candidates[candidates.length - 1];
break;
}
await new Promise((resolve) => setTimeout(resolve, Math.min(workflow.pollMs, Math.max(1, deadline - Date.now()))));
}
if (nextPage === null) throw stableError("external-form-new-page-not-found", "no new browser page appeared before timeout");
page = nextPage;
attachPageSignals(page);
await page.waitForLoadState("domcontentloaded", { timeout: Math.max(1, deadline - Date.now()) }).catch(() => undefined);
assertAllowedUrl(page.url());
return;
}
if (action.type === "screenshot") {
await captureScreenshot(action.id);
return;
}
if (action.type === "inspect") {
inspection = await inspectControls(action);
await writeStatus("running");
return;
}
if (action.type === "pause") {
const screenshot = await captureScreenshot("pause-" + action.id);
await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } });
const continuation = await waitForContinuation(action.id);
if (action.selector) await page.locator(action.selector).first().fill(continuation, { timeout: workflow.timeoutMs });
await writeStatus("running", { pause: null });
return;
}
throw stableError("external-form-action-unsupported", "unsupported action type");
}
function isSubmitControl(locator) {
return locator.evaluate((element) => {
const tag = element.tagName.toLowerCase();
if (tag === "input") return ["submit", "image"].includes(String(element.getAttribute("type") || "text").toLowerCase());
if (tag === "button") return String(element.getAttribute("type") || "submit").toLowerCase() === "submit";
return false;
});
}
async function waitForContinuation(pauseId) {
const file = path.join(stateDir, "continuations", pauseId + ".json");
while (!stopping) {
try {
const payload = JSON.parse(await readFile(file, "utf8"));
await rm(file, { force: true });
if (payload && typeof payload.value === "string" && payload.value.length > 0) return payload.value;
} catch {}
await sleep(workflow.pollMs);
}
throw stableError("workflow-stopped", "workflow was stopped while paused");
}
function inputValue(action) {
if (typeof action.valueRef === "string") {
const value = secrets[action.valueRef];
if (typeof value !== "string" || value.length === 0) throw stableError("external-form-input-missing", "declared input is missing");
return value;
}
if (typeof action.value === "string") return action.value;
throw stableError("external-form-input-missing", "action has no declared value");
}
async function captureScreenshot(name) {
const file = path.join(stateDir, "artifacts", safeName(name) + ".png");
await page.screenshot({ path: file, fullPage: false });
const bytes = await readFile(file);
return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true };
}
async function inspectControls(action) {
const selector = action.selector || "input, button, a, select, textarea";
const controls = await page.locator(selector).evaluateAll((elements) => elements.slice(0, 40).map((element) => {
const tag = element.tagName.toLowerCase();
const className = element.getAttribute("class") || "";
const visibleText = tag === "button" || tag === "a" || tag === "option" || tag === "li"
|| element.getAttribute("role") === "option" || className.includes("rcmpaginatedselect")
? String(element.innerText || "")
: "";
return {
tag,
id: element.getAttribute("id") || "",
name: element.getAttribute("name") || "",
type: element.getAttribute("type") || "",
placeholder: element.getAttribute("placeholder") || "",
text: visibleText,
className,
onclick: element.getAttribute("onclick") || "",
};
}));
return {
actionId: action.id,
selector,
count: controls.length,
truncated: await page.locator(selector).count() > controls.length,
controls: controls.map((control) => Object.fromEntries(
Object.entries(control).map(([key, value]) => [key, redact(String(value)).replace(/\s+/g, " ").trim().slice(0, 160)])
)),
valuesRedacted: true,
};
}
async function writeStatus(phase, extra = {}) {
const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null;
const payload = {
ok: phase !== "failed",
workflowId: workflow.workflowId,
phase,
pid: process.pid,
browserPid,
startedAt,
updatedAt: new Date().toISOString(),
currentAction,
progress: { completed: completed.length, total: workflow.actions.length, completedIds: completed.slice(-20) },
finalUrl,
network,
browserSignals,
inspection,
pause: null,
valuesRedacted: true,
...extra,
};
await writeJsonAtomic(statusPath, payload);
}
async function writeJsonAtomic(file, payload) {
const temp = file + ".tmp";
await writeFile(temp, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
await rename(temp, file);
}
function attachPageSignals(targetPage) {
if (observedPages.has(targetPage)) return;
observedPages.add(targetPage);
targetPage.on("response", (response) => {
network.responses += 1;
if (response.status() >= 500) network.status5xx += 1;
else if (response.status() >= 400) network.status4xx += 1;
});
targetPage.on("requestfailed", () => { network.failed += 1; });
targetPage.on("console", (entry) => { if (entry.type() === "error") browserSignals.consoleErrors += 1; });
targetPage.on("pageerror", (error) => {
browserSignals.pageErrors += 1;
browserSignals.lastPageErrorHash = shaText(error instanceof Error ? error.message : String(error));
});
}
function assertAllowedUrl(raw) {
const url = new URL(raw);
if (!workflow.allowedHosts.includes(url.hostname)) throw stableError("external-form-origin-denied", "navigation host is not declared");
}
function browserProxy() {
if (workflow.browserProxyMode === "direct") return null;
const target = new URL(effectiveOrigin);
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
if (noProxy.split(",").map((item) => item.trim()).filter(Boolean).some((item) => target.hostname === item || target.hostname.endsWith("." + item.replace(/^\./, "")))) return null;
const raw = target.protocol === "https:"
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || ""
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || "";
return raw ? { server: raw } : null;
}
function startFixtureServer(html) {
return new Promise((resolve, reject) => {
const server = createServer((request, response) => {
if (request.url === "/" || request.url === "/form") {
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
response.end(html);
return;
}
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
});
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(server));
});
}
function closeFixtureServer() {
if (fixtureServer === null) return Promise.resolve();
return new Promise((resolve) => fixtureServer.close(() => resolve()));
}
function browserProcessEnvWithoutProxy() {
const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
return Object.fromEntries(Object.entries(process.env).filter(([key, value]) => !blocked.has(key) && value !== undefined));
}
async function waitForBrowserPid() {
const deadline = Date.now() + Math.min(5000, workflow.timeoutMs);
while (Date.now() <= deadline) {
const rows = [];
for (const name of readdirSync("/proc")) {
if (!/^\d+$/.test(name)) continue;
try {
const raw = readFileSync("/proc/" + name + "/stat", "utf8");
const close = raw.lastIndexOf(")");
const fields = raw.slice(close + 2).trim().split(/\s+/);
const command = readFileSync("/proc/" + name + "/cmdline", "utf8").replace(/\0/g, " ");
rows.push({ pid: Number(name), ppid: Number(fields[1]), command });
} catch {}
}
const descendants = new Set([process.pid]);
let changed = true;
while (changed) {
changed = false;
for (const row of rows) {
if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
descendants.add(row.pid);
changed = true;
}
}
}
const match = rows.find((row) => row.pid !== process.pid && descendants.has(row.pid) && /(?:^|[ /])(chrome|chromium)(?:[ /]|$)/i.test(row.command));
if (match) return match.pid;
await sleep(workflow.pollMs);
}
throw stableError("external-form-browser-pid-unconfirmed", "browser process was not confirmed");
}
function safeUrl(raw) {
try {
const url = new URL(raw);
return url.origin + url.pathname;
} catch {
return null;
}
}
function redact(raw) {
let value = String(raw);
for (const secret of secretValues) value = value.split(secret).join("<redacted>");
return value;
}
function shaText(value) {
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
}
function safeName(value) {
return String(value).replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "artifact";
}
function stableError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error("missing " + name);
return value;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
`;
}