// 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 { return { ok: true, command: "web-probe external-form", description: "执行 YAML/JSON 声明的受控外部表单,并在验证码处保持浏览器会话等待人工续填。", usage: [ "bun scripts/cli.ts web-probe external-form plan --workflow ", "bun scripts/cli.ts web-probe external-form start --workflow ", "bun scripts/cli.ts web-probe external-form status --workflow --id ", "printf '%s\\n' '' | bun scripts/cli.ts web-probe external-form continue --workflow --id --value-stdin", "bun scripts/cli.ts web-probe external-form stop --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> { 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 { 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(); 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(); 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 { 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, confirmSubmit: boolean): Record { 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" &- &`, "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 { 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 { 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 { 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, 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 { 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 | 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; } 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 { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} 必须是对象`); return value as Record; } function recordArray(value: unknown, label: string): Record[] { 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(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; }