dsh-remote-agent 0.9.6 → 0.9.8
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/CHANGELOG.md +164 -0
- package/README.md +6 -4
- package/lib/auto-fix.js +304 -0
- package/lib/client.js +279 -9
- package/lib/e2e.js +42 -1
- package/lib/external-tools.js +238 -0
- package/lib/hub/tools.js +289 -0
- package/lib/hub/ui-routes.js +182 -0
- package/lib/node/fs-service.js +276 -1
- package/lib/node/index.js +29 -2
- package/lib/node/process-service.js +406 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +97 -3
- package/scripts/diag-inject.mjs +2 -2
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程与端口诊断(2026-10-20,用户要求"更多工具 = 进程与端口诊断")。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么需要(来自真实排查经历)
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 这两天排查"副机连不上"时,最耗时的几步全靠手工:
|
|
9
|
+
*
|
|
10
|
+
* netstat -ano | findstr :19387 # 谁占着端口
|
|
11
|
+
* Get-NetTCPConnection -LocalPort 19387 # 同上,更结构化
|
|
12
|
+
* Get-Process -Id 12345 # 那个 PID 是什么
|
|
13
|
+
* taskkill /PID 12345 /F # 杀掉卡住的进程
|
|
14
|
+
*
|
|
15
|
+
* 而这些恰恰是**远程排查时最需要**的:用户不在副机前面,看不见任务管理器。
|
|
16
|
+
*
|
|
17
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
* 能力边界(哪一半是只读,哪一半会改状态)
|
|
19
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
*
|
|
21
|
+
* · `list`(列进程)—— **只读**,安全
|
|
22
|
+
* · `port`(查端口占用)—— **只读**,安全
|
|
23
|
+
* · `kill`(杀进程)—— **改状态,不可逆** → 必须 confirm: 'yes'
|
|
24
|
+
*
|
|
25
|
+
* kill 的危险性:
|
|
26
|
+
* · 杀错进程会让副机上的任务全崩(甚至把 DSH 自己杀掉)
|
|
27
|
+
* · Windows 上误杀系统进程可能导致蓝屏
|
|
28
|
+
*
|
|
29
|
+
* 所以 kill 有三道约束(见 `killProcess`):
|
|
30
|
+
* 1. 必须显式 `confirm: 'yes'`
|
|
31
|
+
* 2. **默认拒绝杀本进程及其父进程**(防"AI 把自己杀了")
|
|
32
|
+
* 3. 拒绝杀一批关键系统进程(System / csrss / wininit / services / lsass)
|
|
33
|
+
*
|
|
34
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
* 实现选择:用 node 原生 + PowerShell,不引依赖
|
|
36
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
*
|
|
38
|
+
* 零依赖是核心卖点,所以:
|
|
39
|
+
* · Windows 走 PowerShell(`Get-Process` / `Get-NetTCPConnection`),
|
|
40
|
+
* 回退 `netstat`(老系统没有 Get-NetTCPConnection)
|
|
41
|
+
* · 其它平台走 `ps` / `ss`(或 `lsof`)
|
|
42
|
+
*
|
|
43
|
+
* **所有命令都是写死的常量**,唯一的外部输入(PID、端口)经过严格校验后
|
|
44
|
+
* 作为数字/白名单参数传入 —— 没有字符串拼接注入面。
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { spawnSync } from 'node:child_process'
|
|
48
|
+
|
|
49
|
+
const IS_WIN = process.platform === 'win32'
|
|
50
|
+
const DEFAULT_TIMEOUT_MS = 8000
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 一批**绝不允许**被 kill 的进程名(Windows)。
|
|
54
|
+
*
|
|
55
|
+
* 为什么需要:这些是系统关键进程,杀掉会导致蓝屏或系统不可用。
|
|
56
|
+
* 注意这里只防"手滑/误判",不防恶意 —— 有管理员权限的人总能绕过。
|
|
57
|
+
*/
|
|
58
|
+
const PROTECTED_PROCESS_NAMES = new Set([
|
|
59
|
+
'system',
|
|
60
|
+
'system idle process',
|
|
61
|
+
'registry',
|
|
62
|
+
'memory compression',
|
|
63
|
+
'csrss.exe',
|
|
64
|
+
'wininit.exe',
|
|
65
|
+
'winlogon.exe',
|
|
66
|
+
'services.exe',
|
|
67
|
+
'lsass.exe',
|
|
68
|
+
'smss.exe',
|
|
69
|
+
'svchost.exe',
|
|
70
|
+
'dwm.exe',
|
|
71
|
+
'explorer.exe',
|
|
72
|
+
'fontdrvhost.exe',
|
|
73
|
+
'sihost.exe',
|
|
74
|
+
'ctfmon.exe',
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
/** 跑一个命令,返回 { ok, stdout, stderr, code }。不抛。 */
|
|
78
|
+
function run(cmd, args, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const r = spawnSync(cmd, args, {
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
timeout: timeoutMs,
|
|
84
|
+
shell: false,
|
|
85
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
86
|
+
})
|
|
87
|
+
return {
|
|
88
|
+
ok: r.status === 0,
|
|
89
|
+
stdout: String(r.stdout ?? ''),
|
|
90
|
+
stderr: String(r.stderr ?? ''),
|
|
91
|
+
code: typeof r.status === 'number' ? r.status : -1,
|
|
92
|
+
error: r.error ? String(r.error.message ?? r.error) : '',
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, stdout: '', stderr: '', code: -1, error: String(err?.message ?? err) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 校验 PID:必须是正整数。 */
|
|
100
|
+
export function normalizePid(raw) {
|
|
101
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw ?? '').trim())
|
|
102
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 0x7fffffff) return 0
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 校验端口:1-65535。 */
|
|
107
|
+
export function normalizePort(raw) {
|
|
108
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw ?? '').trim())
|
|
109
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) return 0
|
|
110
|
+
return n
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 列进程。
|
|
115
|
+
*
|
|
116
|
+
* @param {object} [o]
|
|
117
|
+
* @param {number} [o.limit] 最多返回多少个(默认 200,按 CPU/内存排序前的原始顺序)
|
|
118
|
+
* @param {string} [o.filter] 按进程名子串过滤(不区分大小写)
|
|
119
|
+
* @returns {{platform:string, processes:Array, truncated:boolean, error?:string}}
|
|
120
|
+
*/
|
|
121
|
+
export function listProcesses({ limit = 200, filter = '' } = {}) {
|
|
122
|
+
const max = Math.min(Math.max(Number(limit) || 200, 1), 1000)
|
|
123
|
+
const needle = typeof filter === 'string' ? filter.trim().toLowerCase() : ''
|
|
124
|
+
|
|
125
|
+
if (IS_WIN) {
|
|
126
|
+
// PowerShell 输出 JSON,比解析 netstat/tasklist 的表格稳得多。
|
|
127
|
+
//
|
|
128
|
+
// ⚠ 多行脚本用 `;` 拼接,**不能只 join(' ')** —— 那样 `$ErrorActionPreference="Stop"`
|
|
129
|
+
// 和 `Get-Process` 之间没有分隔符,PowerShell 会报 UnexpectedToken。
|
|
130
|
+
// (第一版就是这么写的,实测立刻暴露。)
|
|
131
|
+
const script = [
|
|
132
|
+
'$ErrorActionPreference="Stop";',
|
|
133
|
+
'Get-Process | Select-Object Id,ProcessName,',
|
|
134
|
+
'@{N="cpuSec";E={try{[math]::Round($_.CPU,1)}catch{0}}},',
|
|
135
|
+
'@{N="memMb";E={try{[math]::Round($_.WorkingSet64/1MB,1)}catch{0}}},',
|
|
136
|
+
'@{N="startedAt";E={try{$_.StartTime.ToString("o")}catch{""}}} |',
|
|
137
|
+
'ConvertTo-Json -Compress -Depth 3',
|
|
138
|
+
].join(' ')
|
|
139
|
+
const r = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeoutMs: 15000 })
|
|
140
|
+
if (!r.ok || r.stdout.trim() === '') {
|
|
141
|
+
return { platform: process.platform, processes: [], truncated: false, error: r.error || r.stderr || '无法枚举进程' }
|
|
142
|
+
}
|
|
143
|
+
let arr
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(r.stdout)
|
|
146
|
+
arr = Array.isArray(parsed) ? parsed : [parsed]
|
|
147
|
+
} catch {
|
|
148
|
+
return { platform: process.platform, processes: [], truncated: false, error: '进程列表解析失败' }
|
|
149
|
+
}
|
|
150
|
+
let list = arr.map((p) => ({
|
|
151
|
+
pid: Number(p?.Id) || 0,
|
|
152
|
+
name: String(p?.ProcessName ?? ''),
|
|
153
|
+
cpuSec: Number(p?.cpuSec) || 0,
|
|
154
|
+
memMb: Number(p?.memMb) || 0,
|
|
155
|
+
startedAt: String(p?.startedAt ?? ''),
|
|
156
|
+
}))
|
|
157
|
+
if (needle !== '') list = list.filter((p) => p.name.toLowerCase().includes(needle))
|
|
158
|
+
const truncated = list.length > max
|
|
159
|
+
return { platform: process.platform, processes: list.slice(0, max), truncated }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// POSIX:ps 的字段在不同系统上略有差异,用最保守的集合
|
|
163
|
+
const r = run('ps', ['-eo', 'pid=,comm=,%cpu=,rss=,lstart='], { timeoutMs: 10000 })
|
|
164
|
+
if (!r.ok) {
|
|
165
|
+
return { platform: process.platform, processes: [], truncated: false, error: r.error || r.stderr || 'ps 不可用' }
|
|
166
|
+
}
|
|
167
|
+
let list = []
|
|
168
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
169
|
+
const t = line.trim()
|
|
170
|
+
if (t === '') continue
|
|
171
|
+
const m = /^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/.exec(t)
|
|
172
|
+
if (!m) continue
|
|
173
|
+
list.push({
|
|
174
|
+
pid: Number(m[1]),
|
|
175
|
+
name: m[2],
|
|
176
|
+
cpuSec: Number(m[3]) || 0,
|
|
177
|
+
// ps 的 rss 是 KB
|
|
178
|
+
memMb: Math.round((Number(m[4]) || 0) / 1024 * 10) / 10,
|
|
179
|
+
startedAt: m[5].trim(),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
if (needle !== '') list = list.filter((p) => p.name.toLowerCase().includes(needle))
|
|
183
|
+
const truncated = list.length > max
|
|
184
|
+
return { platform: process.platform, processes: list.slice(0, max), truncated }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 查端口占用(哪个 PID 在监听/连接这个端口)。
|
|
189
|
+
*
|
|
190
|
+
* @param {number} port
|
|
191
|
+
* @returns {{port:number, listeners:Array, error?:string}}
|
|
192
|
+
*/
|
|
193
|
+
export function portUsage(port) {
|
|
194
|
+
const p = normalizePort(port)
|
|
195
|
+
if (p === 0) return { port: 0, listeners: [], error: '端口必须是 1-65535 的整数' }
|
|
196
|
+
|
|
197
|
+
if (IS_WIN) {
|
|
198
|
+
// 首选结构化命令
|
|
199
|
+
const script = [
|
|
200
|
+
'$ErrorActionPreference="SilentlyContinue"',
|
|
201
|
+
`Get-NetTCPConnection -LocalPort ${p} |`,
|
|
202
|
+
' Select-Object LocalAddress,LocalPort,RemoteAddress,RemotePort,State,OwningProcess |',
|
|
203
|
+
' ConvertTo-Json -Compress -Depth 3',
|
|
204
|
+
].join(' ')
|
|
205
|
+
const r = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeoutMs: 12000 })
|
|
206
|
+
if (r.ok && r.stdout.trim() !== '') {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(r.stdout)
|
|
209
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed]
|
|
210
|
+
return {
|
|
211
|
+
port: p,
|
|
212
|
+
listeners: arr.map((x) => ({
|
|
213
|
+
localAddress: String(x?.LocalAddress ?? ''),
|
|
214
|
+
localPort: Number(x?.LocalPort) || p,
|
|
215
|
+
remoteAddress: String(x?.RemoteAddress ?? ''),
|
|
216
|
+
remotePort: Number(x?.RemotePort) || 0,
|
|
217
|
+
state: String(x?.State ?? ''),
|
|
218
|
+
pid: Number(x?.OwningProcess) || 0,
|
|
219
|
+
})),
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
/* 落到 netstat 回退 */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// 回退:netstat(老系统 / Get-NetTCPConnection 不可用)
|
|
226
|
+
const n = run('netstat', ['-ano'], { timeoutMs: 12000 })
|
|
227
|
+
if (!n.ok) return { port: p, listeners: [], error: n.error || n.stderr || 'netstat 不可用' }
|
|
228
|
+
const listeners = []
|
|
229
|
+
for (const line of n.stdout.split(/\r?\n/)) {
|
|
230
|
+
const t = line.trim()
|
|
231
|
+
if (!t.toUpperCase().startsWith('TCP')) continue
|
|
232
|
+
const cols = t.split(/\s+/)
|
|
233
|
+
// TCP local foreign state pid
|
|
234
|
+
if (cols.length < 5) continue
|
|
235
|
+
const local = cols[1]
|
|
236
|
+
const idx = local.lastIndexOf(':')
|
|
237
|
+
if (idx < 0) continue
|
|
238
|
+
if (Number(local.slice(idx + 1)) !== p) continue
|
|
239
|
+
const pid = Number(cols[cols.length - 1]) || 0
|
|
240
|
+
listeners.push({
|
|
241
|
+
localAddress: local.slice(0, idx),
|
|
242
|
+
localPort: p,
|
|
243
|
+
remoteAddress: '',
|
|
244
|
+
remotePort: 0,
|
|
245
|
+
state: cols[3] ?? '',
|
|
246
|
+
pid,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
return { port: p, listeners }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// POSIX:ss 优先,回退 lsof
|
|
253
|
+
const ss = run('ss', ['-lntp'], { timeoutMs: 10000 })
|
|
254
|
+
if (ss.ok) {
|
|
255
|
+
const listeners = []
|
|
256
|
+
for (const line of ss.stdout.split(/\r?\n/)) {
|
|
257
|
+
if (!line.includes(`:${p} `) && !line.includes(`:${p}\t`)) continue
|
|
258
|
+
const cols = line.trim().split(/\s+/)
|
|
259
|
+
const local = cols[3] ?? ''
|
|
260
|
+
const idx = local.lastIndexOf(':')
|
|
261
|
+
if (idx < 0 || Number(local.slice(idx + 1)) !== p) continue
|
|
262
|
+
const pidM = /pid=(\d+)/.exec(line)
|
|
263
|
+
listeners.push({
|
|
264
|
+
localAddress: local.slice(0, idx),
|
|
265
|
+
localPort: p,
|
|
266
|
+
remoteAddress: '',
|
|
267
|
+
remotePort: 0,
|
|
268
|
+
state: cols[0] ?? '',
|
|
269
|
+
pid: pidM ? Number(pidM[1]) : 0,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
return { port: p, listeners }
|
|
273
|
+
}
|
|
274
|
+
const lsof = run('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN'], { timeoutMs: 10000 })
|
|
275
|
+
if (!lsof.ok) return { port: p, listeners: [], error: 'ss 与 lsof 都不可用' }
|
|
276
|
+
const listeners = []
|
|
277
|
+
for (const line of lsof.stdout.split(/\r?\n/).slice(1)) {
|
|
278
|
+
const cols = line.trim().split(/\s+/)
|
|
279
|
+
if (cols.length < 2) continue
|
|
280
|
+
listeners.push({
|
|
281
|
+
localAddress: '',
|
|
282
|
+
localPort: p,
|
|
283
|
+
remoteAddress: '',
|
|
284
|
+
remotePort: 0,
|
|
285
|
+
state: 'LISTEN',
|
|
286
|
+
pid: Number(cols[1]) || 0,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
return { port: p, listeners }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 杀掉一个进程。
|
|
294
|
+
*
|
|
295
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
296
|
+
* 三道约束(每一道都防一种真实事故)
|
|
297
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
298
|
+
*
|
|
299
|
+
* 1. **必须 confirm: 'yes'** —— 调用方(通常是 AI)要显式表示知情
|
|
300
|
+
* 2. **拒绝杀本进程与父进程** —— 防"AI 把自己杀了"导致副机失联,
|
|
301
|
+
* 那会让用户彻底失去远程修复能力(最坏的结果)
|
|
302
|
+
* 3. **拒绝杀关键系统进程** —— 防蓝屏
|
|
303
|
+
*
|
|
304
|
+
* @param {object} o
|
|
305
|
+
* @param {number} o.pid
|
|
306
|
+
* @param {string} o.confirm 必须 'yes'
|
|
307
|
+
* @param {number} [o.selfPid] 本进程 PID(默认 process.pid)
|
|
308
|
+
* @param {number} [o.parentPid] 父进程 PID(默认 process.ppid)
|
|
309
|
+
* @returns {{ok:boolean, pid:number, name?:string, error?:string}}
|
|
310
|
+
*/
|
|
311
|
+
export function killProcess({ pid, confirm, selfPid = process.pid, parentPid = process.ppid } = {}) {
|
|
312
|
+
if (confirm !== 'yes') {
|
|
313
|
+
return { ok: false, pid: 0, error: '杀进程需要显式传 confirm: "yes"(不可逆操作)' }
|
|
314
|
+
}
|
|
315
|
+
const n = normalizePid(pid)
|
|
316
|
+
if (n === 0) return { ok: false, pid: 0, error: 'pid 必须是正整数' }
|
|
317
|
+
|
|
318
|
+
// 约束 2:绝不杀自己或父进程 —— 否则副机失联,用户失去远程修复能力
|
|
319
|
+
if (n === selfPid) {
|
|
320
|
+
return { ok: false, pid: n, error: '拒绝:这是本插件自己的进程(杀掉会让副机失联)' }
|
|
321
|
+
}
|
|
322
|
+
if (n === parentPid) {
|
|
323
|
+
return { ok: false, pid: n, error: '拒绝:这是本插件的父进程(通常是 DSH 本体,杀掉会让副机失联)' }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 先查进程名(用于关键进程保护 + 返回信息)
|
|
327
|
+
let name
|
|
328
|
+
try {
|
|
329
|
+
if (IS_WIN) {
|
|
330
|
+
const r = run('powershell', [
|
|
331
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
332
|
+
`(Get-Process -Id ${n} -ErrorAction SilentlyContinue).ProcessName`,
|
|
333
|
+
], { timeoutMs: 8000 })
|
|
334
|
+
name = r.ok ? r.stdout.trim() : ''
|
|
335
|
+
} else {
|
|
336
|
+
const r = run('ps', ['-p', String(n), '-o', 'comm='], { timeoutMs: 6000 })
|
|
337
|
+
name = r.ok ? r.stdout.trim() : ''
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
name = ''
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 约束 3:关键系统进程
|
|
344
|
+
const lower = name.toLowerCase()
|
|
345
|
+
if (lower !== '' && PROTECTED_PROCESS_NAMES.has(lower)) {
|
|
346
|
+
return { ok: false, pid: n, name, error: `拒绝:${name} 是系统关键进程,杀掉可能导致系统不可用` }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 执行
|
|
350
|
+
if (IS_WIN) {
|
|
351
|
+
const r = run('taskkill', ['/PID', String(n), '/F'], { timeoutMs: 10000 })
|
|
352
|
+
if (!r.ok) {
|
|
353
|
+
return { ok: false, pid: n, name, error: (r.stderr || r.error || 'taskkill 失败').trim().slice(0, 200) }
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, pid: n, name }
|
|
356
|
+
}
|
|
357
|
+
const r = run('kill', ['-TERM', String(n)], { timeoutMs: 8000 })
|
|
358
|
+
if (!r.ok) {
|
|
359
|
+
return { ok: false, pid: n, name, error: (r.stderr || r.error || 'kill 失败').trim().slice(0, 200) }
|
|
360
|
+
}
|
|
361
|
+
return { ok: true, pid: n, name }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 渲染进程列表(人读)。 */
|
|
365
|
+
export function renderProcesses(result) {
|
|
366
|
+
if (!result || !Array.isArray(result.processes)) return '(无数据)'
|
|
367
|
+
if (result.error) return `枚举失败:${result.error}`
|
|
368
|
+
const lines = [`进程(${result.processes.length} 个${result.truncated ? ',已截断' : ''})`, '']
|
|
369
|
+
lines.push('PID\t内存\tCPU秒\t名称')
|
|
370
|
+
for (const p of result.processes) {
|
|
371
|
+
lines.push(`${p.pid}\t${p.memMb}MB\t${p.cpuSec}\t${p.name}`)
|
|
372
|
+
}
|
|
373
|
+
return lines.join('\n')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 渲染端口占用(人读)。 */
|
|
377
|
+
export function renderPortUsage(result) {
|
|
378
|
+
if (!result) return '(无数据)'
|
|
379
|
+
if (result.error) return `查询失败:${result.error}`
|
|
380
|
+
if (!Array.isArray(result.listeners) || result.listeners.length === 0) {
|
|
381
|
+
return `端口 ${result.port}:没有进程在监听或连接。`
|
|
382
|
+
}
|
|
383
|
+
const lines = [`端口 ${result.port}:${result.listeners.length} 条连接`, '']
|
|
384
|
+
for (const l of result.listeners) {
|
|
385
|
+
lines.push(` ${l.state || '?'}\t${l.localAddress}:${l.localPort}\tPID ${l.pid}`)
|
|
386
|
+
}
|
|
387
|
+
return lines.join('\n')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 创建进程服务对象(与 `createFsService` 同款工厂形态)。
|
|
392
|
+
*
|
|
393
|
+
* 为什么用工厂而不是直接导出函数对象:
|
|
394
|
+
* · 与 `fsService` 保持一致的调用形态(副机侧按 `svc[fn](d)` 分发)
|
|
395
|
+
* · 将来若要加"允许杀哪些进程"的白名单配置,这里能接 cfg,
|
|
396
|
+
* 而不用改副机侧的调用点
|
|
397
|
+
*
|
|
398
|
+
* 当前不接任何配置 —— 少一个配置面就少一类配置错误。
|
|
399
|
+
*/
|
|
400
|
+
export function createProcessService() {
|
|
401
|
+
return {
|
|
402
|
+
ps: (d) => listProcesses(d ?? {}),
|
|
403
|
+
port: (d) => portUsage(d?.port),
|
|
404
|
+
kill: (d) => killProcess(d ?? {}),
|
|
405
|
+
}
|
|
406
|
+
}
|
package/package.json
CHANGED
|
@@ -1068,10 +1068,14 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1068
1068
|
const toolsSrc = read('lib/hub/tools.js')
|
|
1069
1069
|
const rootReadme2 = read('README.md')
|
|
1070
1070
|
|
|
1071
|
-
//
|
|
1071
|
+
// 数实际注册:三种注册入口都要算进来(2026-10-20 加了 writeOp 与进程工具)
|
|
1072
|
+
// · `reg(` —— 任务工具 + 进程诊断工具
|
|
1073
|
+
// · `fsOp(` —— 只读文件工具
|
|
1074
|
+
// · `writeOp(` —— 写操作文件工具
|
|
1072
1075
|
const regCount = (toolsSrc.match(/^\s{2}reg\(/gm) || []).length
|
|
1073
1076
|
const fsOpCount = (toolsSrc.match(/^\s{2}fsOp\(/gm) || []).length
|
|
1074
|
-
const
|
|
1077
|
+
const writeOpCount = (toolsSrc.match(/^\s{2}writeOp\(/gm) || []).length
|
|
1078
|
+
const actual = regCount + fsOpCount + writeOpCount
|
|
1075
1079
|
|
|
1076
1080
|
// 从 README 里抽声明(形如「12 个 remote_* 工具」或「工具速查(共 12 个)」)
|
|
1077
1081
|
const m = /(?:共\s*(\d+)\s*个|(\d+)\s*个\s*remote_\*)/.exec(rootReadme2)
|
|
@@ -1079,7 +1083,7 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1079
1083
|
|
|
1080
1084
|
check('83', 'README 声明的工具数量等于代码里实际注册的数量',
|
|
1081
1085
|
declared !== null && declared === actual,
|
|
1082
|
-
`lib/hub/tools.js(实际 ${actual} 个:reg=${regCount} + fsOp=${fsOpCount})`
|
|
1086
|
+
`lib/hub/tools.js(实际 ${actual} 个:reg=${regCount} + fsOp=${fsOpCount} + writeOp=${writeOpCount})`
|
|
1083
1087
|
+ ` vs README.md(声明 ${declared === null ? '未找到' : declared} 个)`)
|
|
1084
1088
|
}
|
|
1085
1089
|
|
|
@@ -1243,6 +1247,96 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1243
1247
|
'lib/client.js(复制按钮 + 编号步骤 + 角色提示 + 任务进度摘要)')
|
|
1244
1248
|
}
|
|
1245
1249
|
|
|
1250
|
+
/* ── 91~94. 四项新能力(2026-10-20)──
|
|
1251
|
+
*
|
|
1252
|
+
* 用户要求"一键修复 / 自动装依赖 / 更多工具 / 重写 UI"。
|
|
1253
|
+
* 每一项都在**放宽边界**,所以断言的重点不是"能用",而是**护栏在不在**:
|
|
1254
|
+
*
|
|
1255
|
+
* · 一键修复 → 会改 DSH 自己的 cordis.patch.yml
|
|
1256
|
+
* · 写操作 → 会改副机的文件(不可逆)
|
|
1257
|
+
* · kill → 会杀副机的进程(杀错会失联)
|
|
1258
|
+
* · 外部工具 → "自动装依赖"的诱惑要顶住(只给命令,不代下载)
|
|
1259
|
+
*/
|
|
1260
|
+
{
|
|
1261
|
+
const autoFixSrc = read('lib/auto-fix.js')
|
|
1262
|
+
const extSrc = read('lib/external-tools.js')
|
|
1263
|
+
const fsSrc = read('lib/node/fs-service.js')
|
|
1264
|
+
const procSrc = read('lib/node/process-service.js')
|
|
1265
|
+
const toolsSrc2 = read('lib/hub/tools.js')
|
|
1266
|
+
const clientSrc7 = read('lib/client.js')
|
|
1267
|
+
const uiSrc6 = read('lib/hub/ui-routes.js')
|
|
1268
|
+
|
|
1269
|
+
check('91', '一键修复:只改托管块 + 备份 + 写后校验 + 二次确认 + 可撤销',
|
|
1270
|
+
// 只读预览与写盘分离
|
|
1271
|
+
/export function planFixes/.test(autoFixSrc)
|
|
1272
|
+
&& /export function applyFixPlan/.test(autoFixSrc)
|
|
1273
|
+
// 写盘复用带备份的 writePatchBlock
|
|
1274
|
+
&& /writePatchBlock/.test(uiSrc6)
|
|
1275
|
+
// 写后校验:重复 id / 条目缺失
|
|
1276
|
+
&& /重复 id/.test(autoFixSrc)
|
|
1277
|
+
// 撤销
|
|
1278
|
+
&& /export function undoFix/.test(autoFixSrc)
|
|
1279
|
+
// 路由层强制 confirm
|
|
1280
|
+
&& /confirm !== 'yes'/.test(uiSrc6)
|
|
1281
|
+
// 绝不给 0.0.0.0(会让 DSH 崩溃)
|
|
1282
|
+
&& /h !== '0\.0\.0\.0'/.test(read('lib/listen-server.js')),
|
|
1283
|
+
'lib/auto-fix.js + lib/hub/ui-routes.js(plan/apply/undo + confirm 硬要求)')
|
|
1284
|
+
|
|
1285
|
+
check('92', '写操作:roots 护栏 + 默认不覆盖 + 不递归删 + 原子写',
|
|
1286
|
+
// 写操作专用解析(允许目标不存在)
|
|
1287
|
+
/function resolveWritable/.test(fsSrc)
|
|
1288
|
+
// 默认不覆盖
|
|
1289
|
+
&& /overwrite !== true/.test(fsSrc)
|
|
1290
|
+
// 不递归删:非空目录必须拒绝
|
|
1291
|
+
&& /directory not empty/.test(fsSrc)
|
|
1292
|
+
// 原子写
|
|
1293
|
+
&& /dshra-tmp/.test(fsSrc)
|
|
1294
|
+
// 单次写入上限
|
|
1295
|
+
&& /WRITE_MAX_BYTES/.test(fsSrc)
|
|
1296
|
+
// hub 侧强制 confirm
|
|
1297
|
+
&& /const writeOp = /.test(toolsSrc2)
|
|
1298
|
+
&& /args\?\.confirm !== 'yes'/.test(toolsSrc2),
|
|
1299
|
+
'lib/node/fs-service.js(resolveWritable + 不覆盖 + 不递归删)+ tools.js(confirm)')
|
|
1300
|
+
|
|
1301
|
+
check('93', '进程诊断:kill 三道护栏(confirm / 拒杀自身与父进程 / 拒杀系统进程)',
|
|
1302
|
+
/export function killProcess/.test(procSrc)
|
|
1303
|
+
// 约束 1:confirm
|
|
1304
|
+
&& /confirm !== 'yes'/.test(procSrc)
|
|
1305
|
+
// 约束 2:拒杀自己与父进程
|
|
1306
|
+
&& /n === selfPid/.test(procSrc)
|
|
1307
|
+
&& /n === parentPid/.test(procSrc)
|
|
1308
|
+
// 约束 3:系统关键进程
|
|
1309
|
+
&& /PROTECTED_PROCESS_NAMES/.test(procSrc)
|
|
1310
|
+
// 命令注入面:PID/端口必须归一化
|
|
1311
|
+
&& /export function normalizePid/.test(procSrc)
|
|
1312
|
+
&& /export function normalizePort/.test(procSrc),
|
|
1313
|
+
'lib/node/process-service.js(kill 三道护栏 + pid/port 归一化)')
|
|
1314
|
+
|
|
1315
|
+
check('94', '外部工具:只检测 + 给命令,绝不代下载(守供应链边界)',
|
|
1316
|
+
/export function probeExternalTools/.test(extSrc)
|
|
1317
|
+
&& /export function toolCatalog/.test(extSrc)
|
|
1318
|
+
// 每个工具都要说明"缺了会怎样"
|
|
1319
|
+
&& /why:/.test(extSrc)
|
|
1320
|
+
// 给安装命令与官方链接,但不执行
|
|
1321
|
+
&& /install:/.test(extSrc)
|
|
1322
|
+
&& /url:/.test(extSrc)
|
|
1323
|
+
// 反例:不得出现代执行安装命令的调用
|
|
1324
|
+
&& !/spawnSync\(\s*['"`](winget|brew|apt|choco|curl|wget)/.test(extSrc),
|
|
1325
|
+
'lib/external-tools.js(探测 + 说明 + 命令,不执行)')
|
|
1326
|
+
|
|
1327
|
+
check('95', 'UI:折叠区块(信息密度)—— 首屏只留当下需要的',
|
|
1328
|
+
// 原生 details(零 JS 状态)
|
|
1329
|
+
/className: 'dra-fold'/.test(clientSrc7)
|
|
1330
|
+
&& /<details|h\('details'/.test(clientSrc7)
|
|
1331
|
+
// 折叠样式
|
|
1332
|
+
&& /\.dra-fold/.test(clientSrc7)
|
|
1333
|
+
// 默认展开策略:没节点时展开接入
|
|
1334
|
+
&& /open: state\.nodes\.length === 0/.test(clientSrc7)
|
|
1335
|
+
// 体检有失败项时默认展开
|
|
1336
|
+
&& /open: Boolean\(envCheck && envCheck\.failed > 0\)/.test(clientSrc7),
|
|
1337
|
+
'lib/client.js(Fold 组件 + dra-fold 样式 + 默认展开策略)')
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1246
1340
|
/* ── 输出 ── */
|
|
1247
1341
|
const failed = results.filter((r) => !r.ok)
|
|
1248
1342
|
if (process.argv.includes('--json')) {
|
package/scripts/diag-inject.mjs
CHANGED
|
@@ -185,14 +185,14 @@ try {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
if (applyHub) {
|
|
188
|
-
// 4a. inject 齐全 → 不抛,且
|
|
188
|
+
// 4a. inject 齐全 → 不抛,且 19 个工具都在(8 任务 + 4 只读 + 4 写操作 + 3 进程)
|
|
189
189
|
try {
|
|
190
190
|
const ctx = fakeCtx(declared)
|
|
191
191
|
applyHub(ctx, { role: 'hub', nodeToken: 'nt_diag' })
|
|
192
192
|
check('inject 齐全时 applyHub 不抛', true)
|
|
193
193
|
check(
|
|
194
194
|
'inject 齐全时注册 12 个 remote_* 工具',
|
|
195
|
-
ctx.registered.tools.length ===
|
|
195
|
+
ctx.registered.tools.length === 19,
|
|
196
196
|
`实际 ${ctx.registered.tools.length} 个:${ctx.registered.tools.join(', ') || '(无)'}`,
|
|
197
197
|
)
|
|
198
198
|
check('inject 齐全时注册了路由', ctx.registered.routes.length > 0, `${ctx.registered.routes.length} 条`)
|