feat: add bounded external form inspection
This commit is contained in:
@@ -46,6 +46,8 @@ spec:
|
|||||||
type: upload
|
type: upload
|
||||||
selector: input[name="resume"]
|
selector: input[name="resume"]
|
||||||
path: config/web-probe-external-form/fixture.html
|
path: config/web-probe-external-form/fixture.html
|
||||||
|
- id: inspect-controls
|
||||||
|
type: inspect
|
||||||
- id: captcha
|
- id: captcha
|
||||||
type: pause
|
type: pause
|
||||||
selector: input[name="custtel"]
|
selector: input[name="custtel"]
|
||||||
|
|||||||
@@ -809,6 +809,10 @@ CI/CD rollout 门禁仍只验证配置声明的 `/health` endpoint。web-probe q
|
|||||||
- `stop` 只终止该 workflow 的精确进程及浏览器后代。
|
- `stop` 只终止该 workflow 的精确进程及浏览器后代。
|
||||||
- 动作合同:
|
- 动作合同:
|
||||||
- 通用动作至少覆盖导航、文本填写、选择、勾选、点击、文件上传、等待、截图、暂停和保存草稿;
|
- 通用动作至少覆盖导航、文本填写、选择、勾选、点击、文件上传、等待、截图、暂停和保存草稿;
|
||||||
|
- 工作流可以声明有界只读控件检查动作,用于获取最多 40 个控件的标签、ID、名称、类型、占位符、
|
||||||
|
截短可见文字、类名和内联点击属性;
|
||||||
|
- 控件检查默认 selector 为 `input, button, a, select, textarea`,不得读取或输出 `value`、Cookie、
|
||||||
|
页面存储、输入控件文字或其他表单值;
|
||||||
- selector、目标值引用和完成条件必须显式声明;
|
- selector、目标值引用和完成条件必须显式声明;
|
||||||
- 本地上传文件必须按 YAML 大小上限进入 workflow 私有目录,并在 runner 终态删除;
|
- 本地上传文件必须按 YAML 大小上限进入 workflow 私有目录,并在 runner 终态删除;
|
||||||
- 普通 `click` 不得操作 submit 控件,提交控件必须使用显式 `submit` 动作;
|
- 普通 `click` 不得操作 submit 控件,提交控件必须使用显式 `submit` 动作;
|
||||||
@@ -819,6 +823,7 @@ CI/CD rollout 门禁仍只验证配置声明的 `/health` endpoint。web-probe q
|
|||||||
- 默认输出只披露字段标识、presence、fingerprint、完成度和 `valuesRedacted=true`。
|
- 默认输出只披露字段标识、presence、fingerprint、完成度和 `valuesRedacted=true`。
|
||||||
- 证据和恢复:
|
- 证据和恢复:
|
||||||
- 状态必须有界披露 phase、当前动作、最终 URL、DOM/网络/console/pageerror 摘要、截图引用和字段完成度;
|
- 状态必须有界披露 phase、当前动作、最终 URL、DOM/网络/console/pageerror 摘要、截图引用和字段完成度;
|
||||||
|
- 控件检查结果最多保留 40 条,单项文字和属性必须限长并经过现有 Secret 脱敏;
|
||||||
- artifact、状态和 continuation 文件必须使用受限权限;
|
- artifact、状态和 continuation 文件必须使用受限权限;
|
||||||
- timeout、失败、人工停止和正常结束都必须复用 OPS-SENTINEL-REQ-018 的浏览器资源回收合同。
|
- timeout、失败、人工停止和正常结束都必须复用 OPS-SENTINEL-REQ-018 的浏览器资源回收合同。
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ native 开发应同时保持前端 HMR 和 API 文件监听热重载。服务端
|
|||||||
- 前端不得把全部密码预加载到页面状态。
|
- 前端不得把全部密码预加载到页面状态。
|
||||||
- API、CLI、日志、TaskTree、Git 和错误响应不得打印密码明文。
|
- API、CLI、日志、TaskTree、Git 和错误响应不得打印密码明文。
|
||||||
- 更新密码时应覆盖该单位的当前明文记录,不保留可由普通接口读取的历史密码。
|
- 更新密码时应覆盖该单位的当前明文记录,不保留可由普通接口读取的历史密码。
|
||||||
|
- 外部招聘表单需要密码时,应按单位把当前账号和密码物化到 `.state/` 下权限为
|
||||||
|
`0600` 的临时 Secret 文件;表单完成 Secret 注入后应立即清除该文件。
|
||||||
|
- 临时 Secret 物化和清除命令只返回单位 ID、文件 presence 与路径,不返回账号或
|
||||||
|
密码值;PostgreSQL 仍为招聘密码的唯一长期事实源。
|
||||||
|
|
||||||
## 7. 验收契约
|
## 7. 验收契约
|
||||||
|
|
||||||
@@ -134,3 +138,4 @@ native 开发应同时保持前端 HMR 和 API 文件监听热重载。服务端
|
|||||||
- 正确登录后能为单个单位生成、查看和复制招聘密码。
|
- 正确登录后能为单个单位生成、查看和复制招聘密码。
|
||||||
- 未登录请求、单位列表请求和密码库摘要请求均不能取得密码明文。
|
- 未登录请求、单位列表请求和密码库摘要请求均不能取得密码明文。
|
||||||
- Git diff、CLI、日志和 TaskTree 中不出现招聘密码明文。
|
- Git diff、CLI、日志和 TaskTree 中不出现招聘密码明文。
|
||||||
|
- 按单位物化的临时 Secret 能被声明式外部表单读取,并能在注入后通过受控命令清除。
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const secretValues = Object.values(secrets).filter((value) => typeof value === "
|
|||||||
const completed = [];
|
const completed = [];
|
||||||
const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 };
|
const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 };
|
||||||
const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null };
|
const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null };
|
||||||
|
let inspection = null;
|
||||||
let browser;
|
let browser;
|
||||||
let context;
|
let context;
|
||||||
let page;
|
let page;
|
||||||
@@ -152,6 +153,11 @@ async function executeAction(action) {
|
|||||||
await captureScreenshot(action.id);
|
await captureScreenshot(action.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.type === "inspect") {
|
||||||
|
inspection = await inspectControls(action);
|
||||||
|
await writeStatus("running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.type === "pause") {
|
if (action.type === "pause") {
|
||||||
const screenshot = await captureScreenshot("pause-" + action.id);
|
const screenshot = await captureScreenshot("pause-" + action.id);
|
||||||
await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } });
|
await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } });
|
||||||
@@ -202,6 +208,36 @@ async function captureScreenshot(name) {
|
|||||||
return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true };
|
return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectControls(action) {
|
||||||
|
const selector = action.selector || "input, button, a, select, textarea";
|
||||||
|
const controls = await page.locator(selector).evaluateAll((elements) => elements.slice(0, 40).map((element) => {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
const visibleText = tag === "button" || tag === "a"
|
||||||
|
? String(element.innerText || "")
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
id: element.getAttribute("id") || "",
|
||||||
|
name: element.getAttribute("name") || "",
|
||||||
|
type: element.getAttribute("type") || "",
|
||||||
|
placeholder: element.getAttribute("placeholder") || "",
|
||||||
|
text: visibleText,
|
||||||
|
className: element.getAttribute("class") || "",
|
||||||
|
onclick: element.getAttribute("onclick") || "",
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
actionId: action.id,
|
||||||
|
selector,
|
||||||
|
count: controls.length,
|
||||||
|
truncated: await page.locator(selector).count() > controls.length,
|
||||||
|
controls: controls.map((control) => Object.fromEntries(
|
||||||
|
Object.entries(control).map(([key, value]) => [key, redact(String(value)).replace(/\s+/g, " ").trim().slice(0, 160)])
|
||||||
|
)),
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function writeStatus(phase, extra = {}) {
|
async function writeStatus(phase, extra = {}) {
|
||||||
const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null;
|
const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -217,6 +253,7 @@ async function writeStatus(phase, extra = {}) {
|
|||||||
finalUrl,
|
finalUrl,
|
||||||
network,
|
network,
|
||||||
browserSignals,
|
browserSignals,
|
||||||
|
inspection,
|
||||||
pause: null,
|
pause: null,
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
...extra,
|
...extra,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { webProbeExternalFormRunnerSource } from "./web-probe-external-form-runn
|
|||||||
|
|
||||||
type ExternalFormAction = {
|
type ExternalFormAction = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: "goto" | "fill" | "select" | "check" | "upload" | "click" | "submit" | "waitFor" | "screenshot" | "pause";
|
readonly type: "goto" | "fill" | "select" | "check" | "upload" | "click" | "submit" | "waitFor" | "screenshot" | "inspect" | "pause";
|
||||||
readonly selector?: string;
|
readonly selector?: string;
|
||||||
readonly url?: string;
|
readonly url?: string;
|
||||||
readonly value?: string;
|
readonly value?: string;
|
||||||
@@ -420,7 +420,7 @@ function stop(workflow: ExternalFormWorkflow, workspace: string, workflowId: str
|
|||||||
|
|
||||||
function parseAction(value: Record<string, unknown>, index: number): ExternalFormAction {
|
function parseAction(value: Record<string, unknown>, index: number): ExternalFormAction {
|
||||||
const label = `spec.actions[${index}]`;
|
const label = `spec.actions[${index}]`;
|
||||||
const type = optionalEnum(value.type, ["goto", "fill", "select", "check", "upload", "click", "submit", "waitFor", "screenshot", "pause"], `${label}.type`);
|
const type = optionalEnum(value.type, ["goto", "fill", "select", "check", "upload", "click", "submit", "waitFor", "screenshot", "inspect", "pause"], `${label}.type`);
|
||||||
if (type === null) throw new Error(`${label}.type 缺失`);
|
if (type === null) throw new Error(`${label}.type 缺失`);
|
||||||
const action: ExternalFormAction = {
|
const action: ExternalFormAction = {
|
||||||
id: simpleId(text(value.id, `${label}.id`), `${label}.id`),
|
id: simpleId(text(value.id, `${label}.id`), `${label}.id`),
|
||||||
|
|||||||
Reference in New Issue
Block a user