diff --git a/config/web-probe-external-form/fixture.html b/config/web-probe-external-form/fixture.html
new file mode 100644
index 00000000..59ffa487
--- /dev/null
+++ b/config/web-probe-external-form/fixture.html
@@ -0,0 +1,16 @@
+
+
+
+
+ External form fixture
+
+
+
+
+
diff --git a/config/web-probe-external-form/fixture.yaml b/config/web-probe-external-form/fixture.yaml
new file mode 100644
index 00000000..2cecc105
--- /dev/null
+++ b/config/web-probe-external-form/fixture.yaml
@@ -0,0 +1,54 @@
+apiVersion: unidesk.pikastech.local/v1alpha1
+kind: ExternalFormWorkflow
+metadata:
+ id: fixture-registration
+spec:
+ runner:
+ node: NC01
+ lane: v03
+ origin:
+ url: http://127.0.0.1
+ allowedHosts:
+ - 127.0.0.1
+ browserProxyMode: direct
+ fixture:
+ htmlPath: config/web-probe-external-form/fixture.html
+ browser:
+ viewportWidth: 1280
+ viewportHeight: 800
+ timeoutMs: 30000
+ maxUploadBytes: 5242880
+ commandTimeoutSeconds: 30
+ pollMs: 250
+ state:
+ remoteRoot: .state/web-probe-external-form
+ allowIrreversibleSubmit: false
+ secret:
+ configPath: config/web-probe-external-form/secrets.fixture.yaml
+ targetId: external-form-fixture
+ secretName: fixture-external-form
+ targetKeys:
+ - applicantName
+ - applicantEmail
+ actions:
+ - id: open-registration
+ type: goto
+ url: /form
+ - id: fill-name
+ type: fill
+ selector: input[name="custname"]
+ valueRef: applicantName
+ - id: fill-email
+ type: fill
+ selector: input[name="custemail"]
+ valueRef: applicantEmail
+ - id: upload-fixture
+ type: upload
+ selector: input[name="resume"]
+ path: config/web-probe-external-form/fixture.html
+ - id: captcha
+ type: pause
+ selector: input[name="custtel"]
+ reason: captcha-required
+ - id: draft-screenshot
+ type: screenshot
diff --git a/config/web-probe-external-form/secrets.fixture.yaml b/config/web-probe-external-form/secrets.fixture.yaml
new file mode 100644
index 00000000..a83adf5a
--- /dev/null
+++ b/config/web-probe-external-form/secrets.fixture.yaml
@@ -0,0 +1,40 @@
+version: 1
+kind: unidesk-secret-distribution
+
+metadata:
+ id: web-probe-external-form-fixture-secrets
+ owner: unidesk
+ relatedIssues:
+ - 2501
+
+sources:
+ root: .state/web-probe-external-form-fixture
+ files:
+ - sourceRef: fixture.env
+ type: env
+ requiredKeys:
+ - APPLICANT_NAME
+ - APPLICANT_EMAIL
+ createIfMissing:
+ enabled: false
+ externalFiles: []
+
+targets:
+ - id: external-form-fixture
+ route: NC01:k3s
+ namespace: fixture
+ scope: web-probe-fixture
+ enabled: false
+
+kubernetesSecrets:
+ - name: fixture-external-form
+ targetId: external-form-fixture
+ secretName: fixture-external-form
+ type: Opaque
+ data:
+ - sourceRef: fixture.env
+ sourceKey: APPLICANT_NAME
+ targetKey: applicantName
+ - sourceRef: fixture.env
+ sourceKey: APPLICANT_EMAIL
+ targetKey: applicantEmail
diff --git a/docs/MDTODO/details/web-observe-runtime-reliability/R6_Task_Report.md b/docs/MDTODO/details/web-observe-runtime-reliability/R6_Task_Report.md
new file mode 100644
index 00000000..039a932e
--- /dev/null
+++ b/docs/MDTODO/details/web-observe-runtime-reliability/R6_Task_Report.md
@@ -0,0 +1,22 @@
+# R6 任务报告
+
+## 结果
+
+- 新增 `web-probe external-form` 声明式入口,支持 `plan/start/status/continue/stop`、Secret sourceRef、验证码暂停续填、截图、本地文件受限上传、草稿动作和不可逆提交双重确认。
+- runner 复用 node/lane、浏览器内存门禁、代理和浏览器 evidence 环境;启动采用持久进程组、明确 `launch-ready.json`、失败 typed diagnostic 与精确清理。
+- 修复 POSIX `sh` 下内建 `kill` 不支持 `--` 导致进程组回收失效的问题,相关 observe 和通用 browser guard 统一改用 `/bin/kill`。
+- 增加仓库自有无真实 PII HTML fixture;临时 HTTP 服务只监听 loopback,并随 workflow 关闭。
+
+## 验证
+
+- `fixture-registration-a9e7e411cb39`:`start -> paused -> continue -> completed`;原始 5/5 动作完成,续填输出仅披露 `valueAccepted=true`。
+- `fixture-registration-8f88fb231b81`:6 动作版本完成导航、Secret 填写和本地文件受限上传后进入 `paused`;暂停截图 SHA-256 为 `a152c78ec379debab87b1b1c993d42dbde408af7998c4cc6fe97038ebde5dfe0`。
+- 同一 workflow 暂停态 `secretMaterialPresent=false`;执行 `stop` 后为 `aliveBefore=true`、`aliveAfter=false`、`uploadMaterialPresent=false`,证明 Secret 删除、精确进程组回收和上传材料终态删除均通过。
+- 历史失效 workflow 的 `status` 已返回 `ok=false`、`failureKind=web-probe-external-form-stale-start`,不再把死进程误报为正常启动。
+- `bun scripts/cli.ts web-probe external-form --help`、`bun scripts/cli.ts web-probe --help`、`bun scripts/cli.ts check --syntax-only`、目标 bundle、runner `node --check` 和 `git diff --check` 均通过。
+
+## 边界
+
+- fixture 仅用于开发 smoke,不替代真实高校招聘入口验收。
+- 最终不可逆提交仍默认拒绝;普通 `click` 不能操作 submit 控件,本任务未对任何外部表单执行最终提交。
+- 输出未包含密码、验证码、身份证号、Cookie、完整字段值或上传文件内容。
diff --git a/docs/MDTODO/web-observe-runtime-reliability.md b/docs/MDTODO/web-observe-runtime-reliability.md
index eea73013..02cf6757 100644
--- a/docs/MDTODO/web-observe-runtime-reliability.md
+++ b/docs/MDTODO/web-observe-runtime-reliability.md
@@ -60,3 +60,7 @@
## R5 [completed]
改进 [UniDesk #2440](https://github.com/pikasTech/unidesk/issues/2440) 的 product-smoke typed 失败证据与工件下钻:一次失败运行直接披露有界 PDF status、mediaType、popup 生命周期、report/screenshot SHA 和受控下钻入口,保持 Secret 脱敏、输出上限与全部业务门禁,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R5_Task_Report.md)。
+
+## R6 [completed]
+
+完成 [UniDesk #2501](https://github.com/pikasTech/unidesk/issues/2501):新增 YAML/文件驱动的受控外部表单工作流,复用 WebProbe node/lane、浏览器资源门禁和 Secret sourceRef,支持 start/status/continue/stop、验证码暂停续填、PII 脱敏、草稿保存和默认禁止不可逆提交;以无真实 PII fixture 完成最小 smoke,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R6_Task_Report.md)。
diff --git a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
index a4a937ec..ae23d617 100644
--- a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
+++ b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
@@ -28,6 +28,7 @@
| Cadence/OTel 稳定性实现引用版本 | draft-2026-07-01-p15-cadence-otel |
| CI/CD source snapshot 实现引用版本 | draft-2026-07-01-p16-cicd-source-snapshot |
| 浏览器资源治理实现引用版本 | draft-2026-07-13-p18-browser-resource-guard |
+| 外部表单工作流实现引用版本 | draft-2026-07-18-p19-external-form-workflow |
| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) |
| 上级规格 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) |
| 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-010401080313 Workbench实时权威](PJ2026-010401080313-workbench-realtime-authority.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010604 公开入口](PJ2026-010604-public-entry.md)、[PJ2026-01060505 Workbench性能](PJ2026-01060505-workbench-performance.md) |
@@ -58,6 +59,7 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
- `workbench-dsflash-go-tool-call-10x` 生产 canary 和 24 小时 dry-run 收口。
- `workbench-auth-session-switch-2users` 账号切换链路哨兵,覆盖账号 A/B 登录、登出、session 列表和 session 切换命令链。
- Secret、prompt、provider payload、artifact 和 dashboard 的脱敏边界。
+- 受控外部表单的声明式动作、人工验证码暂停续填、草稿保存和不可逆提交边界。
### 2.3 范围外
@@ -785,6 +787,39 @@ CI/CD rollout 门禁仍只验证配置声明的 `/health` endpoint。web-probe q
- network、failure、response body、性能、截图、样本和历史证据必须有 YAML 上限;
- 不得用无限数组、无限响应体或 full-page 默认值放大 WebProbe 自身内存。
+### 6.19 OPS-SENTINEL-REQ-019 受控外部表单工作流
+
+| 编号 | 短名 | 主责模块 | 关联模块 |
+| --- | --- | --- | --- |
+| OPS-SENTINEL-REQ-019 | 受控外部表单工作流 | PJ2026-0106050819 外部表单工作流 | Wrapper边界、Secret、浏览器资源治理 |
+
+- 声明和复用:
+ - 工作流必须由 YAML 或 JSON 文件声明;
+ - node、lane、origin、viewport、等待预算、状态目录、允许域名、Secret sourceRef 和动作列表必须来自声明;
+ - CLI 和 runner 不得写死站点、账号、身份证号或一次性申请数据。
+ - 开发 smoke 可以声明仓库内无真实 PII 的 HTML fixture,由同一 runner 启动仅监听 loopback 的临时 HTTP 服务;
+ - fixture 服务必须随 workflow 精确回收,不得替代真实 origin 的交付验收。
+- 生命周期:
+ - CLI 必须提供 `start`、`status`、`continue` 和 `stop` 短连接动作;
+ - `start` 返回稳定 workflow id,不等待人工验证码;
+ - runner 在验证码、邮箱确认或缺少人工字段时保持受控浏览器会话并进入 `paused`;
+ - `continue` 只向指定暂停点注入一次性值并恢复执行;
+ - `stop` 只终止该 workflow 的精确进程及浏览器后代。
+- 动作合同:
+ - 通用动作至少覆盖导航、文本填写、选择、勾选、点击、文件上传、等待、截图、暂停和保存草稿;
+ - selector、目标值引用和完成条件必须显式声明;
+ - 本地上传文件必须按 YAML 大小上限进入 workflow 私有目录,并在 runner 终态删除;
+ - 普通 `click` 不得操作 submit 控件,提交控件必须使用显式 `submit` 动作;
+ - 最终不可逆提交动作默认拒绝,只有工作流声明允许且命令显式确认时才能执行。
+- Secret 和 PII:
+ - Secret 只从声明的 sourceRef 解析,在受控 runner 内短暂注入;
+ - 密码、Cookie、验证码、身份证号和完整表单值不得进入 CLI 输出、日志、issue evidence 或截图文件名;
+ - 默认输出只披露字段标识、presence、fingerprint、完成度和 `valuesRedacted=true`。
+- 证据和恢复:
+ - 状态必须有界披露 phase、当前动作、最终 URL、DOM/网络/console/pageerror 摘要、截图引用和字段完成度;
+ - artifact、状态和 continuation 文件必须使用受限权限;
+ - timeout、失败、人工停止和正常结束都必须复用 OPS-SENTINEL-REQ-018 的浏览器资源回收合同。
+
## 7. 过程控制
Web哨兵架构执行 issue 为 [#883](https://github.com/pikasTech/unidesk/issues/883)。阶段跟踪 issue 为 P0 [#885](https://github.com/pikasTech/unidesk/issues/885)、P1 [#886](https://github.com/pikasTech/unidesk/issues/886)、P2 [#887](https://github.com/pikasTech/unidesk/issues/887)、P3 [#888](https://github.com/pikasTech/unidesk/issues/888)、P4 [#889](https://github.com/pikasTech/unidesk/issues/889)、P5 [#890](https://github.com/pikasTech/unidesk/issues/890) 和 P6 [#891](https://github.com/pikasTech/unidesk/issues/891)。
@@ -821,3 +856,10 @@ P18 浏览器资源治理执行 issue 为 [#1907](https://github.com/pikasTech/u
- `MemAvailable` 与 swap 分离、人工阻断和 cadence 跳过边界;
- 精确 context/browser/process-group 收尾和证据容量上限;
- 无真实浏览器的边界测试和 NC01 只读资源复核。
+
+P19 受控外部表单工作流执行 issue 为 [#2501](https://github.com/pikasTech/unidesk/issues/2501)。P19 closeout 必须回写:
+
+- 工作流声明、Secret sourceRef 和 node/lane/origin 配置引用;
+- `start/status/continue/stop` 生命周期及验证码暂停续填证据;
+- 默认拒绝不可逆提交和显式确认边界;
+- 无真实 PII fixture 的最小 smoke、脱敏输出和精确浏览器回收证据。
diff --git a/scripts/src/help.ts b/scripts/src/help.ts
index ad2d6d41..a7bd0580 100644
--- a/scripts/src/help.ts
+++ b/scripts/src/help.ts
@@ -1090,6 +1090,12 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./web-probe-external-form")).webProbeExternalFormHelp(),
+ { command: "web-probe external-form", mutation: false },
+ );
+ }
if (top === "web-probe") return loadHelp(async () => (await import("./web-probe")).webProbeHelp(), webProbeHelpSummary());
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "web-probe") return null;
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") {
diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts
index 6e803d24..ee7ef558 100644
--- a/scripts/src/hwlab-node-help.ts
+++ b/scripts/src/hwlab-node-help.ts
@@ -97,6 +97,7 @@ export function hwlabNodeWebProbeHelp(): Record {
"bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations",
"bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations --runtime native",
"bun scripts/cli.ts web-probe product-smoke --product temporal --target NC01 --profile admin-readonly",
+ "bun scripts/cli.ts web-probe external-form --help",
"bun scripts/cli.ts web-probe script --node --lane --origin internal --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900",
"bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000",
@@ -108,8 +109,6 @@ export function hwlabNodeWebProbeHelp(): Record {
"bun scripts/cli.ts web-probe observe status webobs-xxxx",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view workbench-triad --trace-id trc_xxxx",
- "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view performance-summary",
- "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --command-id cmd-xxxx",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary",
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts web-probe observe gc --node JD01 --lane v03 --dry-run",
@@ -123,8 +122,6 @@ export function hwlabNodeWebProbeHelp(): Record {
"bun scripts/cli.ts web-probe sentinel dashboard trigger --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
"bun scripts/cli.ts web-probe sentinel inspect-url 'https://monitor.pikapython.com/?run=sentinel-run-xxxx'",
"bun scripts/cli.ts web-probe sentinel inspect-id webobs-xxxx --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
- "bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --raw",
- "bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --full",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node --lane --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id ",
],
actions: {
@@ -133,6 +130,7 @@ export function hwlabNodeWebProbeHelp(): Record {
"console-verify": "Run a YAML-profiled Cloud Console route and viewport matrix with optional Projects, HWPOD, MDTODO and AgentRun interaction contracts.",
"native-readiness": "从 owning YAML 解析 L1 公网 IP 和端口,并单次完成 DOM、交互、浏览器错误、关键失败响应与截图验收。",
"product-smoke": "按产品 owning YAML 执行受控登录和页面验证,支持 API、表单及 Basic Auth 认证合同。",
+ "external-form": "执行声明式外部表单,支持 Secret 注入、验证码暂停续填和默认禁止不可逆提交。",
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.",
@@ -147,7 +145,7 @@ export function hwlabNodeWebProbeHelp(): Record {
"`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.",
"`web-probe console-verify` reads route, viewport, selector, workflow, wait and evidence bounds from the selected owning YAML profile and reuses the existing authenticated script runner.",
"`web-probe native-readiness` 只接受 node、lane 和 YAML profile;公网 IP、端口、路径、selector、交互与失败判据禁止由 URL 参数覆盖。",
- "`web-probe product-smoke` 从产品 YAML 和 Secret sourceRef 读取运行目标、认证合同与凭据,仅输出 presence、fingerprint、DOM/网络摘要及工件哈希。",
+ "`web-probe product-smoke` 从产品 YAML 和 Secret sourceRef 读取目标、认证合同与凭据,仅输出 presence、fingerprint、DOM/网络摘要及工件哈希。",
"observe is passive by default; user actions must be explicit observe command entries in control.jsonl.",
"observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.",
"After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.",
@@ -160,7 +158,6 @@ export function hwlabNodeWebProbeHelp(): Record {
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
- "For monitor-web links or page evidence ids, start with `web-probe sentinel inspect-url` or `inspect-id`; default evidence must be controlled report/observe CLI output, not direct public API curl.",
"`web-probe sentinel report --raw` returns bounded issue evidence JSON, including report/artifact SHA and root-cause signal summaries when available; use `--full` for the full indexed service payload.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel `; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
diff --git a/scripts/src/hwlab-node-web-probe-memory-guard.ts b/scripts/src/hwlab-node-web-probe-memory-guard.ts
index 9e9f056b..15e98a32 100644
--- a/scripts/src/hwlab-node-web-probe-memory-guard.ts
+++ b/scripts/src/hwlab-node-web-probe-memory-guard.ts
@@ -112,7 +112,24 @@ export function webProbeGuardedLaunchShell(
" process.exit(43);",
"})().catch(() => process.exit(43));",
"UNIDESK_WEB_PROBE_STRUCTURED_READINESS",
- "then flock -u 9; exit 43; fi",
+ "then",
+ " structured_root_pid=$(node - \"$launch_output\" <<'UNIDESK_WEB_PROBE_STRUCTURED_ROOT_PID'",
+ "const fs = require('fs');",
+ "let value = null; try { value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8').trim()); } catch {}",
+ "const data = value && typeof value === 'object' && value.data && typeof value.data === 'object' ? value.data : value;",
+ "const pid = data && [data.workerPid, data.jobPid, data.pid].find((item) => Number.isInteger(item) && item > 0);",
+ "if (pid) process.stdout.write(String(pid));",
+ "UNIDESK_WEB_PROBE_STRUCTURED_ROOT_PID",
+ " )",
+ " case \"$structured_root_pid\" in ''|*[!0-9]*) ;; *)",
+ " /bin/kill -TERM -- \"-$structured_root_pid\" 2>/dev/null || true",
+ " cleanup_attempt=0",
+ ` while /bin/kill -0 -- "-$structured_root_pid" 2>/dev/null && [ "$cleanup_attempt" -lt ${cleanupPollAttempts} ]; do cleanup_attempt=$((cleanup_attempt + 1)); sleep ${cleanupPollSeconds}; done`,
+ " if /bin/kill -0 -- \"-$structured_root_pid\" 2>/dev/null; then /bin/kill -KILL -- \"-$structured_root_pid\" 2>/dev/null || true; fi",
+ " ;; esac",
+ " flock -u 9",
+ " exit 43",
+ "fi",
"flock -u 9",
].join("\n");
return [
@@ -120,10 +137,10 @@ export function webProbeGuardedLaunchShell(
`setsid sh -c ${shellQuote(`exec 9>&-\n${command}`)} &`,
"browser_job_pid=$!",
"cleanup_browser_group() {",
- " kill -TERM -- \"-$browser_job_pid\" 2>/dev/null || true",
+ " /bin/kill -TERM -- \"-$browser_job_pid\" 2>/dev/null || true",
" cleanup_attempt=0",
- ` while kill -0 -- "-$browser_job_pid" 2>/dev/null && [ "$cleanup_attempt" -lt ${cleanupPollAttempts} ]; do cleanup_attempt=$((cleanup_attempt + 1)); sleep ${cleanupPollSeconds}; done`,
- " if kill -0 -- \"-$browser_job_pid\" 2>/dev/null; then kill -KILL -- \"-$browser_job_pid\" 2>/dev/null || true; fi",
+ ` while /bin/kill -0 -- "-$browser_job_pid" 2>/dev/null && [ "$cleanup_attempt" -lt ${cleanupPollAttempts} ]; do cleanup_attempt=$((cleanup_attempt + 1)); sleep ${cleanupPollSeconds}; done`,
+ " if /bin/kill -0 -- \"-$browser_job_pid\" 2>/dev/null; then /bin/kill -KILL -- \"-$browser_job_pid\" 2>/dev/null || true; fi",
" wait \"$browser_job_pid\" 2>/dev/null || true",
"}",
"trap 'browser_exit=$?; trap - EXIT TERM INT HUP; cleanup_browser_group; exit \"$browser_exit\"' EXIT",
diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts
index c3ad62db..b6789724 100644
--- a/scripts/src/hwlab-node/web-probe-observe-actions.ts
+++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts
@@ -745,7 +745,7 @@ export function runNodeWebProbeObserveStart(
"UNIDESK_WEB_OBSERVE_LAUNCH_READY",
"launch_ready_rc=$?",
"set -e",
- `if [ "$launch_ready_rc" -ne 0 ]; then term_sent=false; if kill -TERM -- -"$pid" 2>/dev/null; then term_sent=true; fi; i=0; while [ "$i" -lt ${shellQuote(String(cleanupPollCount))} ] && kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; i=$((i+1)); done; kill_sent=false; if kill -0 -- -"$pid" 2>/dev/null; then if kill -KILL -- -"$pid" 2>/dev/null; then kill_sent=true; fi; fi; i=0; while [ "$i" -lt ${shellQuote(String(cleanupPollCount))} ] && kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; i=$((i+1)); done; alive_after=false; if kill -0 -- -"$pid" 2>/dev/null; then alive_after=true; fi; node -e ${shellQuote(launchFailureOutputSource)} "$state_dir" ${shellQuote(jobId)} "$pid" "$term_sent" "$kill_sent" "$alive_after"; flock -u 9; trap - EXIT; exit 0; fi`,
+ `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 ${shellQuote(String(cleanupPollCount))} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; 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 ${shellQuote(String(cleanupPollCount))} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; i=$((i+1)); done; alive_after=false; if /bin/kill -0 -- -"$pid" 2>/dev/null; then alive_after=true; fi; node -e ${shellQuote(launchFailureOutputSource)} "$state_dir" ${shellQuote(jobId)} "$pid" "$term_sent" "$kill_sent" "$alive_after"; flock -u 9; trap - EXIT; exit 0; fi`,
"flock -u 9",
"trap - EXIT",
`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 pid=fs.existsSync(dir+'/pid')?fs.readFileSync(dir+'/pid','utf8').trim():null; console.log(JSON.stringify({ok:true,command:'web-probe-observe start',jobId:process.argv[2],stateDir:dir,pid:Number(pid)||null,manifestPath:dir+'/manifest.json',heartbeat:read('heartbeat.json'),manifest:read('manifest.json'),finalLaunchGuard:read('launch-guard.json'),launchReadiness:read('launch-ready.json'),observerCreated:true,browserCreated:true,observerMutation:true,stateArtifactMutation:true,statusCommand:'bun scripts/cli.ts web-probe observe status --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,stopCommand:'bun scripts/cli.ts web-probe observe stop --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,valuesRedacted:true},null,2))")} "$state_dir" ${shellQuote(jobId)} ${shellQuote(options.node)} ${shellQuote(options.lane)}`,
diff --git a/scripts/src/web-probe-external-form-runner-source.ts b/scripts/src/web-probe-external-form-runner-source.ts
new file mode 100644
index 00000000..91b44b9a
--- /dev/null
+++ b/scripts/src/web-probe-external-form-runner-source.ts
@@ -0,0 +1,346 @@
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-18-p19-external-form-workflow.
+// Responsibility: Persistent, redacted browser runner for declared external form workflows.
+
+export function webProbeExternalFormRunnerSource(): string {
+ return String.raw`#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { readFileSync, readdirSync } from "node:fs";
+import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+const stateDir = path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_STATE_DIR"));
+const workflowPath = path.join(stateDir, "workflow.json");
+const secretPath = path.join(stateDir, "secrets.json");
+const statusPath = path.join(stateDir, "status.json");
+const launchReadyPath = path.join(stateDir, "launch-ready.json");
+const startedAt = new Date().toISOString();
+const workflow = JSON.parse(await readFile(workflowPath, "utf8"));
+const secrets = JSON.parse(await readFile(secretPath, "utf8"));
+await rm(secretPath, { force: true });
+const secretValues = Object.values(secrets).filter((value) => typeof value === "string" && value.length > 0);
+const completed = [];
+const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 };
+const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null };
+let browser;
+let context;
+let page;
+let browserPid = null;
+let fixtureServer = null;
+let effectiveOrigin = workflow.origin;
+let stopping = false;
+let currentAction = null;
+
+process.on("SIGTERM", () => { stopping = true; });
+process.on("SIGINT", () => { stopping = true; });
+
+try {
+ await mkdir(path.join(stateDir, "artifacts"), { recursive: true, mode: 0o700 });
+ await writeFile(path.join(stateDir, "pid"), String(process.pid) + "\n", { mode: 0o600 });
+ await writeStatus("starting");
+ if (typeof workflow.fixtureHtml === "string") {
+ fixtureServer = await startFixtureServer(workflow.fixtureHtml);
+ effectiveOrigin = "http://127.0.0.1:" + fixtureServer.address().port;
+ }
+ const launcher = await import(pathToFileURL(path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_LAUNCHER_PATH"))).href);
+ const { chromium } = await launcher.importPlaywright();
+ const proxy = browserProxy();
+ browser = await launcher.launchChromium(chromium, {
+ env: browserProcessEnvWithoutProxy(),
+ ...(proxy === null ? { args: ["--no-proxy-server"] } : { proxy }),
+ });
+ browserPid = await waitForBrowserPid();
+ context = await browser.newContext({ viewport: workflow.viewport, ...(proxy === null ? {} : { proxy }) });
+ page = await context.newPage();
+ page.on("response", (response) => {
+ network.responses += 1;
+ if (response.status() >= 500) network.status5xx += 1;
+ else if (response.status() >= 400) network.status4xx += 1;
+ });
+ page.on("requestfailed", () => { network.failed += 1; });
+ page.on("console", (entry) => { if (entry.type() === "error") browserSignals.consoleErrors += 1; });
+ page.on("pageerror", (error) => {
+ browserSignals.pageErrors += 1;
+ browserSignals.lastPageErrorHash = shaText(error instanceof Error ? error.message : String(error));
+ });
+ await writeStatus("running", { browserPid });
+ await writeJsonAtomic(launchReadyPath, {
+ ok: true,
+ status: "ready",
+ workflowId: workflow.workflowId,
+ pid: process.pid,
+ browserPid,
+ phase: "running",
+ observedAt: new Date().toISOString(),
+ valuesRedacted: true,
+ });
+
+ for (let index = 0; index < workflow.actions.length; index += 1) {
+ if (stopping) throw stableError("workflow-stopped", "workflow was stopped");
+ const action = workflow.actions[index];
+ currentAction = { index, id: action.id, type: action.type };
+ await writeStatus("running");
+ await executeAction(action);
+ completed.push(action.id);
+ }
+ currentAction = null;
+ await writeStatus("completed", { completedAt: new Date().toISOString() });
+} catch (error) {
+ const code = error && typeof error === "object" && typeof error.code === "string" ? error.code : "external-form-runner-failed";
+ await writeJsonAtomic(launchReadyPath, {
+ ok: false,
+ status: "failed",
+ workflowId: workflow.workflowId,
+ pid: process.pid,
+ browserPid,
+ reason: code,
+ observedAt: new Date().toISOString(),
+ valuesRedacted: true,
+ }).catch(() => undefined);
+ await writeStatus(code === "workflow-stopped" ? "stopped" : "failed", {
+ error: { code, messageHash: shaText(redact(error instanceof Error ? error.message : String(error))) },
+ completedAt: new Date().toISOString(),
+ }).catch(() => undefined);
+ process.exitCode = code === "workflow-stopped" ? 0 : 2;
+} finally {
+ await context?.close().catch(() => undefined);
+ await browser?.close().catch(() => undefined);
+ await closeFixtureServer();
+ await rm(path.join(stateDir, "uploads"), { recursive: true, force: true }).catch(() => undefined);
+ for (const key of Object.keys(secrets)) secrets[key] = "";
+}
+
+async function executeAction(action) {
+ if (action.type === "goto") {
+ const target = new URL(action.url, effectiveOrigin).toString();
+ assertAllowedUrl(target);
+ await page.goto(target, { waitUntil: action.waitUntil || "domcontentloaded", timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "fill") {
+ await page.locator(action.selector).first().fill(inputValue(action), { timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "select") {
+ await page.locator(action.selector).first().selectOption(inputValue(action), { timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "check") {
+ const locator = page.locator(action.selector).first();
+ if (action.checked === false) await locator.uncheck({ timeout: workflow.timeoutMs });
+ else await locator.check({ timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "upload") {
+ await page.locator(action.selector).first().setInputFiles(action.path, { timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "click" || action.type === "submit") {
+ const locator = page.locator(action.selector).first();
+ if (action.type === "click" && await isSubmitControl(locator)) {
+ throw stableError("external-form-submit-action-required", "submit controls require an explicit submit action");
+ }
+ await locator.click({ timeout: workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "waitFor") {
+ await page.locator(action.selector).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || workflow.timeoutMs });
+ return;
+ }
+ if (action.type === "screenshot") {
+ await captureScreenshot(action.id);
+ return;
+ }
+ if (action.type === "pause") {
+ const screenshot = await captureScreenshot("pause-" + action.id);
+ await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } });
+ const continuation = await waitForContinuation(action.id);
+ if (action.selector) await page.locator(action.selector).first().fill(continuation, { timeout: workflow.timeoutMs });
+ await writeStatus("running", { pause: null });
+ return;
+ }
+ throw stableError("external-form-action-unsupported", "unsupported action type");
+}
+
+function isSubmitControl(locator) {
+ return locator.evaluate((element) => {
+ const tag = element.tagName.toLowerCase();
+ if (tag === "input") return ["submit", "image"].includes(String(element.getAttribute("type") || "text").toLowerCase());
+ if (tag === "button") return String(element.getAttribute("type") || "submit").toLowerCase() === "submit";
+ return false;
+ });
+}
+
+async function waitForContinuation(pauseId) {
+ const file = path.join(stateDir, "continuations", pauseId + ".json");
+ while (!stopping) {
+ try {
+ const payload = JSON.parse(await readFile(file, "utf8"));
+ await rm(file, { force: true });
+ if (payload && typeof payload.value === "string" && payload.value.length > 0) return payload.value;
+ } catch {}
+ await sleep(workflow.pollMs);
+ }
+ throw stableError("workflow-stopped", "workflow was stopped while paused");
+}
+
+function inputValue(action) {
+ if (typeof action.valueRef === "string") {
+ const value = secrets[action.valueRef];
+ if (typeof value !== "string" || value.length === 0) throw stableError("external-form-input-missing", "declared input is missing");
+ return value;
+ }
+ if (typeof action.value === "string") return action.value;
+ throw stableError("external-form-input-missing", "action has no declared value");
+}
+
+async function captureScreenshot(name) {
+ const file = path.join(stateDir, "artifacts", safeName(name) + ".png");
+ await page.screenshot({ path: file, fullPage: false });
+ const bytes = await readFile(file);
+ return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true };
+}
+
+async function writeStatus(phase, extra = {}) {
+ const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null;
+ const payload = {
+ ok: phase !== "failed",
+ workflowId: workflow.workflowId,
+ phase,
+ pid: process.pid,
+ browserPid,
+ startedAt,
+ updatedAt: new Date().toISOString(),
+ currentAction,
+ progress: { completed: completed.length, total: workflow.actions.length, completedIds: completed.slice(-20) },
+ finalUrl,
+ network,
+ browserSignals,
+ pause: null,
+ valuesRedacted: true,
+ ...extra,
+ };
+ await writeJsonAtomic(statusPath, payload);
+}
+
+async function writeJsonAtomic(file, payload) {
+ const temp = file + ".tmp";
+ await writeFile(temp, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
+ await rename(temp, file);
+}
+
+function assertAllowedUrl(raw) {
+ const url = new URL(raw);
+ if (!workflow.allowedHosts.includes(url.hostname)) throw stableError("external-form-origin-denied", "navigation host is not declared");
+}
+
+function browserProxy() {
+ if (workflow.browserProxyMode === "direct") return null;
+ const target = new URL(effectiveOrigin);
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
+ if (noProxy.split(",").map((item) => item.trim()).filter(Boolean).some((item) => target.hostname === item || target.hostname.endsWith("." + item.replace(/^\./, "")))) return null;
+ const raw = target.protocol === "https:"
+ ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || ""
+ : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || "";
+ return raw ? { server: raw } : null;
+}
+
+function startFixtureServer(html) {
+ return new Promise((resolve, reject) => {
+ const server = createServer((request, response) => {
+ if (request.url === "/" || request.url === "/form") {
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
+ response.end(html);
+ return;
+ }
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
+ response.end("not found");
+ });
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => resolve(server));
+ });
+}
+
+function closeFixtureServer() {
+ if (fixtureServer === null) return Promise.resolve();
+ return new Promise((resolve) => fixtureServer.close(() => resolve()));
+}
+
+function browserProcessEnvWithoutProxy() {
+ const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
+ return Object.fromEntries(Object.entries(process.env).filter(([key, value]) => !blocked.has(key) && value !== undefined));
+}
+
+async function waitForBrowserPid() {
+ const deadline = Date.now() + Math.min(5000, workflow.timeoutMs);
+ while (Date.now() <= deadline) {
+ const rows = [];
+ for (const name of readdirSync("/proc")) {
+ if (!/^\d+$/.test(name)) continue;
+ try {
+ const raw = readFileSync("/proc/" + name + "/stat", "utf8");
+ const close = raw.lastIndexOf(")");
+ const fields = raw.slice(close + 2).trim().split(/\s+/);
+ const command = readFileSync("/proc/" + name + "/cmdline", "utf8").replace(/\0/g, " ");
+ rows.push({ pid: Number(name), ppid: Number(fields[1]), command });
+ } catch {}
+ }
+ const descendants = new Set([process.pid]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const row of rows) {
+ if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
+ descendants.add(row.pid);
+ changed = true;
+ }
+ }
+ }
+ const match = rows.find((row) => row.pid !== process.pid && descendants.has(row.pid) && /(?:^|[ /])(chrome|chromium)(?:[ /]|$)/i.test(row.command));
+ if (match) return match.pid;
+ await sleep(workflow.pollMs);
+ }
+ throw stableError("external-form-browser-pid-unconfirmed", "browser process was not confirmed");
+}
+
+function safeUrl(raw) {
+ try {
+ const url = new URL(raw);
+ return url.origin + url.pathname;
+ } catch {
+ return null;
+ }
+}
+
+function redact(raw) {
+ let value = String(raw);
+ for (const secret of secretValues) value = value.split(secret).join("");
+ return value;
+}
+
+function shaText(value) {
+ return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
+}
+
+function safeName(value) {
+ return String(value).replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "artifact";
+}
+
+function stableError(code, message) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function requiredEnv(name) {
+ const value = process.env[name];
+ if (!value) throw new Error("missing " + name);
+ return value;
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+`;
+}
diff --git a/scripts/src/web-probe-external-form.ts b/scripts/src/web-probe-external-form.ts
new file mode 100644
index 00000000..2c2d4cc2
--- /dev/null
+++ b/scripts/src/web-probe-external-form.ts
@@ -0,0 +1,601 @@
+// 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;
+}
diff --git a/scripts/src/web-probe.ts b/scripts/src/web-probe.ts
index d2c3b3b5..6b7eaf27 100644
--- a/scripts/src/web-probe.ts
+++ b/scripts/src/web-probe.ts
@@ -6,6 +6,7 @@ import { hwlabNodeWebProbeHelp, hwlabNodeWebProbeScreenshotHelp } from "./hwlab-
import { parseNodeWebProbeOptions } from "./hwlab-node/web-probe";
import { runNodeWebProbe } from "./hwlab-node/web-probe-observe";
import { runWebProbeArtifactInspect } from "./web-probe-artifact";
+import { runWebProbeExternalForm, webProbeExternalFormHelp } from "./web-probe-external-form";
import { compactWebProbeProductSmokeProjection, runWebProbeProductSmoke } from "./web-probe-product-smoke";
export function webProbeHelp(): Record {
@@ -18,10 +19,12 @@ export function webProbeScreenshotHelp(): Record {
export async function runWebProbeCommand(_config: Config, args: string[]): Promise | RenderedCliResult> {
if (args[0] === "screenshot" && (args.includes("--help") || args.includes("-h") || args[1] === "help")) return webProbeScreenshotHelp();
+ if (args[0] === "external-form" && (args.includes("--help") || args.includes("-h") || args[1] === "help")) return webProbeExternalFormHelp();
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return webProbeHelp();
const format = args.includes("--json") ? "json" : args.includes("--yaml") ? "yaml" : "text";
const parsedArgs = args.filter((item) => item !== "--json" && item !== "--yaml");
if (parsedArgs[0] === "artifact") return runWebProbeArtifactInspect(parsedArgs.slice(1));
+ if (parsedArgs[0] === "external-form") return await runWebProbeExternalForm(parsedArgs.slice(1));
if (parsedArgs[0] === "product-smoke") {
const result = runWebProbeProductSmoke(parsedArgs.slice(1));
if (format === "text") return result;