dsh-console-sentinel 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +438 -1
- package/README.md +104 -108
- package/README.zh.md +96 -102
- package/docs/screenshots/01-panel-overview.png +0 -0
- package/docs/screenshots/02-confirm-dialog.png +0 -0
- package/docs/screenshots/03-preview-result.png +0 -0
- package/docs/screenshots/04-detail-view.png +0 -0
- package/docs/screenshots/05-console-tab.png +0 -0
- package/docs/screenshots/06-whitelist-tab.png +0 -0
- package/lib/client.js +733 -91
- package/lib/desktop.js +397 -9
- package/lib/index.js +305 -7
- package/lib/probe.js +2 -1
- package/lib/whitelist.js +12 -6
- package/package.json +90 -82
- package/README-es.md +0 -29
- package/README-hi.md +0 -29
- package/README-pt.md +0 -29
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ const PLUGIN_VERSION = (() => {
|
|
|
26
26
|
try { return createRequire(import.meta.url)('dsh-console-sentinel/package.json').version }
|
|
27
27
|
catch (_) { return '0.0.0' }
|
|
28
28
|
})()
|
|
29
|
-
import { runDesktopCleanup, DEFAULT_PROTECTED_PROCESSES } from './desktop.js'
|
|
29
|
+
import { runDesktopCleanup, runDesktopProbe, forceCloseElevated, runElevatedClose, DEFAULT_PROTECTED_PROCESSES } from './desktop.js'
|
|
30
30
|
import {
|
|
31
31
|
createLedger,
|
|
32
32
|
eligibleForAuto,
|
|
@@ -47,6 +47,91 @@ import {
|
|
|
47
47
|
export const name = 'dsh-console-sentinel'
|
|
48
48
|
export const inject = ['tools']
|
|
49
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Host-process guard — the SAFETY BACKSTOP for any bulk close/hide action.
|
|
52
|
+
//
|
|
53
|
+
// 🛑 2026-11-08 事故:点「关闭空壳窗口」把 DSH 客户端也关了。原因是从 ledger 里
|
|
54
|
+
// 取"空壳"时只按 isEmpty 过滤,没排除宿主自己的进程家族 —— 宿主(Electron /
|
|
55
|
+
// 其下 pwsh、node、conhost)里有些控制台窗口恰好"没有业务子进程",被当成空壳
|
|
56
|
+
// 一并 WM_CLOSE,连带把宿主进程树杀掉。
|
|
57
|
+
//
|
|
58
|
+
// 现在任何批量关闭/隐藏都必须先算出一个"宿主保护集":宿主进程自身 + 全部祖先
|
|
59
|
+
// + 全部后代 + 所有镜像名像 dsh/deepseek/electron 的进程。命中集合的窗口一律跳过。
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
let __hostGuardCache = { at: 0, set: null }
|
|
62
|
+
function hostProcessGuard(ttlMs = 8000) {
|
|
63
|
+
const now = Date.now()
|
|
64
|
+
if (__hostGuardCache.set && now - __hostGuardCache.at < ttlMs) return __hostGuardCache.set
|
|
65
|
+
const guard = new Set()
|
|
66
|
+
const self = process.pid
|
|
67
|
+
guard.add(self)
|
|
68
|
+
try {
|
|
69
|
+
const req = createRequire(import.meta.url)
|
|
70
|
+
const { execFileSync } = req('node:child_process')
|
|
71
|
+
// One shot: pid|ppid|name for every process.
|
|
72
|
+
const raw = execFileSync('powershell.exe',
|
|
73
|
+
['-NoProfile', '-NonInteractive', '-Command',
|
|
74
|
+
"Get-CimInstance Win32_Process | ForEach-Object { '{0}|{1}|{2}' -f $_.ProcessId,$_.ParentProcessId,$_.Name }"],
|
|
75
|
+
{ encoding: 'utf8', timeout: 15000, windowsHide: true })
|
|
76
|
+
const map = new Map() // pid -> { ppid, name }
|
|
77
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
78
|
+
const parts = line.trim().split('|')
|
|
79
|
+
if (parts.length < 3) continue
|
|
80
|
+
const pid = Number(parts[0])
|
|
81
|
+
if (!pid) continue
|
|
82
|
+
map.set(pid, { ppid: Number(parts[1]) || 0, name: String(parts[2] || '') })
|
|
83
|
+
}
|
|
84
|
+
const childrenOf = new Map()
|
|
85
|
+
for (const [pid, info] of map) {
|
|
86
|
+
if (!childrenOf.has(info.ppid)) childrenOf.set(info.ppid, [])
|
|
87
|
+
childrenOf.get(info.ppid).push(pid)
|
|
88
|
+
}
|
|
89
|
+
const seed = new Set([self])
|
|
90
|
+
// 1) ancestors of self
|
|
91
|
+
let cur = self
|
|
92
|
+
const upSeen = new Set()
|
|
93
|
+
while (cur && map.has(cur) && !upSeen.has(cur)) {
|
|
94
|
+
upSeen.add(cur); seed.add(cur); cur = map.get(cur).ppid
|
|
95
|
+
}
|
|
96
|
+
// 2) every process whose image looks like DSH / Electron / DeepSeek harness
|
|
97
|
+
for (const [pid, info] of map) {
|
|
98
|
+
const n = String(info.name).toLowerCase()
|
|
99
|
+
if (n.indexOf('dsh') === 0 || n.indexOf('deepseek') >= 0 || n === 'electron.exe' || n === 'electron') {
|
|
100
|
+
seed.add(pid)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 3) expand to ALL descendants of every seed (whole DSH tree, wherever we sit)
|
|
104
|
+
const stack = [...seed]
|
|
105
|
+
const seen = new Set(seed)
|
|
106
|
+
for (const p of seed) guard.add(p)
|
|
107
|
+
while (stack.length) {
|
|
108
|
+
const p = stack.pop()
|
|
109
|
+
for (const c of (childrenOf.get(p) || [])) {
|
|
110
|
+
if (!seen.has(c)) { seen.add(c); guard.add(c); stack.push(c) }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (_) {
|
|
114
|
+
// Could not enumerate: keep at least self. Callers additionally skip
|
|
115
|
+
// source==='dshHost' rows, so a total enumeration failure is still safe.
|
|
116
|
+
}
|
|
117
|
+
__hostGuardCache = { at: now, set: guard }
|
|
118
|
+
return guard
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 只有"裸解释器"才允许被批量关闭:cmd / powershell / pwsh / python。
|
|
122
|
+
// 显式排除 mpiexec(Fluent MPI)、fluent/ansys/cx* 求解器、node(可能托管服务/中继)、
|
|
123
|
+
// launcher 等——这些一旦被关,会杀掉正在计算的 Fluent 或后台服务。
|
|
124
|
+
const SAFE_SHELL_NAMES = new Set([
|
|
125
|
+
'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe',
|
|
126
|
+
'python', 'python.exe', 'pythonw', 'pythonw.exe',
|
|
127
|
+
])
|
|
128
|
+
function isSafeClosableShell(row) {
|
|
129
|
+
const raw = String((row && (row.processName || row.process)) || '').toLowerCase()
|
|
130
|
+
if (!SAFE_SHELL_NAMES.has(raw)) return false
|
|
131
|
+
if (/mpi|fluent|ansys|cx\d|solver|launcher|cae/.test(raw)) return false
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
50
135
|
export const Config = Schema.object({
|
|
51
136
|
enabled: Schema.boolean().default(true).description(
|
|
52
137
|
'Master switch. When off, no scans run and no actions are taken.',
|
|
@@ -132,6 +217,11 @@ export const Config = Schema.object({
|
|
|
132
217
|
titles: Schema.array(Schema.string()).default([]).description(
|
|
133
218
|
'Window-title substrings that are never auto-managed or bulk-closed.',
|
|
134
219
|
),
|
|
220
|
+
unprotect: Schema.array(Schema.string()).default([]).description(
|
|
221
|
+
'Process image names whose built-in desktop-cleanup protection is lifted, ' +
|
|
222
|
+
'so they CAN be closed by 一键关闭其他窗口. Managed at runtime from the ' +
|
|
223
|
+
'settings panel (taskbar list → 取消保护). Never applies to the DSH host.',
|
|
224
|
+
),
|
|
135
225
|
}).description('Static exemption list; merged with the runtime-managed whitelist file.'),
|
|
136
226
|
})
|
|
137
227
|
|
|
@@ -841,7 +931,7 @@ function registerHttpUi(ctx, deps) {
|
|
|
841
931
|
hidden: rows.filter((row) => row.hidden).length,
|
|
842
932
|
},
|
|
843
933
|
windows: rows,
|
|
844
|
-
whitelist: { processes: wl.processes, titles: wl.titles },
|
|
934
|
+
whitelist: { processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [] },
|
|
845
935
|
whitelistPath: DEFAULT_WHITELIST_PATH,
|
|
846
936
|
protectedProcesses: [
|
|
847
937
|
...(config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES),
|
|
@@ -905,7 +995,12 @@ function registerHttpUi(ctx, deps) {
|
|
|
905
995
|
}))
|
|
906
996
|
|
|
907
997
|
register('/hide-empty', guard(async (req, res) => {
|
|
908
|
-
const
|
|
998
|
+
const hostSet = hostProcessGuard()
|
|
999
|
+
const all = ledger.snapshot()
|
|
1000
|
+
const targets = all.filter((row) =>
|
|
1001
|
+
row.isEmpty && !row.hidden &&
|
|
1002
|
+
row.source !== 'dsh-host' && !hostSet.has(Number(row.pid)) &&
|
|
1003
|
+
isSafeClosableShell(row))
|
|
909
1004
|
let hidden = 0
|
|
910
1005
|
for (const row of targets) {
|
|
911
1006
|
const out = await runWindowAction(row.hwnd, 'hide')
|
|
@@ -915,6 +1010,72 @@ function registerHttpUi(ctx, deps) {
|
|
|
915
1010
|
wsSendJson(res, 200, { ok: true, hidden, status: statusPayload() })
|
|
916
1011
|
}))
|
|
917
1012
|
|
|
1013
|
+
// 关闭空壳窗口:对"裸解释器空壳"发 WM_CLOSE(真正结束,不是隐藏)。
|
|
1014
|
+
// 三重硬保护:① 宿主全进程树 ② source=dsh-host ③ 只认 cmd/powershell/pwsh/python,
|
|
1015
|
+
// 绝不动 mpiexec/fluent/ansys/node —— 否则会误杀正在计算的 Fluent 或宿主。
|
|
1016
|
+
register('/close-empty', guard(async (req, res) => {
|
|
1017
|
+
const hostSet = hostProcessGuard()
|
|
1018
|
+
const all = ledger.snapshot()
|
|
1019
|
+
const targets = all.filter((row) =>
|
|
1020
|
+
row.isEmpty &&
|
|
1021
|
+
row.source !== 'dsh-host' && !hostSet.has(Number(row.pid)) &&
|
|
1022
|
+
isSafeClosableShell(row))
|
|
1023
|
+
const skipped = all.filter((row) => row.isEmpty && !targets.includes(row)).length
|
|
1024
|
+
let closed = 0
|
|
1025
|
+
for (const row of targets) {
|
|
1026
|
+
const out = await runWindowAction(row.hwnd, 'close')
|
|
1027
|
+
if (out && out.ok) closed += 1
|
|
1028
|
+
}
|
|
1029
|
+
await scan()
|
|
1030
|
+
wsSendJson(res, 200, { ok: true, closed, skipped, status: statusPayload() })
|
|
1031
|
+
}))
|
|
1032
|
+
|
|
1033
|
+
// 按软件一键关闭:对指定进程名的"全部窗口"发 WM_CLOSE(等于对该软件每个窗口按 Alt+F4),
|
|
1034
|
+
// 不需要逐个点。宿主/DSH/Fluent 等硬保护;提权软件走一次 UAC 提权优雅关闭。
|
|
1035
|
+
register('/close-process', guard(async (req, res) => {
|
|
1036
|
+
const body = await wsReadJsonBody(req)
|
|
1037
|
+
const procName = String(body.process || '').trim()
|
|
1038
|
+
if (!procName) return wsSendJson(res, 400, { ok: false, reason: 'no-process' })
|
|
1039
|
+
const target = procName.toLowerCase().replace(/\.exe$/, '')
|
|
1040
|
+
if (/^dsh|electron|deepseek/i.test(target)) {
|
|
1041
|
+
return wsSendJson(res, 200, { ok: false, reason: 'host-protected' })
|
|
1042
|
+
}
|
|
1043
|
+
let probe
|
|
1044
|
+
try { probe = await runDesktopProbe(config.probeTimeoutMs || 20000) }
|
|
1045
|
+
catch (err) { return wsSendJson(res, 200, { ok: false, reason: String((err && err.message) || err) }) }
|
|
1046
|
+
if (!probe || !probe.ok) return wsSendJson(res, 200, { ok: false, reason: (probe && probe.reason) || 'probe-failed' })
|
|
1047
|
+
const hostSet = hostProcessGuard()
|
|
1048
|
+
const procOf = (w) => String(w.process ?? w.Process ?? '').toLowerCase().replace(/\.exe$/, '')
|
|
1049
|
+
const pidOf = (w) => Number(w.pid ?? w.Pid)
|
|
1050
|
+
const hwndOf = (w) => Number(w.hwnd ?? w.Hwnd)
|
|
1051
|
+
const elevOf = (w) => Boolean(w.elevated ?? w.Elevated)
|
|
1052
|
+
const wins = (probe.windows || []).filter((w) => procOf(w) === target && !hostSet.has(pidOf(w)))
|
|
1053
|
+
if (!wins.length) return wsSendJson(res, 200, { ok: true, process: procName, total: 0, closedCount: 0, note: 'no-windows' })
|
|
1054
|
+
const normalH = [], elevH = []
|
|
1055
|
+
for (const w of wins) (elevOf(w) ? elevH : normalH).push(hwndOf(w))
|
|
1056
|
+
let closed = 0
|
|
1057
|
+
for (const h of normalH) {
|
|
1058
|
+
const r = await runWindowAction(h, 'close')
|
|
1059
|
+
if (r && r.ok) closed += 1
|
|
1060
|
+
}
|
|
1061
|
+
let elevRes = null
|
|
1062
|
+
if (elevH.length) {
|
|
1063
|
+
elevRes = await runElevatedClose(elevH, 120000)
|
|
1064
|
+
closed += (elevRes.closedCount || 0)
|
|
1065
|
+
}
|
|
1066
|
+
await scan()
|
|
1067
|
+
wsSendJson(res, 200, {
|
|
1068
|
+
ok: true,
|
|
1069
|
+
process: procName,
|
|
1070
|
+
total: wins.length,
|
|
1071
|
+
closedCount: closed,
|
|
1072
|
+
normal: normalH.length,
|
|
1073
|
+
elevated: elevH.length,
|
|
1074
|
+
uacCancelled: Boolean(elevRes && elevRes.reason === 'uac-denied-or-cancelled'),
|
|
1075
|
+
status: statusPayload(),
|
|
1076
|
+
})
|
|
1077
|
+
}))
|
|
1078
|
+
|
|
918
1079
|
register('/window', guard(async (req, res) => {
|
|
919
1080
|
const body = await wsReadJsonBody(req)
|
|
920
1081
|
const hwnd = Number(body.hwnd)
|
|
@@ -922,6 +1083,26 @@ function registerHttpUi(ctx, deps) {
|
|
|
922
1083
|
if (!hwnd || supportedActions.indexOf(action) < 0) {
|
|
923
1084
|
return wsSendJson(res, 400, { ok: false, reason: 'bad-params' })
|
|
924
1085
|
}
|
|
1086
|
+
// 单窗口操作也不能碰宿主 / Fluent / MPI(否则点一下就把 DSH 或正在算的 Fluent 关了)。
|
|
1087
|
+
const procHint = String(body.process || '').toLowerCase().replace(/\.exe$/, '')
|
|
1088
|
+
const sysLocked = procHint && (/^dsh|electron|deepseek|mpi|fluent|ansys|^cx\d|solver|cae|launcher/i.test(procHint))
|
|
1089
|
+
const hostSet = hostProcessGuard()
|
|
1090
|
+
const rec = ledger.snapshot().find((r) => Number(r.hwnd) === hwnd)
|
|
1091
|
+
if (sysLocked || (rec && (hostSet.has(Number(rec.pid)) || rec.source === 'dsh-host'))) {
|
|
1092
|
+
return wsSendJson(res, 200, { ok: false, reason: 'host-protected', hwnd, action, status: statusPayload() })
|
|
1093
|
+
}
|
|
1094
|
+
// 管理员窗口:非提权 WM_CLOSE 会被 UIPI 丢弃 → 走一次提权优雅关闭(WM_CLOSE)。
|
|
1095
|
+
if (action === 'close' && Boolean(body.elevated)) {
|
|
1096
|
+
const er = await runElevatedClose([hwnd], 120000)
|
|
1097
|
+
await scan()
|
|
1098
|
+
const ok = (er.closedCount || 0) > 0
|
|
1099
|
+
return wsSendJson(res, 200, {
|
|
1100
|
+
ok,
|
|
1101
|
+
hwnd, action, elevated: true,
|
|
1102
|
+
reason: ok ? '' : (er.reason || 'not-closed'),
|
|
1103
|
+
status: statusPayload(),
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
925
1106
|
const result = await runWindowAction(hwnd, action)
|
|
926
1107
|
await scan()
|
|
927
1108
|
wsSendJson(res, 200, { ok: Boolean(result && result.ok), hwnd, action, result, status: statusPayload() })
|
|
@@ -931,33 +1112,150 @@ function registerHttpUi(ctx, deps) {
|
|
|
931
1112
|
const body = await wsReadJsonBody(req)
|
|
932
1113
|
const dryRun = Boolean(body.dryRun)
|
|
933
1114
|
const wl = getWhitelist()
|
|
1115
|
+
const norm = (s) => String(s).toLowerCase().replace(/\.exe$/, '')
|
|
1116
|
+
// Effective protection = (built-in + config + whitelist) minus the
|
|
1117
|
+
// processes the user explicitly lifted via 取消保护. The DSH host is
|
|
1118
|
+
// always kept protected regardless of the unprotect list.
|
|
1119
|
+
const unprotect = new Set((wl.unprotect || []).map(norm))
|
|
1120
|
+
const baseProtected = [
|
|
1121
|
+
...(config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES),
|
|
1122
|
+
...wl.processes,
|
|
1123
|
+
]
|
|
1124
|
+
const effectiveProtected = baseProtected.filter((p) => {
|
|
1125
|
+
const n = norm(p)
|
|
1126
|
+
if (n === 'dsh desktop' || n.startsWith('dsh next') || n === 'dsh') return true // host never closable
|
|
1127
|
+
return !unprotect.has(n)
|
|
1128
|
+
})
|
|
934
1129
|
const result = await runDesktopCleanup({
|
|
935
1130
|
confirm: !dryRun,
|
|
936
1131
|
selfPid: process.pid,
|
|
937
|
-
protectedProcesses:
|
|
1132
|
+
protectedProcesses: effectiveProtected,
|
|
938
1133
|
keepTitles: [...(config.cleanup?.keepTitles || []), ...wl.titles],
|
|
1134
|
+
// Never let 一键清理任务栏窗口 touch the DSH host process family.
|
|
1135
|
+
keepPids: [...hostProcessGuard()],
|
|
939
1136
|
})
|
|
940
1137
|
const fmt = (w) => 'hwnd=' + w.hwnd + ' pid=' + w.pid + ' [' + (w.process || '?') + '] ' + (w.title || '').slice(0, 70)
|
|
1138
|
+
const detailed = (w) => ({
|
|
1139
|
+
hwnd: w.hwnd,
|
|
1140
|
+
pid: w.pid,
|
|
1141
|
+
process: w.process || '',
|
|
1142
|
+
title: w.title || '',
|
|
1143
|
+
icon: w.icon || '',
|
|
1144
|
+
})
|
|
941
1145
|
wsSendJson(res, 200, {
|
|
942
1146
|
ok: result.ok,
|
|
943
1147
|
dryRun: result.dryRun,
|
|
944
1148
|
fgHwnd: result.fgHwnd,
|
|
945
1149
|
planned: (result.targets || []).map(fmt),
|
|
1150
|
+
plannedDetailed: (result.targets || []).map(detailed),
|
|
946
1151
|
plannedCount: (result.targets || []).length,
|
|
947
1152
|
kept: (result.kept || []).map((k) => ({ reason: k.keepReason, text: fmt(k) })),
|
|
948
1153
|
closedCount: (result.closed || []).length,
|
|
1154
|
+
elevatedCount: result.elevatedCount || 0,
|
|
1155
|
+
elevatedUacCancelled: Boolean(result.elevatedUacCancelled),
|
|
949
1156
|
reason: result.reason,
|
|
950
1157
|
})
|
|
951
1158
|
}))
|
|
952
1159
|
|
|
1160
|
+
register('/taskbar-windows', guard(async (req, res) => {
|
|
1161
|
+
// Enumerate every visible taskbar window, attach its exe icon (base64
|
|
1162
|
+
// PNG data URL) and the current whitelist / protection state so the
|
|
1163
|
+
// settings panel can present a checkbox list the user picks exemptions
|
|
1164
|
+
// from, and can lift built-in protection per process ("取消保护").
|
|
1165
|
+
let probe
|
|
1166
|
+
try {
|
|
1167
|
+
probe = await runDesktopProbe(config.probeTimeoutMs || 20000)
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
return wsSendJson(res, 200, { ok: false, reason: String((err && err.message) || err), windows: [] })
|
|
1170
|
+
}
|
|
1171
|
+
if (!probe || !probe.ok) {
|
|
1172
|
+
return wsSendJson(res, 200, { ok: false, reason: (probe && probe.reason) || 'probe-failed', windows: [] })
|
|
1173
|
+
}
|
|
1174
|
+
const wl = getWhitelist()
|
|
1175
|
+
const norm = (s) => String(s).toLowerCase().replace(/\.exe$/, '')
|
|
1176
|
+
const wlProc = new Set((wl.processes || []).map(norm))
|
|
1177
|
+
const wlTitle = (wl.titles || []).map((s) => String(s).toLowerCase()).filter(Boolean)
|
|
1178
|
+
const hardSet = new Set((config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES).map(norm))
|
|
1179
|
+
const unprotectSet = new Set((wl.unprotect || []).map(norm))
|
|
1180
|
+
// The DSH host window itself must never be closeable — not even via 取消保护.
|
|
1181
|
+
const isHostProc = (p) => p === 'dsh desktop' || p.startsWith('dsh next') || p === 'dsh'
|
|
1182
|
+
const seen = new Set()
|
|
1183
|
+
const windows = []
|
|
1184
|
+
for (const raw of probe.windows || []) {
|
|
1185
|
+
const hwnd = raw.hwnd ?? raw.Hwnd
|
|
1186
|
+
const pid = raw.pid ?? raw.Pid
|
|
1187
|
+
const proc = raw.process ?? raw.Process ?? ''
|
|
1188
|
+
const title = raw.title ?? raw.Title ?? ''
|
|
1189
|
+
const icon = raw.icon ?? raw.Icon ?? ''
|
|
1190
|
+
const cls = raw.class ?? raw.Class ?? ''
|
|
1191
|
+
const procLower = norm(proc)
|
|
1192
|
+
const titleLower = String(title).toLowerCase()
|
|
1193
|
+
const byProcess = wlProc.has(procLower)
|
|
1194
|
+
const byTitle = !byProcess && wlTitle.some((t) => t && titleLower.indexOf(t) >= 0)
|
|
1195
|
+
const isHard = hardSet.has(procLower) || isHostProc(procLower)
|
|
1196
|
+
const isUnprotect = unprotectSet.has(procLower)
|
|
1197
|
+
const hostLocked = isHostProc(procLower)
|
|
1198
|
+
const elevated = Boolean(raw.elevated ?? raw.Elevated)
|
|
1199
|
+
const key = String(hwnd) + '|' + String(proc) + '|' + String(title)
|
|
1200
|
+
if (seen.has(key)) continue
|
|
1201
|
+
seen.add(key)
|
|
1202
|
+
windows.push({
|
|
1203
|
+
hwnd,
|
|
1204
|
+
pid,
|
|
1205
|
+
process: proc,
|
|
1206
|
+
title,
|
|
1207
|
+
icon,
|
|
1208
|
+
class: cls,
|
|
1209
|
+
whitelisted: byProcess || byTitle,
|
|
1210
|
+
whitelistedBy: byProcess ? 'process' : (byTitle ? 'title' : ''),
|
|
1211
|
+
// effective protection == built-in protected AND not lifted by user
|
|
1212
|
+
protected: (isHard && !isUnprotect) || hostLocked,
|
|
1213
|
+
hardProtected: isHard,
|
|
1214
|
+
unprotected: isHard && isUnprotect && !hostLocked,
|
|
1215
|
+
hostLocked,
|
|
1216
|
+
// Elevated rows cannot be closed by WM_CLOSE (UIPI drops it); the
|
|
1217
|
+
// panel offers 强制关闭(管理员) which goes through UAC + taskkill.
|
|
1218
|
+
elevated,
|
|
1219
|
+
})
|
|
1220
|
+
}
|
|
1221
|
+
wsSendJson(res, 200, { ok: true, selfElevated: Boolean(probe.selfElevated), windows })
|
|
1222
|
+
}))
|
|
1223
|
+
|
|
1224
|
+
// 强制关闭(管理员): UAC-elevated taskkill for windows whose owner runs
|
|
1225
|
+
// elevated. An unelevated WM_CLOSE is silently dropped by Windows UIPI,
|
|
1226
|
+
// which is why Taskmgr / iTop icons "would not close" (measured
|
|
1227
|
+
// 2026-11-07). One UAC prompt per batch; answering No returns
|
|
1228
|
+
// uac-denied-or-cancelled and changes nothing.
|
|
1229
|
+
register('/force-close', guard(async (req, res) => {
|
|
1230
|
+
const body = await wsReadJsonBody(req)
|
|
1231
|
+
const pids = Array.isArray(body.pids) ? body.pids : (body.pid ? [body.pid] : [])
|
|
1232
|
+
// Only processes the caller actually listed are accepted; forceCloseElevated
|
|
1233
|
+
// additionally re-verifies every pid with tasklist and blocks the DSH host.
|
|
1234
|
+
const allowedProcesses = Array.isArray(body.allowedProcesses) ? body.allowedProcesses : []
|
|
1235
|
+
const result = await forceCloseElevated(pids, {
|
|
1236
|
+
selfPid: process.pid,
|
|
1237
|
+
allowedProcesses,
|
|
1238
|
+
timeoutMs: 120000,
|
|
1239
|
+
})
|
|
1240
|
+
wsSendJson(res, 200, {
|
|
1241
|
+
ok: Boolean(result.ok),
|
|
1242
|
+
reason: result.reason || '',
|
|
1243
|
+
killedCount: result.killedCount || 0,
|
|
1244
|
+
total: result.total || 0,
|
|
1245
|
+
results: result.results || [],
|
|
1246
|
+
rejected: result.rejected || [],
|
|
1247
|
+
stderr: result.stderr || '',
|
|
1248
|
+
})
|
|
1249
|
+
}))
|
|
1250
|
+
|
|
953
1251
|
register('/whitelist', guard(async (req, res) => {
|
|
954
1252
|
if ((req.method || 'GET').toUpperCase() === 'GET') {
|
|
955
1253
|
const wl = getWhitelist()
|
|
956
|
-
return wsSendJson(res, 200, { ok: true, processes: wl.processes, titles: wl.titles, path: DEFAULT_WHITELIST_PATH })
|
|
1254
|
+
return wsSendJson(res, 200, { ok: true, processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [], path: DEFAULT_WHITELIST_PATH })
|
|
957
1255
|
}
|
|
958
1256
|
const body = await wsReadJsonBody(req)
|
|
959
1257
|
const act = String(body.action || 'list')
|
|
960
|
-
const kind = body.kind === 'title' ? 'titles' : 'processes'
|
|
1258
|
+
const kind = body.kind === 'title' ? 'titles' : (body.kind === 'unprotect' ? 'unprotect' : 'processes')
|
|
961
1259
|
const items = Array.isArray(body.items) ? body.items : []
|
|
962
1260
|
let wl = getWhitelist()
|
|
963
1261
|
if (act === 'add') {
|
|
@@ -973,7 +1271,7 @@ function registerHttpUi(ctx, deps) {
|
|
|
973
1271
|
setWhitelist(wl)
|
|
974
1272
|
ctx.emit('console-sentinel/whitelist', { action: act, kind })
|
|
975
1273
|
}
|
|
976
|
-
wsSendJson(res, 200, { ok: true, action: act, processes: wl.processes, titles: wl.titles, path: DEFAULT_WHITELIST_PATH })
|
|
1274
|
+
wsSendJson(res, 200, { ok: true, action: act, processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [], path: DEFAULT_WHITELIST_PATH })
|
|
977
1275
|
}))
|
|
978
1276
|
})
|
|
979
1277
|
} catch (error) {
|
package/lib/probe.js
CHANGED
|
@@ -72,7 +72,7 @@ $cb = [PInvoke.W32+EnumWindowsProc]{
|
|
|
72
72
|
if ($cls -eq 'CASCADIA_HOSTING_WINDOW_CLASS' -or $cls -eq 'PseudoConsoleWindow' -or $cls -eq 'ConsoleWindowClass') {
|
|
73
73
|
$t = [System.Text.StringBuilder]::new(512); $null = [PInvoke.W32]::GetWindowText($hWnd, $t, 512)
|
|
74
74
|
$procid = 0; $null = [PInvoke.W32]::GetWindowThreadProcessId($hWnd, [ref]$procid)
|
|
75
|
-
$global:__sentinelWins += [PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $t.ToString(); PID = [int]$procid; Visible = [PInvoke.W32]::IsWindowVisible($hWnd) }
|
|
75
|
+
$global:__sentinelWins += [PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $t.ToString(); PID = [int]$procid; Visible = [PInvoke.W32]::IsWindowVisible($hWnd); IconPath = [string]::Empty }
|
|
76
76
|
}
|
|
77
77
|
return $true
|
|
78
78
|
}
|
|
@@ -127,6 +127,7 @@ foreach ($pid_ in $consolePids) {
|
|
|
127
127
|
if ($ppid -and $byPid.ContainsKey([int]$ppid)) { $pName = $byPid[[int]$ppid].Name }
|
|
128
128
|
$kids = @()
|
|
129
129
|
if ($childMap.ContainsKey([int]$pid_)) { $kids = @($childMap[[int]$pid_]) }
|
|
130
|
+
|
|
130
131
|
$procs[[string]$pid_] = @{
|
|
131
132
|
Name = $p.Name
|
|
132
133
|
CpuMs = [int64]($p.TotalProcessorTime.TotalMilliseconds)
|
package/lib/whitelist.js
CHANGED
|
@@ -27,23 +27,29 @@ function normItems(arr) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function emptyWhitelist() {
|
|
30
|
-
return { processes: [], titles: [] }
|
|
30
|
+
return { processes: [], titles: [], unprotect: [] }
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function sanitizeWhitelist(raw) {
|
|
34
34
|
const r = raw && typeof raw === 'object' ? raw : {}
|
|
35
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
processes: normItems(r.processes),
|
|
37
|
+
titles: normItems(r.titles),
|
|
38
|
+
unprotect: normItems(r.unprotect),
|
|
39
|
+
}
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function mergeWhitelists(...lists) {
|
|
39
43
|
const processes = []
|
|
40
44
|
const titles = []
|
|
45
|
+
const unprotect = []
|
|
41
46
|
for (const l of lists) {
|
|
42
47
|
const c = sanitizeWhitelist(l)
|
|
43
48
|
for (const p of c.processes) if (!processes.includes(p)) processes.push(p)
|
|
44
49
|
for (const t of c.titles) if (!titles.includes(t)) titles.push(t)
|
|
50
|
+
for (const u of c.unprotect) if (!unprotect.includes(u)) unprotect.push(u)
|
|
45
51
|
}
|
|
46
|
-
return { processes, titles }
|
|
52
|
+
return { processes, titles, unprotect }
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export function loadWhitelist(file = DEFAULT_WHITELIST_PATH) {
|
|
@@ -62,7 +68,7 @@ export function saveWhitelist(data, file = DEFAULT_WHITELIST_PATH) {
|
|
|
62
68
|
return clean
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
// kind is the canonical plural key: 'processes' | 'titles'.
|
|
71
|
+
// kind is the canonical plural key: 'processes' | 'titles' | 'unprotect'.
|
|
66
72
|
export function addWhitelistItems(data, kind, items) {
|
|
67
73
|
const base = sanitizeWhitelist(data)
|
|
68
74
|
return mergeWhitelists(base, { [kind]: items })
|
|
@@ -70,9 +76,9 @@ export function addWhitelistItems(data, kind, items) {
|
|
|
70
76
|
|
|
71
77
|
export function removeWhitelistItems(data, kind, items) {
|
|
72
78
|
const base = sanitizeWhitelist(data)
|
|
73
|
-
const key = kind === 'titles' ? 'titles' : 'processes'
|
|
79
|
+
const key = kind === 'titles' ? 'titles' : (kind === 'unprotect' ? 'unprotect' : 'processes')
|
|
74
80
|
const drop = new Set(normItems(items).map((s) => s.toLowerCase()))
|
|
75
|
-
const out = { processes: base.processes, titles: base.titles }
|
|
81
|
+
const out = { processes: base.processes, titles: base.titles, unprotect: base.unprotect }
|
|
76
82
|
out[key] = base[key].filter((v) => !drop.has(v.toLowerCase()))
|
|
77
83
|
return out
|
|
78
84
|
}
|
package/package.json
CHANGED
|
@@ -1,82 +1,90 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-console-sentinel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH console window
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/loyalchiiina/dsh-console-sentinel.git"
|
|
9
|
-
},
|
|
10
|
-
"homepage": "https://github.com/loyalchiiina/dsh-console-sentinel#readme",
|
|
11
|
-
"bugs": {
|
|
12
|
-
"url": "https://github.com/loyalchiiina/dsh-console-sentinel/issues"
|
|
13
|
-
},
|
|
14
|
-
"type": "module",
|
|
15
|
-
"main": "lib/index.js",
|
|
16
|
-
"exports": {
|
|
17
|
-
".": "./lib/index.js",
|
|
18
|
-
"./client": "./lib/client.js",
|
|
19
|
-
"./package.json": "./package.json",
|
|
20
|
-
"./locale/*.json": "./locale/*.json",
|
|
21
|
-
"./icon": "./icon.svg"
|
|
22
|
-
},
|
|
23
|
-
"files": [
|
|
24
|
-
"lib",
|
|
25
|
-
"lib/**/*.js",
|
|
26
|
-
"cordis.patch.yml",
|
|
27
|
-
"LICENSE",
|
|
28
|
-
"README.md",
|
|
29
|
-
"README.zh.md",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"README
|
|
33
|
-
"README
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"dsh
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"@deepseek-ai/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
},
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-console-sentinel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH console sentinel for Windows — one-click cleanup of taskbar windows and empty console shells. Graceful WM_CLOSE (= Alt+F4) per window or whole app, UAC-elevated close for admin apps, icon-grid and detail views, preview-before-execute, persistent whitelist, and hard protection for the DSH host and Fluent/MPI solvers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/loyalchiiina/dsh-console-sentinel.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/loyalchiiina/dsh-console-sentinel#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/loyalchiiina/dsh-console-sentinel/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./client": "./lib/client.js",
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"lib/**/*.js",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README.zh.md",
|
|
30
|
+
"README-zh.md",
|
|
31
|
+
"README.es.md",
|
|
32
|
+
"README.pt.md",
|
|
33
|
+
"README.hi.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"icon.svg",
|
|
36
|
+
"docs/screenshots/*.png",
|
|
37
|
+
"locale/*.json"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"dsh",
|
|
44
|
+
"dsh-plugin",
|
|
45
|
+
"deepseek-harness",
|
|
46
|
+
"console",
|
|
47
|
+
"window",
|
|
48
|
+
"taskbar",
|
|
49
|
+
"close-window",
|
|
50
|
+
"alt-f4",
|
|
51
|
+
"wm-close",
|
|
52
|
+
"uac",
|
|
53
|
+
"elevated",
|
|
54
|
+
"powershell",
|
|
55
|
+
"process-manager",
|
|
56
|
+
"windows"
|
|
57
|
+
],
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@deepseek-ai/cordis": ">=4.0.2",
|
|
60
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.2",
|
|
61
|
+
"@deepseek-ai/schemastery": ">=3.18.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@deepseek-ai/cordis": {
|
|
65
|
+
"optional": false
|
|
66
|
+
},
|
|
67
|
+
"@deepseek-ai/dsh-tools": {
|
|
68
|
+
"optional": false
|
|
69
|
+
},
|
|
70
|
+
"@deepseek-ai/schemastery": {
|
|
71
|
+
"optional": false
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"dsh": {
|
|
75
|
+
"bundle": {
|
|
76
|
+
"patch": "./cordis.patch.yml"
|
|
77
|
+
},
|
|
78
|
+
"client": {
|
|
79
|
+
"inject": [
|
|
80
|
+
"@deepseek-ai/dsh-client-connection",
|
|
81
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
83
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
84
|
+
"@deepseek-ai/dsh-client-locale"
|
|
85
|
+
],
|
|
86
|
+
"platform": "web"
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"packageManager": "pnpm@11.7.0"
|
|
90
|
+
}
|