@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +51 -2
- package/cordis.patch.yml +399 -0
- package/package.json +32 -4
- package/tools/dsh-plugin-fly-binary/index.js +520 -0
- package/tools/dsh-plugin-fly-binary/package.json +23 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
- package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
- package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
- package/tools/dsh-plugin-fly-diag/index.js +772 -0
- package/tools/dsh-plugin-fly-diag/package.json +24 -0
- package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
- package/tools/dsh-plugin-fly-guard/index.js +101 -0
- package/tools/dsh-plugin-fly-guard/package.json +26 -0
- package/tools/dsh-plugin-fly-pwn/index.js +583 -0
- package/tools/dsh-plugin-fly-pwn/package.json +23 -0
- package/tools/dsh-plugin-fly-recon/index.js +184 -0
- package/tools/dsh-plugin-fly-recon/package.json +23 -0
- package/tools/dsh-plugin-fly-reverse/index.js +178 -0
- package/tools/dsh-plugin-fly-reverse/package.json +23 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
blockOf,
|
|
7
|
+
collapseNeighbors,
|
|
8
|
+
parseNetworkEntry,
|
|
9
|
+
parsePingStats,
|
|
10
|
+
parseSpeedSample,
|
|
11
|
+
parseWifiReport,
|
|
12
|
+
summarizeChannels,
|
|
13
|
+
} from './wifi.js'
|
|
14
|
+
|
|
15
|
+
/** fly-diag — local host diagnostics for the Diagnostics view / agent. */
|
|
16
|
+
export const name = 'fly-diag'
|
|
17
|
+
export const inject = ['tools', 'shell']
|
|
18
|
+
|
|
19
|
+
function shq(value) {
|
|
20
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run one confined command through the host `shell` seam.
|
|
25
|
+
*
|
|
26
|
+
* `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
|
|
27
|
+
* 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
|
|
28
|
+
* projection carries the run result. An earlier revision of this file awaited a
|
|
29
|
+
* nonexistent `shell.run()`, so every shell-backed tool threw
|
|
30
|
+
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
31
|
+
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
32
|
+
* names a seam method the executor does not have.
|
|
33
|
+
*/
|
|
34
|
+
async function runCmd(shell, argv, exec, opts = {}) {
|
|
35
|
+
const result = await (await shell.execute(shell.resolve({
|
|
36
|
+
command: argv.map(shq).join(' '),
|
|
37
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
|
|
38
|
+
...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
|
|
39
|
+
signal: exec.signal,
|
|
40
|
+
}))).result()
|
|
41
|
+
if (result.aborted) {
|
|
42
|
+
const error = new Error('tool call aborted')
|
|
43
|
+
error.name = 'AbortError'
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
if (result.sandbox?.denied) {
|
|
47
|
+
const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
|
48
|
+
error.name = 'SandboxError'
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A failed command must never come back as an empty success.
|
|
56
|
+
*
|
|
57
|
+
* A probe the sandbox blocks (`ps` under a restrictive policy) or a command the
|
|
58
|
+
* host lacks must surface as an error, never as an empty table — the sibling
|
|
59
|
+
* `dsh-plugin-fly-diagnostics` workbench already asserts the same rule.
|
|
60
|
+
*/
|
|
61
|
+
function requireOk(run, what) {
|
|
62
|
+
if (run.exitCode === 0) return run.stdout
|
|
63
|
+
const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
|
|
64
|
+
const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
|
|
65
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
|
|
66
|
+
error.name = 'CommandError'
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 网络 / 无线排障面 ────────────────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// 这一组把「到底是哪一层慢」变成可测量的量:链路层(信道/带宽/协商速率/MCS)、
|
|
73
|
+
// 空口与局域网层(网关 RTT 与抖动)、外网层(WAN RTT)。判据是**抖动与结构性变化**,
|
|
74
|
+
// 不是任何一次测速的绝对值 —— 一次 611 Mbps 不能证明「间歇性变慢」消失了,只有
|
|
75
|
+
// 记录仪里那段时间的链路和 RTT 才能。全部解析走 `wifi.js` 的纯函数(夹具门在
|
|
76
|
+
// `scripts/tests/wifi-report.spec.mjs`),这个文件只负责采集与编排。
|
|
77
|
+
|
|
78
|
+
/** `system_profiler SPAirPortDataType`:darwin-only,是本机链路唯一的只读来源。 */
|
|
79
|
+
const AIRPORT_ARGV = ['system_profiler', 'SPAirPortDataType']
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 默认吞吐目标:国内 CDN 上一个稳定的大文件,可用 `url` 覆盖。
|
|
83
|
+
*
|
|
84
|
+
* 「默认跑满」是用户的裁决(一次调用就看清上限),代价是每次调用都在真实链路上
|
|
85
|
+
* 产生流量 —— 所以结果里必定回传 `totalBytes`,让 agent 和人都知道这次花了多少。
|
|
86
|
+
* 门禁不碰外网:它把 `url` 指向自己起的 127.0.0.1 服务。
|
|
87
|
+
*/
|
|
88
|
+
const DEFAULT_SPEED_URL = 'https://registry.npmmirror.com/-/binary/node/v20.11.0/node-v20.11.0-darwin-arm64.tar.gz'
|
|
89
|
+
|
|
90
|
+
/** 只允许这两种 scheme 进 curl。`file:` 会把本地文件读了回显进工具结果。 */
|
|
91
|
+
const ALLOWED_SCHEMES = ['http:', 'https:']
|
|
92
|
+
|
|
93
|
+
/** 记录仪默认落点。绝不写 `~/.dsh`(用户配置区)。 */
|
|
94
|
+
const WATCH_DIR = join(os.homedir(), '.dsh-fly', 'netdiag')
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 校验 agent 给的 URL 之后再交给 curl。
|
|
98
|
+
*
|
|
99
|
+
* `runCmd` 对每个 argv 元素做 shell 引号,元字符逃不出去;但 `curl file:///etc/passwd`
|
|
100
|
+
* 是一个**完全合法**的参数,会把本地文件读出来回显。挡住它的是 scheme 白名单 ——
|
|
101
|
+
* 分类不了的 URL 一律拒绝,不猜。
|
|
102
|
+
*/
|
|
103
|
+
function requireHttpUrl(value, fallback) {
|
|
104
|
+
const text = String(value ?? '').trim()
|
|
105
|
+
let parsed
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(text === '' ? fallback : text)
|
|
108
|
+
} catch {
|
|
109
|
+
const error = new Error(`不是合法的 URL:${text}`)
|
|
110
|
+
error.name = 'ArgumentError'
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
|
|
114
|
+
const error = new Error(`拒绝 ${parsed.protocol} 目标:只允许 http/https(file: 会读本地文件)`)
|
|
115
|
+
error.name = 'ArgumentError'
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
return parsed.href
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 校验主机目标。
|
|
123
|
+
*
|
|
124
|
+
* 要挡的不是 shell 元字符(`runCmd` 已经引号了),而是**开头的 `-`**:`-f` 会被
|
|
125
|
+
* `ping` 当成 flood 参数,一个 agent 传进来的字符串就成了压测指令。所以只接受主机名
|
|
126
|
+
* 或 IP 的字符集,且首字符必须是字母数字。
|
|
127
|
+
*/
|
|
128
|
+
function requireHost(value, fallback) {
|
|
129
|
+
const text = String(value ?? '').trim()
|
|
130
|
+
const host = text === '' ? fallback : text
|
|
131
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]*[A-Za-z0-9])?$/u.test(host)) {
|
|
132
|
+
// 理由要写**实际**的原因:这条只可能因为"含空格 / shell 元字符 / 开头是 -"被拒,
|
|
133
|
+
// 而只提 "-" 的文案会让输入 `192.168.1.1; foo` 的人看不懂自己错在哪。
|
|
134
|
+
const error = new Error(
|
|
135
|
+
`拒绝目标 ${JSON.stringify(host)}:只接受主机名或 IP 字面量 ——`
|
|
136
|
+
+ ' 不许有空格或 shell 元字符(; | & $ ` 引号 括号),也不许以 "-" 开头(会被当成命令参数,如 ping -f 是 flood)。',
|
|
137
|
+
)
|
|
138
|
+
error.name = 'ArgumentError'
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
return host
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 夹取整数参数。 */
|
|
145
|
+
function clampInteger(value, fallback, min, max) {
|
|
146
|
+
const parsed = Number(value)
|
|
147
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
148
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `ping -q` 一条:macOS 的 `-i` 是间隔秒、`-W` 是单包超时(ms)、`-t` 是整轮超时(s)。 */
|
|
152
|
+
function pingArgv(host, count) {
|
|
153
|
+
const seconds = Math.max(8, Math.ceil(count * 0.4) + 4)
|
|
154
|
+
return ['ping', '-c', String(count), '-i', '0.2', '-W', '2000', '-t', String(seconds), '-q', host]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 把 ping 统计拼成一行。无回应时明说「无回应」,绝不写成 0ms。 */
|
|
158
|
+
function pingLine(label, stats) {
|
|
159
|
+
if (stats.avgMs === null) {
|
|
160
|
+
return `${label}: 全程无回应(发出 ${stats.transmitted ?? '?'},收到 ${stats.received ?? 0},丢包 ${stats.lossPercent ?? '?'}%)`
|
|
161
|
+
}
|
|
162
|
+
return `${label}: avg ${stats.avgMs}ms max ${stats.maxMs}ms 抖动 ${stats.stddevMs}ms 丢包 ${stats.lossPercent}%`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 把 epoch 秒渲染成本地时刻。 */
|
|
166
|
+
function stampOf(epoch) {
|
|
167
|
+
return new Date(epoch * 1000).toLocaleString('sv-SE')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 记录仪的三个路径。 */
|
|
171
|
+
function watchPaths(dir) {
|
|
172
|
+
const base = String(dir ?? '').trim() === '' ? WATCH_DIR : String(dir).trim()
|
|
173
|
+
return { dir: base, log: join(base, 'samples.log'), stop: join(base, 'stop'), pid: join(base, 'sampler.pid') }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 切分记录仪的样本日志。
|
|
178
|
+
*
|
|
179
|
+
* 存的是**原始输出**而不是解析后的数字:`report` 因此用同一套纯函数解析(不存在一份
|
|
180
|
+
* shell 侧、一份 JS 侧的重复解析),而且现场证据原样留着可以复核。
|
|
181
|
+
*/
|
|
182
|
+
function parseSampleLog(text) {
|
|
183
|
+
const samples = []
|
|
184
|
+
const marks = []
|
|
185
|
+
let current = null
|
|
186
|
+
let section = null
|
|
187
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
188
|
+
const sample = /^@@SAMPLE (\d+)\s*$/u.exec(line)
|
|
189
|
+
if (sample !== null) {
|
|
190
|
+
current = { epoch: Number(sample[1]), complete: false, link: [], gw: [], wan: [] }
|
|
191
|
+
samples.push(current)
|
|
192
|
+
section = null
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
const mark = /^@@MARK (\d+)\s*(.*)$/u.exec(line)
|
|
196
|
+
if (mark !== null) {
|
|
197
|
+
marks.push({ epoch: Number(mark[1]), message: mark[2] })
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (line === '@@LINK') { section = 'link'; continue }
|
|
201
|
+
if (line === '@@GW') { section = 'gw'; continue }
|
|
202
|
+
if (line === '@@WAN') { section = 'wan'; continue }
|
|
203
|
+
if (line === '@@END') {
|
|
204
|
+
if (current !== null) current.complete = true
|
|
205
|
+
section = null
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (current === null || section === null) continue
|
|
209
|
+
current[section].push(line)
|
|
210
|
+
}
|
|
211
|
+
return { samples, marks }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 只统计有真实测量的值:`null` 不能当 0 混进均值。 */
|
|
215
|
+
function numericSummary(values) {
|
|
216
|
+
const known = values.filter(value => typeof value === 'number' && Number.isFinite(value)).sort((a, b) => a - b)
|
|
217
|
+
if (known.length === 0) return { count: 0, min: null, median: null, max: null }
|
|
218
|
+
return {
|
|
219
|
+
count: known.length,
|
|
220
|
+
min: known[0],
|
|
221
|
+
median: known[Math.floor(known.length / 2)],
|
|
222
|
+
max: known[known.length - 1],
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 把样本日志解析成对齐后的一张表。 */
|
|
227
|
+
function readWatch(logText) {
|
|
228
|
+
const { samples, marks } = parseSampleLog(logText)
|
|
229
|
+
// 只有走到 `@@END` 的样本才算一个样本。
|
|
230
|
+
//
|
|
231
|
+
// 记录仪是**边跑边写**的,所以文件末尾任何时刻都可能有一个只写了一半的样本(`mark`
|
|
232
|
+
// 也是往同一个文件追加一行,能落在样本中间)。半个样本不是证据:把它当数据用,
|
|
233
|
+
// 「ping 还没写完」会被渲染成「无回应」—— 一个凭空的故障结论。丢掉,并且**说出丢了多少**,
|
|
234
|
+
// 否则就成了静默的丢数据。
|
|
235
|
+
const complete = samples.filter(sample => sample.complete)
|
|
236
|
+
const rows = complete.map(sample => ({
|
|
237
|
+
epoch: sample.epoch,
|
|
238
|
+
link: parseNetworkEntry(sample.link),
|
|
239
|
+
gw: parsePingStats(sample.gw.join('\n')),
|
|
240
|
+
wan: parsePingStats(sample.wan.join('\n')),
|
|
241
|
+
}))
|
|
242
|
+
return { rows, marks, skipped: samples.length - complete.length }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 一次采样的 shell 片段,原样追加到日志。
|
|
247
|
+
*
|
|
248
|
+
* 长驻记录仪与一次性采样共用它 —— 两条路写出的格式必须逐字节同形,否则 `report`
|
|
249
|
+
* 只能解析其中一种。
|
|
250
|
+
*/
|
|
251
|
+
function sampleSnippet(paths, { count, gateway, wan, indent = '', withLink = true }) {
|
|
252
|
+
const body = [`${indent}{`, `${indent} echo "@@SAMPLE $(date +%s)"`]
|
|
253
|
+
if (withLink) {
|
|
254
|
+
body.push(
|
|
255
|
+
`${indent} echo "@@LINK"`,
|
|
256
|
+
`${indent} ${shq(AIRPORT_ARGV[0])} ${shq(AIRPORT_ARGV[1])} 2>/dev/null | sed -n '/Current Network Information:/,/Other Local Wi-Fi Networks:/p'`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
body.push(
|
|
260
|
+
`${indent} echo "@@GW"`,
|
|
261
|
+
`${indent} ${pingArgv(gateway, count).map(shq).join(' ')}`,
|
|
262
|
+
`${indent} echo "@@WAN"`,
|
|
263
|
+
`${indent} ${pingArgv(wan, count).map(shq).join(' ')}`,
|
|
264
|
+
`${indent} echo "@@END"`,
|
|
265
|
+
`${indent}} >> ${shq(paths.log)}`,
|
|
266
|
+
)
|
|
267
|
+
return body.join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 记录仪的长驻脚本。
|
|
272
|
+
*
|
|
273
|
+
* 整段跑在**一个**长驻 `sh` 里(不是每次采样起一个进程),并且走 `ctx.shell` 接缝 ——
|
|
274
|
+
* 这五个安全插件被门禁钉着不许绕过接缝直接开子进程,而包资产要动 `files` 与打包断言,
|
|
275
|
+
* 一条一次性 shell 循环把两者都避开。链路采样每 `linkEvery` 次取一次:`system_profiler`
|
|
276
|
+
* 要 1–2 秒,每次采样都跑它会在「几天」这个尺度上白烧 CPU。
|
|
277
|
+
*/
|
|
278
|
+
function samplerScript(paths, { interval, count, gateway, wan, linkEvery }) {
|
|
279
|
+
return [
|
|
280
|
+
`mkdir -p ${shq(paths.dir)}`,
|
|
281
|
+
`rm -f ${shq(paths.stop)}`,
|
|
282
|
+
`echo $$ > ${shq(paths.pid)}`,
|
|
283
|
+
'i=0',
|
|
284
|
+
`while [ ! -f ${shq(paths.stop)} ]; do`,
|
|
285
|
+
' i=$((i+1))',
|
|
286
|
+
` if [ $((i % ${linkEvery})) -eq 1 ]; then`,
|
|
287
|
+
sampleSnippet(paths, { count, gateway, wan, indent: ' ' }),
|
|
288
|
+
' else',
|
|
289
|
+
sampleSnippet(paths, { count, gateway, wan, indent: ' ', withLink: false }),
|
|
290
|
+
' fi',
|
|
291
|
+
` sleep ${interval}`,
|
|
292
|
+
'done',
|
|
293
|
+
].join('\n')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 采集本机链路。失败或没有 Wi-Fi 时返回 `available:false`,从不抛异常给面板。 */
|
|
297
|
+
async function collectLink(shell, signal) {
|
|
298
|
+
const run = await runCmd(shell, AIRPORT_ARGV, { signal }, { timeoutMs: 30000, stdoutMaxBytes: 4194304 })
|
|
299
|
+
if (run.exitCode !== 0) {
|
|
300
|
+
return { available: false, error: `system_profiler 失败(exit ${run.exitCode}):${run.stderr.trim().split('\n')[0] ?? ''}` }
|
|
301
|
+
}
|
|
302
|
+
const report = parseWifiReport(run.stdout)
|
|
303
|
+
const iface = report.interfaces.find(candidate => candidate.current !== null) ?? null
|
|
304
|
+
if (iface === null) {
|
|
305
|
+
return {
|
|
306
|
+
available: false,
|
|
307
|
+
interfaces: report.interfaces.map(candidate => candidate.name),
|
|
308
|
+
error: '系统报告里没有「当前网络」:本机没有关联的 Wi-Fi,或这不是 macOS。这不是「链路正常」,是没有数据。',
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const link = iface.current
|
|
312
|
+
return {
|
|
313
|
+
available: true,
|
|
314
|
+
interface: iface.name,
|
|
315
|
+
countryCode: iface.countryCode,
|
|
316
|
+
phy: link.phy,
|
|
317
|
+
channel: link.channel,
|
|
318
|
+
band: link.band,
|
|
319
|
+
widthMhz: link.widthMhz,
|
|
320
|
+
rssiDbm: link.rssiDbm,
|
|
321
|
+
noiseDbm: link.noiseDbm,
|
|
322
|
+
snrDb: link.snrDb,
|
|
323
|
+
transmitRateMbps: link.transmitRateMbps,
|
|
324
|
+
mcs: link.mcs,
|
|
325
|
+
note: 'transmitRateMbps 是上行(本机发给 AP);macOS 不提供下行速率。rssiDbm / noiseDbm / snrDb 为 null 表示没有测量结果,不是 0。',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 采集信道占用与可用性。只读系统已缓存的扫描结果,不触发扫描、不发探测请求。 */
|
|
330
|
+
async function collectChannels(shell, { rounds = 3, signal } = {}) {
|
|
331
|
+
const neighbors = []
|
|
332
|
+
let report = { interfaces: [] }
|
|
333
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
334
|
+
const run = await runCmd(shell, AIRPORT_ARGV, { signal }, { timeoutMs: 30000, stdoutMaxBytes: 4194304 })
|
|
335
|
+
if (run.exitCode !== 0) {
|
|
336
|
+
return { available: false, error: `system_profiler 失败(exit ${run.exitCode}):${run.stderr.trim().split('\n')[0] ?? ''}` }
|
|
337
|
+
}
|
|
338
|
+
report = parseWifiReport(run.stdout)
|
|
339
|
+
neighbors.push(...report.interfaces.flatMap(candidate => candidate.neighbors))
|
|
340
|
+
}
|
|
341
|
+
// 并集落回第一个接口再交给 summarizeChannels —— 它就是读这个形状的。
|
|
342
|
+
const merged = {
|
|
343
|
+
interfaces: report.interfaces.map((candidate, index) => (index === 0 ? { ...candidate, neighbors } : candidate)),
|
|
344
|
+
}
|
|
345
|
+
const plan = summarizeChannels(merged)
|
|
346
|
+
const collapsed = collapseNeighbors(neighbors)
|
|
347
|
+
return {
|
|
348
|
+
available: collapsed.length > 0 || plan.permittedChannels.length > 0,
|
|
349
|
+
rounds,
|
|
350
|
+
interfaceName: plan.interfaceName,
|
|
351
|
+
permittedChannels: plan.permittedChannels,
|
|
352
|
+
blocks: plan.blocks.map(block => ({
|
|
353
|
+
span: block.span,
|
|
354
|
+
primary: block.primary,
|
|
355
|
+
dfs: block.dfs,
|
|
356
|
+
permitted: block.permitted,
|
|
357
|
+
occupied: block.occupied,
|
|
358
|
+
occupants: block.occupants.map(entry => ({
|
|
359
|
+
channel: entry.channel,
|
|
360
|
+
widthMhz: entry.widthMhz,
|
|
361
|
+
bestRssiDbm: entry.bestRssiDbm,
|
|
362
|
+
samples: entry.samples,
|
|
363
|
+
})),
|
|
364
|
+
})),
|
|
365
|
+
width160: plan.width160,
|
|
366
|
+
collapsedNeighbors: collapsed.length,
|
|
367
|
+
note: '只读系统缓存的扫描结果。BSSID/SSID 被 macOS 抹名,所以「同信道同带宽的多个 AP」会被折成一行,占用数偏保守。bestRssiDbm 为 null 表示没有测量结果,不是 0。',
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function apply(ctx) {
|
|
372
|
+
const shell = ctx.shell
|
|
373
|
+
|
|
374
|
+
ctx.tools.register(defineTool({
|
|
375
|
+
name: 'diag_host',
|
|
376
|
+
description: 'Collect host identity and load metrics.',
|
|
377
|
+
parameters: {},
|
|
378
|
+
output: {
|
|
379
|
+
schema: { type: 'string' },
|
|
380
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
381
|
+
},
|
|
382
|
+
async execute(_args, exec) {
|
|
383
|
+
exec.signal.throwIfAborted()
|
|
384
|
+
const mem = os.totalmem()
|
|
385
|
+
return JSON.stringify(
|
|
386
|
+
{
|
|
387
|
+
hostname: os.hostname(),
|
|
388
|
+
platform: os.platform(),
|
|
389
|
+
release: os.release(),
|
|
390
|
+
arch: os.arch(),
|
|
391
|
+
cpus: os.cpus().length,
|
|
392
|
+
cpuModel: os.cpus()[0]?.model,
|
|
393
|
+
totalMemGB: +(mem / 1024 ** 3).toFixed(2),
|
|
394
|
+
freeMemGB: +(os.freemem() / 1024 ** 3).toFixed(2),
|
|
395
|
+
loadavg: os.loadavg().map((n) => +n.toFixed(2)),
|
|
396
|
+
uptimeHours: +(os.uptime() / 3600).toFixed(2),
|
|
397
|
+
},
|
|
398
|
+
null,
|
|
399
|
+
2,
|
|
400
|
+
)
|
|
401
|
+
},
|
|
402
|
+
}))
|
|
403
|
+
|
|
404
|
+
ctx.tools.register(defineTool({
|
|
405
|
+
name: 'diag_procs',
|
|
406
|
+
description: 'List top processes by CPU (ps).',
|
|
407
|
+
parameters: {
|
|
408
|
+
limit: { type: 'number', description: 'Max rows (default 15)' },
|
|
409
|
+
},
|
|
410
|
+
output: {
|
|
411
|
+
schema: { type: 'string' },
|
|
412
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
413
|
+
},
|
|
414
|
+
async execute(args, exec) {
|
|
415
|
+
const limit = Math.max(1, Math.min(50, Number(args.limit) || 15))
|
|
416
|
+
const stdout = requireOk(await runCmd(shell, ['ps', 'aux'], exec, { stdoutMaxBytes: 1048576 }), 'ps aux')
|
|
417
|
+
const lines = stdout.split('\n').filter(Boolean)
|
|
418
|
+
const [header, ...rest] = lines
|
|
419
|
+
if (header === undefined) return 'ps aux produced no output (the command succeeded, which is suspicious)'
|
|
420
|
+
const body = rest
|
|
421
|
+
.sort((a, b) => {
|
|
422
|
+
const pa = parseFloat(a.split(/\s+/)[2] || '0')
|
|
423
|
+
const pb = parseFloat(b.split(/\s+/)[2] || '0')
|
|
424
|
+
return pb - pa
|
|
425
|
+
})
|
|
426
|
+
.slice(0, limit)
|
|
427
|
+
return [header, ...body].join('\n')
|
|
428
|
+
},
|
|
429
|
+
}))
|
|
430
|
+
|
|
431
|
+
ctx.tools.register(defineTool({
|
|
432
|
+
name: 'diag_net',
|
|
433
|
+
description: 'Snapshot listening sockets (lsof/netstat best-effort).',
|
|
434
|
+
parameters: {},
|
|
435
|
+
output: {
|
|
436
|
+
schema: { type: 'string' },
|
|
437
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
438
|
+
},
|
|
439
|
+
async execute(_args, exec) {
|
|
440
|
+
const lsof = await runCmd(shell, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN'], exec)
|
|
441
|
+
if (lsof.exitCode === 0) return lsof.stdout.slice(0, 8000)
|
|
442
|
+
const listeners = requireOk(await runCmd(shell, ['netstat', '-an'], exec), 'netstat -an')
|
|
443
|
+
.split('\n').filter((l) => l.includes('LISTEN')).slice(0, 80)
|
|
444
|
+
if (listeners.length > 0) return listeners.join('\n')
|
|
445
|
+
return 'no listening TCP sockets (lsof failed and netstat reported no LISTEN lines)'
|
|
446
|
+
},
|
|
447
|
+
}))
|
|
448
|
+
|
|
449
|
+
// ── 无线 / 网络排障工具 ────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
ctx.tools.register(defineTool({
|
|
452
|
+
name: 'diag_wifi_link',
|
|
453
|
+
description: '本机 Wi-Fi 链路快照:PHY 模式、主信道、带宽、RSSI/底噪/SNR、协商速率、MCS。被动读系统报告,不发任何包。',
|
|
454
|
+
parameters: {},
|
|
455
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
456
|
+
async execute(_args, exec) {
|
|
457
|
+
exec.signal.throwIfAborted()
|
|
458
|
+
return JSON.stringify(await collectLink(shell, exec.signal), null, 2)
|
|
459
|
+
},
|
|
460
|
+
}))
|
|
461
|
+
|
|
462
|
+
ctx.tools.register(defineTool({
|
|
463
|
+
name: 'diag_wifi_channels',
|
|
464
|
+
description: '5GHz 信道占用图与可用性:邻居按(频段/带宽/主信道)折叠,并算出哪些 80MHz 块被允许、160MHz 是否可能。只读系统扫描缓存,不发探测请求、不连接任何东西。',
|
|
465
|
+
parameters: {
|
|
466
|
+
rounds: { type: 'number', description: '读缓存的轮数(默认 3,上限 8)。缓存要几轮才收敛,一轮会漏报。' },
|
|
467
|
+
},
|
|
468
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
469
|
+
async execute(args, exec) {
|
|
470
|
+
exec.signal.throwIfAborted()
|
|
471
|
+
const snapshot = await collectChannels(shell, { rounds: clampInteger(args.rounds, 3, 1, 8), signal: exec.signal })
|
|
472
|
+
return JSON.stringify(snapshot, null, 2)
|
|
473
|
+
},
|
|
474
|
+
}))
|
|
475
|
+
|
|
476
|
+
ctx.tools.register(defineTool({
|
|
477
|
+
name: 'diag_net_rtt',
|
|
478
|
+
description: '同时测局域网网关与外网的 RTT/丢包/抖动 —— 把「空口被抢」和「路由器/运营商排队」分开的第一刀。会发 ICMP 包。',
|
|
479
|
+
parameters: {
|
|
480
|
+
gateway: { type: 'string', description: '网关地址(默认 192.168.1.1)' },
|
|
481
|
+
wan: { type: 'string', description: '外网目标(默认 223.5.5.5,阿里公共 DNS)' },
|
|
482
|
+
count: { type: 'number', description: '每个目标发几个包(默认 20,上限 60)' },
|
|
483
|
+
},
|
|
484
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
485
|
+
async execute(args, exec) {
|
|
486
|
+
const gateway = requireHost(args.gateway, '192.168.1.1')
|
|
487
|
+
const wanTarget = requireHost(args.wan, '223.5.5.5')
|
|
488
|
+
const count = clampInteger(args.count, 20, 4, 60)
|
|
489
|
+
|
|
490
|
+
exec.signal.throwIfAborted()
|
|
491
|
+
const gwRun = await runCmd(shell, pingArgv(gateway, count), exec, { timeoutMs: 90000, stdoutMaxBytes: 262144 })
|
|
492
|
+
exec.signal.throwIfAborted()
|
|
493
|
+
const wanRun = await runCmd(shell, pingArgv(wanTarget, count), exec, { timeoutMs: 90000, stdoutMaxBytes: 262144 })
|
|
494
|
+
|
|
495
|
+
const gw = parsePingStats(gwRun.stdout)
|
|
496
|
+
const wan = parsePingStats(wanRun.stdout)
|
|
497
|
+
// 100% 丢包时 ping 退出码非 0,但那是**有意义的结果**(网关没回应),不是调用失败。
|
|
498
|
+
// 只有连统计行都解析不出来,才说明这条数据不可信 —— 那时必须大声报错。
|
|
499
|
+
if (gw.transmitted === null || wan.transmitted === null) {
|
|
500
|
+
const error = new Error(
|
|
501
|
+
'ping 输出解析不出统计行,这条数据不可信:'
|
|
502
|
+
+ `\n${gwRun.command} → exit ${gwRun.exitCode}`
|
|
503
|
+
+ `\n${wanRun.command} → exit ${wanRun.exitCode}`,
|
|
504
|
+
)
|
|
505
|
+
error.name = 'CommandError'
|
|
506
|
+
throw error
|
|
507
|
+
}
|
|
508
|
+
const delta = gw.avgMs === null || wan.avgMs === null ? null : wan.avgMs - gw.avgMs
|
|
509
|
+
const verdict = gw.avgMs === null
|
|
510
|
+
? '网关全程无回应:先确认网关地址、以及本机是否真的在这个网段 —— 这一层没数据,下面什么都推不出来。'
|
|
511
|
+
: wan.avgMs === null
|
|
512
|
+
? '网关通、外网不通:问题在光猫/路由器上联或运营商侧,不在无线空口。'
|
|
513
|
+
: delta > 30
|
|
514
|
+
? `外网比网关多 ${delta.toFixed(1)}ms:这段差值来自光猫/路由器的排队与路径,跟 Wi-Fi 空口无关。`
|
|
515
|
+
: `外网与网关只差 ${delta.toFixed(1)}ms,路径干净。此时若仍感到卡,更可能在空口 —— 用 diag_wifi_link 看协商速率与抖动。`
|
|
516
|
+
return JSON.stringify({
|
|
517
|
+
gateway: { target: gateway, ...gw, line: pingLine(`网关 ${gateway}`, gw) },
|
|
518
|
+
wan: { target: wanTarget, ...wan, line: pingLine(`外网 ${wanTarget}`, wan) },
|
|
519
|
+
deltaMs: delta,
|
|
520
|
+
verdict,
|
|
521
|
+
jitterNote: gw.stddevMs !== null && gw.stddevMs > 2
|
|
522
|
+
? `网关抖动 ${gw.stddevMs}ms 偏大:空口在被抢,看 diag_wifi_channels 里有没有同频邻居。`
|
|
523
|
+
: null,
|
|
524
|
+
}, null, 2)
|
|
525
|
+
},
|
|
526
|
+
}))
|
|
527
|
+
|
|
528
|
+
ctx.tools.register(defineTool({
|
|
529
|
+
name: 'diag_net_throughput',
|
|
530
|
+
description: '聚合吞吐测试。默认按「跑满」配置(8 流 × 10 秒),会在目标链路上产生真实流量;结果回传本次实际消耗的字节数。',
|
|
531
|
+
parameters: {
|
|
532
|
+
url: { type: 'string', description: '下载目标(默认一个国内 CDN 大文件,可覆盖)' },
|
|
533
|
+
streams: { type: 'number', description: '并行流数(默认 8,上限 16)' },
|
|
534
|
+
seconds: { type: 'number', description: '每流时长预算秒(默认 10,上限 30)' },
|
|
535
|
+
},
|
|
536
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
537
|
+
async execute(args, exec) {
|
|
538
|
+
const url = requireHttpUrl(args.url, DEFAULT_SPEED_URL)
|
|
539
|
+
const streams = clampInteger(args.streams, 8, 1, 16)
|
|
540
|
+
const seconds = clampInteger(args.seconds, 10, 2, 30)
|
|
541
|
+
// -w 同时给出速率与字节数:字节数是「这次花了你多少流量」的凭据。只报速率、
|
|
542
|
+
// 不报消耗,等于让调用者无法为这次测量负责。
|
|
543
|
+
const one = `curl -sL -o /dev/null -w '%{speed_download} %{size_download}\\n' --max-time ${seconds} ${shq(url)}`
|
|
544
|
+
const script = `i=0\nwhile [ "$i" -lt ${streams} ]; do ${one} & i=$((i+1)); done\nwait`
|
|
545
|
+
exec.signal.throwIfAborted()
|
|
546
|
+
const run = await runCmd(shell, ['sh', '-c', script], exec, { timeoutMs: (seconds + 30) * 1000, stdoutMaxBytes: 1048576 })
|
|
547
|
+
const samples = run.stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
548
|
+
const [speed, size] = line.trim().split(/\s+/)
|
|
549
|
+
return { bps: parseSpeedSample(speed), bytes: Number(size) || 0 }
|
|
550
|
+
})
|
|
551
|
+
const measured = samples.filter(sample => sample.bps !== null)
|
|
552
|
+
if (measured.length === 0) {
|
|
553
|
+
// 一条流都没测到速率 = 目标不可达/被拒,绝不能报成「0 Mbps 测到了」。
|
|
554
|
+
const error = new Error(
|
|
555
|
+
`没有一条流测到速率(${streams} 流 / ${seconds} 秒,exit ${run.exitCode}):目标可能不可达或被拒。`
|
|
556
|
+
+ `\n命令:${run.command}`
|
|
557
|
+
+ `\n输出:${run.stdout.trim().slice(0, 400) === '' ? '(空)' : run.stdout.trim().slice(0, 400)}`
|
|
558
|
+
+ (run.stderr.trim() === '' ? '' : `\n错误:${run.stderr.trim().slice(0, 400)}`),
|
|
559
|
+
)
|
|
560
|
+
error.name = 'CommandError'
|
|
561
|
+
throw error
|
|
562
|
+
}
|
|
563
|
+
const aggregateBps = measured.reduce((sum, sample) => sum + sample.bps, 0)
|
|
564
|
+
const totalBytes = samples.reduce((sum, sample) => sum + sample.bytes, 0)
|
|
565
|
+
return JSON.stringify({
|
|
566
|
+
url,
|
|
567
|
+
streams,
|
|
568
|
+
seconds,
|
|
569
|
+
measuredStreams: measured.length,
|
|
570
|
+
aggregateMbps: Number(((aggregateBps * 8) / 1048576).toFixed(1)),
|
|
571
|
+
perStreamMbps: measured.map(sample => Number(((sample.bps * 8) / 1048576).toFixed(1))),
|
|
572
|
+
totalBytes,
|
|
573
|
+
totalMiB: Number((totalBytes / 1048576).toFixed(1)),
|
|
574
|
+
note: '默认按「跑满」配置(8 流 × 10 秒);totalBytes 是本次实际消耗的流量。测出来的是「无线链路 + 外网」合起来的上限,不是链路理论值。',
|
|
575
|
+
}, null, 2)
|
|
576
|
+
},
|
|
577
|
+
}))
|
|
578
|
+
|
|
579
|
+
ctx.tools.register(defineTool({
|
|
580
|
+
name: 'diag_net_watch',
|
|
581
|
+
description: '长时间记录仪:定期把「链路 + 网关 RTT + 外网 RTT」原样追加到日志,供事后对齐「什么时候卡的」。间歇性故障只能这样证明,单次测速证明不了。',
|
|
582
|
+
parameters: {
|
|
583
|
+
action: { type: 'string', description: 'start | stop | sample | mark | report | status(默认 status)' },
|
|
584
|
+
dir: { type: 'string', description: '日志目录(默认 ~/.dsh-fly/netdiag)' },
|
|
585
|
+
message: { type: 'string', description: 'mark 时的备注,例如「电视卡了」' },
|
|
586
|
+
interval: { type: 'number', description: '采样间隔秒(默认 10,下限 3,上限 600)' },
|
|
587
|
+
count: { type: 'number', description: '每次采样每个目标发几个包(默认 8,上限 30)' },
|
|
588
|
+
gateway: { type: 'string', description: '网关地址(默认 192.168.1.1)' },
|
|
589
|
+
wan: { type: 'string', description: '外网目标(默认 223.5.5.5)' },
|
|
590
|
+
samples: { type: 'number', description: 'report 最多回看多少个样本(默认 300,上限 5000)' },
|
|
591
|
+
},
|
|
592
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
593
|
+
async execute(args, exec) {
|
|
594
|
+
const paths = watchPaths(args.dir)
|
|
595
|
+
const action = String(args.action ?? 'status').trim() === '' ? 'status' : String(args.action).trim()
|
|
596
|
+
const gateway = requireHost(args.gateway, '192.168.1.1')
|
|
597
|
+
const wan = requireHost(args.wan, '223.5.5.5')
|
|
598
|
+
const count = clampInteger(args.count, 8, 4, 30)
|
|
599
|
+
|
|
600
|
+
if (action === 'start') {
|
|
601
|
+
const interval = clampInteger(args.interval, 10, 3, 600)
|
|
602
|
+
const linkEvery = Math.max(1, clampInteger(Math.round(60 / interval), 3, 1, 60))
|
|
603
|
+
const script = samplerScript(paths, { interval, count, gateway, wan, linkEvery })
|
|
604
|
+
// 长驻进程必须显式 onExpiry:'none':resolve() 缺省是 onExpiry:'kill' + 120s,
|
|
605
|
+
// 记录仪会在两分钟后被杀,症状是「跑一会儿就没了」。这里的 result() 永不 settle,
|
|
606
|
+
// 所以只 await 到「进程起来了」——正是黑盒包踩过的那条。
|
|
607
|
+
await shell.execute(shell.resolve({
|
|
608
|
+
command: script,
|
|
609
|
+
onExpiry: 'none',
|
|
610
|
+
timeoutMs: 7 * 24 * 3600 * 1000,
|
|
611
|
+
signal: exec.signal,
|
|
612
|
+
}))
|
|
613
|
+
return [
|
|
614
|
+
`记录仪已启动`,
|
|
615
|
+
`日志:${paths.log}`,
|
|
616
|
+
`间隔 ${interval}s;每 ${linkEvery} 次采样取一次链路;每次每目标 ${count} 个包`,
|
|
617
|
+
`网关 ${gateway} / 外网 ${wan}`,
|
|
618
|
+
'',
|
|
619
|
+
`卡住时立刻打标记:diag_net_watch action=mark message="电视卡了"`,
|
|
620
|
+
`停止:diag_net_watch action=stop`,
|
|
621
|
+
`回看:diag_net_watch action=report`,
|
|
622
|
+
].join('\n')
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (action === 'stop') {
|
|
626
|
+
const run = await runCmd(
|
|
627
|
+
shell,
|
|
628
|
+
['sh', '-c', `mkdir -p ${shq(paths.dir)} && touch ${shq(paths.stop)}`],
|
|
629
|
+
exec,
|
|
630
|
+
{ timeoutMs: 20000 },
|
|
631
|
+
)
|
|
632
|
+
requireOk(run, '写停止文件')
|
|
633
|
+
return `停止信号已写入 ${paths.stop}\n采样循环会在当前这一轮 sleep 结束后退出,最多等一个采样间隔。用 action=status 确认。`
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (action === 'sample') {
|
|
637
|
+
const run = await runCmd(
|
|
638
|
+
shell,
|
|
639
|
+
['sh', '-c', `mkdir -p ${shq(paths.dir)}\n${sampleSnippet(paths, { count, gateway, wan })}`],
|
|
640
|
+
exec,
|
|
641
|
+
{ timeoutMs: 30000 },
|
|
642
|
+
)
|
|
643
|
+
requireOk(run, '采样')
|
|
644
|
+
return `已采样并追加到 ${paths.log}\n用 action=report 看,或 action=mark 标一下刚才发生了什么。`
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (action === 'mark') {
|
|
648
|
+
const message = String(args.message ?? '').trim()
|
|
649
|
+
if (message === '') {
|
|
650
|
+
const error = new Error('mark 需要 message,例如 action=mark message="电视卡了"')
|
|
651
|
+
error.name = 'ArgumentError'
|
|
652
|
+
throw error
|
|
653
|
+
}
|
|
654
|
+
const run = await runCmd(
|
|
655
|
+
shell,
|
|
656
|
+
['sh', '-c', `mkdir -p ${shq(paths.dir)} && printf '@@MARK %s %s\\n' "$(date +%s)" ${shq(message)} >> ${shq(paths.log)}`],
|
|
657
|
+
exec,
|
|
658
|
+
{ timeoutMs: 20000 },
|
|
659
|
+
)
|
|
660
|
+
requireOk(run, '写标记')
|
|
661
|
+
return `已标记「${message}」@ ${stampOf(Math.floor(Date.now() / 1000))}\n日志:${paths.log}`
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (action === 'report') {
|
|
665
|
+
const limit = clampInteger(args.samples, 300, 1, 5000)
|
|
666
|
+
const run = await runCmd(
|
|
667
|
+
shell,
|
|
668
|
+
['sh', '-c', `test -f ${shq(paths.log)} && tail -n ${limit * 20} ${shq(paths.log)}`],
|
|
669
|
+
exec,
|
|
670
|
+
{ timeoutMs: 30000, stdoutMaxBytes: 8388608 },
|
|
671
|
+
)
|
|
672
|
+
if (run.stdout.trim() === '') {
|
|
673
|
+
return `还没有样本(${paths.log} 不存在或为空)。先 action=start 起记录仪,或 action=sample 取一个。`
|
|
674
|
+
}
|
|
675
|
+
const { rows, marks, skipped } = readWatch(run.stdout)
|
|
676
|
+
if (rows.length === 0) {
|
|
677
|
+
return skipped > 0
|
|
678
|
+
? `日志里有 ${skipped} 个样本还没写完(记录仪正在写),暂时没有完整的样本可看。稍后再试。`
|
|
679
|
+
: `日志里没解析出完整样本(${paths.log}):文件可能刚被截断。`
|
|
680
|
+
}
|
|
681
|
+
const first = rows[0]
|
|
682
|
+
const last = rows[rows.length - 1]
|
|
683
|
+
const lines = [
|
|
684
|
+
`记录仪 ${paths.dir}`,
|
|
685
|
+
`样本 ${rows.length} 个,标记 ${marks.length} 个,跨度 ${stampOf(first.epoch)} → ${stampOf(last.epoch)}`
|
|
686
|
+
+ (skipped > 0 ? `(另有 ${skipped} 个未写完已跳过)` : ''),
|
|
687
|
+
'',
|
|
688
|
+
'时刻 信道/带宽 协商 SNR 网关avg/抖动 外网avg/抖动 网关丢包',
|
|
689
|
+
]
|
|
690
|
+
for (const row of rows) {
|
|
691
|
+
const link = row.link
|
|
692
|
+
const channel = link.channel === null ? '—' : `${link.channel}/${link.widthMhz ?? '?'}`
|
|
693
|
+
lines.push([
|
|
694
|
+
stampOf(row.epoch).padEnd(21),
|
|
695
|
+
channel.padEnd(10),
|
|
696
|
+
(link.transmitRateMbps === null ? '—' : String(link.transmitRateMbps)).padStart(5),
|
|
697
|
+
(link.snrDb === null ? '—' : String(link.snrDb)).padStart(5),
|
|
698
|
+
(row.gw.avgMs === null ? '无回应' : `${row.gw.avgMs}/${row.gw.stddevMs}`).padEnd(16),
|
|
699
|
+
(row.wan.avgMs === null ? '无回应' : `${row.wan.avgMs}/${row.wan.stddevMs}`).padEnd(16),
|
|
700
|
+
row.gw.lossPercent === null ? '—' : `${row.gw.lossPercent}%`,
|
|
701
|
+
].join(' '))
|
|
702
|
+
}
|
|
703
|
+
const gwJitter = numericSummary(rows.map(row => row.gw.stddevMs))
|
|
704
|
+
const rate = numericSummary(rows.map(row => row.link.transmitRateMbps))
|
|
705
|
+
const widths = [...new Set(rows.map(row => row.link.widthMhz).filter(width => width !== null))].sort((a, b) => a - b)
|
|
706
|
+
const blocks = [...new Set(rows
|
|
707
|
+
.map(row => (row.link.channel === null ? null : blockOf(row.link.channel)?.primary ?? null))
|
|
708
|
+
.filter(primary => primary !== null))].sort((a, b) => a - b)
|
|
709
|
+
lines.push('')
|
|
710
|
+
lines.push(gwJitter.count === 0
|
|
711
|
+
? `网关抖动:一个样本都没测到(${rows.length} 个样本全部无回应)`
|
|
712
|
+
: `网关抖动:min ${gwJitter.min} / 中位 ${gwJitter.median} / max ${gwJitter.max} ms(有测量的 ${gwJitter.count}/${rows.length} 个样本)`)
|
|
713
|
+
lines.push(rate.count === 0
|
|
714
|
+
? '协商速率:这些样本里没有链路数据'
|
|
715
|
+
: `协商速率:min ${rate.min} / 中位 ${rate.median} / max ${rate.max} Mbps`)
|
|
716
|
+
lines.push(`出现过的带宽:${widths.length === 0 ? '无链路样本' : widths.map(width => `${width}MHz`).join(', ')}`)
|
|
717
|
+
lines.push(`出现过的 80MHz 块:${blocks.length === 0 ? '无链路样本' : blocks.join(', ')}`)
|
|
718
|
+
if (widths.length > 1) {
|
|
719
|
+
lines.push('⚠ 带宽变过 —— 这正是「某台设备突然慢」的形状:射频在降档或换道。')
|
|
720
|
+
}
|
|
721
|
+
if (blocks.length > 1) {
|
|
722
|
+
lines.push('⚠ 换过信道块(可能遇到过雷达被迫换道,或被控制器调优)。')
|
|
723
|
+
}
|
|
724
|
+
for (const mark of marks) {
|
|
725
|
+
const nearest = rows.reduce((best, row) => (
|
|
726
|
+
best === null || Math.abs(row.epoch - mark.epoch) < Math.abs(best.epoch - mark.epoch) ? row : best
|
|
727
|
+
), null)
|
|
728
|
+
lines.push('')
|
|
729
|
+
lines.push(`标记「${mark.message}」@ ${stampOf(mark.epoch)} → 最近样本 ${stampOf(nearest.epoch)}`)
|
|
730
|
+
lines.push(` 网关 ${nearest.gw.avgMs ?? '无回应'}ms(抖动 ${nearest.gw.stddevMs ?? '—'}ms,丢包 ${nearest.gw.lossPercent ?? '—'}%)`
|
|
731
|
+
+ ` 外网 ${nearest.wan.avgMs ?? '无回应'}ms(抖动 ${nearest.wan.stddevMs ?? '—'}ms)`
|
|
732
|
+
+ ` 链路 ${nearest.link.channel ?? '—'}/${nearest.link.widthMhz ?? '—'}MHz 协商 ${nearest.link.transmitRateMbps ?? '—'}Mbps`)
|
|
733
|
+
}
|
|
734
|
+
return lines.join('\n')
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// status(默认):永远给出可读结论,绝不返回空字符串。
|
|
738
|
+
//
|
|
739
|
+
// 存活探测用 `kill -0`(POSIX 的存在性测试,不投递信号)而**不是** `ps -p`:
|
|
740
|
+
// `ps` 在受限沙箱里整个跑不动(连 `ps -p $$` 都 exit 126),于是「没在跑」会在
|
|
741
|
+
// 进程其实活着的时候报出来 —— 一把看不见东西的尺子给出的是假结论。
|
|
742
|
+
const run = await runCmd(shell, ['sh', '-c', [
|
|
743
|
+
`d=${shq(paths.dir)}`,
|
|
744
|
+
'echo "目录: $d"',
|
|
745
|
+
'pid=""',
|
|
746
|
+
'if [ -f "$d/sampler.pid" ]; then pid=$(cat "$d/sampler.pid" 2>/dev/null); fi',
|
|
747
|
+
'case "$pid" in ""|*[!0-9]*) pid="" ;; esac',
|
|
748
|
+
'if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then',
|
|
749
|
+
' echo "记录仪: 运行中 (pid $pid)"',
|
|
750
|
+
'else',
|
|
751
|
+
' echo "记录仪: 没在跑"',
|
|
752
|
+
'fi',
|
|
753
|
+
'if [ -f "$d/stop" ]; then echo "停止信号: 存在(下次 start 会清掉)"; fi',
|
|
754
|
+
'if [ -f "$d/samples.log" ]; then',
|
|
755
|
+
' echo "样本: $(grep -c \'^@@SAMPLE\' "$d/samples.log") 个;标记: $(grep -c \'^@@MARK\' "$d/samples.log") 个"',
|
|
756
|
+
' age=$(( $(date +%s) - $(stat -f %m "$d/samples.log" 2>/dev/null || echo 0) ))',
|
|
757
|
+
' echo "日志: $d/samples.log($(wc -c < "$d/samples.log" | tr -d \' \') 字节,最后写入 ${age}s 前)"',
|
|
758
|
+
'else',
|
|
759
|
+
' echo "还没有日志文件"',
|
|
760
|
+
'fi',
|
|
761
|
+
].join('\n')], exec, { timeoutMs: 20000, stdoutMaxBytes: 262144 })
|
|
762
|
+
return run.stdout.trim() === '' ? '记录仪状态未知:状态命令没有输出。' : run.stdout.trim()
|
|
763
|
+
},
|
|
764
|
+
}))
|
|
765
|
+
|
|
766
|
+
// 这里**故意没有** ctx.provide。本包是 agent 平面(package.json 里没有 dsh.bundle),
|
|
767
|
+
// preset 按会话挂载它 —— 于是 provide 落在会话上下文里,任何一个 Host 行都看不见。
|
|
768
|
+
// 这是实测结论(把本包装进 e2e profile 后 /fly/netops/wifi 回 503),不是推断。
|
|
769
|
+
// 工作台要的无线数据由宿主平面的 dsh-plugin-fly-diagnostics 采集,发布成
|
|
770
|
+
// flyDiagnostics.wifi()。两个包不能互相 import,所以 wifi.js 在两处**逐字节相同**,
|
|
771
|
+
// 由 scripts/verify-security-tools.mjs 的一致性断言守着(同 ELF 解析器的先例)。
|
|
772
|
+
}
|