@szc-ft/mcp-szcd-client 0.29.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.
Files changed (23) hide show
  1. package/opencode-extension/skills/local-browser-test/SKILL.md +253 -8
  2. package/opencode-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
  3. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
  4. package/opencode-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
  5. package/opencode-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
  6. package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
  7. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +50 -3
  8. package/package.json +1 -1
  9. package/qwen-extension/qwen-extension.json +1 -1
  10. package/qwen-extension/skills/local-browser-test/SKILL.md +253 -8
  11. package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
  12. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
  13. package/qwen-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
  14. package/qwen-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
  15. package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
  16. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +50 -3
  17. package/standard-skill/local-browser-test/SKILL.md +253 -8
  18. package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
  19. package/standard-skill/local-browser-test/lib/browser-engine.js +701 -17
  20. package/standard-skill/local-browser-test/lib/cdp-browser-config.js +115 -0
  21. package/standard-skill/local-browser-test/lib/decrypt-passwords.js +210 -0
  22. package/standard-skill/local-browser-test/lib/shared-deps.js +2 -2
  23. package/standard-skill/local-browser-test/local-browser-executor.js +50 -3
@@ -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"];
@@ -8,11 +8,12 @@
8
8
  * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
9
  * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
10
  * node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
11
+ * node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
11
12
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
13
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
14
  *
14
15
  * 参数:
15
- * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
16
17
  * --plan 测试计划 JSON 字符串
17
18
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
19
  * --output 结果输出文件路径(默认 stdout)
@@ -228,6 +229,15 @@ function parseArgs() {
228
229
  case "--test-report":
229
230
  parsed.testReport = args[++i];
230
231
  break;
232
+ case "--expression":
233
+ parsed.expression = args[++i];
234
+ break;
235
+ case "--expect-result-contains":
236
+ parsed.expectResultContains = args[++i];
237
+ break;
238
+ case "--fail-on-falsy":
239
+ parsed.failOnFalsy = true;
240
+ break;
231
241
  case "--help":
232
242
  case "-h":
233
243
  printHelp();
@@ -249,11 +259,12 @@ function printHelp() {
249
259
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
260
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
261
  node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
262
+ node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
252
263
  node local-browser-executor.js --plan '<JSON>' [options]
253
264
  node local-browser-executor.js --plan-file <path> [options]
254
265
 
255
266
  选项:
256
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
267
+ --action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
257
268
  --plan <json> 测试计划 JSON 字符串
258
269
  --plan-file <path> 测试计划 JSON 文件路径
259
270
  --output <path> 结果输出文件路径(默认 stdout)
@@ -274,6 +285,9 @@ function printHelp() {
274
285
  --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
286
  --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
287
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
288
+ --expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
289
+ --expect-result-contains <text> evaluate 时期望结果包含的文本
290
+ --fail-on-falsy evaluate 时结果为 falsy 则标记失败
277
291
  --url-contains-assert <text> assert 当前 URL 包含文本
278
292
  --text-contains <text> assert 页面文本包含内容
279
293
  --expect-visible assert selector 可见
@@ -493,6 +507,34 @@ async function executeAction(args) {
493
507
  });
494
508
  return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
509
  }
510
+ case "evaluate": {
511
+ // 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
512
+ const resolved = await engine._resolveObserveFrame({
513
+ frameUrlContains: args.frameUrlContains,
514
+ frameContentContains: args.frameContentContains,
515
+ frameIndex: args.frameIndex,
516
+ });
517
+ engine._activeFrame = resolved.frame;
518
+ const evalResult = await engine.safeEval(args.expression);
519
+ const evalPassed = args.failOnFalsy ? !!evalResult : true;
520
+ const containsMatch = args.expectResultContains
521
+ ? JSON.stringify(evalResult).includes(args.expectResultContains)
522
+ : true;
523
+ return {
524
+ type: "evaluate",
525
+ expression: args.expression,
526
+ result: evalResult,
527
+ passed: evalPassed && containsMatch,
528
+ frameTarget: resolved.frameId,
529
+ checks: {
530
+ failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
531
+ expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
532
+ },
533
+ browserInfo: initResult,
534
+ startTime: startedAt,
535
+ endTime: new Date().toISOString(),
536
+ };
537
+ }
496
538
  case "assert": {
497
539
  const expect = {};
498
540
  if (args.expectVisible) expect.visible = true;
@@ -509,6 +551,11 @@ async function executeAction(args) {
509
551
  });
510
552
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
553
  }
554
+ case "plan": {
555
+ const plan = loadPlan(args);
556
+ const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
557
+ return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
558
+ }
512
559
  case "api-capture":
513
560
  case "apiCapture": {
514
561
  const result = await engine.captureApi({
@@ -628,7 +675,7 @@ async function executeAction(args) {
628
675
  return {
629
676
  error: `未知 action: ${args.action}`,
630
677
  errorCode: "UNKNOWN_ACTION",
631
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
678
+ supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
632
679
  startTime: startedAt,
633
680
  endTime: new Date().toISOString(),
634
681
  };