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