From 3ba9b94bf49ea06552eea528b93acaedbbd501fe Mon Sep 17 00:00:00 2001 From: pikastech Date: Sat, 18 Jul 2026 11:10:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=97=E6=8E=A7?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E8=A1=A8=E5=8D=95=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/web-probe-external-form/fixture.html | 16 + config/web-probe-external-form/fixture.yaml | 54 ++ .../secrets.fixture.yaml | 40 ++ .../R6_Task_Report.md | 22 + .../MDTODO/web-observe-runtime-reliability.md | 4 + .../PJ2026-01060508-web-probe-sentinel.md | 42 ++ scripts/src/help.ts | 6 + scripts/src/hwlab-node-help.ts | 9 +- .../src/hwlab-node-web-probe-memory-guard.ts | 25 +- .../hwlab-node/web-probe-observe-actions.ts | 2 +- .../web-probe-external-form-runner-source.ts | 346 ++++++++++ scripts/src/web-probe-external-form.ts | 601 ++++++++++++++++++ scripts/src/web-probe.ts | 3 + 13 files changed, 1159 insertions(+), 11 deletions(-) create mode 100644 config/web-probe-external-form/fixture.html create mode 100644 config/web-probe-external-form/fixture.yaml create mode 100644 config/web-probe-external-form/secrets.fixture.yaml create mode 100644 docs/MDTODO/details/web-observe-runtime-reliability/R6_Task_Report.md create mode 100644 scripts/src/web-probe-external-form-runner-source.ts create mode 100644 scripts/src/web-probe-external-form.ts 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;