Files
pikasTech-unidesk/scripts/src/web-probe-external-form.ts
T
2026-07-18 11:10:04 +02:00

602 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-18-p19-external-form-workflow.
// Responsibility: Typed lifecycle and Secret injection for declared external form workflows.
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename, isAbsolute, resolve } from "node:path";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
import { runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell } from "./hwlab-node-web-probe-memory-guard";
import { readDeclaredSecretValues } from "./secrets";
import { nodeWebProbeHostProxyEnv } from "./hwlab-node/web-probe-observe";
import { runTransWorkspaceStdinScript } from "./hwlab-node/public-exposure";
import { shellQuote } from "./hwlab-node/utils";
import { webProbeExternalFormRunnerSource } from "./web-probe-external-form-runner-source";
type ExternalFormAction = {
readonly id: string;
readonly type: "goto" | "fill" | "select" | "check" | "upload" | "click" | "submit" | "waitFor" | "screenshot" | "pause";
readonly selector?: string;
readonly url?: string;
readonly value?: string;
readonly valueRef?: string;
readonly checked?: boolean;
readonly path?: string;
readonly reason?: string;
readonly state?: string;
readonly waitUntil?: string;
readonly timeoutMs?: number;
};
type ExternalFormWorkflow = {
readonly source: string;
readonly id: string;
readonly node: string;
readonly lane: string;
readonly origin: string;
readonly allowedHosts: readonly string[];
readonly browserProxyMode: "auto" | "direct";
readonly fixtureHtml: string | null;
readonly viewport: { readonly width: number; readonly height: number };
readonly timeoutMs: number;
readonly maxUploadBytes: number;
readonly commandTimeoutSeconds: number;
readonly pollMs: number;
readonly remoteRoot: string;
readonly allowIrreversibleSubmit: boolean;
readonly secret: {
readonly configPath: string;
readonly targetId: string;
readonly secretName: string;
readonly targetKeys: readonly string[];
};
readonly actions: readonly ExternalFormAction[];
};
type ExternalFormOptions = {
readonly action: "start" | "status" | "continue" | "stop" | "plan";
readonly workflowPath: string;
readonly workflowId: string | null;
readonly confirmSubmit: boolean;
readonly continuationValue: string | null;
};
export function webProbeExternalFormHelp(): Record<string, unknown> {
return {
ok: true,
command: "web-probe external-form",
description: "执行 YAML/JSON 声明的受控外部表单,并在验证码处保持浏览器会话等待人工续填。",
usage: [
"bun scripts/cli.ts web-probe external-form plan --workflow <file>",
"bun scripts/cli.ts web-probe external-form start --workflow <file>",
"bun scripts/cli.ts web-probe external-form status --workflow <file> --id <workflow-id>",
"printf '%s\\n' '<value>' | bun scripts/cli.ts web-probe external-form continue --workflow <file> --id <workflow-id> --value-stdin",
"bun scripts/cli.ts web-probe external-form stop --workflow <file> --id <workflow-id>",
],
safety: {
secrets: "表单敏感值只允许通过工作流的 Secret sourceRef/valueRef 注入。",
continuation: "验证码等一次性值只从 stdin 读取,不接受命令行明文参数。",
submission: "submit 默认拒绝;工作流声明允许且 start 显式 --confirm-submit 时才执行。",
output: "输出只披露动作 ID、presence、fingerprint、进度和 artifact 哈希。",
},
valuesRedacted: true,
};
}
export async function runWebProbeExternalForm(args: string[]): Promise<Record<string, unknown>> {
const options = await parseOptions(args);
const workflow = readWorkflow(options.workflowPath);
if (!isHwlabRuntimeLane(workflow.lane)) throw new Error(`外部表单 lane 未声明:${workflow.lane}`);
const spec = hwlabRuntimeLaneSpecForNode(workflow.lane, workflow.node);
if (options.action === "plan") return plan(workflow);
if (options.action === "start") return start(workflow, spec, options.confirmSubmit);
const workflowId = requiredWorkflowId(options.workflowId);
if (options.action === "status") return remoteStatus(workflow, spec.workspace, workflowId);
if (options.action === "continue") {
if (options.continuationValue === null) throw new Error("web-probe external-form continue 需要 --value-stdin");
return continueWorkflow(workflow, spec.workspace, workflowId, options.continuationValue);
}
return stop(workflow, spec.workspace, workflowId);
}
async function parseOptions(args: string[]): Promise<ExternalFormOptions> {
const action = args[0];
if (action !== "start" && action !== "status" && action !== "continue" && action !== "stop" && action !== "plan") {
throw new Error("web-probe external-form 需要 plan|start|status|continue|stop");
}
const values = new Map<string, string>();
const known = new Set(["--workflow", "--id"]);
let valueStdin = false;
let confirmSubmit = false;
for (let index = 1; index < args.length; index += 1) {
const item = args[index];
if (item === "--value-stdin") {
if (valueStdin) throw new Error("--value-stdin 不能重复");
valueStdin = true;
continue;
}
if (item === "--confirm-submit") {
if (confirmSubmit) throw new Error("--confirm-submit 不能重复");
confirmSubmit = true;
continue;
}
const equals = item.indexOf("=");
const key = equals > 0 ? item.slice(0, equals) : item;
if (!known.has(key)) throw new Error(`web-probe external-form 不支持参数 ${item}`);
const value = equals > 0 ? item.slice(equals + 1) : args[++index];
if (value === undefined || value.startsWith("--")) throw new Error(`${key} 需要一个值`);
if (values.has(key)) throw new Error(`${key} 不能重复`);
values.set(key, value);
}
const workflowPath = values.get("--workflow");
if (workflowPath === undefined) throw new Error("web-probe external-form 缺少 --workflow");
if (valueStdin && action !== "continue") throw new Error("--value-stdin 只适用于 continue");
if (confirmSubmit && action !== "start") throw new Error("--confirm-submit 只适用于 start");
const continuationValue = valueStdin ? (await Bun.stdin.text()).replace(/\r?\n$/u, "") : null;
if (valueStdin && !continuationValue) throw new Error("--value-stdin 未收到非空值");
return {
action,
workflowPath,
workflowId: values.get("--id") ?? null,
confirmSubmit,
continuationValue,
};
}
function readWorkflow(pathArg: string): ExternalFormWorkflow {
const absolute = isAbsolute(pathArg) ? pathArg : rootPath(pathArg);
const raw = readFileSync(absolute, "utf8");
const root = record(pathArg.endsWith(".json") ? JSON.parse(raw) : Bun.YAML.parse(raw), pathArg);
if (text(root.apiVersion, "apiVersion") !== "unidesk.pikastech.local/v1alpha1") throw new Error(`${pathArg}.apiVersion 不受支持`);
if (text(root.kind, "kind") !== "ExternalFormWorkflow") throw new Error(`${pathArg}.kind 必须是 ExternalFormWorkflow`);
const metadata = record(root.metadata, `${pathArg}.metadata`);
const spec = record(root.spec, `${pathArg}.spec`);
const runner = record(spec.runner, `${pathArg}.spec.runner`);
const origin = record(spec.origin, `${pathArg}.spec.origin`);
const browser = record(spec.browser, `${pathArg}.spec.browser`);
const state = record(spec.state, `${pathArg}.spec.state`);
const secret = record(spec.secret, `${pathArg}.spec.secret`);
const fixture = spec.fixture === undefined ? null : record(spec.fixture, `${pathArg}.spec.fixture`);
const originUrl = new URL(text(origin.url, "spec.origin.url")).toString();
const allowedHosts = textArray(origin.allowedHosts, "spec.origin.allowedHosts");
if (!allowedHosts.includes(new URL(originUrl).hostname)) throw new Error("spec.origin.allowedHosts 必须包含 origin.url 主机");
const actions = recordArray(spec.actions, "spec.actions").map(parseAction);
const ids = new Set<string>();
for (const action of actions) {
if (ids.has(action.id)) throw new Error(`重复 action id${action.id}`);
ids.add(action.id);
}
return {
source: absolute,
id: simpleId(text(metadata.id, "metadata.id"), "metadata.id"),
node: simpleId(text(runner.node, "spec.runner.node"), "spec.runner.node"),
lane: simpleId(text(runner.lane, "spec.runner.lane"), "spec.runner.lane"),
origin: originUrl,
allowedHosts,
browserProxyMode: optionalEnum(origin.browserProxyMode, ["auto", "direct"], "spec.origin.browserProxyMode") ?? "auto",
fixtureHtml: fixture === null ? null : readFixtureHtml(text(fixture.htmlPath, "spec.fixture.htmlPath")),
viewport: {
width: positiveInteger(browser.viewportWidth, "spec.browser.viewportWidth"),
height: positiveInteger(browser.viewportHeight, "spec.browser.viewportHeight"),
},
timeoutMs: positiveInteger(browser.timeoutMs, "spec.browser.timeoutMs"),
maxUploadBytes: positiveInteger(browser.maxUploadBytes, "spec.browser.maxUploadBytes"),
commandTimeoutSeconds: positiveInteger(browser.commandTimeoutSeconds, "spec.browser.commandTimeoutSeconds"),
pollMs: positiveInteger(browser.pollMs, "spec.browser.pollMs"),
remoteRoot: safeRemoteRoot(text(state.remoteRoot, "spec.state.remoteRoot")),
allowIrreversibleSubmit: spec.allowIrreversibleSubmit === true,
secret: {
configPath: text(secret.configPath, "spec.secret.configPath"),
targetId: text(secret.targetId, "spec.secret.targetId"),
secretName: text(secret.secretName, "spec.secret.secretName"),
targetKeys: textArray(secret.targetKeys, "spec.secret.targetKeys"),
},
actions,
};
}
function plan(workflow: ExternalFormWorkflow): Record<string, unknown> {
return {
ok: true,
command: "web-probe external-form plan",
workflow: summary(workflow),
lifecycle: ["start", "status", "continue", "stop"],
irreversibleSubmit: {
declared: workflow.allowIrreversibleSubmit,
present: workflow.actions.some((action) => action.type === "submit"),
requiresExplicitConfirmation: true,
},
secret: {
configPath: workflow.secret.configPath,
targetId: workflow.secret.targetId,
secretName: workflow.secret.secretName,
targetKeys: workflow.secret.targetKeys,
valuesPrinted: false,
},
mutation: false,
valuesRedacted: true,
};
}
function start(workflow: ExternalFormWorkflow, spec: ReturnType<typeof hwlabRuntimeLaneSpecForNode>, confirmSubmit: boolean): Record<string, unknown> {
const hasSubmit = workflow.actions.some((action) => action.type === "submit");
if (hasSubmit && (!workflow.allowIrreversibleSubmit || !confirmSubmit)) {
throw new Error("工作流包含不可逆 submit;必须同时声明 allowIrreversibleSubmit=true 并显式传 --confirm-submit");
}
const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
if (memoryGuard.status !== "allowed") {
return {
...memoryGuard,
command: "web-probe external-form start",
workflow: summary(workflow),
secretResolved: false,
mutation: false,
valuesRedacted: true,
};
}
const declared = readDeclaredSecretValues(workflow.secret);
const workflowId = `${workflow.id}-${randomBytes(6).toString("hex")}`;
const stateDir = `${workflow.remoteRoot}/${workflowId}`;
const proxy = nodeWebProbeHostProxyEnv(spec, workflow.browserProxyMode);
const prepared = prepareActions(workflow, stateDir);
const payload = {
workflowId,
origin: workflow.origin,
allowedHosts: workflow.allowedHosts,
viewport: workflow.viewport,
timeoutMs: workflow.timeoutMs,
pollMs: workflow.pollMs,
actions: prepared.actions,
browserProxyMode: workflow.browserProxyMode,
fixtureHtml: workflow.fixtureHtml,
};
const runner = webProbeExternalFormRunnerSource();
const setup = [
"set -eu",
`state_dir=${shellQuote(stateDir)}`,
'umask 077; mkdir -p "$state_dir/continuations" "$state_dir/artifacts" "$state_dir/uploads"',
b64Write("workflow.json", JSON.stringify(payload)),
b64Write("secrets.json", JSON.stringify(declared.values)),
b64Write("runner.mjs", runner),
...prepared.uploads.map((upload) => b64WriteBuffer(`uploads/${upload.name}`, upload.bytes)),
'chmod 600 "$state_dir/workflow.json" "$state_dir/secrets.json" "$state_dir/runner.mjs"',
];
const runnerEnvironment = [
...proxy.envAssignments,
...webProbeBrowserEvidenceEnvAssignments(spec),
...(spec.webProbe?.playwrightBrowsersPath === undefined ? [] : [`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(spec.webProbe.playwrightBrowsersPath)}`]),
'UNIDESK_EXTERNAL_FORM_STATE_DIR="$state_dir"',
`UNIDESK_EXTERNAL_FORM_LAUNCHER_PATH=${shellQuote(resolve(spec.workspace, "scripts/src/browser-launcher.mjs"))}`,
].join(" ");
const launchPolicy = spec.webProbe?.resourcePolicy?.memoryStartGuard;
const lifecyclePolicy = spec.webProbe?.resourcePolicy?.observerLifecycle;
if (launchPolicy === undefined) throw new Error("web-probe memory start policy is missing from the owning YAML");
if (lifecyclePolicy === undefined) throw new Error("web-probe observer lifecycle policy is missing from the owning YAML");
const cleanupPollSeconds = Math.max(0.05, launchPolicy.launchReadyPollMilliseconds / 1000);
const cleanupPollAttempts = Math.max(1, Math.ceil(lifecyclePolicy.closeStepTimeoutMs / launchPolicy.launchReadyPollMilliseconds));
const launchFailureSource = [
"const fs=require('fs');",
"const crypto=require('crypto');",
"const dir=process.argv[1], id=process.argv[2], pid=Number(process.argv[3]);",
"const bool=(v)=>v==='true';",
"const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}};",
"const stderr=(()=>{try{return fs.readFileSync(dir+'/stderr.log')}catch{return Buffer.alloc(0)}})();",
"const tail=stderr.subarray(Math.max(0,stderr.length-2048));",
"const digest=(v)=>'sha256:'+crypto.createHash('sha256').update(v).digest('hex');",
"const status=read('status.json');",
"console.log(JSON.stringify({ok:false,status:'blocked',reason:status?.error?.code||'launch-readiness-unconfirmed',failureKind:'web-probe-external-form-launch-readiness',workflowId:id,pid,stateDir:dir,phase:'failed',browserCreated:false,browserCreationAttempted:true,launchReadiness:read('launch-ready.json'),statusArtifact:status,diagnostic:{stderrBytes:stderr.length,stderrHash:digest(stderr),stderrTailBytes:tail.length,stderrTailHash:digest(tail),valuesPrinted:false},cleanup:{attempted:true,processGroupId:pid,termSent:bool(process.argv[4]),killSent:bool(process.argv[5]),aliveAfter:bool(process.argv[6]),completed:!bool(process.argv[6]),valuesRedacted:true},valuesRedacted:true}))",
].join("");
const launch = [
...setup,
"if ! command -v setsid >/dev/null 2>&1; then rm -f \"$state_dir/secrets.json\"; rm -rf \"$state_dir/uploads\"; printf '%s\\n' '{\"ok\":false,\"failureKind\":\"host-setsid-unavailable\",\"valuesRedacted\":true}'; exit 0; fi",
`setsid env ${runnerEnvironment} node "$state_dir/runner.mjs" >"$state_dir/stdout.log" 2>"$state_dir/stderr.log" </dev/null 9>&- &`,
"pid=$!",
'printf "%s\\n" "$pid" >"$state_dir/pid"',
"set +e",
`node - "$pid" "$state_dir" ${shellQuote(workflowId)} ${shellQuote(String(launchPolicy.launchReadyTimeoutSeconds * 1000))} ${shellQuote(String(launchPolicy.launchReadyPollMilliseconds))} >"$state_dir/launch-check.json" <<'NODE'`,
externalFormLaunchReadinessSource(),
"NODE",
"launch_ready_rc=$?",
"set -e",
`if [ "$launch_ready_rc" -ne 0 ]; then term_sent=false; if /bin/kill -TERM -- -"$pid" 2>/dev/null; then term_sent=true; fi; i=0; while [ "$i" -lt ${cleanupPollAttempts} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${cleanupPollSeconds}; i=$((i+1)); done; kill_sent=false; if /bin/kill -0 -- -"$pid" 2>/dev/null; then if /bin/kill -KILL -- -"$pid" 2>/dev/null; then kill_sent=true; fi; fi; i=0; while [ "$i" -lt ${cleanupPollAttempts} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${cleanupPollSeconds}; i=$((i+1)); done; alive_after=false; if /bin/kill -0 -- -"$pid" 2>/dev/null; then alive_after=true; fi; rm -f "$state_dir/secrets.json"; rm -rf "$state_dir/uploads"; node -e ${shellQuote(launchFailureSource)} "$state_dir" ${shellQuote(workflowId)} "$pid" "$term_sent" "$kill_sent" "$alive_after"; exit 0; fi`,
`node -e ${shellQuote("const fs=require('fs'); const dir=process.argv[1]; const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}}; const ready=read('launch-ready.json'); console.log(JSON.stringify({ok:true,jobId:process.argv[2],workflowId:process.argv[2],pid:Number(process.argv[3]),workerPid:Number(process.argv[3]),browserPid:ready&&ready.browserPid||null,phase:'running',launchReadiness:ready,valuesRedacted:true}))")} "$state_dir" ${shellQuote(workflowId)} "$pid"`,
].join("\n");
const shell = webProbeGuardedLaunchShell(spec, "manual-start", launch, "structured-output");
const result = runTransWorkspaceStdinScript(workflow.node, spec.workspace, shell, workflow.commandTimeoutSeconds);
const parsed = parseRemoteJson(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
command: "web-probe external-form start",
workflowId,
node: workflow.node,
lane: workflow.lane,
workspace: spec.workspace,
phase: parsed?.phase ?? "blocked",
stateDir,
failureKind: parsed?.failureKind ?? (result.exitCode === 0 ? null : "web-probe-external-form-start-transport"),
launchReadiness: parsed?.launchReadiness ?? null,
diagnostic: parsed?.diagnostic ?? null,
cleanup: parsed?.cleanup ?? null,
secret: declared.summary,
transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
next: {
status: nextCommand("status", workflow.source, workflowId),
continue: `${nextCommand("continue", workflow.source, workflowId)} --value-stdin`,
stop: nextCommand("stop", workflow.source, workflowId),
},
valuesRedacted: true,
};
}
function remoteStatus(workflow: ExternalFormWorkflow, workspace: string, workflowId: string): Record<string, unknown> {
const stateDir = statePath(workflow, workflowId);
const shell = [
"set -eu",
`state_dir=${shellQuote(stateDir)}`,
'test -f "$state_dir/status.json"',
'node - "$state_dir/status.json" "$state_dir/pid" "$state_dir" <<\'NODE\'',
"const fs=require('fs'); const s=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));",
"let pid=null,alive=false; try{pid=Number(fs.readFileSync(process.argv[3],'utf8').trim()); const cmd=fs.readFileSync('/proc/'+pid+'/cmdline','utf8').replace(/\\0/g,' '); alive=cmd.includes(process.argv[4]+'/runner.mjs')}catch{}",
"const stale=!alive&&['starting','running','paused'].includes(String(s.phase));",
"let uploadMaterialPresent=false; try{uploadMaterialPresent=fs.readdirSync(process.argv[4]+'/uploads').length>0}catch{}",
"console.log(JSON.stringify({...s,ok:s.ok===true&&!stale&&s.phase!=='failed',processAlive:alive,secretMaterialPresent:fs.existsSync(process.argv[4]+'/secrets.json'),uploadMaterialPresent,staleStart:stale,failureKind:stale?'web-probe-external-form-stale-start':s.phase==='failed'?s.error?.code||'external-form-runner-failed':null,phase:stale?'failed':s.phase,valuesRedacted:true}));",
"NODE",
].join("\n");
const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
const parsed = parseRemoteJson(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
command: "web-probe external-form status",
node: workflow.node,
lane: workflow.lane,
workspace,
...(parsed ?? { phase: "not-found" }),
transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
valuesRedacted: true,
};
}
function continueWorkflow(workflow: ExternalFormWorkflow, workspace: string, workflowId: string, value: string): Record<string, unknown> {
const stateDir = statePath(workflow, workflowId);
const payload = Buffer.from(JSON.stringify({ value }), "utf8").toString("base64");
const shell = [
"set -eu",
`state_dir=${shellQuote(stateDir)}`,
'status_file="$state_dir/status.json"; test -f "$status_file"',
'pause_id=$(node - "$status_file" <<\'NODE\'',
"const fs=require('fs'); const s=JSON.parse(fs.readFileSync(process.argv[2],'utf8')); if(s.phase!=='paused'||!s.pause?.id) process.exit(2); process.stdout.write(s.pause.id);",
"NODE",
")",
'case "$pause_id" in ""|*[!A-Za-z0-9_.-]*) exit 2;; esac',
'file="$state_dir/continuations/$pause_id.json"',
`printf %s ${shellQuote(payload)} | base64 -d >"$file"`,
'chmod 600 "$file"',
'printf \'{"ok":true,"phase":"continuation-queued","valuesRedacted":true}\\n\'',
].join("\n");
const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
const parsed = parseRemoteJson(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
command: "web-probe external-form continue",
workflowId,
phase: parsed?.phase ?? "blocked",
valueAccepted: result.exitCode === 0,
valuesPrinted: false,
transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
next: { status: nextCommand("status", workflow.source, workflowId) },
valuesRedacted: true,
};
}
function stop(workflow: ExternalFormWorkflow, workspace: string, workflowId: string): Record<string, unknown> {
const stateDir = statePath(workflow, workflowId);
const shell = [
"set -eu",
`state_dir=${shellQuote(stateDir)}`,
'pid=$(cat "$state_dir/pid"); case "$pid" in ""|*[!0-9]*) exit 2;; esac',
'group_alive() { ps -o stat= -g "$pid" 2>/dev/null | grep -qv "^[[:space:]]*Z"; }',
'alive_before=false; owned=false; if [ -r "/proc/$pid/cmdline" ] && tr "\\000" " " <"/proc/$pid/cmdline" | grep -F -- "$state_dir/runner.mjs" >/dev/null; then owned=true; fi',
'if [ "$owned" != true ]; then rm -f "$state_dir/secrets.json"; rm -rf "$state_dir/uploads"; printf \'{"ok":true,"phase":"stopped","aliveBefore":false,"aliveAfter":false,"valuesRedacted":true}\\n\'; exit 0; fi',
'if group_alive; then alive_before=true; /bin/kill -TERM -- "-$pid" 2>/dev/null || true; fi',
"attempt=0",
'while group_alive && [ "$attempt" -lt 20 ]; do attempt=$((attempt + 1)); sleep 0.25; done',
'if group_alive; then /bin/kill -KILL -- "-$pid" 2>/dev/null || true; fi',
'alive_after=false; if group_alive; then alive_after=true; fi',
'node - "$alive_before" "$alive_after" <<\'NODE\'',
"console.log(JSON.stringify({ok:process.argv[3]==='false',phase:'stopped',aliveBefore:process.argv[2]==='true',aliveAfter:process.argv[3]==='true',valuesRedacted:true}));",
"NODE",
].join("\n");
const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
const parsed = parseRemoteJson(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
command: "web-probe external-form stop",
workflowId,
...(parsed ?? { phase: "blocked" }),
transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
valuesRedacted: true,
};
}
function parseAction(value: Record<string, unknown>, index: number): ExternalFormAction {
const label = `spec.actions[${index}]`;
const type = optionalEnum(value.type, ["goto", "fill", "select", "check", "upload", "click", "submit", "waitFor", "screenshot", "pause"], `${label}.type`);
if (type === null) throw new Error(`${label}.type 缺失`);
const action: ExternalFormAction = {
id: simpleId(text(value.id, `${label}.id`), `${label}.id`),
type,
selector: optionalText(value.selector, `${label}.selector`),
url: optionalText(value.url, `${label}.url`),
value: optionalText(value.value, `${label}.value`),
valueRef: optionalText(value.valueRef, `${label}.valueRef`),
checked: value.checked === undefined ? undefined : value.checked === true,
path: optionalText(value.path, `${label}.path`),
reason: optionalText(value.reason, `${label}.reason`),
state: optionalText(value.state, `${label}.state`),
waitUntil: optionalText(value.waitUntil, `${label}.waitUntil`),
timeoutMs: value.timeoutMs === undefined ? undefined : positiveInteger(value.timeoutMs, `${label}.timeoutMs`),
};
if (type === "goto" && action.url === undefined) throw new Error(`${label}.url 缺失`);
if (["fill", "select"].includes(type) && action.value === undefined && action.valueRef === undefined) throw new Error(`${label} 缺少 value/valueRef`);
if (["fill", "select", "check", "upload", "click", "submit", "waitFor"].includes(type) && action.selector === undefined) throw new Error(`${label}.selector 缺失`);
if (type === "upload" && action.path === undefined) throw new Error(`${label}.path 缺失`);
if (type === "pause" && action.reason === undefined) throw new Error(`${label}.reason 缺失`);
return action;
}
function summary(workflow: ExternalFormWorkflow): Record<string, unknown> {
return {
id: workflow.id,
source: workflow.source,
node: workflow.node,
lane: workflow.lane,
origin: new URL(workflow.origin).origin,
allowedHosts: workflow.allowedHosts,
actionCount: workflow.actions.length,
fixture: workflow.fixtureHtml === null ? null : { repoOwned: true, valuesPrinted: false },
pauseIds: workflow.actions.filter((action) => action.type === "pause").map((action) => action.id),
valuesRedacted: true,
};
}
function b64Write(name: string, value: string): string {
return b64WriteBuffer(name, Buffer.from(value, "utf8"));
}
function b64WriteBuffer(name: string, value: Buffer): string {
return `printf %s ${shellQuote(value.toString("base64"))} | base64 -d >"$state_dir/${name}"`;
}
function prepareActions(workflow: ExternalFormWorkflow, stateDir: string): {
actions: ExternalFormAction[];
uploads: Array<{ name: string; bytes: Buffer }>;
} {
const uploads: Array<{ name: string; bytes: Buffer }> = [];
const actions = workflow.actions.map((action) => {
if (action.type !== "upload" || action.path === undefined) return action;
const bytes = readFileSync(isAbsolute(action.path) ? action.path : rootPath(action.path));
if (bytes.byteLength === 0 || bytes.byteLength > workflow.maxUploadBytes) {
throw new Error(`upload ${action.id} 文件必须为 1B 到 ${workflow.maxUploadBytes}B`);
}
const fileName = `${action.id}-${safeFileName(basename(action.path))}`;
uploads.push({ name: fileName, bytes });
return { ...action, path: `${stateDir}/uploads/${fileName}` };
});
return { actions, uploads };
}
function safeFileName(value: string): string {
const normalized = value.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "");
if (normalized.length === 0) throw new Error("upload 文件名无可用字符");
return normalized.slice(0, 120);
}
function statePath(workflow: ExternalFormWorkflow, workflowId: string): string {
return `${workflow.remoteRoot}/${simpleId(workflowId, "--id")}`;
}
function nextCommand(action: string, source: string, workflowId: string): string {
return `bun scripts/cli.ts web-probe external-form ${action} --workflow ${shellQuote(source)} --id ${workflowId}`;
}
function requiredWorkflowId(value: string | null): string {
if (value === null) throw new Error("web-probe external-form 缺少 --id");
return simpleId(value, "--id");
}
function safeRemoteRoot(value: string): string {
if (isAbsolute(value) || value.includes("..") || !/^\.state\/[A-Za-z0-9_./-]+$/u.test(value)) throw new Error("spec.state.remoteRoot 必须是 .state/ 下的安全相对路径");
return value.replace(/\/+$/u, "");
}
function readFixtureHtml(pathArg: string): string {
if (isAbsolute(pathArg) || pathArg.includes("..")) throw new Error("spec.fixture.htmlPath 必须是仓库内安全相对路径");
const value = readFileSync(rootPath(pathArg), "utf8");
if (value.length === 0 || value.length > 256 * 1024) throw new Error("spec.fixture.htmlPath 必须是 1B 到 256KiB 的 HTML 文件");
return value;
}
function parseRemoteJson(stdout: string): Record<string, unknown> | null {
const lines = stdout.trim().split(/\r?\n/u).filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
const value = JSON.parse(lines[index]) as unknown;
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
} catch {}
}
return null;
}
function externalFormLaunchReadinessSource(): string {
return String.raw`
const fs = require("fs");
const runnerPid = Number(process.argv[2]);
const stateDir = process.argv[3];
const expectedWorkflowId = process.argv[4];
const timeoutMs = Number(process.argv[5]);
const pollMs = Number(process.argv[6]);
const deadline = Date.now() + timeoutMs;
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const read = (name) => { try { return JSON.parse(fs.readFileSync(stateDir + "/" + name, "utf8")); } catch { return null; } };
while (Date.now() <= deadline) {
const alive = fs.existsSync("/proc/" + runnerPid);
const ready = read("launch-ready.json");
if (alive && ready?.ok === true && ready.workflowId === expectedWorkflowId && ready.pid === runnerPid && Number.isInteger(ready.browserPid)) {
console.log(JSON.stringify({ ok: true, workflowId: expectedWorkflowId, runnerPid, browserPid: ready.browserPid, readiness: "runner-state-and-browser", valuesRedacted: true }));
process.exit(0);
}
if (!alive || ready?.ok === false) {
console.log(JSON.stringify({ ok: false, reason: !alive ? "runner-exited-before-ready" : ready.reason, workflowId: expectedWorkflowId, runnerPid, runnerAlive: alive, valuesRedacted: true }));
process.exit(43);
}
pause(pollMs);
}
console.log(JSON.stringify({ ok: false, reason: "launch-readiness-timeout", workflowId: expectedWorkflowId, runnerPid, runnerAlive: fs.existsSync("/proc/" + runnerPid), valuesRedacted: true }));
process.exit(44);
`;
}
function sha(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function record(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} 必须是对象`);
return value as Record<string, unknown>;
}
function recordArray(value: unknown, label: string): Record<string, unknown>[] {
if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} 必须是非空数组`);
return value.map((item, index) => record(item, `${label}[${index}]`));
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} 必须是非空字符串`);
return value;
}
function optionalText(value: unknown, label: string): string | undefined {
return value === undefined ? undefined : text(value, label);
}
function textArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} 必须是非空字符串数组`);
return value.map((item, index) => text(item, `${label}[${index}]`));
}
function positiveInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${label} 必须是正整数`);
return Number(value);
}
function simpleId(value: string, label: string): string {
if (!/^[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${label} 只能包含 A-Z、a-z、0-9、点、下划线和连字符`);
return value;
}
function optionalEnum<T extends string>(value: unknown, allowed: readonly T[], label: string): T | null {
if (value === undefined) return null;
if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${label} 必须是 ${allowed.join("|")}`);
return value as T;
}