@dsh-fly/dsh-plugin-fly-mode 0.1.9 → 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-fly/dsh-plugin-fly-mode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Fly 模式作为一个可安装的包:**两个 preset** —— Fly 安全模式(preset-fly,8 个安全工具内联 + smart)与 Fly 标准模式(preset-fly-standard,上游 standard 的行表 + smart,无安全工具);依赖单独发布的 dsh-plugin-fly-smart。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -146,6 +146,9 @@ tail -f .fly-blackbox/capture/all.log
|
|
|
146
146
|
| `bx_ca` | CA 信任状态 + **要人跑的命令文本** + `security verify-cert` 事后证据 | 不受门控(永不提权) |
|
|
147
147
|
| `bx_flows` | 离线读 dump:`hosts` / `list` / `show` / `grep`(版本闸 + 拒绝截断) | 不受门控(只读) |
|
|
148
148
|
| `bx_diff` | 改前/改后字段级 diff,显式区分**缺失** / **假值** / **类型变化** | 不受门控(只读) |
|
|
149
|
+
| `bx_cdp_targets` | **进程内通道第一件**:列 `127.0.0.1:<port>` 上可附加的 target(page/worker/iframe)。只走回环、端口只认参数、控制通道地址不打印;整件在 `FLY_ALLOW_INTERCEPT` 门后 |
|
|
150
|
+
| `bx_cdp_scripts` | 附加上去收 `Debugger.scriptParsed`:**已解析**脚本清单(会回放已解析的;**不返回源码**;窗口一结束就断开) |
|
|
151
|
+
| `bx_cdp_coverage` | 精确覆盖率:采一段窗口 → `takePreciseCoverage` → 逐脚本"已执行字节"(区间**求并集**;拿不到快照就**大声失败**,不给半份) |
|
|
149
152
|
|
|
150
153
|
规则文件固定 `<workspace>/.fly-blackbox/rules.json`,由**你自己用文件工具写**,`bx_proxy start` 只读并校验;
|
|
151
154
|
schema 与"显式 off、无静默默认值"的纪律见 `reference/rules.schema.md`。
|
|
@@ -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
|
* 三条纪律写死在实现里,因为每一条都对应一次实测事故:
|
|
@@ -433,6 +435,286 @@ function workspaceRoot(exec) {
|
|
|
433
435
|
* 读一行文本,走接缝。用于**用户数据**(dump、规则文件)—— 这样沙箱拒绝会以
|
|
434
436
|
* `SandboxError` 报出。允许 fs 的两处例外(自带资产 / 自身状态文件)见 §3.1。
|
|
435
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
|
+
|
|
436
718
|
async function readTextFileVia(shell, exec, path, what, opts = {}) {
|
|
437
719
|
const run = await runCmd(shell, ['base64', '-i', path], exec, { stdoutMaxBytes: 64 * 1024 * 1024 })
|
|
438
720
|
if (missingTool(run)) throw new Error(`${what} 读不到:${path} —— 本机没有 base64(不应发生)`)
|
|
@@ -1191,6 +1473,11 @@ const GATE_TABLE = {
|
|
|
1191
1473
|
bx_har: { actionless: true },
|
|
1192
1474
|
// 没有 action 参数,只读两份 JSON。
|
|
1193
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: [] },
|
|
1194
1481
|
}
|
|
1195
1482
|
|
|
1196
1483
|
function gateReason(tool, action) {
|
|
@@ -1895,4 +2182,183 @@ export function apply(ctx) {
|
|
|
1895
2182
|
return [`${lines.length} 处差异:`, ...lines.map(line => ` ${line}`)].join('\n')
|
|
1896
2183
|
},
|
|
1897
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
|
+
}))
|
|
1898
2364
|
}
|