@szc-ft/mcp-szcd-client 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/opencode-extension/skills/local-browser-test/SKILL.md +218 -6
- package/opencode-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +642 -5
- package/opencode-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/opencode-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +218 -6
- package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +642 -5
- package/qwen-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/qwen-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
- package/standard-skill/local-browser-test/SKILL.md +218 -6
- package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +642 -5
- package/standard-skill/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/standard-skill/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/standard-skill/local-browser-test/lib/shared-deps.js +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP 方式获取浏览器运行配置
|
|
3
|
+
* 通过 Chrome DevTools Protocol 获取浏览器命令行参数,解析 user-data-dir 等配置
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 通过 wmic 获取 msedge.exe 的完整命令行,解析关键参数
|
|
10
|
+
* @param {"msedge"|"chrome"} browserName
|
|
11
|
+
* @returns {{ userDataDir: string, remoteDebuggingPort: string, args: string[] }}
|
|
12
|
+
*/
|
|
13
|
+
export function getBrowserConfigFromProcess(browserName = 'msedge') {
|
|
14
|
+
const exeName = browserName === 'chrome' ? 'chrome.exe' : 'msedge.exe';
|
|
15
|
+
// 方案1:wmic /format:list 获取命令行
|
|
16
|
+
let output = '';
|
|
17
|
+
try {
|
|
18
|
+
const raw = execSync(
|
|
19
|
+
`wmic process where "name='${exeName}'" get commandline /format:list`,
|
|
20
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
|
|
21
|
+
);
|
|
22
|
+
// 解析 key=value 格式(每行 CommandLine=...,空格行分隔进程)
|
|
23
|
+
const blocks = raw.split(/\r\n\r\n/);
|
|
24
|
+
output = blocks
|
|
25
|
+
.map(b => {
|
|
26
|
+
const m = b.match(/CommandLine=(.+)/s);
|
|
27
|
+
return m ? m[1].trim() : '';
|
|
28
|
+
})
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.join('\n');
|
|
31
|
+
} catch {
|
|
32
|
+
// 方案2:PowerShell Get-CimInstance
|
|
33
|
+
try {
|
|
34
|
+
const psScript = `Get-CimInstance Win32_Process -Filter 'Name="${exeName}"' | Where-Object { $_.CommandLine -like '*--remote-debugging-port*' } | ForEach-Object { $_.CommandLine }`;
|
|
35
|
+
output = execSync(
|
|
36
|
+
`powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`,
|
|
37
|
+
{ encoding: 'utf8', timeout: 8000, windowsHide: true }
|
|
38
|
+
);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 解析命令行参数
|
|
45
|
+
const lines = output.split('\n').filter(l => l.includes('--remote-debugging-port') && l.trim());
|
|
46
|
+
if (lines.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
const cmdLine = lines[0].trim();
|
|
49
|
+
|
|
50
|
+
// 提取关键参数
|
|
51
|
+
const config = {
|
|
52
|
+
userDataDir: null,
|
|
53
|
+
remoteDebuggingPort: null,
|
|
54
|
+
args: [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// 解析 --user-data-dir="path" 或 --user-data-dir=path 或 --user-data-dir path
|
|
58
|
+
const uddMatch = cmdLine.match(/--user-data-dir[= ]"?([^ "]+)"?/i);
|
|
59
|
+
if (uddMatch) {
|
|
60
|
+
config.userDataDir = uddMatch[1].replace(/\\/g, '/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 解析 --remote-debugging-port
|
|
64
|
+
const rdpMatch = cmdLine.match(/--remote-debugging-port[= ](\d+)/i);
|
|
65
|
+
if (rdpMatch) {
|
|
66
|
+
config.remoteDebuggingPort = rdpMatch[1];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 返回所有参数
|
|
70
|
+
const argMatches = cmdLine.match(/--[\w-]+(?:[= ][^ "]+)?/g);
|
|
71
|
+
if (argMatches) {
|
|
72
|
+
config.args = argMatches.map(a => a.trim());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 通过 CDP 连接获取浏览器版本信息
|
|
80
|
+
* @param {string} cdpUrl - CDP 地址,默认 http://localhost:9222
|
|
81
|
+
*/
|
|
82
|
+
export async function getBrowserInfoFromCDP(cdpUrl = 'http://localhost:9222') {
|
|
83
|
+
try {
|
|
84
|
+
const resp = await fetch(`${cdpUrl}/json/version`);
|
|
85
|
+
const data = await resp.json();
|
|
86
|
+
return {
|
|
87
|
+
browser: data.Browser || data.browser,
|
|
88
|
+
userAgent: data['User-Agent'],
|
|
89
|
+
protocolVersion: data['Protocol-Version'],
|
|
90
|
+
webSocketDebuggerUrl: data.webSocketDebuggerUrl,
|
|
91
|
+
};
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 综合获取浏览器完整配置
|
|
99
|
+
* 先通过进程命令行获取 user-data-dir,再通过 CDP 获取版本信息
|
|
100
|
+
*/
|
|
101
|
+
export async function getFullBrowserConfig(cdpUrl = 'http://localhost:9222') {
|
|
102
|
+
const procConfig = getBrowserConfigFromProcess();
|
|
103
|
+
const browserInfo = await getBrowserInfoFromCDP(cdpUrl);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...procConfig,
|
|
107
|
+
browserInfo,
|
|
108
|
+
loginDataPath: procConfig?.userDataDir
|
|
109
|
+
? join(procConfig.userDataDir, 'Default', 'Login Data')
|
|
110
|
+
: null,
|
|
111
|
+
localStatePath: procConfig?.userDataDir
|
|
112
|
+
? join(procConfig.userDataDir, 'Local State')
|
|
113
|
+
: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 Edge/Chrome 的 Login Data 数据库中解密保存的密码
|
|
3
|
+
*
|
|
4
|
+
* 支持 v10 格式(使用 AES-256-GCM + DPAPI 加密密钥)
|
|
5
|
+
* 不支持 v20 格式(App-Bound Encryption,需在浏览器进程内解密)
|
|
6
|
+
*
|
|
7
|
+
* 依赖:
|
|
8
|
+
* - sql.js(纯 JS SQLite,npm install sql.js)
|
|
9
|
+
* - Node.js 内置 crypto
|
|
10
|
+
* - Windows DPAPI(通过 PowerShell 子进程)
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
13
|
+
import { createDecipheriv } from 'crypto';
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { createRequire } from 'module';
|
|
17
|
+
|
|
18
|
+
// sql.js 在共享缓存 ~/.szcd-mcp/deps/node_modules/ 中
|
|
19
|
+
// ESM 的 NODE_PATH 不支持,用 createRequire 兼容
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const initSqlJs = require('sql.js');
|
|
22
|
+
|
|
23
|
+
// ============ DPAPI 解密(通过 PowerShell) ============
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 使用 Windows DPAPI 解密数据
|
|
27
|
+
* 通过 PowerShell 调用 System.Security.Cryptography.ProtectedData
|
|
28
|
+
*/
|
|
29
|
+
export function dpapiDecrypt(encryptedBuffer) {
|
|
30
|
+
const tmpDir = process.env.TEMP || process.env.TMP || '/tmp';
|
|
31
|
+
const tmpIn = join(tmpDir, '_dpapi_in.bin');
|
|
32
|
+
const tmpOut = join(tmpDir, '_dpapi_out.bin');
|
|
33
|
+
|
|
34
|
+
writeFileSync(tmpIn, encryptedBuffer);
|
|
35
|
+
|
|
36
|
+
// PowerShell 脚本:读文件 → DPAPI 解密 → 写文件
|
|
37
|
+
const psScript = [
|
|
38
|
+
'Add-Type -AssemblyName System.Security',
|
|
39
|
+
`$in=[System.IO.File]::ReadAllBytes('${tmpIn.replace(/\\/g, '\\\\')}')`,
|
|
40
|
+
`$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,'CurrentUser')`,
|
|
41
|
+
`[System.IO.File]::WriteAllBytes('${tmpOut.replace(/\\/g, '\\\\')}',$out)`,
|
|
42
|
+
].join(';');
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
execSync(`powershell -NoProfile -Command "${psScript}"`, {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
timeout: 10000,
|
|
49
|
+
});
|
|
50
|
+
return readFileSync(tmpOut);
|
|
51
|
+
} finally {
|
|
52
|
+
try { unlinkSync(tmpIn); } catch {}
|
|
53
|
+
try { unlinkSync(tmpOut); } catch {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// ============ 浏览器配置检测 ============
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 获取 Edge/Chrome 的 user-data-dir 配置
|
|
62
|
+
* 通过解析进程命令行参数
|
|
63
|
+
*/
|
|
64
|
+
export function detectUserDataDir(browserName = 'msedge') {
|
|
65
|
+
const exeName = browserName === 'chrome' ? 'chrome.exe' : 'msedge.exe';
|
|
66
|
+
|
|
67
|
+
let output = '';
|
|
68
|
+
try {
|
|
69
|
+
output = execSync(
|
|
70
|
+
`wmic process where "name='${exeName}'" get commandline /format:csv`,
|
|
71
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
try {
|
|
75
|
+
output = execSync(
|
|
76
|
+
`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"Name='${exeName}'\\" | Where-Object {\\$_.CommandLine -like '*--remote-debugging-port*'} | Select-Object -ExpandProperty CommandLine"`,
|
|
77
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lines = output.split('\n').filter(l => l.includes('--remote-debugging-port'));
|
|
85
|
+
if (lines.length === 0) return null;
|
|
86
|
+
|
|
87
|
+
const match = lines[0].match(/--user-data-dir[= ]"?([^ "]+)"?/i);
|
|
88
|
+
return match ? match[1].replace(/\\/g, '/') : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
// ============ AES-256-GCM 解密(v10 格式) ============
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 解密 v10 格式的密码
|
|
96
|
+
* 格式: "v10" (3 bytes) + nonce (12 bytes) + ciphertext + tag (16 bytes)
|
|
97
|
+
*/
|
|
98
|
+
function decryptV10Password(aesKey, encryptedBlob) {
|
|
99
|
+
const raw = Buffer.from(encryptedBlob);
|
|
100
|
+
if (raw.length < 31) throw new Error('v10 blob too short');
|
|
101
|
+
|
|
102
|
+
const nonce = raw.subarray(3, 15); // 12 bytes after v10
|
|
103
|
+
const tag = raw.subarray(raw.length - 16); // last 16 bytes
|
|
104
|
+
const ciphertext = raw.subarray(15, raw.length - 16);
|
|
105
|
+
|
|
106
|
+
const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce);
|
|
107
|
+
decipher.setAuthTag(tag);
|
|
108
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
// ============ 主入口 ============
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从浏览器配置目录解密所有保存的密码
|
|
116
|
+
* @param {object} options
|
|
117
|
+
* @param {string} [options.userDataDir] - Edge/Chrome 的 user-data-dir 路径,不传则自动检测
|
|
118
|
+
* @param {string} [options.filter] - 过滤 URL 的关键词,默认 '%'(全部)
|
|
119
|
+
* @param {boolean} [options.verbose] - 是否输出详细信息
|
|
120
|
+
* @returns {Array<{url: string, username: string, password: string}>}
|
|
121
|
+
*/
|
|
122
|
+
export async function decryptAllPasswords(options = {}) {
|
|
123
|
+
const {
|
|
124
|
+
userDataDir = detectUserDataDir(),
|
|
125
|
+
filter = '%',
|
|
126
|
+
verbose = false,
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
if (!userDataDir) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'未检测到浏览器 user-data-dir。请确保 Edge/Chrome 以 --remote-debugging-port 和 --user-data-dir 参数启动'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const localStatePath = join(userDataDir, 'Local State');
|
|
136
|
+
const loginDataPath = join(userDataDir, 'Default', 'Login Data');
|
|
137
|
+
|
|
138
|
+
if (!existsSync(localStatePath)) {
|
|
139
|
+
throw new Error(`Local State 文件不存在: ${localStatePath}`);
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(loginDataPath)) {
|
|
142
|
+
throw new Error(`Login Data 文件不存在: ${loginDataPath}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (verbose) {
|
|
146
|
+
console.log(`[decrypt] userDataDir: ${userDataDir}`);
|
|
147
|
+
console.log(`[decrypt] Login Data: ${loginDataPath}`);
|
|
148
|
+
console.log(`[decrypt] Local State: ${localStatePath}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 1. 读取 Local State 获取加密密钥
|
|
152
|
+
const localState = JSON.parse(readFileSync(localStatePath, 'utf8'));
|
|
153
|
+
if (!localState.os_crypt?.encrypted_key) {
|
|
154
|
+
throw new Error('Local State 中未找到 os_crypt.encrypted_key');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const encKeyBuf = Buffer.from(localState.os_crypt.encrypted_key, 'base64');
|
|
158
|
+
if (encKeyBuf.length < 5) throw new Error('加密密钥格式异常');
|
|
159
|
+
|
|
160
|
+
// 去除 "DPAPI" 前缀(5 字节)
|
|
161
|
+
const aesKey = dpapiDecrypt(encKeyBuf.subarray(5));
|
|
162
|
+
if (verbose) console.log(`[decrypt] AES key: ${aesKey.length} bytes`);
|
|
163
|
+
|
|
164
|
+
// 2. 读取 SQLite 数据库
|
|
165
|
+
const SQL = await initSqlJs();
|
|
166
|
+
const db = new SQL.Database(readFileSync(loginDataPath));
|
|
167
|
+
const rows = db.exec(
|
|
168
|
+
`SELECT origin_url, username_value, password_value FROM logins WHERE origin_url LIKE '${filter}'`
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const credentials = [];
|
|
172
|
+
const values = rows[0]?.values || [];
|
|
173
|
+
|
|
174
|
+
for (const [url, username, pwdBlob] of values) {
|
|
175
|
+
if (!pwdBlob || pwdBlob.length < 3) continue;
|
|
176
|
+
|
|
177
|
+
const raw = Buffer.from(pwdBlob);
|
|
178
|
+
const prefix = raw.subarray(0, 3).toString();
|
|
179
|
+
|
|
180
|
+
if (prefix === 'v10') {
|
|
181
|
+
try {
|
|
182
|
+
const password = decryptV10Password(aesKey, pwdBlob).toString('utf8');
|
|
183
|
+
credentials.push({ url, username, password });
|
|
184
|
+
if (verbose) console.log(`[decrypt] ✓ ${username} @ ${url}`);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (verbose) console.log(`[decrypt] ✗ ${username}: ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
} else if (verbose) {
|
|
189
|
+
console.log(`[decrypt] ⊘ ${username}: unsupported format (${prefix})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
db.close();
|
|
194
|
+
return credentials;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 获取指定 URL 的凭据(便捷方法)
|
|
199
|
+
* @param {string} urlPattern - URL 匹配模式
|
|
200
|
+
* @param {object} [options]
|
|
201
|
+
* @returns {Promise<{username: string, password: string}|null>}
|
|
202
|
+
*/
|
|
203
|
+
export async function getCredential(urlPattern, options = {}) {
|
|
204
|
+
const creds = await decryptAllPasswords({
|
|
205
|
+
...options,
|
|
206
|
+
filter: `%${urlPattern}%`,
|
|
207
|
+
verbose: false,
|
|
208
|
+
});
|
|
209
|
+
return creds.length > 0 ? creds[0] : null;
|
|
210
|
+
}
|
|
@@ -13,8 +13,8 @@ import fs from "node:fs";
|
|
|
13
13
|
/** 共享缓存目录 */
|
|
14
14
|
const SHARED_DEPS_DIR = path.join(homedir(), ".szcd-mcp", "deps");
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
const CORE_PACKAGES = ["puppeteer-core"];
|
|
16
|
+
/** 核心浏览器自动化依赖(主链路 observe/act/assert + 凭据解密) */
|
|
17
|
+
const CORE_PACKAGES = ["puppeteer-core", "sql.js"];
|
|
18
18
|
|
|
19
19
|
/** 视觉对比依赖 */
|
|
20
20
|
const VISUAL_COMPARE_PACKAGES = ["pixelmatch", "pngjs", "sharp"];
|
|
@@ -25,6 +25,9 @@ compatibility:
|
|
|
25
25
|
**视觉对比(用 --action compare):**
|
|
26
26
|
- "对比设计稿" / "还原度" / "像素对比"
|
|
27
27
|
|
|
28
|
+
**凭据管理(用 decrypt-creds.mjs):**
|
|
29
|
+
- "获取密码" / "解密密码" / "自动登录" / "读取浏览器保存的密码" / "接管浏览器,帮我登录"
|
|
30
|
+
|
|
28
31
|
## ⚡ 路径解析(必读,避免 ad-hoc 脚本)
|
|
29
32
|
|
|
30
33
|
**执行器内置在 skill 目录中(自包含),会话开始时先解析 skill 路径:**
|
|
@@ -149,15 +152,22 @@ Agent / SKILL.md
|
|
|
149
152
|
→ $SKILL_DIR/lib/expect.js (validation/locator/url/api + poll)
|
|
150
153
|
→ puppeteer-core + CDP
|
|
151
154
|
→ 用户真实 Chrome / Edge
|
|
155
|
+
|
|
156
|
+
── 凭据分支 ──
|
|
157
|
+
→ $SKILL_DIR/decrypt-creds.mjs (CLI 入口)
|
|
158
|
+
→ $SKILL_DIR/lib/cdp-browser-config.js (检测 user-data-dir)
|
|
159
|
+
→ $SKILL_DIR/lib/decrypt-passwords.js (DPAPI + AES-GCM 解密)
|
|
160
|
+
→ 输出 { username, password, url }
|
|
152
161
|
```
|
|
153
162
|
|
|
154
|
-
执行器 + lib
|
|
163
|
+
执行器 + lib + 凭据解密模块内置在 skill 目录中(自包含),`postinstall` 会把整个 skill 目录复制到各 IDE。
|
|
155
164
|
**禁止 AI 在用户机器上写 ad-hoc puppeteer 脚本**——会丢失:
|
|
156
165
|
- 微前端 frame 探活 + 自动召回
|
|
157
166
|
- `closeAllDropdowns` 跨 frame 清理
|
|
158
167
|
- Ant Select VERIFY 回写
|
|
159
168
|
- testCase recovery 重试
|
|
160
169
|
- API 按 frame scope 过滤
|
|
170
|
+
- 浏览器凭据自动解密(使用内置 `decrypt-creds.mjs`)
|
|
161
171
|
|
|
162
172
|
`local-browser-executor.cjs`(同目录)是 CommonJS 过渡版,仅支持基础诊断。**Ant 适配层 / testCase DSL / expect 断言仅 `local-browser-executor.js`(ES Module 版)支持。**
|
|
163
173
|
|
|
@@ -168,6 +178,9 @@ Agent / SKILL.md
|
|
|
168
178
|
### connect 模式(推荐)
|
|
169
179
|
连接用户已启动的调试 Chrome,天然继承登录态/微前端环境。
|
|
170
180
|
|
|
181
|
+
**connect 时自动解密凭据**:连接浏览器后,engine 自动检测 `--user-data-dir` 并解密所有保存的密码,
|
|
182
|
+
结果缓存到 `~/.szcd-mcp/deps/decrypted-creds.json`。后续可直接用 `engine._credentials` 获取。
|
|
183
|
+
|
|
171
184
|
用户一次性配置:
|
|
172
185
|
```bash
|
|
173
186
|
# macOS
|
|
@@ -632,12 +645,12 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.
|
|
|
632
645
|
## 依赖管理
|
|
633
646
|
|
|
634
647
|
浏览器测试依赖采用**共享缓存 + 按需安装**方案:
|
|
635
|
-
-
|
|
648
|
+
- 主链路(`observe/act/assert/diagnose/screenshot` + 凭据解密)安装 `puppeteer-core` + `sql.js` 到 `~/.szcd-mcp/deps/`
|
|
636
649
|
- 只有执行设计稿像素对比 `compare` 时才安装 `pixelmatch/pngjs/sharp`
|
|
637
650
|
- 后续所有项目共享复用,无需重复安装
|
|
638
651
|
- 如果核心依赖自动安装失败,引导用户手动执行:
|
|
639
652
|
```bash
|
|
640
|
-
cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com puppeteer-core
|
|
653
|
+
cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com puppeteer-core sql.js
|
|
641
654
|
```
|
|
642
655
|
- 如果视觉对比依赖自动安装失败,引导用户手动执行:
|
|
643
656
|
```bash
|
|
@@ -720,6 +733,133 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
|
|
|
720
733
|
```
|
|
721
734
|
`runTask` 会自动从 `context.lastApiCapture` 拿菜单树,不需要再传文件。
|
|
722
735
|
|
|
736
|
+
## 🔑 凭据解密(自动获取浏览器保存的密码)
|
|
737
|
+
|
|
738
|
+
当测试需要登录但浏览器已保存密码时,使用内置的 `decrypt-creds.mjs` 自动从 Edge/Chrome 的密码管理器中解密凭据,**无需人工输入密码**。
|
|
739
|
+
|
|
740
|
+
### 工作原理
|
|
741
|
+
|
|
742
|
+
```
|
|
743
|
+
Agent / SKILL.md
|
|
744
|
+
→ $SKILL_DIR/lib/cdp-browser-config.js 自动检测浏览器 user-data-dir
|
|
745
|
+
→ $SKILL_DIR/lib/decrypt-passwords.js DPAPI + AES-GCM 解密 v10 密码
|
|
746
|
+
→ $SKILL_DIR/decrypt-creds.mjs CLI 入口(一键调用)
|
|
747
|
+
→ 返回 { username, password, url }
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### 前置条件
|
|
751
|
+
|
|
752
|
+
- 浏览器以 `--remote-debugging-port=9222` 启动(CDP 已连接)
|
|
753
|
+
- 浏览器以 `--user-data-dir=<path>` 指定了自定义数据目录(开发/测试环境常见)
|
|
754
|
+
- 密码以 **v10 格式**存储(开发环境默认;生产环境 v20 需 Python 方案)
|
|
755
|
+
- skill 目录下已安装 `sql.js`:`cd $SKILL_DIR && npm install sql.js`
|
|
756
|
+
- 首次解密后结果自动缓存到 `~/.szcd-mcp/deps/decrypted-creds.json`,后续秒级返回
|
|
757
|
+
|
|
758
|
+
### 使用方式
|
|
759
|
+
|
|
760
|
+
```bash
|
|
761
|
+
# 自动检测浏览器配置 + 解密所有凭据(使用共享缓存依赖)
|
|
762
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --url aicityos
|
|
763
|
+
|
|
764
|
+
# 首次运行:全量解密 + 写入缓存(~/.szcd-mcp/deps/decrypted-creds.json)
|
|
765
|
+
# 后续运行:秒级返回(自动检测 Login Data 修改时间判断缓存是否过期)
|
|
766
|
+
|
|
767
|
+
# 强制刷新缓存(如手动修改了密码)
|
|
768
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --url aicityos --refresh
|
|
769
|
+
|
|
770
|
+
# JSON 格式输出(供脚本解析)
|
|
771
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --url aicityos --json
|
|
772
|
+
|
|
773
|
+
# 手动指定 user-data-dir(跳过自动检测)
|
|
774
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --user-data-dir "C:/EdgeDevSession" --url aicityos
|
|
775
|
+
|
|
776
|
+
# 查看帮助
|
|
777
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --help
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### 典型场景:SSO 登录自动化
|
|
781
|
+
|
|
782
|
+
**⚠️ 强制流程:解密后必须先向用户澄清使用哪一个账号登录,禁止自动选择!**
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
# 1. 解密凭据(使用共享缓存依赖)
|
|
786
|
+
CREDS=$(NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/decrypt-creds.mjs" --url aicityos --json)
|
|
787
|
+
|
|
788
|
+
# ❌ 错误:拿到第一个凭据就直接登录
|
|
789
|
+
# USER=$(echo "$CREDS" | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'))[0].username")
|
|
790
|
+
|
|
791
|
+
# ✅ 正确:先把去重后的用户列表展示给用户,让用户选择
|
|
792
|
+
# LLM 根据解密结果,用 AskUserQuestion 列出所有可用账号供用户选择:
|
|
793
|
+
# "解密到以下 X 个账号,请选择使用哪一个登录:"
|
|
794
|
+
# - admin (密码: Jx***01#)
|
|
795
|
+
# - 18718576547 (密码: Jx***01#)
|
|
796
|
+
# - zengzhijie (密码: zen***123)
|
|
797
|
+
# ...
|
|
798
|
+
# 用户选择后,再继续后续步骤
|
|
799
|
+
|
|
800
|
+
# 2. 用户确认后,用选定的凭据构造登录 testCase
|
|
801
|
+
cat > /tmp/login.json <<EOF
|
|
802
|
+
{
|
|
803
|
+
"title": "自动登录",
|
|
804
|
+
"options": { "timeout": 30000 },
|
|
805
|
+
"steps": [
|
|
806
|
+
{ "type": "antInput", "placeholder": "请输入账户", "value": "$USER" },
|
|
807
|
+
{ "type": "antInput", "placeholder": "请输入密码", "value": "$PASS" },
|
|
808
|
+
{ "type": "antInput", "placeholder": "请输入图形验证码", "value": "6" },
|
|
809
|
+
{ "type": "act", "click": "text==登 录" }
|
|
810
|
+
]
|
|
811
|
+
}
|
|
812
|
+
EOF
|
|
813
|
+
|
|
814
|
+
# 3. 执行登录
|
|
815
|
+
node "$EXECUTOR" --action plan --plan-file /tmp/login.json --url-contains "platform.aicityos.com" --wait 8000
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
**LLM 处理流程:**
|
|
819
|
+
1. 调用 `decrypt-creds.mjs --json` 获取用户列表(首次慢,后续秒级缓存命中)
|
|
820
|
+
2. 对凭据去重(同一用户名取首次出现的密码)
|
|
821
|
+
3. **用 `AskUserQuestion` 列出所有可用账号(只展示完整用户名,不展示密码),让用户选择**
|
|
822
|
+
4. 用户选择后,从解密结果中查找对应密码,构造登录 testCase
|
|
823
|
+
5. 执行登录
|
|
824
|
+
|
|
825
|
+
**⚠️ 强制约束:**
|
|
826
|
+
- 只展示账号列表,**不展示密码**(含脱敏也不展示)
|
|
827
|
+
- **展示完整账号**,不截断
|
|
828
|
+
- **每次登录都向用户询问**,不自动选择
|
|
829
|
+
|
|
830
|
+
**多账号切换场景(如测试不同角色权限):**
|
|
831
|
+
- 首次登录时一次性询问:"本次测试需要用到哪些账号?",用户可多选
|
|
832
|
+
- 后续切换直接用缓存的选择,不再重复询问
|
|
833
|
+
- 构造 testCase 时,用 `dataset` 数组参数化不同账号:
|
|
834
|
+
```json
|
|
835
|
+
{
|
|
836
|
+
"title": "多账号权限测试",
|
|
837
|
+
"dataset": [
|
|
838
|
+
{ "user": "admin", "pwd": "Jx@shSmt01#" },
|
|
839
|
+
{ "user": "18718576547", "pwd": "Jx@shSmt01#" }
|
|
840
|
+
],
|
|
841
|
+
"steps": [
|
|
842
|
+
{ "type": "cdpScript", "script": "logout" },
|
|
843
|
+
{ "type": "cdpScript", "script": "login", "params": { "username": "{{user}}", "password": "{{pwd}}" } },
|
|
844
|
+
{ "type": "act", "click": "text=提交", "waitAfterEach": 3000 }
|
|
845
|
+
]
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
### 技术栈(纯 Node.js,零原生编译)
|
|
850
|
+
|
|
851
|
+
| 模块 | 功能 | 依赖 |
|
|
852
|
+
|------|------|------|
|
|
853
|
+
| `lib/cdp-browser-config.js` | 通过 wmic + CDP 自动获取 `--user-data-dir` | Node.js 内置 |
|
|
854
|
+
| `lib/decrypt-passwords.js` | DPAPI 解密 AES 密钥 + AES-256-GCM 解密密码 | `sql.js`(共享缓存)+ `crypto` + PowerShell |
|
|
855
|
+
| `decrypt-creds.mjs` | CLI 入口:检测配置 → 解密 → 输出 | 以上两者 |
|
|
856
|
+
|
|
857
|
+
### 兼容性说明
|
|
858
|
+
|
|
859
|
+
- ✅ **v10 格式**(开发/测试环境 Edge/Chrome):完全支持
|
|
860
|
+
- ❌ **v20 格式**(生产环境,App-Bound Encryption):需 Python `pywin32` + `cryptography` 方案
|
|
861
|
+
- ⚠️ Windows 专属(DPAPI),macOS/Linux 需 Keychain/KWallet 方案
|
|
862
|
+
|
|
723
863
|
## 反模式备忘(来自 6/17 + 6/18 实战反馈)
|
|
724
864
|
|
|
725
865
|
| 错误做法 | 正确做法 | 原因 |
|
|
@@ -734,6 +874,76 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
|
|
|
734
874
|
| 每个 Select 后忘记 `closeAllDropdowns` | `act` 自动检测上一次 antSelect/antTreeSelect 并预清理 | 下拉浮层残留会污染下一次 Select 操作,选项串选 |
|
|
735
875
|
| 硬编码 `sleep(15*200)` 等选项渲染 | `antSelect` 内置 `pollEval` 15×200ms 轮询,`expect(poll)` 可自定义条件 | 选项渲染时间受网络/数据量影响,固定 sleep 时机不可靠 |
|
|
736
876
|
| 表单校验用 ad-hoc evaluate 检查 | 用 `expect validation.toPass` 或 `formFill` 的 `validateAfter` | 语义断言自带 poll + 结构化错误信息(field + message),可直接进报告 |
|
|
877
|
+
| 登录/登出写多步 antInput + act | 用 `cdpScript` 一次搞定:`{ "type": "cdpScript", "script": "login", "params": {...} }` | 已封装为内置脚本,自动处理 appId/viewId 参数保留和 cookie 清理 |
|
|
878
|
+
| 登出后硬编码裸 URL 导航 | 从主 frame 提取 `appId`/`viewId` 构建完整登录 URL | 裸 URL 不会自动补充 SSO 参数,导致后续登录失败 |
|
|
879
|
+
| CDP captureApi 请求数为 0 | 关闭 DevTools 后再用 `Network.enable` 监听 | DevTools 的 Network 面板会抢占 CDP Network 域,导致 captureApi 捕获不到请求 |
|
|
880
|
+
|
|
881
|
+
## 🛡️ 测试健壮性三层架构(L1/L2/L3)
|
|
882
|
+
|
|
883
|
+
每次 testCase 执行都会自动运行三层监控与恢复,无需额外配置:
|
|
884
|
+
|
|
885
|
+
```
|
|
886
|
+
testCase 执行
|
|
887
|
+
├── L1 自动监控(每步内置)
|
|
888
|
+
│ ├── Runtime.exceptionThrown → browserErrors[]
|
|
889
|
+
│ ├── console.error → consoleErrors[]
|
|
890
|
+
│ └── Network.response ≥400 → failedRequests[]
|
|
891
|
+
│
|
|
892
|
+
├── L2 智能诊断(失败时自动生成)
|
|
893
|
+
│ ├── 失败步骤定位(哪个 step/dataRow 失败)
|
|
894
|
+
│ ├── 网络错误归类(401/403/5xx 统计)
|
|
895
|
+
│ └── 推断建议(session 过期/验证码/服务器异常)
|
|
896
|
+
│
|
|
897
|
+
└── L3 自动修复(条件触发)
|
|
898
|
+
├── 登录失败 → 刷新页面 + 重新登录(maxRetries 次)
|
|
899
|
+
├── 元素未找到 → 等待渲染 + 重试
|
|
900
|
+
└── 网络错误 → 页面重载 + 重试
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
### testCase 新增选项
|
|
904
|
+
|
|
905
|
+
```json
|
|
906
|
+
{
|
|
907
|
+
"title": "测试",
|
|
908
|
+
"options": {
|
|
909
|
+
"autoRecover": true, // 开启自动恢复(默认 true)
|
|
910
|
+
"autoRecoverRetries": 2 // 最大恢复重试次数(默认 2)
|
|
911
|
+
},
|
|
912
|
+
"steps": [...]
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
### testCase 返回增强
|
|
917
|
+
|
|
918
|
+
```json
|
|
919
|
+
{
|
|
920
|
+
"passedSteps": 8, // 最终通过数
|
|
921
|
+
"failedSteps": 0, // 最终失败数
|
|
922
|
+
"browserErrors": [...], // L1: JS 异常详情
|
|
923
|
+
"failedRequests": [...], // L1: 4xx/5xx 请求列表
|
|
924
|
+
"consoleErrors": [...], // L1: console.error 消息
|
|
925
|
+
"diagnosticHint": { // L2: 诊断结果(仅失败时)
|
|
926
|
+
"failedAt": [...],
|
|
927
|
+
"suggestions": ["建议刷新登录页重试..."],
|
|
928
|
+
"summary": "1 个步骤失败, 0 个请求异常"
|
|
929
|
+
},
|
|
930
|
+
"autoRecovery": { // L3: 恢复结果(仅触发时)
|
|
931
|
+
"strategies": [{"type": "login", "result": "success"}],
|
|
932
|
+
"recovered": true,
|
|
933
|
+
"retries": 2
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
### cdpScript login 增强
|
|
939
|
+
|
|
940
|
+
- `params.username` 必填,`params.password` 可选(自动从 `engine._credentials` 查找)
|
|
941
|
+
- 内置验证码过期重试:失败时自动刷新页面重新填表(`params.maxRetries` 默认 3)
|
|
942
|
+
- 自动处理 appId/viewId URL 参数
|
|
943
|
+
|
|
944
|
+
```json
|
|
945
|
+
{ "type": "cdpScript", "script": "login", "params": { "username": "admin" } }
|
|
946
|
+
```
|
|
737
947
|
|
|
738
948
|
## Ant Design 适配层(6/22 反馈落地)
|
|
739
949
|
|
|
@@ -799,18 +1009,20 @@ node local-browser-executor.js --action test-case --test-case-file /tmp/case.jso
|
|
|
799
1009
|
|------|---------|---------|------|---------|
|
|
800
1010
|
| 1 | evaluate | 浏览器端 | DOM 查询/操作 | 简单检查、点击元素 |
|
|
801
1011
|
| 2 | rawScript | Node.js 端 | 完整 puppeteer API | 跨 frame、等待、多标签页 |
|
|
802
|
-
| 3 | cdpScript | Node.js 端 |
|
|
803
|
-
| 4 | 内置 cdpScript | Node.js 端 | 预定义通用 UI 交互 |
|
|
1012
|
+
| 3 | cdpScript | Node.js 端 | 调用内置或用户注册脚本 | 登录/登出/切换账号/抽屉/弹窗等通用交互 |
|
|
1013
|
+
| 4 | 内置 cdpScript | Node.js 端 | 预定义通用 UI 交互 + 凭据管理 | login/logout/getCredentials/drawer-action/modal-action/switch-tab/wait-api/get-table-data |
|
|
804
1014
|
|
|
805
1015
|
**evaluate**:浏览器端执行 JS,只能操作 DOM,不能等待/跨 frame/调用 puppeteer
|
|
806
1016
|
**rawScript**:Node.js 端执行 async 函数,接收 `(page, frame, params, browser)`,可调用所有 puppeteer API
|
|
807
1017
|
**registerCdpScript**:注册可复用脚本,后续通过 cdpScript 步骤按 name 调用
|
|
808
|
-
**cdpScript
|
|
1018
|
+
**cdpScript**:调用内置脚本或用户注册脚本。内置脚本包括登录(支持自动查凭据+验证码重试)、登出(自动保留 appId/viewId)、凭据列表获取等
|
|
809
1019
|
|
|
810
1020
|
### 内置 cdpScript(通用 UI 交互模式)
|
|
811
1021
|
|
|
812
1022
|
| script | 功能 | params |
|
|
813
1023
|
|--------|------|--------|
|
|
1024
|
+
| `login` | **自动登录**(填表+提交) | `username`, `password`, `captcha?`(默认6), `loginUrl?` |
|
|
1025
|
+
| `logout` | **登出**(清 cookie+跳转登录页) | `appUrl?`(默认/szc-ai-data/) |
|
|
814
1026
|
| `drawer-action` | Drawer 操作 | `action: 'search'\|'select'\|'confirm'\|'cancel'`, `radioIndex?`, `searchText?`, `searchPlaceholder?` |
|
|
815
1027
|
| `modal-action` | Modal/Popconfirm 操作 | `action: 'confirm'\|'cancel'`, `buttonText?` |
|
|
816
1028
|
| `switch-tab` | 多标签页切换 | `urlContains?`, `titleIncludes?`, `pageIndex?` |
|