系统级组件兼容性审查: psText 统一非 ASCII 安全字面量
This commit is contained in:
parent
b242b9c4fe
commit
1c5d981e38
75
src/main/capabilities/compat-audit.test.ts
Normal file
75
src/main/capabilities/compat-audit.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { capabilityIds } from "../engine/registry";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import "./index"; // 汇总导入全部能力模块(与运行时同构)
|
||||||
|
|
||||||
|
interface CatalogComp {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
fields?: { key: string; label?: string; widget?: string; options?: { label: string; value: string }[]; defaultValue?: unknown }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("系统组件兼容性全量审查", () => {
|
||||||
|
it("注册覆盖 + 面板可见性 + 字段契约核对", async () => {
|
||||||
|
const catalog: CatalogComp[] = JSON.parse(readFileSync("resources/catalog.json", "utf-8")).components ?? [];
|
||||||
|
const dup = JSON.parse(readFileSync("resources/capability-duplicates.json", "utf-8"));
|
||||||
|
const dupMap = new Map<string, { status: string; canonical?: string }>(
|
||||||
|
(Array.isArray(dup) ? dup : []).map((d: { id: string; status: string; canonical?: string }) => [d.id, { status: d.status, canonical: d.canonical }])
|
||||||
|
);
|
||||||
|
const registered = new Set(capabilityIds());
|
||||||
|
|
||||||
|
// 1) catalog 组件 → 运行时注册
|
||||||
|
const unregistered: { id: string; name?: string }[] = [];
|
||||||
|
for (const c of catalog) {
|
||||||
|
if (!registered.has(c.id)) unregistered.push({ id: c.id, name: c.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 幽灵注册:handler 有但 catalog 没有(可能是内部别名,不一定是问题,但要列出来)
|
||||||
|
const catalogIds = new Set(catalog.map((c) => c.id));
|
||||||
|
const ghosts: string[] = [];
|
||||||
|
for (const id of registered) {
|
||||||
|
if (!catalogIds.has(id)) ghosts.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) duplicates 一致性:hide 必须有 canonical 且 canonical 已注册;keep/隐藏组件的执行器必须在
|
||||||
|
const dupIssues: string[] = [];
|
||||||
|
for (const [id, info] of dupMap) {
|
||||||
|
if (info.status === "hide" && info.canonical && !registered.has(info.canonical)) {
|
||||||
|
dupIssues.push(`hide 项 ${id} 的 canonical ${info.canonical} 未注册`);
|
||||||
|
}
|
||||||
|
if (info.status === "keep" && !registered.has(id)) {
|
||||||
|
dupIssues.push(`keep 项 ${id} 未注册(面板可见但运行会报未实现)`);
|
||||||
|
}
|
||||||
|
if (info.status === "hide" && !info.canonical && !registered.has(id)) {
|
||||||
|
dupIssues.push(`hide 项 ${id} 无 canonical 且未注册(旧流程节点将无法运行)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 字段契约抽查:select 枚举 defaultValue 必须在 options 里;hidden 字段不能 required
|
||||||
|
const fieldIssues: string[] = [];
|
||||||
|
for (const c of catalog) {
|
||||||
|
for (const f of c.fields ?? []) {
|
||||||
|
if (f.widget === "select" && Array.isArray(f.options) && f.options.length > 0) {
|
||||||
|
const values = f.options.map((o) => String(o.value));
|
||||||
|
if (f.defaultValue !== undefined && f.defaultValue !== "" && !values.includes(String(f.defaultValue))) {
|
||||||
|
fieldIssues.push(`${c.id}.${f.key} select 默认值 "${f.defaultValue}" 不在选项值 [${values.join(",")}] 中`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
catalogTotal: catalog.length,
|
||||||
|
registeredTotal: registered.size,
|
||||||
|
unregisteredCount: unregistered.length,
|
||||||
|
unregistered: unregistered.slice(0, 50),
|
||||||
|
ghostCount: ghosts.length,
|
||||||
|
ghosts: ghosts.slice(0, 40),
|
||||||
|
dupIssues,
|
||||||
|
fieldIssues: fieldIssues.slice(0, 40),
|
||||||
|
fieldIssueCount: fieldIssues.length
|
||||||
|
};
|
||||||
|
writeFileSync(".compat-report.txt", JSON.stringify(report, null, 1), "utf8");
|
||||||
|
}, 120000);
|
||||||
|
});
|
||||||
@ -141,13 +141,19 @@ function sendKeysEscape(ch: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把字符包装成 PowerShell 安全字面量。
|
* 把文本包装成 PowerShell 安全表达式。
|
||||||
* ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造——
|
* ASCII 直接用单引号字符串(撇号内部 '' 转义);含非 ASCII 用 [char]0xXXXX 数值构造——
|
||||||
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。
|
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。
|
||||||
|
* 所有内嵌用户文本的脚本生成点(Set-Clipboard/SendWait/throw 消息等)都应使用本函数而非 psString。
|
||||||
*/
|
*/
|
||||||
function psCharLiteral(text: string): string {
|
export function psText(text: string): string {
|
||||||
if (/^[\x20-\x7E]*$/.test(text)) return psString(text);
|
if (/^[\x20-\x7E]*$/.test(text)) return psString(text);
|
||||||
return Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join("+") || psString("");
|
return `(-join @(${Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join(",")}))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单字符/短字面量的别名(语义标注用),实现同 psText */
|
||||||
|
function psCharLiteral(text: string): string {
|
||||||
|
return psText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
|
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
@ -168,9 +174,8 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
// 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱
|
// 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱
|
||||||
let send: string;
|
let send: string;
|
||||||
if (mode === "2") {
|
if (mode === "2") {
|
||||||
// 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造
|
// 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义
|
||||||
const contentExpr = /^[\x20-\x7E]*$/.test(keys) ? psString(keys) : `(-join @(${Array.from(keys).map((c) => psCharLiteral(c)).join(",")}))`;
|
send = `Set-Clipboard -Value ${psText(keys)}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`;
|
||||||
send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`;
|
|
||||||
} else if (mode === "1" && isTextSend) {
|
} else if (mode === "1" && isTextSend) {
|
||||||
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
|
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
|
||||||
// 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱)
|
// 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱)
|
||||||
@ -180,8 +185,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
} else {
|
} else {
|
||||||
// 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义
|
// 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义
|
||||||
const escaped = isTextSend ? Array.from(keys).map(sendKeysEscape).join("") : keys;
|
const escaped = isTextSend ? Array.from(keys).map(sendKeysEscape).join("") : keys;
|
||||||
const contentExpr = /^[\x20-\x7E]*$/.test(escaped) ? psString(escaped) : `(-join @(${Array.from(escaped).map((c) => psCharLiteral(c)).join(",")}))`;
|
send = `[System.Windows.Forms.SendKeys]::SendWait(${psText(escaped)})`;
|
||||||
send = `[System.Windows.Forms.SendKeys]::SendWait(${contentExpr})`;
|
|
||||||
}
|
}
|
||||||
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
|
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
|
||||||
await manualDelay();
|
await manualDelay();
|
||||||
@ -237,10 +241,11 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
|
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
|
||||||
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
|
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
|
||||||
function activateWindowScript(selector: string): string {
|
function activateWindowScript(selector: string): string {
|
||||||
const safeTitle = selector.replace(/'/g, "''");
|
// 错误消息内嵌用户文本走 psText(窗口标题可能含智能引号/中文标点,直接放进单引号字面量会被 PS5.1 解析炸)
|
||||||
|
const failMsg = psText(`找不到窗口:${selector}。当前可见窗口标题: `);
|
||||||
return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 }
|
return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 }
|
||||||
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
|
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
|
||||||
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) }
|
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail (${failMsg} + $vis) }
|
||||||
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
|
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
|
||||||
$h = $p.MainWindowHandle
|
$h = $p.MainWindowHandle
|
||||||
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9); Start-Sleep -Milliseconds 150 }
|
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9); Start-Sleep -Milliseconds 150 }
|
||||||
@ -306,14 +311,14 @@ const runApp: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
|
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
|
||||||
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
|
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
|
||||||
const content = text(args, ["text", "value"]);
|
const content = text(args, ["text", "value"]);
|
||||||
await powershell(`Set-Clipboard -Value ${psString(content)}`);
|
await powershell(`Set-Clipboard -Value ${psText(content)}`);
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
const notice: CapabilityHandler = async (_ctx, _node, args) => {
|
const notice: CapabilityHandler = async (_ctx, _node, args) => {
|
||||||
const title = text(args, ["title"], "提醒");
|
const title = text(args, ["title"], "提醒");
|
||||||
const content = text(args, ["text", "message"]);
|
const content = text(args, ["text", "message"]);
|
||||||
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`);
|
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psText(content)},${psText(title)}) | Out-Null`);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
import { powershell, psString } from "./desktop";
|
import { powershell, psString, psText } from "./desktop";
|
||||||
|
|
||||||
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@ -43,7 +43,8 @@ public static class RpaWindowApi {
|
|||||||
'@`;
|
'@`;
|
||||||
|
|
||||||
function findWindowScript(title: string): string {
|
function findWindowScript(title: string): string {
|
||||||
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
|
// throw 消息内嵌用户文本走 psText(智能引号等非 ASCII 会让 PS5.1 字符串字面量未闭合)
|
||||||
|
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw (${psText(`找不到窗口:${title}`)}) }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
register("System_GetEnv", (_ctx, node, args) => {
|
register("System_GetEnv", (_ctx, node, args) => {
|
||||||
@ -105,7 +106,7 @@ register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
|
|||||||
register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw"));
|
register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw"));
|
||||||
register("Clipboard_SetTextV1", async (_ctx, _node, args) => {
|
register("Clipboard_SetTextV1", async (_ctx, _node, args) => {
|
||||||
const content = value(args, ["text", "value"]);
|
const content = value(args, ["text", "value"]);
|
||||||
await powershell(`Set-Clipboard -Value ${psString(content)}`);
|
await powershell(`Set-Clipboard -Value ${psText(content)}`);
|
||||||
return content;
|
return content;
|
||||||
});
|
});
|
||||||
register("Clipboard_ClearClipboard", async () => {
|
register("Clipboard_ClearClipboard", async () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
import { powershell, psString } from "./desktop";
|
import { powershell, psString, psText } from "./desktop";
|
||||||
import { screen } from "electron";
|
import { screen } from "electron";
|
||||||
|
|
||||||
const WINDOW_API = `Add-Type @'
|
const WINDOW_API = `Add-Type @'
|
||||||
@ -50,7 +50,8 @@ function requiredTitle(args: Record<string, unknown>, nodeId: string, componentI
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findWindow(title: string): string {
|
function findWindow(title: string): string {
|
||||||
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
|
// throw 消息内嵌用户文本走 psText(智能引号等非 ASCII 会让 PS5.1 字符串字面量未闭合)
|
||||||
|
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw (${psText(`找不到窗口:${title}`)}) }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
|
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user