dsh-mesh-node 0.0.0-stage → 0.0.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,469 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir, hostname } from 'node:os'
5
+ import { z } from 'zod'
6
+ import { ok, denied, invalidArg } from '../core/result.js'
7
+ import { isProxyTimeout, maybeProxy, proxyTimeoutMessage } from '../core/desktop-proxy.js'
8
+
9
+ /**
10
+ * pc_app / pc_window / pc_browser:应用、窗口、浏览器操作(白名单 / 结构化参数)。
11
+ *
12
+ * 安全原则:
13
+ * - 启动只接受白名单别名;关闭只接受「进程名」且拒绝系统关键进程,且需 confirm=true;
14
+ * - 窗口操作只接受 PID(整数)或白名单应用名,不接受任意标题/命令;
15
+ * - 浏览器:只允许 http(s) URL;CDP 只连 127.0.0.1:9222(本机回环);
16
+ * - 不接收任意命令行、不注入按键。
17
+ */
18
+ const WINDOWS = process.platform === 'win32'
19
+
20
+ const APP_ALIASES = {
21
+ chrome: 'chrome.exe',
22
+ edge: 'msedge.exe',
23
+ notepad: 'notepad.exe',
24
+ explorer: 'explorer.exe',
25
+ taskmgr: 'taskmgr.exe',
26
+ code: 'Code.exe',
27
+ wechat: 'WeChat.exe',
28
+ word: 'WINWORD.EXE',
29
+ excel: 'EXCEL.EXE',
30
+ wps: 'wps.exe'
31
+ }
32
+
33
+ const PROTECTED = ['dsh', 'node', 'npm', 'powershell', 'pwsh', 'cmd', 'conhost', 'explorer', 'winlogon', 'csrss', 'services', 'lsass']
34
+
35
+ function run(command, args, timeoutMs = 20000) {
36
+ return new Promise((resolve) => {
37
+ const started = Date.now()
38
+ let stdout = ''
39
+ let stderr = ''
40
+ let child
41
+ try {
42
+ child = spawn(command, args, { windowsHide: true, timeout: timeoutMs })
43
+ } catch (error) {
44
+ resolve({ ok: false, error: String(error?.message || error), stdout, stderr, ms: Date.now() - started })
45
+ return
46
+ }
47
+ child.stdout?.on('data', (chunk) => (stdout += chunk))
48
+ child.stderr?.on('data', (chunk) => (stderr += chunk))
49
+ child.on('error', (error) => resolve({ ok: false, error: String(error?.message || error), stdout, stderr, ms: Date.now() - started }))
50
+ child.on('close', (code) => resolve({ ok: code === 0, code, error: code === 0 ? null : stderr.trim() || `exit ${code}`, stdout: stdout.trim(), stderr: stderr.trim(), ms: Date.now() - started }))
51
+ })
52
+ }
53
+
54
+ function powershell(script) {
55
+ return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script])
56
+ }
57
+
58
+ const WINDOW_SCRIPT = `
59
+ $sig = @'
60
+ using System;
61
+ using System.Text;
62
+ using System.Runtime.InteropServices;
63
+ public class W {
64
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
65
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
66
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
67
+ [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
68
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
69
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
70
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
71
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
72
+ [DllImport("user32.dll")] public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
73
+ public static string ClassOf(IntPtr h) {
74
+ var sb = new StringBuilder(256);
75
+ GetClassName(h, sb, sb.Capacity);
76
+ return sb.ToString().Replace("|", "/");
77
+ }
78
+ public static string ProcOf(uint pid) {
79
+ try { return System.Diagnostics.Process.GetProcessById((int)pid).ProcessName.Replace("|", "/"); } catch { return ""; }
80
+ }
81
+ public static System.Collections.Generic.List<string> List() {
82
+ var list = new System.Collections.Generic.List<string>();
83
+ EnumWindows((h, l) => {
84
+ if (!IsWindowVisible(h)) return true;
85
+ var sb = new StringBuilder(512);
86
+ GetWindowText(h, sb, sb.Capacity);
87
+ if (sb.Length == 0) return true;
88
+ uint pid; GetWindowThreadProcessId(h, out pid);
89
+ list.Add(h.ToString() + "|" + pid + "|" + ClassOf(h) + "|" + ProcOf(pid) + "|" + sb.ToString().Replace("|", "/") + "|" + (IsIconic(h) ? "1" : "0"));
90
+ return true;
91
+ }, IntPtr.Zero);
92
+ return list;
93
+ }
94
+ }
95
+
96
+ public class WClose {
97
+ [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
98
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
99
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
100
+ [DllImport("user32.dll")] public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
101
+ [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
102
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
103
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam);
104
+ public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
105
+ static IntPtr found = IntPtr.Zero;
106
+ public static string Describe(IntPtr h) {
107
+ uint pid; GetWindowThreadProcessId(h, out pid);
108
+ var c = new StringBuilder(256); GetClassName(h, c, c.Capacity);
109
+ var t = new StringBuilder(512); GetWindowText(h, t, t.Capacity);
110
+ string pn = "";
111
+ try { pn = System.Diagnostics.Process.GetProcessById((int)pid).ProcessName; } catch {}
112
+ return h.ToString() + "|" + pid + "|" + c.ToString().Replace("|", "/") + "|" + pn.Replace("|", "/") + "|" + t.ToString().Replace("|", "/");
113
+ }
114
+ public static string ByHwnd(long hv) {
115
+ var h = new IntPtr(hv);
116
+ if (!IsWindow(h)) return "NOT_FOUND";
117
+ if (!IsWindowVisible(h)) return "NOT_VISIBLE";
118
+ return Describe(h);
119
+ }
120
+ public static string ByPid(uint pid) {
121
+ found = IntPtr.Zero;
122
+ EnumWindows((h, l) => {
123
+ if (!IsWindowVisible(h)) return true;
124
+ uint p; GetWindowThreadProcessId(h, out p);
125
+ if (p != pid) return true;
126
+ var sb = new StringBuilder(256); GetWindowText(h, sb, sb.Capacity);
127
+ if (sb.Length == 0) return true;
128
+ found = h;
129
+ return false;
130
+ }, IntPtr.Zero);
131
+ if (found == IntPtr.Zero) return "NOT_FOUND";
132
+ return Describe(found);
133
+ }
134
+ public static bool Close(IntPtr h) { return PostMessage(h, 0x0010, IntPtr.Zero, IntPtr.Zero); }
135
+ }
136
+ '@
137
+ Add-Type -TypeDefinition $sig
138
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
139
+ [W]::List() | ForEach-Object { $_ }
140
+ `
141
+
142
+ function parseWindows(text) {
143
+ const rows = []
144
+ for (const line of String(text || '').split(/\r?\n/)) {
145
+ const parts = line.split('|')
146
+ if (parts.length < 6) continue
147
+ rows.push({ hwnd: parts[0], pid: Number(parts[1]), className: parts[2], processName: parts[3], title: parts[4], minimized: parts[5] === '1' })
148
+ }
149
+ return rows
150
+ }
151
+
152
+ const PROTECTED_WINDOW_CLASSES = ['Progman', 'WorkerW', 'Shell_TrayWnd', 'Shell_SecondaryTrayWnd', 'ApplicationFrameWindow']
153
+ const PROTECTED_WINDOW_SUFFIXES = ['CoreWindow']
154
+
155
+ function normalizeHwnd(value) {
156
+ const s = String(value || '').trim()
157
+ if (/^0x[0-9a-fA-F]{1,16}$/.test(s)) return BigInt(s).toString()
158
+ if (/^[0-9]{1,20}$/.test(s)) return s
159
+ return null
160
+ }
161
+
162
+ async function windowAction(scriptFooter) {
163
+ const r = await powershell(WINDOW_SCRIPT + '\n' + scriptFooter)
164
+ return r
165
+ }
166
+
167
+
168
+ // ── 浏览器用户配置(Chrome/Edge 自带的多 profile)────────────────────────────────
169
+ // 用户做自动化时常在浏览器里建多个「用户配置」,这里让手机能列出并按指定 profile 打开。
170
+ const BROWSER_PROFILE_SOURCES = {
171
+ chrome: {
172
+ userData: () => join(process.env.LOCALAPPDATA || '', 'Google/Chrome/User Data'),
173
+ exe: () =>
174
+ [
175
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
176
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
177
+ join(process.env.LOCALAPPDATA || '', 'Google/Chrome/Application/chrome.exe')
178
+ ].find((p) => existsSync(p)) || null
179
+ },
180
+ edge: {
181
+ userData: () => join(process.env.LOCALAPPDATA || '', 'Microsoft/Edge/User Data'),
182
+ exe: () =>
183
+ [
184
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
185
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
186
+ join(process.env.LOCALAPPDATA || '', 'Microsoft/Edge/Application/msedge.exe')
187
+ ].find((p) => existsSync(p)) || null
188
+ }
189
+ }
190
+
191
+ /** 读某个浏览器的 profile 清单:优先 Local State 的 profile.info_cache(含显示名),退化到扫目录名。 */
192
+ export function readBrowserProfiles(userDataDir) {
193
+ const rows = []
194
+ if (!userDataDir) return rows
195
+ try {
196
+ const localState = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8'))
197
+ const cache = localState?.profile?.info_cache || {}
198
+ for (const [dir, info] of Object.entries(cache)) {
199
+ if (!/^[A-Za-z0-9 _.-]{1,60}$/.test(dir)) continue
200
+ rows.push({
201
+ dir,
202
+ name: String(info?.name || dir).slice(0, 80),
203
+ isDefault: dir === 'Default',
204
+ avatarIndex: typeof info?.avatar_index === 'number' ? info.avatar_index : null
205
+ })
206
+ }
207
+ } catch {
208
+ /* Local State 缺失/损坏 → 退化到目录扫描 */
209
+ }
210
+ if (!rows.length) {
211
+ try {
212
+ for (const name of readdirSync(userDataDir)) {
213
+ if (name === 'Default' || /^Profile \d+$/.test(name)) rows.push({ dir: name, name, isDefault: name === 'Default', avatarIndex: null })
214
+ }
215
+ } catch {
216
+ /* 目录也不存在 → 空清单 */
217
+ }
218
+ }
219
+ return rows.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.dir.localeCompare(b.dir, 'en', { numeric: true }))
220
+ }
221
+
222
+ /** 把用户给的 profile(目录名或显示名)解析成真实目录名;未知则返回 null。 */
223
+ function resolveBrowserProfile(profiles, wanted) {
224
+ const raw = String(wanted || '').trim()
225
+ if (!raw) return null
226
+ const byDir = profiles.find((p) => p.dir.toLowerCase() === raw.toLowerCase())
227
+ if (byDir) return byDir.dir
228
+ const byName = profiles.find((p) => p.name.toLowerCase() === raw.toLowerCase())
229
+ return byName ? byName.dir : null
230
+ }
231
+
232
+ export function buildControlApps(ctx) {
233
+ return [
234
+ {
235
+ name: 'pc_app',
236
+ title: 'Application control (whitelisted) on the peer machine',
237
+ description:
238
+ 'Whitelisted application control: list(list running GUI apps) / start(alias: ' +
239
+ Object.keys(APP_ALIASES).join(', ') +
240
+ ') / close(process name, graceful, confirm=true). System/DSh processes are protected.',
241
+ inputSchema: z.object({
242
+ action: z.enum(['list', 'start', 'close']),
243
+ app: z.string().max(40).optional(),
244
+ path: z.string().max(20).optional(),
245
+ confirm: z.boolean().optional()
246
+ }),
247
+ handler: async ({ action, app, path, confirm }) => {
248
+ const cfg = ctx.getConfig()
249
+ if (cfg?.pcControl?.enabled !== true) return denied('pc_app is disabled on this node')
250
+ if (!WINDOWS) return denied('pc_app is only implemented on Windows')
251
+ const proxiedApp = await maybeProxy('pc_app', { action, app, path, confirm })
252
+ if (isProxyTimeout(proxiedApp)) return invalidArg(proxyTimeoutMessage('pc_app', proxiedApp))
253
+ if (proxiedApp) return proxiedApp
254
+ if (action === 'list') {
255
+ const r = await powershell(`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle.Trim() -ne '' } | Select-Object -First 40 ProcessName,Id,MainWindowTitle | ConvertTo-Json -Compress`)
256
+ if (!r.ok) return invalidArg('list failed: ' + r.error)
257
+ let list = []
258
+ try { list = JSON.parse(r.stdout || '[]') } catch {}
259
+ return ok({ action, host: hostname(), apps: list, ms: r.ms })
260
+ }
261
+ if (action === 'start') {
262
+ const key = String(app || '').toLowerCase()
263
+ const exe = APP_ALIASES[key]
264
+ if (!exe) return invalidArg('unknown app alias: ' + app + ' (allowed: ' + Object.keys(APP_ALIASES).join(', ') + ')')
265
+ const args = []
266
+ if (key === 'explorer' && path) {
267
+ if (!/^[A-Za-z]:[\\/]?$/.test(path)) return invalidArg('path for explorer must be a drive root like D: or D:\\')
268
+ args.push(path.replace(/\/$/, '\\'))
269
+ }
270
+ const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: false })
271
+ child.unref()
272
+ return ok({ action, app: key, exe, path: path || null, started: true, host: hostname() })
273
+ }
274
+ if (action === 'close') {
275
+ if (confirm !== true) return invalidArg('close requires confirm=true')
276
+ const name = String(app || '').replace(/\.exe$/i, '')
277
+ if (!/^[A-Za-z0-9_.-]{1,40}$/.test(name)) return invalidArg('invalid process name')
278
+ if (PROTECTED.includes(name.toLowerCase())) return denied('protected process: ' + name)
279
+ const r = await powershell(`$p = Get-Process -Name '${name}' -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 }; if (-not $p) { '0' } else { $n = 0; foreach ($x in $p) { if ($x.CloseMainWindow()) { $n++ } }; $n }`)
280
+ if (!r.ok) return invalidArg('close failed: ' + r.error)
281
+ return ok({ action, app: name, closedWindows: Number(r.stdout || 0), host: hostname(), ms: r.ms })
282
+ }
283
+ return invalidArg('unknown action')
284
+ }
285
+ },
286
+ {
287
+ name: 'pc_window',
288
+ title: 'Window control (list / focus / minimize / close)',
289
+ description:
290
+ 'Window control on the peer machine: list(visible windows: hwnd, pid, className, processName, title, minimized) / focus(pid) / minimize(pid) / close(hwnd or pid, confirm=true, graceful WM_CLOSE). System/desktop windows (Progman/WorkerW/Shell_TrayWnd/CoreWindow/ApplicationFrameWindow) are refused; close only posts WM_CLOSE to the window, never kills a process.',
291
+ inputSchema: z.object({
292
+ action: z.enum(['list', 'focus', 'minimize', 'close']),
293
+ pid: z.number().int().positive().optional(),
294
+ hwnd: z.string().max(20).optional(),
295
+ app: z.string().max(40).optional(),
296
+ confirm: z.boolean().optional()
297
+ }),
298
+ handler: async ({ action, pid, hwnd, app, confirm }) => {
299
+ const cfg = ctx.getConfig()
300
+ if (cfg?.pcControl?.enabled !== true) return denied('pc_window is disabled on this node')
301
+ if (!WINDOWS) return denied('pc_window is only implemented on Windows')
302
+ const proxiedWindow = await maybeProxy('pc_window', { action, pid, hwnd, app, confirm })
303
+ if (isProxyTimeout(proxiedWindow)) return invalidArg(proxyTimeoutMessage('pc_window', proxiedWindow))
304
+ if (proxiedWindow) return proxiedWindow
305
+ if (action === 'list') {
306
+ const r = await windowAction('')
307
+ if (!r.ok) return invalidArg('list failed: ' + r.error)
308
+ const rows = parseWindows(r.stdout)
309
+ return ok({ action, host: hostname(), count: rows.length, windows: rows.slice(0, 60), ms: r.ms })
310
+ }
311
+ if (action === 'close') {
312
+ if (confirm !== true) return invalidArg('close requires confirm=true')
313
+ const targetHwnd = normalizeHwnd(hwnd)
314
+ const targetPid = Number(pid || 0)
315
+ if (!targetHwnd && !targetPid) return invalidArg(hwnd ? 'E_WINDOW_NOT_FOUND: invalid hwnd (expected 0xHEX or decimal)' : 'E_WINDOW_NOT_FOUND: hwnd or pid is required')
316
+ const finder = targetHwnd
317
+ ? '$info = [WClose]::ByHwnd([long]' + targetHwnd + ')'
318
+ : '$info = [WClose]::ByPid([uint32]' + targetPid + ')'
319
+ const footer = finder + '\n' +
320
+ "if ($info -eq 'NOT_FOUND') { 'NOT_FOUND' }\n" +
321
+ "elseif ($info -eq 'NOT_VISIBLE') { 'NOT_VISIBLE' }\n" +
322
+ 'else {\n' +
323
+ " $parts = $info -split '\\|'\n" +
324
+ ' $cls = $parts[2]\n' +
325
+ " $protected = @('Progman','WorkerW','Shell_TrayWnd','Shell_SecondaryTrayWnd','ApplicationFrameWindow')\n" +
326
+ " if (($protected -contains $cls) -or ($cls -like '*CoreWindow*')) { 'PROTECTED|' + $info }\n" +
327
+ " else { $closed = [WClose]::Close([IntPtr]::new([long]$parts[0])); 'CLOSED|' + $closed + '|' + $info }\n" +
328
+ '}'
329
+ const r = await windowAction(footer)
330
+ if (!r.ok) return invalidArg('close failed: ' + r.error)
331
+ const lines = String(r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
332
+ const line = lines[lines.length - 1] || ''
333
+ if (line === 'NOT_FOUND') return invalidArg('E_WINDOW_NOT_FOUND: window not found (hwnd=' + (targetHwnd || '-') + ', pid=' + (targetPid || '-') + ')')
334
+ if (line === 'NOT_VISIBLE') return invalidArg('E_WINDOW_NOT_FOUND: window exists but is not visible')
335
+ const kind = line.split('|')[0]
336
+ const rest = line.slice(kind.length + 1).split('|')
337
+ const protectedInfo = { hwnd: rest[0], pid: Number(rest[1]), className: rest[2], processName: rest[3], title: rest[4] }
338
+ if (kind === 'PROTECTED') return denied('refusing to close protected system/desktop window (className=' + protectedInfo.className + ', title=' + protectedInfo.title + ')')
339
+ if (kind !== 'CLOSED') return invalidArg('unexpected close result: ' + line.slice(0, 200))
340
+ const info = { hwnd: rest[1], pid: Number(rest[2]), className: rest[3], processName: rest[4], title: rest[5] }
341
+ return ok({ action: 'close', host: hostname(), closed: rest[0] === 'True' || rest[0] === 'true', ...info, ms: r.ms })
342
+ }
343
+ const targetPid = Number(pid || 0)
344
+ if (!targetPid) return invalidArg('pid is required')
345
+ const where = `Where-Object { $_.pid -eq ${targetPid} }`
346
+ const script = action === 'focus'
347
+ ? `[W]::List() | ForEach-Object { $p = $_ -split '\\|'; if ([int]$p[1] -eq ${targetPid}) { [W]::SetForegroundWindow([IntPtr]::new([long]$p[0])) } }`
348
+ : `[W]::List() | ForEach-Object { $p = $_ -split '\\|'; if ([int]$p[1] -eq ${targetPid}) { [W]::ShowWindow([IntPtr]::new([long]$p[0]), 6) } }`
349
+ const r = await windowAction(script)
350
+ if (!r.ok) return invalidArg(action + ' failed: ' + r.error)
351
+ return ok({ action, pid: targetPid, host: hostname(), ms: r.ms })
352
+ }
353
+ },
354
+ {
355
+ name: 'pc_browser',
356
+ title: 'Browser control (open URL / list browsers / CDP tabs on 127.0.0.1:9222)',
357
+ description:
358
+ 'Browser control on the peer machine. actions: browsers(list installed Chrome/Edge) / profiles(list browser user profiles of Chrome/Edge) / ' +
359
+ 'open-url(http(s) URL; browser=default|chrome|edge; profile=<profile dir or display name> opens with that browser user profile) / ' +
360
+ 'debug-launch(launch a dedicated automation browser with CDP on 127.0.0.1:9222) / cdp-list / cdp-open(url) / cdp-close(id). CDP only connects to loopback.',
361
+ inputSchema: z.object({
362
+ action: z.enum(['browsers', 'profiles', 'open-url', 'debug-launch', 'cdp-list', 'cdp-open', 'cdp-close']),
363
+ url: z.string().max(2000).optional(),
364
+ browser: z.enum(['default', 'chrome', 'edge']).optional(),
365
+ profile: z.string().max(80).optional(),
366
+ newWindow: z.boolean().optional(),
367
+ id: z.string().max(100).optional()
368
+ }),
369
+ handler: async ({ action, url, browser = 'default', profile, newWindow, id }) => {
370
+ const cfg = ctx.getConfig()
371
+ if (cfg?.pcControl?.enabled !== true) return denied('pc_browser is disabled on this node')
372
+ if (!WINDOWS) return denied('pc_browser is only implemented on Windows')
373
+ const proxiedBrowser = await maybeProxy('pc_browser', { action, url, browser, profile, newWindow, id })
374
+ if (isProxyTimeout(proxiedBrowser)) return invalidArg(proxyTimeoutMessage('pc_browser', proxiedBrowser))
375
+ if (proxiedBrowser) return proxiedBrowser
376
+ const chromePaths = [
377
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
378
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
379
+ join(process.env.LOCALAPPDATA || '', 'Google/Chrome/Application/chrome.exe')
380
+ ]
381
+ const edgePaths = [
382
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
383
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'
384
+ ]
385
+ const findBrowser = (list) => list.find((p) => existsSync(p)) || null
386
+ const isHttp = (u) => /^https?:\/\//i.test(String(u || ''))
387
+ if (action === 'browsers') {
388
+ const out = { chrome: findBrowser(chromePaths), edge: findBrowser(edgePaths) }
389
+ return ok({ action, host: hostname(), browsers: out })
390
+ }
391
+ if (action === 'profiles') {
392
+ const wanted = browser === 'default' ? ['chrome', 'edge'] : [browser]
393
+ const out = {}
394
+ for (const name of wanted) {
395
+ const src = BROWSER_PROFILE_SOURCES[name]
396
+ const userDataDir = src?.userData() || null
397
+ out[name] = { installed: Boolean(src?.exe()), userDataDir, profiles: readBrowserProfiles(userDataDir) }
398
+ }
399
+ return ok({ action, host: hostname(), browsers: out })
400
+ }
401
+ if (action === 'open-url') {
402
+ if (!isHttp(url)) return invalidArg('url must start with http:// or https://')
403
+ const exe = browser === 'chrome' ? findBrowser(chromePaths) : browser === 'edge' ? findBrowser(edgePaths) : null
404
+ if (profile) {
405
+ // 指定浏览器用户配置时,必须明确是 chrome 还是 edge(default 无从判断)
406
+ const targetName = browser === 'default' ? 'chrome' : browser
407
+ const targetExe = targetName === 'chrome' ? findBrowser(chromePaths) : findBrowser(edgePaths)
408
+ if (!targetExe) return invalidArg('browser not installed: ' + targetName)
409
+ const src = BROWSER_PROFILE_SOURCES[targetName]
410
+ const profiles = readBrowserProfiles(src?.userData() || '')
411
+ if (!profiles.length) return invalidArg('no browser user profiles found under ' + (src?.userData() || '?') + '(浏览器是否装过/启动过?)')
412
+ const dir = resolveBrowserProfile(profiles, profile)
413
+ if (!dir) {
414
+ return invalidArg('unknown browser profile: ' + profile + ';可用: ' + profiles.map((p) => p.dir + '(' + p.name + ')').join(', '))
415
+ }
416
+ const args = ['--profile-directory=' + dir]
417
+ if (newWindow === true) args.push('--new-window')
418
+ args.push(url)
419
+ const child = spawn(targetExe, args, { detached: true, stdio: 'ignore', windowsHide: false })
420
+ child.unref()
421
+ return ok({ action, browser: targetName, profile: dir, profileName: (profiles.find((p) => p.dir === dir) || {}).name || dir, url, host: hostname() })
422
+ }
423
+ if (exe) {
424
+ const child = spawn(exe, [url], { detached: true, stdio: 'ignore', windowsHide: false })
425
+ child.unref()
426
+ return ok({ action, browser, url, host: hostname() })
427
+ }
428
+ const child = spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true })
429
+ child.unref()
430
+ return ok({ action, browser: 'default', url, host: hostname() })
431
+ }
432
+ if (action === 'debug-launch') {
433
+ const exe = browser === 'chrome' ? findBrowser(chromePaths) : findBrowser(edgePaths)
434
+ if (!exe) return invalidArg('browser not installed: ' + browser)
435
+ const profile = join(homedir(), '.dsh-mesh', 'browser-profile')
436
+ const child = spawn(exe, ['--remote-debugging-port=9222', '--user-data-dir=' + profile, '--no-first-run', '--no-default-browser-check', 'about:blank'], { detached: true, stdio: 'ignore', windowsHide: false })
437
+ child.unref()
438
+ return ok({ action, browser, exe, cdp: 'http://127.0.0.1:9222', profile, host: hostname(), note: '等待 2~3 秒后再 cdp-list' })
439
+ }
440
+ const base = 'http://127.0.0.1:9222'
441
+ const fetchJson = async (path, method = 'GET') => {
442
+ const res = await fetch(base + path, { method, signal: AbortSignal.timeout(5000) })
443
+ const text = await res.text()
444
+ try { return JSON.parse(text) } catch { return text }
445
+ }
446
+ try {
447
+ if (action === 'cdp-list') {
448
+ const list = await fetchJson('/json/list')
449
+ const tabs = Array.isArray(list) ? list.map((t) => ({ id: t.id, type: t.type, title: t.title, url: t.url })) : list
450
+ return ok({ action, host: hostname(), tabs })
451
+ }
452
+ if (action === 'cdp-open') {
453
+ if (!isHttp(url)) return invalidArg('url must start with http:// or https://')
454
+ const tab = await fetchJson('/json/new?' + encodeURIComponent(url), 'PUT')
455
+ return ok({ action, host: hostname(), tab: { id: tab.id, title: tab.title, url: tab.url } })
456
+ }
457
+ if (action === 'cdp-close') {
458
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(String(id || ''))) return invalidArg('invalid tab id')
459
+ const r = await fetchJson('/json/close/' + id)
460
+ return ok({ action, host: hostname(), result: r })
461
+ }
462
+ } catch (error) {
463
+ return invalidArg('CDP not available (open debug browser first via debug-launch): ' + String(error?.message || error))
464
+ }
465
+ return invalidArg('unknown action')
466
+ }
467
+ }
468
+ ]
469
+ }
@@ -0,0 +1,160 @@
1
+ import { lstat, readdir } from 'node:fs/promises'
2
+ import { isAbsolute, join, resolve as resolvePath, sep } from 'node:path'
3
+ import { hostname } from 'node:os'
4
+ import { z } from 'zod'
5
+ import { ok, denied, invalidArg } from '../core/result.js'
6
+
7
+ /**
8
+ * pc_path:只读「路径探查」(存在性 / 目录清单 / 大小时间)。
9
+ *
10
+ * 为什么单独一个工具:手机经常需要在电脑上确认「D:\xxx 这个目录在不在」「某路径下有没有这些子目录」,
11
+ * 用 fs_list/fs_stat 会被 fsRoots 限制在 Code / .dsh 存储里,看不到业务盘;
12
+ * 委托对端 AI 又慢又费 token。这些是纯确定性的只读查询,应该直连。
13
+ *
14
+ * 安全边界(刻意收窄):
15
+ * - 只读:不写、不改、不删、不执行;不返回文件内容,只返回名字/类型/大小/时间;
16
+ * - 只接受绝对路径,拒绝 `..` 段;
17
+ * - 敏感段黑名单(.ssh / .gnupg / .aws / 凭据文件等)与系统敏感目录直接 E_DENIED;
18
+ * - names 只接受单层名字(不含分隔符),相当于「查这些子项在不在」,不能拿它做递归遍历。
19
+ */
20
+ const DENY_SEGMENTS = new Set(['.ssh', '.gnupg', '.aws', '.kube', '.azure', '.netrc', '.npmrc', '.credentials'])
21
+ const DENY_BASENAMES = new Set(['id_rsa', 'id_ed25519', 'id_ecdsa', '.netrc'])
22
+ const DENY_BASENAME_PREFIXES = ['credentials', 'secrets', '.credentials', '.secrets']
23
+
24
+ function deniedName(name) {
25
+ const lower = String(name || '').toLowerCase()
26
+ if (DENY_SEGMENTS.has(lower)) return true
27
+ if (DENY_BASENAMES.has(lower)) return true
28
+ return DENY_BASENAME_PREFIXES.some((prefix) => lower.startsWith(prefix))
29
+ }
30
+ const DENY_PATH_PREFIXES_WIN = ['c:\\windows\\system32\\config', 'c:\\windows\\system32\\drivers\\etc']
31
+
32
+ function kindOf(st) {
33
+ if (!st) return null
34
+ if (st.isDirectory()) return 'dir'
35
+ if (st.isSymbolicLink()) return 'link'
36
+ if (st.isFile()) return 'file'
37
+ return 'other'
38
+ }
39
+
40
+ function normalizeInput(raw) {
41
+ const s = String(raw || '').trim()
42
+ if (!s) return { error: 'path is required' }
43
+ if (s.includes('\0')) return { error: 'invalid path' }
44
+ if (!isAbsolute(s)) return { error: 'path must be absolute (e.g. D:\\work or C:/work)' }
45
+ // 先按原始字符串拦 `..`(resolve 会把 .. 折叠掉,折叠后就检查不到了)
46
+ if (s.split(/[\\/]+/).some((seg) => seg === '..')) return { error: 'path must not contain ".."' }
47
+ const normalized = resolvePath(s)
48
+ const segments = normalized.split(/[\\/]+/).filter(Boolean)
49
+ if (segments.some((seg) => seg === '..')) return { error: 'path must not contain ".."' }
50
+ const lower = segments.map((seg) => seg.toLowerCase())
51
+ if (lower.some((seg) => DENY_SEGMENTS.has(seg))) return { error: 'DENIED_SENSITIVE', denied: true }
52
+ if (deniedName(segments[segments.length - 1])) return { error: 'DENIED_SENSITIVE', denied: true }
53
+ if (process.platform === 'win32') {
54
+ const flat = normalized.toLowerCase().replace(/\//g, '\\')
55
+ if (DENY_PATH_PREFIXES_WIN.some((p) => flat === p || flat.startsWith(p + '\\'))) return { error: 'DENIED_SENSITIVE', denied: true }
56
+ }
57
+ return { path: normalized, segments }
58
+ }
59
+
60
+ async function describe(target) {
61
+ try {
62
+ const st = await lstat(target)
63
+ return { exists: true, kind: kindOf(st), size: kindOf(st) === 'file' ? st.size : null, mtime: st.mtime?.toISOString() || null }
64
+ } catch (error) {
65
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return { exists: false, kind: null, size: null, mtime: null }
66
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') return { exists: null, kind: null, size: null, mtime: null, error: 'EACCES' }
67
+ return { exists: null, kind: null, size: null, mtime: null, error: String(error?.code || error?.message || error) }
68
+ }
69
+ }
70
+
71
+ function retarget(input) {
72
+ const parsed = normalizeInput(input)
73
+ if (parsed.error) return parsed
74
+ return parsed
75
+ }
76
+
77
+ export function buildControlPath() {
78
+ return [
79
+ {
80
+ name: 'pc_path',
81
+ title: 'Read-only path probe (exists / list / stat)',
82
+ description:
83
+ 'Read-only path inspection on the peer machine (NOT a file reader, NOT a shell): exists(path, names?) → does the path exist and do these child names exist; ' +
84
+ 'list(path, limit?) → child names with dir/file kinds; stat(path) → kind/size/mtime. ' +
85
+ 'Absolute paths only, ".." rejected, sensitive segments (.ssh/.gnupg/credentials…) and Windows config dirs denied. Never returns file contents, never writes.',
86
+ inputSchema: z.object({
87
+ action: z.enum(['exists', 'list', 'stat']),
88
+ path: z.string().max(1000),
89
+ names: z.array(z.string().max(120)).max(50).optional(),
90
+ limit: z.number().int().min(1).max(200).optional()
91
+ }),
92
+ handler: async ({ action, path, names, limit }) => {
93
+ const target = retarget(path)
94
+ if (target.error) {
95
+ return target.denied ? denied(`path denied: ${path}`) : invalidArg(target.error)
96
+ }
97
+ const base = target.path
98
+ const started = Date.now()
99
+
100
+ if (action === 'exists') {
101
+ const info = await describe(base)
102
+ const payload = { action, host: hostname(), path: base, ...info, ms: Date.now() - started }
103
+ if (Array.isArray(names) && names.length) {
104
+ if (!info.exists) return ok({ ...payload, checks: {} })
105
+ const checks = {}
106
+ for (const rawName of names) {
107
+ const name = String(rawName || '').trim()
108
+ if (!name || /[\\/]/.test(name) || name === '.' || name === '..') {
109
+ checks[rawName] = { exists: null, kind: null, error: 'invalid name' }
110
+ continue
111
+ }
112
+ if (deniedName(name)) {
113
+ checks[rawName] = { exists: null, kind: null, error: 'denied' }
114
+ continue
115
+ }
116
+ const child = await describe(base.endsWith(sep) ? base + name : join(base, name))
117
+ checks[rawName] = { exists: child.exists, kind: child.kind, ...(child.error ? { error: child.error } : {}) }
118
+ }
119
+ payload.checks = checks
120
+ }
121
+ return ok(payload)
122
+ }
123
+
124
+ if (action === 'stat') {
125
+ const info = await describe(base)
126
+ if (info.error === 'EACCES') return denied('E_PERMISSION: access denied: ' + base)
127
+ return ok({ action, host: hostname(), path: base, ...info, ms: Date.now() - started })
128
+ }
129
+
130
+ const max = Number(limit || 100)
131
+ try {
132
+ const entries = await readdir(base, { withFileTypes: true })
133
+ const rows = entries
134
+ .map((e) => ({
135
+ name: e.name,
136
+ kind: e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : e.isFile() ? 'file' : 'other'
137
+ }))
138
+ .filter((row) => !deniedName(row.name))
139
+ .sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1))
140
+ return ok({
141
+ action,
142
+ host: hostname(),
143
+ path: base,
144
+ exists: true,
145
+ total: rows.length,
146
+ truncated: rows.length > max,
147
+ entries: rows.slice(0, max),
148
+ ms: Date.now() - started
149
+ })
150
+ } catch (error) {
151
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
152
+ return ok({ action, host: hostname(), path: base, exists: false, total: 0, entries: [], ms: Date.now() - started })
153
+ }
154
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') return denied('E_PERMISSION: access denied: ' + base)
155
+ return invalidArg('list failed: ' + String(error?.code || error?.message || error))
156
+ }
157
+ }
158
+ }
159
+ ]
160
+ }