@dsh-fly/dsh-plugin-fly-mode 0.1.8 → 0.1.10
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/README.md +3 -3
- package/cordis.patch.yml +0 -1
- package/package.json +2 -2
- package/tools/dsh-plugin-fly-artifact/index.js +21 -2
- package/tools/dsh-plugin-fly-binary/index.js +110 -22
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +3 -0
- package/tools/dsh-plugin-fly-blackbox/index.js +490 -5
- package/tools/dsh-plugin-fly-diag/index.js +177 -28
- package/tools/dsh-plugin-fly-pwn/index.js +91 -9
- package/tools/dsh-plugin-fly-recon/index.js +117 -16
- package/tools/dsh-plugin-fly-reverse/index.js +132 -18
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* · **知识层**:一个 bundled skill(`assets/client-blackbox/`),按需加载。agent 读到的是
|
|
8
8
|
* 心智模型(三源一闸 S1/S2/S3/G)、六条判据 T1–T6、五阶段带 Gate 流程与可复用脚本模板。
|
|
9
9
|
* 知识本身不产生副作用,所以不受授权闸门管。
|
|
10
|
-
* ·
|
|
10
|
+
* · **执行层**:十个工具,走宿主 `shell` 接缝(最近三件 `bx_cdp_targets` / `bx_cdp_scripts` /
|
|
11
|
+
* `bx_cdp_coverage` 是**进程内通道**:CDP 调试端口上的 target 清单、已解析脚本清单、精确覆盖率 ——
|
|
12
|
+
* 线缆层看不见"进程里显示了什么、哪段 JS 改的它",它们补这一半;只连回环、整件在授权门后)。它只管**可观测性与判定**(起代理、判管道、
|
|
11
13
|
* 读抓包、比对前后),改写路径(请求/响应注入)必须显式授权。
|
|
12
14
|
*
|
|
13
15
|
* 三条纪律写死在实现里,因为每一条都对应一次实测事故:
|
|
@@ -306,6 +308,19 @@ function assertNotPrivileged(command) {
|
|
|
306
308
|
* 这一点(本仓既有五个插件的 `runCmd` 正是这个写法;门禁桩现在按真接缝的形状发布
|
|
307
309
|
* `result()`,所以这类"桩和真接缝不同形"的假绿会在这一层被抓住)。
|
|
308
310
|
*/
|
|
311
|
+
/**
|
|
312
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
313
|
+
*
|
|
314
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
315
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
316
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回。加 `./` 之后它就是一个普通相对路径:
|
|
317
|
+
* 不存在就正常报错,存在就是那个文件。
|
|
318
|
+
*/
|
|
319
|
+
function safePath(value) {
|
|
320
|
+
const text = String(value ?? '')
|
|
321
|
+
return text.startsWith('-') ? './' + text : text
|
|
322
|
+
}
|
|
323
|
+
|
|
309
324
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
310
325
|
const command = typeof argv === 'string' ? argv : argv.map(shq).join(' ')
|
|
311
326
|
assertNotPrivileged(command)
|
|
@@ -420,6 +435,286 @@ function workspaceRoot(exec) {
|
|
|
420
435
|
* 读一行文本,走接缝。用于**用户数据**(dump、规则文件)—— 这样沙箱拒绝会以
|
|
421
436
|
* `SandboxError` 报出。允许 fs 的两处例外(自带资产 / 自身状态文件)见 §3.1。
|
|
422
437
|
*/
|
|
438
|
+
// ── CDP(进程内通道)───────────────────────────────────────────────────────
|
|
439
|
+
//
|
|
440
|
+
// 为什么加在 blackbox 而不是新开一个包(裁决 2026-10-15):线缆层(mitm 抓包)看得见
|
|
441
|
+
// "谁跟谁说了什么",看不见"进程里到底显示了什么、哪段 JS 改的它";Electron / Chromium 内核
|
|
442
|
+
// 宿主这两层都开着,所以 CDP 是同一个使命的另一条通道。
|
|
443
|
+
//
|
|
444
|
+
// 两条结构性纪律:
|
|
445
|
+
// · **只走回环**:URL 由端口拼出来,参数表里**没有 host** ⇒ 结构上不可能指向别的主机;
|
|
446
|
+
// · **端口只认参数**:状态文件里的 `port` 是 mitm **代理**端口,拿它当调试端口是类别错误。
|
|
447
|
+
|
|
448
|
+
/** 一次列多少条 target(超出的报 omitted,不静默丢)。 */
|
|
449
|
+
const CDP_TARGETS_SHOWN = 50
|
|
450
|
+
|
|
451
|
+
/** CDP 调试端口:只认显式参数。 */
|
|
452
|
+
function requireCdpPort(value) {
|
|
453
|
+
const port = Number(value)
|
|
454
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
455
|
+
const error = new Error(
|
|
456
|
+
`CDP 端口必须是 1..65535 的整数(收到 ${JSON.stringify(value)})。`
|
|
457
|
+
+ '这一项**只认参数**:.fly-blackbox 状态里的 port 是 mitm 代理端口,不是调试端口。',
|
|
458
|
+
)
|
|
459
|
+
error.name = 'ArgumentError'
|
|
460
|
+
throw error
|
|
461
|
+
}
|
|
462
|
+
return port
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 只走回环:URL 由端口拼出来(没有 host 参数,所以不可能指向别处)。 */
|
|
466
|
+
function cdpListUrl(port) {
|
|
467
|
+
return `http://127.0.0.1:${port}/json/list`
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 连不上时的**大声**文案:是什么 + 为什么只可能是这几种 + 下一步(怎么起目标)。 */
|
|
471
|
+
function cdpUnreachableError(port, run) {
|
|
472
|
+
const detail = (run.stderr ?? '').trim().split('\n').filter(Boolean)[0] ?? ''
|
|
473
|
+
const error = new Error([
|
|
474
|
+
`连不上 CDP 调试端口 127.0.0.1:${port}:${detail === '' ? `curl 退出码 ${run.exitCode}` : detail}`,
|
|
475
|
+
'—— 这一项**只连回环**,所以只剩三种可能:那个进程没在监听这个端口 / 端口号给错了 / 目标没带调试开关起来。',
|
|
476
|
+
'怎么起一个带调试端口的目标(端口号自己换一个没人用的):',
|
|
477
|
+
` · Chromium 内核浏览器:open -a "Google Chrome" --args --remote-debugging-port=${port} --user-data-dir=/tmp/cdp-${port}`,
|
|
478
|
+
` · Electron 应用:<App 可执行文件> --remote-debugging-port=${port}`,
|
|
479
|
+
'顺带一句:调试端口开着就等于把那个界面完全交出去(能读 DOM、也能改它),用完关掉那个实例。',
|
|
480
|
+
].join('\n'))
|
|
481
|
+
error.name = 'CommandError'
|
|
482
|
+
return error
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** `/json/list` 的返回体:不是 JSON / 不是数组,都要说清"是那端口不对,不是没有 target"。 */
|
|
486
|
+
function parseCdpTargets(text, port) {
|
|
487
|
+
let parsed
|
|
488
|
+
try {
|
|
489
|
+
parsed = JSON.parse(text)
|
|
490
|
+
} catch (error) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`127.0.0.1:${port}/json/list 返回的不是 JSON —— 这**不是**"没有 target",`
|
|
493
|
+
+ `是那个端口上不是调试接口(${error.message})。返回体头 120 字:${JSON.stringify(text.slice(0, 120))}`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(parsed)) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`127.0.0.1:${port}/json/list 的返回不是数组(CDP 的约定是数组)—— 实际是 ${typeof parsed}:`
|
|
499
|
+
+ '这**不是**"没有 target",是那个端口上不是调试接口。',
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
return parsed.filter(item => item !== null && typeof item === 'object' && !Array.isArray(item))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 取一次 target 清单(走接缝的 curl,所以有审计、沙箱拒绝会照常浮出)。 */
|
|
506
|
+
async function fetchCdpTargets(shell, exec, port) {
|
|
507
|
+
const run = await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
508
|
+
if (run.exitCode !== 0) return { ok: false, run }
|
|
509
|
+
return { ok: true, targets: parseCdpTargets(run.stdout, port) }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 从清单里挑一个**可附加**的 target:给了 id 就按 id,否则优先第一个 page。 */
|
|
513
|
+
function pickCdpTarget(targets, wanted, port) {
|
|
514
|
+
const attachable = targets.filter(item => typeof item.webSocketDebuggerUrl === 'string' && item.webSocketDebuggerUrl !== '')
|
|
515
|
+
const wantedId = wanted === undefined || wanted === null ? '' : String(wanted).trim()
|
|
516
|
+
if (wantedId !== '') {
|
|
517
|
+
const found = attachable.find(item => item.id === wantedId)
|
|
518
|
+
if (found === undefined) {
|
|
519
|
+
throw new Error(`127.0.0.1:${port} 上没有 id=${wantedId} 的可附加 target(可附加的有:${attachable.map(item => `${item.id}(${item.type})`).join(', ') || '无'})`)
|
|
520
|
+
}
|
|
521
|
+
return found
|
|
522
|
+
}
|
|
523
|
+
const page = attachable.find(item => item.type === 'page')
|
|
524
|
+
if (page !== undefined) return page
|
|
525
|
+
if (attachable.length > 0) return attachable[0]
|
|
526
|
+
throw new Error(`127.0.0.1:${port} 上没有可附加的 target(清单里 ${targets.length} 条,但都没有 webSocketDebuggerUrl)—— 这不等于"这个进程没有页面",只是没有可附加的调试目标。`)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 采样窗口:夹在合理区间里(给 0 或负值等于"什么都不收",那就不是测量了)。 */
|
|
530
|
+
function clampWindow(value, fallback, lo, hi) {
|
|
531
|
+
if (value === undefined || value === null) return fallback
|
|
532
|
+
const window = Number(value)
|
|
533
|
+
if (!Number.isFinite(window)) return fallback
|
|
534
|
+
return Math.min(hi, Math.max(lo, Math.round(window)))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 把一组 offset 区间**求并集**:V8 的函数区间会嵌套,直接相加会重复计数。
|
|
539
|
+
* (这是 bx_cdp_coverage 的口径;尺子反过来也专抓"直接相加"那种错。)
|
|
540
|
+
*/
|
|
541
|
+
function mergeRanges(ranges) {
|
|
542
|
+
const sorted = ranges.slice().sort((a, b) => a.startOffset - b.startOffset)
|
|
543
|
+
const merged = []
|
|
544
|
+
for (const range of sorted) {
|
|
545
|
+
const last = merged.at(-1)
|
|
546
|
+
if (last !== undefined && range.startOffset <= last.endOffset) {
|
|
547
|
+
last.endOffset = Math.max(last.endOffset, range.endOffset)
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
merged.push({ startOffset: range.startOffset, endOffset: range.endOffset })
|
|
551
|
+
}
|
|
552
|
+
return merged
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** `Profiler.takePreciseCoverage` 的返回 → 逐脚本"跑过多少"(区间求并集后统计)。 */
|
|
556
|
+
function summarizeCoverage(scripts) {
|
|
557
|
+
const rows = []
|
|
558
|
+
let usedTotal = 0
|
|
559
|
+
let sizeTotal = 0
|
|
560
|
+
for (const script of scripts) {
|
|
561
|
+
const functions = Array.isArray(script.functions) ? script.functions : []
|
|
562
|
+
const rangesOf = entry => (Array.isArray(entry.ranges) ? entry.ranges : [])
|
|
563
|
+
const executed = functions.flatMap(entry => rangesOf(entry)
|
|
564
|
+
.filter(range => (range.count ?? 0) > 0)
|
|
565
|
+
.map(range => ({ startOffset: range.startOffset, endOffset: range.endOffset })))
|
|
566
|
+
const ends = functions.flatMap(entry => rangesOf(entry).map(range => range.endOffset))
|
|
567
|
+
const size = ends.length === 0 ? 0 : Math.max(...ends)
|
|
568
|
+
const used = mergeRanges(executed).reduce((sum, range) => sum + (range.endOffset - range.startOffset), 0)
|
|
569
|
+
usedTotal += used
|
|
570
|
+
sizeTotal += size
|
|
571
|
+
rows.push({
|
|
572
|
+
scriptId: script.scriptId,
|
|
573
|
+
url: typeof script.url === 'string' && script.url !== '' ? script.url : '(inline/eval)',
|
|
574
|
+
functions: functions.length,
|
|
575
|
+
executedFunctions: functions.filter(entry => rangesOf(entry).some(range => (range.count ?? 0) > 0)).length,
|
|
576
|
+
usedBytes: used,
|
|
577
|
+
sizeBytes: size,
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
return { rows, usedTotal, sizeTotal }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** 取 target → 挑 target → 连上去跑一段会话;连不上就给两条信号合起来的判词。 */
|
|
584
|
+
async function cdpAttach(transport, shell, exec, { port, target, commands, finish, windowMs }) {
|
|
585
|
+
const fetched = await fetchCdpTargets(shell, exec, port)
|
|
586
|
+
if (!fetched.ok) throw cdpUnreachableError(port, fetched.run)
|
|
587
|
+
const picked = pickCdpTarget(fetched.targets, target, port)
|
|
588
|
+
try {
|
|
589
|
+
const session = await transport.session(picked.webSocketDebuggerUrl, { commands, finish, windowMs })
|
|
590
|
+
return { session, picked }
|
|
591
|
+
} catch (error) {
|
|
592
|
+
throw new Error(await cdpUnreachableDetail(shell, exec, port, error, { httpKnownOk: true }))
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** 真传输:Node 全局 WebSocket(22+,零依赖)。 */
|
|
597
|
+
function realCdpTransport() {
|
|
598
|
+
return {
|
|
599
|
+
/**
|
|
600
|
+
* 连上去 → 发 commands → 收 windowMs 事件 → 发 finish → 等它们的响应 → 关掉。
|
|
601
|
+
*
|
|
602
|
+
* 实测的三种失败**在客户端长得一模一样**(error.message 为空、close code 1006、没有 reason),
|
|
603
|
+
* 所以这里只如实记下 closeCode/reason,**不猜**是哪一种失败 —— 分辨交给调用方(它会另跑一次
|
|
604
|
+
* /json/list 探针)。
|
|
605
|
+
*/
|
|
606
|
+
session(wsUrl, { commands = [], finish = [], windowMs = 1200, finishTimeoutMs = 3000 } = {}) {
|
|
607
|
+
return new Promise((resolve, reject) => {
|
|
608
|
+
let ws
|
|
609
|
+
try {
|
|
610
|
+
ws = new WebSocket(wsUrl)
|
|
611
|
+
} catch (error) {
|
|
612
|
+
reject(error)
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
const events = []
|
|
616
|
+
const results = {}
|
|
617
|
+
const wanted = new Map()
|
|
618
|
+
let opened = false
|
|
619
|
+
let settled = false
|
|
620
|
+
let windowTimer
|
|
621
|
+
let finishTimer
|
|
622
|
+
const settle = (payload, error) => {
|
|
623
|
+
if (settled) return
|
|
624
|
+
settled = true
|
|
625
|
+
clearTimeout(windowTimer)
|
|
626
|
+
clearTimeout(finishTimer)
|
|
627
|
+
try { ws.close() } catch { /* 关不掉就算了,下面已经把结果交出去了 */ }
|
|
628
|
+
if (error !== undefined) reject(error)
|
|
629
|
+
else resolve(payload)
|
|
630
|
+
}
|
|
631
|
+
const send = (spec, id) => {
|
|
632
|
+
wanted.set(id, spec.key ?? spec.method)
|
|
633
|
+
ws.send(JSON.stringify({ id, method: spec.method, ...spec.params === undefined ? {} : { params: spec.params } }))
|
|
634
|
+
}
|
|
635
|
+
ws.onopen = () => {
|
|
636
|
+
opened = true
|
|
637
|
+
let id = 0
|
|
638
|
+
for (const spec of commands) { id += 1; send(spec, id) }
|
|
639
|
+
windowTimer = setTimeout(() => {
|
|
640
|
+
let pending = 0
|
|
641
|
+
for (const spec of finish) { id += 1; pending += 1; send(spec, id) }
|
|
642
|
+
if (pending === 0) { settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' }); return }
|
|
643
|
+
finishTimer = setTimeout(() => settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' }), finishTimeoutMs)
|
|
644
|
+
}, windowMs)
|
|
645
|
+
}
|
|
646
|
+
ws.onmessage = (message) => {
|
|
647
|
+
let parsed
|
|
648
|
+
try {
|
|
649
|
+
parsed = JSON.parse(String(message.data))
|
|
650
|
+
} catch {
|
|
651
|
+
return // 不认识的帧就当没看见(不假装解析过)
|
|
652
|
+
}
|
|
653
|
+
if (typeof parsed.method === 'string') {
|
|
654
|
+
events.push(parsed)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
if (typeof parsed.id === 'number' && wanted.has(parsed.id)) {
|
|
658
|
+
results[wanted.get(parsed.id)] = parsed.error === undefined ? { result: parsed.result } : { error: parsed.error }
|
|
659
|
+
if (finishTimer !== undefined && [...wanted.values()].every(key => Object.hasOwn(results, key))) {
|
|
660
|
+
settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' })
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
ws.onerror = () => {
|
|
665
|
+
// 实测:message 是空串 ⇒ 这里什么都不记,别编原因(closeCode 由 onclose 给)
|
|
666
|
+
}
|
|
667
|
+
ws.onclose = (event) => {
|
|
668
|
+
const closeCode = event.code
|
|
669
|
+
const closeReason = event.reason ?? ''
|
|
670
|
+
if (opened) {
|
|
671
|
+
settle({ events, results, closedEarly: true, closeCode, closeReason })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const error = new Error(`WebSocket 没连上 ${wsUrl}(close code ${closeCode}${closeReason === '' ? '' : ` · ${closeReason}`})`)
|
|
675
|
+
error.name = 'CdpOpenError'
|
|
676
|
+
error.closeCode = closeCode
|
|
677
|
+
error.closeReason = closeReason
|
|
678
|
+
settle(undefined, error)
|
|
679
|
+
}
|
|
680
|
+
})
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 连不上时把两种信号合起来判:WebSocket 侧(只有 closeCode)+ /json/list 探针(HTTP 侧通不通)。
|
|
687
|
+
* **不猜**是哪一种 —— 实测三种失败在客户端同形(见 realCdpTransport 的注释)。
|
|
688
|
+
*/
|
|
689
|
+
async function cdpUnreachableDetail(shell, exec, port, error, { httpKnownOk = false } = {}) {
|
|
690
|
+
// 注意别拿"假的探针结果"当哨兵:一开始这里用 `{ exitCode: 1 }` 表示"HTTP 侧已知是活的",
|
|
691
|
+
// 于是它和自己的失败分支**同形**,被读成"两条路都不通"(测试当场抓到)。现在用**独立布尔**。
|
|
692
|
+
const probe = httpKnownOk
|
|
693
|
+
? undefined
|
|
694
|
+
: await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
695
|
+
const httpOk = httpKnownOk || probe.exitCode === 0
|
|
696
|
+
const wsShape = `WebSocket 侧:close code ${error?.closeCode ?? '(无)'}${(error?.closeReason ?? '') === '' ? '、reason 空' : `、reason ${error.closeReason}`}`
|
|
697
|
+
if (!httpOk) {
|
|
698
|
+
const detail = (probe.stderr ?? '').trim().split('\n').filter(Boolean)[0] ?? ''
|
|
699
|
+
return [
|
|
700
|
+
`连不上 CDP 调试端口 127.0.0.1:${port} —— HTTP 探针(/json/list)也失败:${detail === '' ? `curl 退出码 ${probe.exitCode}` : detail}`,
|
|
701
|
+
`${wsShape}。两条路都不通 ⇒ 最可能是那个进程没在监听这个端口。`,
|
|
702
|
+
'怎么起一个带调试端口的目标(端口号自己换一个没人用的):',
|
|
703
|
+
` · Chromium 内核浏览器:open -a "Google Chrome" --args --remote-debugging-port=${port} --user-data-dir=/tmp/cdp-${port}`,
|
|
704
|
+
` · Electron 应用:<App 可执行文件> --remote-debugging-port=${port}`,
|
|
705
|
+
].join('\n')
|
|
706
|
+
}
|
|
707
|
+
return [
|
|
708
|
+
`127.0.0.1:${port} 的 HTTP 侧是**活的**(/json/list 正常返回),但 WebSocket 握手没成 —— ${wsShape}`,
|
|
709
|
+
'(实测:Node 自带的 WebSocket 把"没人听""握手被拒""握上就断"报成**同一个形状** —— error.message 为空、close 1006;'
|
|
710
|
+
+ '所以这里不猜是哪一种,只给两条分辨路。)',
|
|
711
|
+
'常见原因:Chrome 会拒绝 Host 头不是 localhost/IP 的握手;那个调试端口已被另一个工具占着(同一时间只允许一个客户端);目标刚退出。',
|
|
712
|
+
'另外:如果你要的是"看页面里有什么、跑了哪些代码",用同样在这个包里的 bx_cdp_targets 先看一眼清单是否还在。',
|
|
713
|
+
].join('\n')
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
|
|
423
718
|
async function readTextFileVia(shell, exec, path, what, opts = {}) {
|
|
424
719
|
const run = await runCmd(shell, ['base64', '-i', path], exec, { stdoutMaxBytes: 64 * 1024 * 1024 })
|
|
425
720
|
if (missingTool(run)) throw new Error(`${what} 读不到:${path} —— 本机没有 base64(不应发生)`)
|
|
@@ -968,11 +1263,17 @@ export function readHarDocument(buffer, path) {
|
|
|
968
1263
|
try {
|
|
969
1264
|
parsed = JSON.parse(payload.toString('utf8').replace(/^\uFEFF/u, ''))
|
|
970
1265
|
} catch (error) {
|
|
971
|
-
|
|
1266
|
+
// Q6(2026-10-13):失败文案要对读它的模型可行动 —— what + why + **what to try next**,
|
|
1267
|
+
// 且不臆断具体原因(写错的「为什么」比没有更糟)。
|
|
1268
|
+
throw new Error(`${path} 不是合法 JSON${gzip ? '(已按 gzip magic 解压)' : ''}:${error.message}`
|
|
1269
|
+
+ ' —— 修法:确认这是 HAR 导出(浏览器 DevTools 的 Save all as HAR,或 mitmproxy 的硬拷贝转 HAR);'
|
|
1270
|
+
+ '只做普通 JSON 校验的话,先看它是不是别的格式。')
|
|
972
1271
|
}
|
|
973
1272
|
const entries = parsed?.log?.entries
|
|
974
1273
|
if (!Array.isArray(entries)) {
|
|
975
|
-
throw new Error(`${path} 不像 HAR:log.entries 不是数组(HAR 1.2 的条目都在 log.entries 下)。`
|
|
1274
|
+
throw new Error(`${path} 不像 HAR:log.entries 不是数组(HAR 1.2 的条目都在 log.entries 下)。`
|
|
1275
|
+
+ ' —— 修法:重新导出一份 HAR,或确认导出的是 HAR 而不是本仓自己的 mitmproxy dump'
|
|
1276
|
+
+ '(`.mitm` 那种用 bx_flows 读)。')
|
|
976
1277
|
}
|
|
977
1278
|
const creator = parsed.log.creator
|
|
978
1279
|
return {
|
|
@@ -1172,6 +1473,11 @@ const GATE_TABLE = {
|
|
|
1172
1473
|
bx_har: { actionless: true },
|
|
1173
1474
|
// 没有 action 参数,只读两份 JSON。
|
|
1174
1475
|
bx_diff: { actionless: true },
|
|
1476
|
+
// 进程内通道:**整件受控**(照 bx_doctor 的先例)—— 附加调试接口会读走那个进程的实时状态,
|
|
1477
|
+
// 后续的 Debugger/Profiler 还会在目标里打开仪表,属于"主动探测"那一档。
|
|
1478
|
+
bx_cdp_targets: { readOnly: [] },
|
|
1479
|
+
bx_cdp_scripts: { readOnly: [] },
|
|
1480
|
+
bx_cdp_coverage: { readOnly: [] },
|
|
1175
1481
|
}
|
|
1176
1482
|
|
|
1177
1483
|
function gateReason(tool, action) {
|
|
@@ -1696,7 +2002,7 @@ export function apply(ctx) {
|
|
|
1696
2002
|
const root = workspaceRoot(exec)
|
|
1697
2003
|
const state = readState(root)
|
|
1698
2004
|
const action = String(args.action ?? '')
|
|
1699
|
-
const dump = args.path ?? state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm')
|
|
2005
|
+
const dump = safePath(args.path ?? state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm'))
|
|
1700
2006
|
|
|
1701
2007
|
const size = await statSizeVia(shell, exec, dump, 'dump')
|
|
1702
2008
|
if (size > 32 * 1024 * 1024) {
|
|
@@ -1773,7 +2079,7 @@ export function apply(ctx) {
|
|
|
1773
2079
|
},
|
|
1774
2080
|
output: stringOutput,
|
|
1775
2081
|
async execute(args, exec) {
|
|
1776
|
-
const path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
2082
|
+
const path = safePath(typeof args.path === 'string' ? args.path.trim() : '')
|
|
1777
2083
|
if (path === '') throw new Error('bx_har 需要 path(.har 或 .har.gz 的路径)')
|
|
1778
2084
|
const groupBy = args.groupBy ?? 'host'
|
|
1779
2085
|
if (!HAR_GROUPINGS.includes(groupBy)) {
|
|
@@ -1876,4 +2182,183 @@ export function apply(ctx) {
|
|
|
1876
2182
|
return [`${lines.length} 处差异:`, ...lines.map(line => ` ${line}`)].join('\n')
|
|
1877
2183
|
},
|
|
1878
2184
|
}))
|
|
2185
|
+
// 传输是可注入的(`ctx.flyCdpTransport`)—— 桩就钉在这里:真接缝是 Node 全局 WebSocket,
|
|
2186
|
+
// 而实测它把"没人听 / 握手被拒 / 握上就断"报成**同一个形状**,所以桩必须复刻那个形状。
|
|
2187
|
+
const cdpTransport = () => (typeof ctx.flyCdpTransport?.session === 'function' ? ctx.flyCdpTransport : realCdpTransport())
|
|
2188
|
+
|
|
2189
|
+
// ── bx_cdp_targets(进程内通道第一件)───────────────────────
|
|
2190
|
+
ctx.tools.register(defineTool({
|
|
2191
|
+
name: 'bx_cdp_targets',
|
|
2192
|
+
description: 'List debuggable targets (pages/workers/iframes) in a Chromium/Electron process started with --remote-debugging-port, via GET 127.0.0.1:<port>/json/list. Loopback only; needs FLY_ALLOW_INTERCEPT=1.',
|
|
2193
|
+
parameters: {
|
|
2194
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2195
|
+
},
|
|
2196
|
+
output: stringOutput,
|
|
2197
|
+
async execute(args, exec) {
|
|
2198
|
+
const port = requireCdpPort(args.port)
|
|
2199
|
+
const run = await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
2200
|
+
if (run.exitCode !== 0) throw cdpUnreachableError(port, run)
|
|
2201
|
+
const targets = parseCdpTargets(run.stdout, port)
|
|
2202
|
+
const shown = targets.slice(0, CDP_TARGETS_SHOWN)
|
|
2203
|
+
return JSON.stringify({
|
|
2204
|
+
tool: 'bx_cdp_targets',
|
|
2205
|
+
target: `127.0.0.1:${port}`,
|
|
2206
|
+
operation: `GET ${cdpListUrl(port)}(只走回环;端口只认参数)`,
|
|
2207
|
+
observed: {
|
|
2208
|
+
count: targets.length,
|
|
2209
|
+
shown: shown.length,
|
|
2210
|
+
omitted: targets.length - shown.length,
|
|
2211
|
+
targets: shown.map(item => ({
|
|
2212
|
+
id: item.id,
|
|
2213
|
+
type: item.type,
|
|
2214
|
+
title: item.title,
|
|
2215
|
+
url: item.url,
|
|
2216
|
+
attachable: typeof item.webSocketDebuggerUrl === 'string' && item.webSocketDebuggerUrl !== '',
|
|
2217
|
+
})),
|
|
2218
|
+
},
|
|
2219
|
+
confidence: 'observed',
|
|
2220
|
+
limitations: [
|
|
2221
|
+
'这是**某一刻**的清单:target 会随页面开关、进程进退而变(attach 之后还会变)',
|
|
2222
|
+
'只列**开了调试端口的那一个进程** —— 本机别的 Electron / 浏览器没开端口就完全看不见,'
|
|
2223
|
+
+ '这**不是**"本机没有这类应用"',
|
|
2224
|
+
'只走回环(参数表里没有 host),所以它**不能**用来连远端设备 —— 那是另一件事',
|
|
2225
|
+
'`webSocketDebuggerUrl`(真正的控制通道)**故意不打印**,只给 attachable 真假;'
|
|
2226
|
+
+ '拿到它就能完全接管那个页面,需要附加上去的动作在后续工具里、同样在这道门后面',
|
|
2227
|
+
'`/json/list` 的字段随 Chromium 版本变(本工具只挑 id/type/title/url 四列 + attachable),'
|
|
2228
|
+
+ '少一列就是少一列,不当作失败',
|
|
2229
|
+
],
|
|
2230
|
+
}, null, 2)
|
|
2231
|
+
},
|
|
2232
|
+
}))
|
|
2233
|
+
// ── bx_cdp_scripts(进程内通道第二件)───────────────────────
|
|
2234
|
+
ctx.tools.register(defineTool({
|
|
2235
|
+
name: 'bx_cdp_scripts',
|
|
2236
|
+
description: 'Attach to a CDP target on a loopback debug port and inventory its parsed scripts (Debugger.enable + Debugger.scriptParsed over a short window). Replays already-parsed scripts; source text is NOT returned. Needs FLY_ALLOW_INTERCEPT=1.',
|
|
2237
|
+
parameters: {
|
|
2238
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2239
|
+
target: { type: 'string', description: 'target id from bx_cdp_targets; omit = first page target' },
|
|
2240
|
+
windowMs: { type: 'number', description: 'how long to collect scriptParsed (default 1200, clamped 200..5000)' },
|
|
2241
|
+
},
|
|
2242
|
+
output: stringOutput,
|
|
2243
|
+
async execute(args, exec) {
|
|
2244
|
+
const port = requireCdpPort(args.port)
|
|
2245
|
+
const windowMs = clampWindow(args.windowMs, 1200, 200, 5000)
|
|
2246
|
+
const { session, picked } = await cdpAttach(cdpTransport(), shell, exec, {
|
|
2247
|
+
port, target: args.target, windowMs, commands: [{ method: 'Debugger.enable' }],
|
|
2248
|
+
})
|
|
2249
|
+
const enable = session.results['Debugger.enable']
|
|
2250
|
+
if (enable?.error !== undefined) {
|
|
2251
|
+
throw new Error(`Debugger.enable 被目标拒绝(${enable.error.code ?? '?'} ${enable.error.message ?? ''})—— 这个 target 不允许附加调试域,换一个 target 或换一个实例。`)
|
|
2252
|
+
}
|
|
2253
|
+
const scripts = session.events.filter(event => event.method === 'Debugger.scriptParsed').map(event => event.params ?? {})
|
|
2254
|
+
const shown = scripts.slice(0, CDP_TARGETS_SHOWN)
|
|
2255
|
+
return JSON.stringify({
|
|
2256
|
+
tool: 'bx_cdp_scripts',
|
|
2257
|
+
target: `127.0.0.1:${port} · ${picked.id}(${picked.type})`,
|
|
2258
|
+
operation: `WebSocket 附加 → Debugger.enable → 收 ${windowMs}ms 的 Debugger.scriptParsed`,
|
|
2259
|
+
observed: {
|
|
2260
|
+
count: scripts.length,
|
|
2261
|
+
shown: shown.length,
|
|
2262
|
+
omitted: scripts.length - shown.length,
|
|
2263
|
+
closedEarly: session.closedEarly,
|
|
2264
|
+
closeCode: session.closeCode,
|
|
2265
|
+
scripts: shown.map(item => ({
|
|
2266
|
+
scriptId: item.scriptId,
|
|
2267
|
+
url: typeof item.url === 'string' && item.url !== '' ? item.url : '(inline/eval)',
|
|
2268
|
+
length: item.length,
|
|
2269
|
+
isModule: item.isModule,
|
|
2270
|
+
sourceMap: typeof item.sourceMapURL === 'string' && item.sourceMapURL !== '',
|
|
2271
|
+
hasSourceURL: item.hasSourceURL,
|
|
2272
|
+
})),
|
|
2273
|
+
},
|
|
2274
|
+
confidence: 'observed',
|
|
2275
|
+
limitations: [
|
|
2276
|
+
'这是**已经解析过**的脚本清单:`Debugger.enable` 会**回放**已解析的(实测挂上去立刻收到 7 条),'
|
|
2277
|
+
+ '它不是"将要解析什么"的预测',
|
|
2278
|
+
'看不见**还没解析的懒加载代码**(窗口内没加载就不出现)、**已被 GC 的脚本**、以及**别的 target**'
|
|
2279
|
+
+ '(worker / iframe 在清单里是独立条目,要各自附加)',
|
|
2280
|
+
'`url` 显示 `(inline/eval)` = 那段脚本没有 URL(内联或 eval);`length` 是**源文本字符数**,不是字节数',
|
|
2281
|
+
'**不返回源码**(要源码得再发 `Debugger.getScriptSource`,那是另一件事、另一个工具)',
|
|
2282
|
+
`只收了一个窗口(${windowMs}ms),窗口一结束就**断开连接** —— CDP 的域随连接走,断开即释放,目标上不留仪表`,
|
|
2283
|
+
session.closedEarly
|
|
2284
|
+
? `⚠️ 连接在窗口内断了(close code ${session.closeCode}):上面这份是**窗口内收到的部分**,不是完整清单`
|
|
2285
|
+
: '窗口内连接保持正常(没有提前断开)',
|
|
2286
|
+
],
|
|
2287
|
+
}, null, 2)
|
|
2288
|
+
},
|
|
2289
|
+
}))
|
|
2290
|
+
|
|
2291
|
+
// ── bx_cdp_coverage(进程内通道第三件)───────────────────────
|
|
2292
|
+
ctx.tools.register(defineTool({
|
|
2293
|
+
name: 'bx_cdp_coverage',
|
|
2294
|
+
description: 'Attach to a CDP target on a loopback debug port, measure precise JS coverage (Profiler.startPreciseCoverage + takePreciseCoverage) over a short window, and report executed-byte ratios per script. Fails loudly when the snapshot cannot be taken. Needs FLY_ALLOW_INTERCEPT=1.',
|
|
2295
|
+
parameters: {
|
|
2296
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2297
|
+
target: { type: 'string', description: 'target id from bx_cdp_targets; omit = first page target' },
|
|
2298
|
+
windowMs: { type: 'number', description: 'sampling window before the snapshot (default 1500, clamped 300..10000)' },
|
|
2299
|
+
},
|
|
2300
|
+
output: stringOutput,
|
|
2301
|
+
async execute(args, exec) {
|
|
2302
|
+
const port = requireCdpPort(args.port)
|
|
2303
|
+
const windowMs = clampWindow(args.windowMs, 1500, 300, 10000)
|
|
2304
|
+
const { session, picked } = await cdpAttach(cdpTransport(), shell, exec, {
|
|
2305
|
+
port,
|
|
2306
|
+
target: args.target,
|
|
2307
|
+
windowMs,
|
|
2308
|
+
commands: [
|
|
2309
|
+
{ method: 'Profiler.enable' },
|
|
2310
|
+
{ method: 'Profiler.startPreciseCoverage', params: { callCount: true, detailed: false }, key: 'start' },
|
|
2311
|
+
],
|
|
2312
|
+
finish: [{ method: 'Profiler.takePreciseCoverage', key: 'take' }],
|
|
2313
|
+
})
|
|
2314
|
+
const took = session.results.take
|
|
2315
|
+
if (took === undefined) {
|
|
2316
|
+
throw new Error(session.closedEarly
|
|
2317
|
+
? `采样窗口里连接断了(close code ${session.closeCode})—— **拿不到快照**,所以不给"部分覆盖率":半份快照会让人把"没跑过"读成"跑过了"。`
|
|
2318
|
+
: '发了 Profiler.takePreciseCoverage 但没等到响应 —— 目标可能卡住了(这一次没有覆盖率可报)。')
|
|
2319
|
+
}
|
|
2320
|
+
if (took.error !== undefined) {
|
|
2321
|
+
throw new Error(`Profiler.takePreciseCoverage 被目标拒绝(${took.error.code ?? '?'} ${took.error.message ?? ''})—— 目标可能不允许精确覆盖率采样。`)
|
|
2322
|
+
}
|
|
2323
|
+
const started = session.results.start
|
|
2324
|
+
if (started?.error !== undefined) {
|
|
2325
|
+
throw new Error(`Profiler.startPreciseCoverage 被目标拒绝(${started.error.code ?? '?'} ${started.error.message ?? ''})—— 这一步没成功,后面的数就不是覆盖率。`)
|
|
2326
|
+
}
|
|
2327
|
+
const summary = summarizeCoverage(Array.isArray(took.result?.result) ? took.result.result : [])
|
|
2328
|
+
const ranked = summary.rows.slice().sort((a, b) => b.sizeBytes - a.sizeBytes)
|
|
2329
|
+
const shown = ranked.slice(0, CDP_TARGETS_SHOWN)
|
|
2330
|
+
const percent = summary.sizeTotal === 0 ? null : Math.round(1000 * summary.usedTotal / summary.sizeTotal) / 10
|
|
2331
|
+
return JSON.stringify({
|
|
2332
|
+
tool: 'bx_cdp_coverage',
|
|
2333
|
+
target: `127.0.0.1:${port} · ${picked.id}(${picked.type})`,
|
|
2334
|
+
operation: `WebSocket 附加 → Profiler.enable + startPreciseCoverage → 采 ${windowMs}ms → takePreciseCoverage`,
|
|
2335
|
+
observed: {
|
|
2336
|
+
windowMs,
|
|
2337
|
+
scripts: summary.rows.length,
|
|
2338
|
+
shown: shown.length,
|
|
2339
|
+
omitted: ranked.length - shown.length,
|
|
2340
|
+
executedBytes: summary.usedTotal,
|
|
2341
|
+
totalBytes: summary.sizeTotal,
|
|
2342
|
+
executedPercent: percent,
|
|
2343
|
+
rows: shown.map(row => ({
|
|
2344
|
+
url: row.url,
|
|
2345
|
+
functions: row.functions,
|
|
2346
|
+
executedFunctions: row.executedFunctions,
|
|
2347
|
+
executedBytes: row.usedBytes,
|
|
2348
|
+
sizeBytes: row.sizeBytes,
|
|
2349
|
+
})),
|
|
2350
|
+
},
|
|
2351
|
+
confidence: 'observed',
|
|
2352
|
+
limitations: [
|
|
2353
|
+
`只反映**这个 ${windowMs}ms 窗口内**跑过的代码 —— 再采一次会变;它不是"静态可达性",`
|
|
2354
|
+
+ '更不能读成"这段代码永远不会跑"',
|
|
2355
|
+
'**没加载的脚本根本不出现**(覆盖率只报看得见的脚本)—— 清单里没有 ≠ 这个应用没有那段代码',
|
|
2356
|
+
'`detailed: false` ⇒ **函数粒度**(不是块粒度 / 不是逐语句)',
|
|
2357
|
+
'区间**求了并集**再相加(V8 的函数区间会嵌套,直接相加会重复计数)—— 这是本工具的口径',
|
|
2358
|
+
'采样期间目标带着 V8 的仪表跑,会略微变慢;窗口一结束就**断开连接**(域随连接走,断开即释放)',
|
|
2359
|
+
'`执行比例` 是**字节口径**(已执行区间 / 脚本最大偏移),不是函数比例',
|
|
2360
|
+
],
|
|
2361
|
+
}, null, 2)
|
|
2362
|
+
},
|
|
2363
|
+
}))
|
|
1879
2364
|
}
|