602 lines
32 KiB
TypeScript
602 lines
32 KiB
TypeScript
// 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;
|
||
}
|