dsh-remote-plugin 0.7.2 → 0.7.3

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.
Binary file
package/gateway.cjs CHANGED
@@ -37,6 +37,96 @@ const path = require('node:path')
37
37
  const os = require('node:os')
38
38
  const crypto = require('node:crypto')
39
39
 
40
+ // Crash records deliberately contain facts and source positions, never arbitrary error text.
41
+ const CRASH_LOG_FILE=process.env.DSH_REMOTE_CRASH_LOG_FILE || path.join(os.homedir(),'.dsh-remote','crashes.jsonl')
42
+ const AUTO_RESTART=process.env.DSH_REMOTE_AUTO_RESTART!=='0'
43
+ const CRASH_KINDS=new Set(['fatal','exit','restart','restart-failed','restart-limit','manual-stop','startup','desktop-crash'])
44
+ const CRASH_COMPONENTS=new Set(['gateway','dsh','desktop'])
45
+ const CRASH_CODES=new Set(['EADDRINUSE','EACCES','EPERM','ENOENT','ENOMEM','ECONNRESET','ETIMEDOUT','ERR_OUT_OF_MEMORY','WINDOWS_APPLICATION_ERROR','PROCESS_START_FAILED','PROCESS_STOP_TIMEOUT','EXTERNAL_PROCESS','PORT_IN_USE','SERVICE_FAILED','SERVICE_TIMEOUT','EVENTS_TIMEOUT','UPSTREAM_TIMEOUT','INTERNAL_ERROR','LAUNCH_CONFIG_INVALID','STATUS_PARSE_FAILED'])
46
+ function safeCrashRecord(value) {
47
+ const row={at:Number.isFinite(value?.at)?value.at:Date.now(),component:CRASH_COMPONENTS.has(value?.component)?value.component:'gateway',kind:CRASH_KINDS.has(value?.kind)?value.kind:'fatal'}
48
+ if(Number.isInteger(value?.exitCode))row.exitCode=value.exitCode
49
+ if(/^(SIG[A-Z]+)$/.test(value?.signal||''))row.signal=value.signal
50
+ if(CRASH_CODES.has(value?.code))row.code=value.code
51
+ if(['Error','TypeError','RangeError','ReferenceError','SyntaxError','URIError','AggregateError'].includes(value?.errorName))row.errorName=value.errorName
52
+ if(Number.isInteger(value?.attempt)&&value.attempt>=0&&value.attempt<=10)row.attempt=value.attempt
53
+ if(Array.isArray(value?.frames))row.frames=value.frames.slice(0,4).filter(f=>['gateway.js','gateway.cjs','index.js'].includes(f?.file)&&Number.isInteger(f.line)&&Number.isInteger(f.column)).map(f=>({file:f.file,line:f.line,column:f.column}))
54
+ return row
55
+ }
56
+ function crashErrorFacts(error) {
57
+ const frames=[]
58
+ for(const line of String(error?.stack||'').split('\n').slice(1,12)){
59
+ const match=line.match(/(?:[\\/])(gateway\.(?:js|cjs)|index\.js):(\d+):(\d+)\)?$/)
60
+ if(match)frames.push({file:match[1],line:Number(match[2]),column:Number(match[3])})
61
+ }
62
+ return {code:error?.code,errorName:error?.name,frames}
63
+ }
64
+ function recordCrash(component,kind,facts={}) {
65
+ const row=safeCrashRecord({at:Date.now(),component,kind,...facts})
66
+ try {
67
+ fs.mkdirSync(path.dirname(CRASH_LOG_FILE),{recursive:true,mode:0o700})
68
+ if(fs.existsSync(CRASH_LOG_FILE)&&fs.statSync(CRASH_LOG_FILE).size>128*1024){try{fs.unlinkSync(CRASH_LOG_FILE+'.1')}catch{};fs.renameSync(CRASH_LOG_FILE,CRASH_LOG_FILE+'.1')}
69
+ fs.appendFileSync(CRASH_LOG_FILE,JSON.stringify(row)+'\n',{mode:0o600})
70
+ } catch {}
71
+ return row
72
+ }
73
+ function crashRecords(limit=40) {
74
+ const rows=[]
75
+ for(const file of [CRASH_LOG_FILE+'.1',CRASH_LOG_FILE])try{const stat=fs.statSync(file);if(stat.size>256*1024)continue;for(const line of fs.readFileSync(file,'utf8').split('\n'))try{rows.push(safeCrashRecord(JSON.parse(line)))}catch{}}catch{}
76
+ return rows.slice(-Math.min(40,limit))
77
+ }
78
+ function crashReply(res,status,value) {res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
79
+ function crashUploadSummary() {
80
+ // The deployed collector already persists serverInfo (500 chars), so this works before any server upgrade.
81
+ const recent=crashRecords().filter(r=>r.kind!=='startup'&&r.kind!=='manual-stop').slice(-3)
82
+ const report={schema:1,records:recent}
83
+ while(JSON.stringify(report).length>490){if(report.records.some(r=>r.frames?.length)){for(const r of report.records)delete r.frames}else report.records.shift()}
84
+ return report
85
+ }
86
+ function runGatewaySupervisor() {
87
+ const port=Number(process.env.PORT)||8787,lease=path.join(path.dirname(CRASH_LOG_FILE),'gateway-supervisor-'+port+'.json')
88
+ const nonce=crypto.randomBytes(12).toString('hex')
89
+ try {
90
+ fs.mkdirSync(path.dirname(lease),{recursive:true,mode:0o700})
91
+ if(fs.existsSync(lease)){
92
+ const old=JSON.parse(fs.readFileSync(lease,'utf8'));let alive=false
93
+ try{process.kill(old.pid,0);alive=true}catch(e){alive=e.code!=='ESRCH'}
94
+ if(alive){console.error('[recovery] supervisor already running');process.exit(78)}
95
+ fs.unlinkSync(lease)
96
+ }
97
+ fs.writeFileSync(lease,JSON.stringify({pid:process.pid,nonce}),{flag:'wx',mode:0o600})
98
+ } catch {console.error('[recovery] cannot acquire supervisor lease');process.exit(78)}
99
+ let worker=null,stopping=false,timer=null,attempts=[]
100
+ const cleanup=()=>{try{if(JSON.parse(fs.readFileSync(lease,'utf8')).nonce===nonce)fs.unlinkSync(lease)}catch{}}
101
+ process.once('exit',cleanup)
102
+ const stop=()=>{if(stopping)return;stopping=true;clearTimeout(timer);recordCrash('gateway','manual-stop');if(worker)worker.kill();else process.exit(0)}
103
+ process.on('SIGTERM',stop);process.on('SIGINT',stop)
104
+ const launch=()=>{
105
+ const env={...process.env,DSH_REMOTE_SUPERVISED:'1',DSH_REMOTE_SUPERVISOR_PID:String(process.pid)}
106
+ delete env.NODE_CHANNEL_FD;delete env.NODE_CHANNEL_SERIALIZATION_MODE
107
+ const args=process.pkg?process.argv.slice(2).filter(a=>a!=='--supervise'):[__filename,...process.argv.slice(2).filter(a=>a!=='--supervise')]
108
+ try{worker=spawn(process.execPath,args,{cwd:process.cwd(),env,windowsHide:true,stdio:['ignore','inherit','inherit']})}catch(error){recordCrash('gateway','restart-failed',crashErrorFacts(error));process.exit(1)}
109
+ let handled=false
110
+ const exited=(code,signal)=>{
111
+ if(handled)return;handled=true;worker=null
112
+ if(stopping||code===0||code===78){process.exit(code===78?78:0);return}
113
+ recordCrash('gateway','exit',{exitCode:code,signal})
114
+ if(!AUTO_RESTART){process.exit(1);return}
115
+ const now=Date.now();attempts=attempts.filter(t=>now-t<5*60_000)
116
+ if(attempts.length>=5){recordCrash('gateway','restart-limit',{attempt:5});process.exit(1);return}
117
+ attempts.push(now);recordCrash('gateway','restart',{attempt:attempts.length})
118
+ timer=setTimeout(launch,Math.min(30000,1000*2**(attempts.length-1)))
119
+ }
120
+ worker.once('error',error=>{recordCrash('gateway','restart-failed',crashErrorFacts(error));exited(1,null)})
121
+ worker.once('exit',exited)
122
+ }
123
+ launch()
124
+ }
125
+ if(process.argv.includes('--supervise')&&process.env.DSH_REMOTE_SUPERVISED!=='1'){
126
+ runGatewaySupervisor()
127
+ }else{
128
+
129
+
40
130
  let statsCore = null
41
131
  let statsStore = null
42
132
  try {
@@ -259,6 +349,35 @@ function fsInsideReal(real) {
259
349
  }
260
350
  return false
261
351
  }
352
+ // 网关自身的状态与安装目录永远不出现在 /fs 通道里: 设备密钥同样能访问 /fs,
353
+ // 若允许读取就等同于把主令牌(以及其它设备密钥)直接交出去, 允许写入还能替换
354
+ // 正在对外提供的静态资源或网关文件。显式配置到其它位置的状态文件按精确路径保护。
355
+ const FS_PROTECTED_DIRS = [...new Set([
356
+ path.resolve(path.join(FS_DEFAULT_ROOT, '.dsh-remote')),
357
+ path.resolve(ROOT),
358
+ ])]
359
+ const FS_PROTECTED_FILES = [...new Set([TOKEN_FILE, DEVICE_KEYS_FILE, NOTES_FILE, WORKBENCH_FILE,
360
+ HANDOFF_FILE, DSH_UPSTREAM_COOKIE_FILE, DSH_LAUNCH_FILE].filter(Boolean).map(value => path.resolve(String(value))))]
361
+
362
+ function fsSamePath(left, right) {
363
+ const a = path.resolve(String(left || ''))
364
+ const b = path.resolve(String(right || ''))
365
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
366
+ }
367
+
368
+ /** 路径是否命中受保护目录(目录内全部)或受保护文件(精确匹配)。realpath 结果同样适用。 */
369
+ function fsProtectedPath(abs) {
370
+ if (!abs) return true
371
+ const candidate = path.resolve(String(abs))
372
+ for (const dir of FS_PROTECTED_DIRS) {
373
+ if (fsInsideRoot(candidate, dir)) return true
374
+ }
375
+ for (const file of FS_PROTECTED_FILES) {
376
+ if (fsSamePath(candidate, file)) return true
377
+ }
378
+ return false
379
+ }
380
+
262
381
 
263
382
  const FS_MIME = {
264
383
  ...MIME,
@@ -534,6 +653,7 @@ const runtimeState = {
534
653
  unhandledRejections: 0,
535
654
  lastErrorAt: 0,
536
655
  lastError: '',
656
+ recovery: {enabled:AUTO_RESTART,supervised:process.env.DSH_REMOTE_SUPERVISED==='1',supervisorPid:Number(process.env.DSH_REMOTE_SUPERVISOR_PID)||0},
537
657
  }
538
658
 
539
659
  function pruneDevices(now = Date.now()) {
@@ -1043,8 +1163,11 @@ async function windowsServiceStatus() {
1043
1163
  // a gateway restart or PID reuse must not turn an unrelated process into our child.
1044
1164
  let dshManagedChild = null
1045
1165
  let dshManagedExit = null
1166
+ const intentionalDshStops=new WeakSet()
1167
+ const desktopChildren=new Map()
1046
1168
  function readDshLaunch() {
1047
1169
  const config = JSON.parse(fs.readFileSync(DSH_LAUNCH_FILE, 'utf8'))
1170
+ if (config.kind === 'desktop') return readDesktopLaunch(config)
1048
1171
  if (config.version !== 1 || !path.isAbsolute(config.executable || '') ||
1049
1172
  !path.isAbsolute(config.cwd || '') || !Array.isArray(config.args) ||
1050
1173
  !config.args.length || !config.args.every(arg => typeof arg === 'string' && !arg.includes('\0')) ||
@@ -1062,6 +1185,77 @@ function readDshLaunch() {
1062
1185
  return { ...config, env }
1063
1186
  }
1064
1187
 
1188
+ // Desktop roots are adopted only with birth time, executable, Host ancestry and TCP ownership evidence.
1189
+ // Never use taskkill /T: the detached gateway must survive the desktop's exit.
1190
+ function readDesktopLaunch(config) {
1191
+ const d = config.desktop, validPid = value => Number.isSafeInteger(value) && value > 0
1192
+ if (process.platform !== 'win32' || config.version !== 1 || config.profile !== 'desktop' ||
1193
+ !path.isAbsolute(config.executable || '') || !fs.statSync(config.executable).isFile() ||
1194
+ !path.isAbsolute(config.cwd || '') || !fs.statSync(config.cwd).isDirectory() ||
1195
+ !Array.isArray(config.args) || config.args.length || !d || !validPid(d.root?.pid) || !validPid(d.host?.pid) || d.root.pid === d.host.pid ||
1196
+ !/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(d.root.started || '') || !/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(d.host.started || '') ||
1197
+ !path.isAbsolute(d.profilePath || '') || !path.isAbsolute(d.hostScript || '') ||
1198
+ !/[\\/]@deepseek-ai[\\/]dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(d.hostScript) ||
1199
+ !['127.0.0.1','localhost','[::1]'].includes(UPSTREAM.hostname) || config.upstream !== UPSTREAM.origin) throw new Error('Desktop 启动配置无效或与当前上游不匹配')
1200
+ return { ...config, env:typeof config.env?.DSH_HOME === 'string' ? {DSH_HOME:config.env.DSH_HOME} : {} }
1201
+ }
1202
+ async function desktopProcessEvidence(config) {
1203
+ const d = config.desktop
1204
+ const script = '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(' + [d.root.pid,d.host.pid].map(pid =>
1205
+ "Get-CimInstance Win32_Process -Filter 'ProcessId=" + pid + "' | ForEach-Object { @{pid=$_.ProcessId;parent=$_.ParentProcessId;executable=$_.ExecutablePath;command=$_.CommandLine;started=$_.CreationDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')} }").join('; ') +
1206
+ '); $owners=@(Get-NetTCPConnection -State Listen -LocalPort ' + UPSTREAM_PORT + ' -ErrorAction SilentlyContinue | ForEach-Object {$_.OwningProcess}); @{rows=$rows;owners=$owners} | ConvertTo-Json -Compress -Depth 4'
1207
+ const result = await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],10000)
1208
+ if (!result.ok) throw new Error('无法读取 Desktop 进程身份: ' + result.stderr)
1209
+ return JSON.parse(result.stdout)
1210
+ }
1211
+ function verifyDesktopEvidence(config, evidence) {
1212
+ const d=config.desktop, rows=evidence.rows || [], owners=evidence.owners || []
1213
+ const root=rows.find(row=>row.pid===d.root.pid), host=rows.find(row=>row.pid===d.host.pid)
1214
+ const sameExecutable=row=>row?.executable && path.resolve(row.executable).toLowerCase()===path.resolve(config.executable).toLowerCase()
1215
+ const rootValid=sameExecutable(root) && root.started===d.root.started && !/--(?:type|expose-internals)\b/.test(root.command || '')
1216
+ const hostValid=sameExecutable(host) && host.started===d.host.started && host.parent===d.root.pid &&
1217
+ (host.command || '').toLowerCase().includes(d.hostScript.toLowerCase()) && (host.command || '').toLowerCase().includes(d.profilePath.toLowerCase())
1218
+ return {rootValid:!!rootValid,hostValid:!!hostValid,occupied:owners.length>0,ownedPort:owners.length>0 && owners.every(pid=>pid===d.host.pid),rootPresent:!!root,hostPresent:!!host}
1219
+ }
1220
+ async function dshDesktopStatus(config) {
1221
+ try {
1222
+ const proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
1223
+ const external=(proof.rootPresent&&!proof.rootValid)||(proof.hostPresent&&!proof.hostValid)||(proof.occupied&&(!proof.hostValid||!proof.ownedPort))
1224
+ return {ok:true,supported:true,manager:'desktop',service:'DSH Desktop',running:proof.rootValid,mainPid:proof.rootValid?config.desktop.root.pid:0,
1225
+ canRestart:proof.rootValid&&!external,canStart:!external,activeState:proof.rootValid?'active':'inactive',subState:external?'external':proof.rootValid?'running':'stopped',
1226
+ ...(external?{code:'EXTERNAL_PROCESS',message:'Desktop 进程身份或端口归属已变化,请从当前 Desktop 重新加载 Remote 插件'}:{})}
1227
+ } catch(error) {return {ok:false,supported:false,manager:'desktop',code:'STATUS_PARSE_FAILED',message:'无法核验 Desktop 进程身份',detail:error.message,canRestart:false}}
1228
+ }
1229
+ async function executeDshDesktopAction(action, config) {
1230
+ let proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
1231
+ if ((proof.rootPresent&&!proof.rootValid)||(proof.hostPresent&&!proof.hostValid)||(proof.occupied&&(!proof.hostValid||!proof.ownedPort))) return {ok:false,code:'EXTERNAL_PROCESS',error:'Desktop 进程身份或端口归属已变化,未停止任何进程'}
1232
+ if (action==='restart' && proof.rootValid) {
1233
+ const literal=value=>"'"+String(value).replace(/'/g,"''")+"'"
1234
+ const script="$ErrorActionPreference='Stop'; $p=Get-Process -Id "+config.desktop.root.pid+"; if($p.Path -ine "+literal(config.executable)+" -or $p.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') -ne "+literal(config.desktop.root.started)+") {throw 'Desktop process identity changed'}; $p.Kill(); if(-not $p.WaitForExit(10000)){throw 'Desktop stop timed out'}"
1235
+ const ownedChild=desktopChildren.get(config.desktop.root.pid)
1236
+ if(ownedChild)intentionalDshStops.add(ownedChild)
1237
+ const stopped=await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],12000)
1238
+ if(!stopped.ok)return {ok:false,code:'PROCESS_STOP_TIMEOUT',error:'Desktop 未完成退出,未启动第二个实例'}
1239
+ // Host handles parent IPC disconnect through its normal shutdown service.
1240
+ const stopDeadline=Date.now()+15000
1241
+ do {
1242
+ proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
1243
+ if(!proof.occupied&&!proof.hostPresent)break
1244
+ if(proof.hostPresent&&!proof.hostValid)return {ok:false,code:'EXTERNAL_PROCESS',error:'退出期间 Host 身份已变化,未启动第二个实例'}
1245
+ await delay(250)
1246
+ } while(Date.now()<stopDeadline)
1247
+ if(proof.occupied||proof.hostPresent)return {ok:false,code:'PROCESS_STOP_TIMEOUT',error:'Desktop Host 尚未退出,未启动第二个实例'}
1248
+ } else if(proof.rootValid)return {ok:true,code:'ALREADY_RUNNING'}
1249
+ if(await dshPortOccupied())return {ok:false,code:'PORT_IN_USE',error:'DSH 端口已被占用,未启动重复实例'}
1250
+ const env={...process.env,...config.env}
1251
+ for(const key of ['ELECTRON_RUN_AS_NODE','NODE_CHANNEL_FD','NODE_CHANNEL_SERIALIZATION_MODE','NODE_OPTIONS'])delete env[key]
1252
+ const child=spawn(config.executable,[],{cwd:config.cwd,env,detached:true,windowsHide:true,shell:false,stdio:'ignore'})
1253
+ try {await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject)})}catch(error){return {ok:false,code:'SERVICE_FAILED',error:'Desktop 启动失败: '+error.message}}
1254
+ child.on('error',()=>{});child.unref()
1255
+ desktopChildren.set(child.pid,child);watchDshChild(child,'desktop')
1256
+ return {ok:true,code:'STARTED',pid:child.pid}
1257
+ }
1258
+
1065
1259
  function dshPortOccupied() {
1066
1260
  return new Promise(resolve => {
1067
1261
  const socket = net.connect({ host: UPSTREAM.hostname.replace(/^\[|\]$/g, ''), port: UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80) })
@@ -1077,6 +1271,8 @@ async function dshProcessStatus() {
1077
1271
  try { readDshLaunch() } catch (err) {
1078
1272
  return { ...base, supported: false, code: 'LAUNCH_CONFIG_INVALID', message: '缺少有效 DSH 启动配置,请先从 DSH Web 启动 Remote 插件', detail: err.message }
1079
1273
  }
1274
+ const launch = readDshLaunch()
1275
+ if (launch.kind === 'desktop') return dshDesktopStatus(launch)
1080
1276
  if (dshManagedChild) {
1081
1277
  return { ...base, running: true, mainPid: dshManagedChild.pid, owned: true, canRestart: true, activeState: 'active', subState: 'running' }
1082
1278
  }
@@ -1091,6 +1287,7 @@ async function dshProcessStatus() {
1091
1287
 
1092
1288
  async function executeDshProcessAction(action) {
1093
1289
  const config = readDshLaunch()
1290
+ if (config.kind === 'desktop') return executeDshDesktopAction(action, config)
1094
1291
  if (action === 'restart' && !dshManagedChild && await dshPortOccupied()) {
1095
1292
  return { ok: false, code: 'EXTERNAL_PROCESS', error: '当前 DSH 并非本网关启动,不能安全重启;请在原启动器中停止后重试' }
1096
1293
  }
@@ -1101,6 +1298,7 @@ async function executeDshProcessAction(action) {
1101
1298
  const onExit = () => { clearTimeout(timer); resolve(true) }
1102
1299
  child.once('exit', onExit)
1103
1300
  })
1301
+ intentionalDshStops.add(child)
1104
1302
  child.kill()
1105
1303
  if (!await stopped) return { ok: false, code: 'PROCESS_STOP_TIMEOUT', error: 'DSH 进程未及时退出,未启动第二个实例' }
1106
1304
  }
@@ -1117,6 +1315,7 @@ async function executeDshProcessAction(action) {
1117
1315
  } finally { fs.closeSync(fd) }
1118
1316
  dshManagedChild = child
1119
1317
  dshManagedExit = null
1318
+ watchDshChild(child,'dsh')
1120
1319
  child.once('exit', code => {
1121
1320
  if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code, logFile } }
1122
1321
  })
@@ -1180,7 +1379,7 @@ async function dshServiceStatus() {
1180
1379
  }
1181
1380
 
1182
1381
  async function executeDshServiceAction(action, initial) {
1183
- if (initial?.manager === 'process') return executeDshProcessAction(action)
1382
+ if (['process','desktop'].includes(initial?.manager)) return executeDshProcessAction(action)
1184
1383
  if (process.platform !== 'win32') {
1185
1384
  return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
1186
1385
  }
@@ -1231,6 +1430,75 @@ async function probeDshUpstream() {
1231
1430
 
1232
1431
  let dshControlOperation = null
1233
1432
 
1433
+ // Owned children expose abnormal exit codes. Adopted Desktop instances require an exact Windows crash event.
1434
+ const recoveryAttempts=new Map(),recoveryTimers=new Map()
1435
+ function watchDshChild(child,component) {
1436
+ child.once('exit',(exitCode,signal)=>{
1437
+ desktopChildren.delete(child.pid)
1438
+ if(intentionalDshStops.has(child)||exitCode===0)return
1439
+ recordCrash(component,'exit',{exitCode,signal})
1440
+ let config;try{config=readDshLaunch()}catch{return}
1441
+ if(component==='desktop'&&config.desktop.root.pid!==child.pid)return
1442
+ scheduleDshRecovery(component,component==='desktop'?config.desktop.root.started:'owned',child.pid,'start')
1443
+ })
1444
+ }
1445
+ function scheduleDshRecovery(component,identity,pid,action) {
1446
+ if(!AUTO_RESTART||recoveryTimers.has(component))return
1447
+ const now=Date.now(),attempts=(recoveryAttempts.get(component)||[]).filter(t=>now-t<5*60_000)
1448
+ if(attempts.length>=5){recordCrash(component,'restart-limit',{attempt:5});return}
1449
+ attempts.push(now);recoveryAttempts.set(component,attempts)
1450
+ recordCrash(component,'restart',{attempt:attempts.length})
1451
+ const run=async()=>{
1452
+ if(dshControlOperation&&!dshControlOperation.done){recoveryTimers.set(component,setTimeout(run,1000));return}
1453
+ recoveryTimers.delete(component)
1454
+ try{
1455
+ const config=readDshLaunch()
1456
+ if(component==='desktop'&&(config.kind!=='desktop'||config.desktop.root.pid!==pid||config.desktop.root.started!==identity))return
1457
+ if(component==='dsh'&&dshManagedChild)return
1458
+ const operation={operationId:crypto.randomUUID(),action,startedAt:Date.now(),updatedAt:Date.now(),done:false,ok:false,stage:'queued',steps:[],automatic:true}
1459
+ dshControlOperation=operation
1460
+ await runDshControlOperation(operation)
1461
+ if(!operation.ok)recordCrash(component,'restart-failed',{code:operation.code})
1462
+ }catch(error){recordCrash(component,'restart-failed',crashErrorFacts(error))}
1463
+ }
1464
+ recoveryTimers.set(component,setTimeout(()=>{void run()},Math.min(30000,1000*2**(attempts.length-1))))
1465
+ }
1466
+ function matchingDesktopCrash(config,records) {
1467
+ const d=config.desktop
1468
+ return records.some(row=>{
1469
+ const pid=Number(row.pid),expected=pid===d.root.pid?d.root:pid===d.host.pid?d.host:null
1470
+ return expected&&path.resolve(String(row.executable||'')).toLowerCase()===path.resolve(config.executable).toLowerCase()&&
1471
+ Number.isFinite(Date.parse(row.at))&&Date.parse(row.at)>=Date.parse(expected.started)&&Date.now()-Date.parse(row.at)<120000
1472
+ })
1473
+ }
1474
+ async function desktopCrashEvidence(config) {
1475
+ // Application Error event 1000 contains executable, PID and timestamp; no message text is retained.
1476
+ const script=String.raw`[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(Get-WinEvent -FilterHashtable @{LogName='Application';Id=1000;StartTime=(Get-Date).AddMinutes(-2)} -MaxEvents 20 -ErrorAction SilentlyContinue | ForEach-Object { [xml]$xml=$_.ToXml(); $data=@{}; foreach($item in $xml.Event.EventData.Data){$data[$item.Name]=$item.'#text'}; @{pid=$data.ProcessId;executable=$data.AppPath;at=$_.TimeCreated.ToUniversalTime().ToString('o')} }); ConvertTo-Json -InputObject $rows -Compress`
1477
+ const result=await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],10000)
1478
+ if(!result.ok)return false
1479
+ try{return matchingDesktopCrash(config,JSON.parse(result.stdout||'[]'))}catch{return false}
1480
+ }
1481
+ let desktopWatchBusy=false
1482
+ const desktopCrashSeen=new Set()
1483
+ async function checkDesktopCrash() {
1484
+ if(!AUTO_RESTART||desktopWatchBusy||(dshControlOperation&&!dshControlOperation.done))return
1485
+ let config;try{config=readDshLaunch()}catch{return}
1486
+ if(config.kind!=='desktop')return
1487
+ const alive=pid=>{try{process.kill(pid,0);return true}catch(e){return e.code!=='ESRCH'}}
1488
+ if(alive(config.desktop.root.pid)&&alive(config.desktop.host.pid))return
1489
+ const identity=config.desktop.root.started+':'+config.desktop.root.pid+':'+config.desktop.host.pid
1490
+ if(desktopCrashSeen.has(identity)||recoveryTimers.has('desktop'))return
1491
+ desktopWatchBusy=true
1492
+ try{
1493
+ if(await desktopCrashEvidence(config)){
1494
+ desktopCrashSeen.add(identity);if(desktopCrashSeen.size>20)desktopCrashSeen.delete(desktopCrashSeen.values().next().value)
1495
+ recordCrash('desktop','desktop-crash',{code:'WINDOWS_APPLICATION_ERROR'})
1496
+ scheduleDshRecovery('desktop',config.desktop.root.started,config.desktop.root.pid,'restart')
1497
+ }
1498
+ }catch{}finally{desktopWatchBusy=false}
1499
+ }
1500
+
1501
+
1234
1502
  function dshOperationStep(operation, stage, message, extra = {}) {
1235
1503
  const now = Date.now()
1236
1504
  operation.stage = stage
@@ -1275,7 +1543,7 @@ async function runDshControlOperation(operation) {
1275
1543
  const initial = await dshServiceStatus()
1276
1544
  operation.initialStatus = initial
1277
1545
  operation.observed = initial
1278
- const manager = initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1546
+ const manager = initial.manager === 'desktop' ? 'DSH Desktop' : initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1279
1547
  operation.service = initial.service
1280
1548
  if (!initial.supported) {
1281
1549
  failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
@@ -1287,7 +1555,7 @@ async function runDshControlOperation(operation) {
1287
1555
  : await executeDshServiceAction(operation.action, initial)
1288
1556
  operation.command = { ok: command.ok, code: command.code, signal: command.signal }
1289
1557
  if (!command.ok) {
1290
- const failure = initial.manager === 'process'
1558
+ const failure = ['process','desktop'].includes(initial.manager)
1291
1559
  ? { code: command.code, message: command.error, detail: '' }
1292
1560
  : classifyDshServiceFailure(command)
1293
1561
  failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
@@ -2812,6 +3080,7 @@ function serveFeedback(req, res, url) {
2812
3080
  const contact = String(payload.contact || '').trim()
2813
3081
  const appVersion = String(payload.appVersion || '').trim()
2814
3082
  const includeDiagnostics = payload.includeDiagnostics === true
3083
+ const includeCrashLogs = payload.includeCrashLogs === true
2815
3084
  if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
2816
3085
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
2817
3086
  res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
@@ -2864,6 +3133,7 @@ function serveFeedback(req, res, url) {
2864
3133
  gatewayVersion: VERSION,
2865
3134
  clientIp: maskIp(ip),
2866
3135
  ...(includeDiagnostics ? { diagnostics: compatibilityDiagnostics() } : {}),
3136
+ ...(includeCrashLogs ? { serverInfo: JSON.stringify(crashUploadSummary()) } : {}),
2867
3137
  ...(pollVote || {})
2868
3138
  }),
2869
3139
  signal: AbortSignal.timeout(8000)
@@ -3154,6 +3424,7 @@ async function serveAdminApi(req, res, url) {
3154
3424
  res.end(JSON.stringify({ ok: true, bye: true }))
3155
3425
  // 给响应留出发送时间, 然后退出; 由插件/系统按需再拉起
3156
3426
  setTimeout(() => {
3427
+ recordCrash('gateway','manual-stop')
3157
3428
  console.log('[shutdown] 收到管理端停止指令, 网关退出')
3158
3429
  process.exit(0)
3159
3430
  }, 150)
@@ -3305,6 +3576,7 @@ async function fsResolve(input) {
3305
3576
  else if (/^~[\\/]/.test(raw)) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
3306
3577
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
3307
3578
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
3579
+ if (fsProtectedPath(abs)) return { error: 'forbidden' }
3308
3580
  if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
3309
3581
  if (FS_WINDOWS_DEFAULT) return { error: 'forbidden' }
3310
3582
  let workspaces = await loadFsWorkspaceRoots(false)
@@ -3324,6 +3596,7 @@ function fsRealChecked(abs) {
3324
3596
  return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
3325
3597
  }
3326
3598
  if (!fsInsideReal(real)) return { error: 'forbidden' }
3599
+ if (fsProtectedPath(real)) return { error: 'forbidden' }
3327
3600
  return { abs: real }
3328
3601
  }
3329
3602
 
@@ -3390,11 +3663,12 @@ async function fsList(req, res, url) {
3390
3663
  const entries = []
3391
3664
  for (const d of dirents) {
3392
3665
  const full = path.join(checked.abs, d.name)
3666
+ if (fsProtectedPath(full)) continue
3393
3667
  try {
3394
3668
  // 符号链接指向允许根之外时直接不展示, 点进去/下载也必然被 realpath 复核拒绝
3395
3669
  if (d.isSymbolicLink()) {
3396
3670
  const real = fs.realpathSync(full)
3397
- if (!fsInsideReal(real)) continue
3671
+ if (!fsInsideReal(real) || fsProtectedPath(real)) continue
3398
3672
  }
3399
3673
  const info = fs.statSync(full)
3400
3674
  if (!info.isFile() && !info.isDirectory()) continue
@@ -3598,6 +3872,10 @@ function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
3598
3872
  }
3599
3873
  const target = path.join(dirReal, name)
3600
3874
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
3875
+ if (fsProtectedPath(target)) {
3876
+ fsJson(res, 403, { error: 'forbidden' })
3877
+ return null
3878
+ }
3601
3879
  let exists = false
3602
3880
  try {
3603
3881
  const st = fs.lstatSync(target)
@@ -3821,7 +4099,9 @@ async function fsUploadProbe(req, res, url) {
3821
4099
  if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
3822
4100
  return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
3823
4101
  }
3824
- const target = fsTargetState(path.join(checked.abs, name))
4102
+ const probeTarget = path.join(checked.abs, name)
4103
+ if (fsProtectedPath(probeTarget)) return fsJson(res, 403, { error: 'forbidden' })
4104
+ const target = fsTargetState(probeTarget)
3825
4105
  let targetSize = 0
3826
4106
  if (target.exists) {
3827
4107
  try { targetSize = fs.statSync(path.join(checked.abs, name)).size } catch {}
@@ -3860,6 +4140,7 @@ async function fsMkdir(req, res, url) {
3860
4140
  const name = url.searchParams.get('name') || ''
3861
4141
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '目录名不能为空且不能包含路径分隔符' })
3862
4142
  const target = path.join(checked.abs, name)
4143
+ if (fsProtectedPath(target)) return fsJson(res, 403, { error: 'forbidden' })
3863
4144
  try {
3864
4145
  fs.mkdirSync(target)
3865
4146
  } catch (err) {
@@ -3903,6 +4184,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3903
4184
  const target = path.join(dirReal, name)
3904
4185
 
3905
4186
  // 已有分片尺寸对齐: 回卷重写允许, 越界/缺洞拒绝
4187
+ if (fsProtectedPath(target)) return fsJson(res, 403, { error: 'forbidden' })
3906
4188
  let existing = 0
3907
4189
  try {
3908
4190
  const st = fs.statSync(part)
@@ -4743,6 +5025,14 @@ const server = http.createServer(async (req, res) => {
4743
5025
  if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
4744
5026
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
4745
5027
  if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
5028
+ if (url.pathname === '/crash-logs') {
5029
+ cors(res)
5030
+ if(req.method==='OPTIONS'){res.writeHead(204);res.end();return}
5031
+ if(req.method!=='GET'){res.setHeader('allow','GET, OPTIONS');return crashReply(res,405,{error:'read-only'})}
5032
+ if(!authorized(req,url))return crashReply(res,401,{error:'unauthorized'})
5033
+ touchDevice(req)
5034
+ return crashReply(res,200,{schema:1,recovery:runtimeState.recovery,records:crashRecords(),upload:crashUploadSummary()})
5035
+ }
4746
5036
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
4747
5037
  if (url.pathname === '/handoff') return serveHandoff(req, res, url)
4748
5038
  if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
@@ -4773,19 +5063,20 @@ server.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
4773
5063
  server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS
4774
5064
  server.timeout = 0
4775
5065
 
4776
- // 最后一层护栏: 任何未捕获异常只记录不退出(网关单点服务, 不能因单请求竞态离线)
4777
- process.on('uncaughtException', (err) => {
4778
- runtimeState.uncaughtExceptions++
4779
- runtimeState.lastErrorAt = Date.now()
4780
- runtimeState.lastError = String(err?.message || err || 'uncaught exception')
4781
- try { console.error('[uncaughtException]', err?.stack || String(err)) } catch {}
4782
- })
4783
- process.on('unhandledRejection', (err) => {
4784
- runtimeState.unhandledRejections++
4785
- runtimeState.lastErrorAt = Date.now()
4786
- runtimeState.lastError = String(err?.message || err || 'unhandled rejection')
4787
- try { console.error('[unhandledRejection]', err?.stack || String(err)) } catch {}
4788
- })
5066
+ // Fatal state is recorded synchronously, then a supervised worker exits for a clean restart.
5067
+ let fatalExiting=false
5068
+ function fatalGateway(error,kind) {
5069
+ if(fatalExiting)return
5070
+ fatalExiting=true
5071
+ runtimeState[kind==='uncaughtException'?'uncaughtExceptions':'unhandledRejections']++
5072
+ runtimeState.lastErrorAt=Date.now();runtimeState.lastError='fatal '+kind
5073
+ recordCrash('gateway','fatal',crashErrorFacts(error))
5074
+ try{console.error('[recovery] '+kind+'; see local crash records')}catch{}
5075
+ // Exit on the next turn; never continue accepting work in an undefined state.
5076
+ setImmediate(()=>process.exit(error?.code==='EADDRINUSE'?78:1))
5077
+ }
5078
+ process.on('uncaughtException',error=>fatalGateway(error,'uncaughtException'))
5079
+ process.on('unhandledRejection',error=>fatalGateway(error,'unhandledRejection'))
4789
5080
 
4790
5081
  function wsPingFrame(masked) {
4791
5082
  if (!masked) return Buffer.from([0x89, 0x00])
@@ -5133,6 +5424,8 @@ server.on('clientError', (err, socket) => {
5133
5424
  })
5134
5425
 
5135
5426
  server.listen(PORT, HOST, () => {
5427
+ recordCrash('gateway','startup')
5428
+ const crashWatch=setInterval(()=>{void checkDesktopCrash()},5000);crashWatch.unref?.()
5136
5429
  const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
5137
5430
  console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
5138
5431
  console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
@@ -5153,3 +5446,5 @@ server.listen(PORT, HOST, () => {
5153
5446
  scanStatsOnce(2000)
5154
5447
  setInterval(() => scanStatsOnce(0), 5 * 60 * 1000)
5155
5448
  })
5449
+
5450
+ } // gateway worker
package/index.mjs CHANGED
@@ -234,6 +234,7 @@ const GATEWAY_ENV_KEYS = [
234
234
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
235
235
  'DSH_REMOTE_DSH_COOKIE_FILE',
236
236
  'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
237
+ 'DSH_REMOTE_AUTO_RESTART', 'DSH_REMOTE_CRASH_LOG_FILE',
237
238
  'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
238
239
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
239
240
  'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
@@ -389,7 +390,7 @@ async function startGateway() {
389
390
  '--user', '--unit=dsh-remote-gateway', '--service-type=exec',
390
391
  ...gatewaySystemdEnvArgs(),
391
392
  '--setenv=PORT=' + port, '--setenv=HOST=' + host, '--setenv=DSH_UPSTREAM=' + upstream,
392
- '--', process.execPath, script,
393
+ '--', process.execPath, script, '--supervise',
393
394
  ])) === 0
394
395
  } catch {}
395
396
  if (sysd) {
@@ -405,11 +406,14 @@ async function startGateway() {
405
406
  try {
406
407
  logFd = openSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, 'a')
407
408
  } catch {}
408
- const child = spawn(process.execPath, [script], {
409
+ const gatewayEnv = { ...process.env, PORT: port, HOST: host, DSH_UPSTREAM: upstream }
410
+ delete gatewayEnv.NODE_CHANNEL_FD
411
+ delete gatewayEnv.NODE_CHANNEL_SERIALIZATION_MODE
412
+ const child = spawn(process.execPath, [script, '--supervise'], {
409
413
  cwd: dirname(script),
410
414
  detached: true,
411
415
  stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
412
- env: { ...process.env, PORT: port, HOST: host, DSH_UPSTREAM: upstream },
416
+ env: gatewayEnv,
413
417
  })
414
418
  child.unref()
415
419
  writeGatewayPid(child.pid)
@@ -986,18 +990,36 @@ async function serveStatic(req, res, ctx) {
986
990
  }
987
991
 
988
992
  const pluginCenters = new WeakMap()
993
+ export function desktopLaunchConfig(ctx) {
994
+ if (process.platform !== 'win32' || !process.versions.electron) return null
995
+ const profile = detectProfile(ctx)
996
+ const hostScript = process.argv[1] || ''
997
+ if (profile?.name !== 'desktop' || !/[\\/]@deepseek-ai[\\/]dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(hostScript)) return null
998
+ const pid = process.pid, parent = process.ppid
999
+ try {
1000
+ const script = '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); @(' + [pid,parent].map(id =>
1001
+ "Get-CimInstance Win32_Process -Filter 'ProcessId=" + id + "' | ForEach-Object { @{pid=$_.ProcessId;parent=$_.ParentProcessId;executable=$_.ExecutablePath;command=$_.CommandLine;started=$_.CreationDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')} }").join('; ') + ') | ConvertTo-Json -Compress'
1002
+ const rows = JSON.parse(execFileSync('powershell.exe', ['-NoLogo','-NoProfile','-NonInteractive','-Command',script], { encoding:'utf8', timeout:8000, windowsHide:true }))
1003
+ const host = rows.find(row => row.pid === pid), root = rows.find(row => row.pid === parent)
1004
+ if (!host || !root || host.parent !== root.pid || resolve(root.executable) !== resolve(process.execPath) || resolve(host.executable) !== resolve(process.execPath) || /--(?:type|expose-internals)\b/.test(root.command)) return null
1005
+ return { version:1, kind:'desktop', executable:process.execPath, args:[], cwd:dirname(process.execPath), profile:'desktop', env:{DSH_HOME:profile.home},
1006
+ upstream:upstreamUrlForListener(ctx.webServer), desktop:{root:{pid:root.pid,started:root.started},host:{pid:host.pid,started:host.started},hostScript,profilePath:profile.dir} }
1007
+ } catch (error) { logGateway('Desktop 启动信息核验失败: ' + error.message); return null }
1008
+ }
1009
+
989
1010
  export function saveDshLaunchConfig(ctx) {
990
- // Desktop owns its Electron runtime. Never record a desktop or unknown launcher.
991
- if (process.platform !== 'win32' || process.versions.electron) return false
1011
+ if (process.platform !== 'win32') return false
1012
+ const desktop = desktopLaunchConfig(ctx)
1013
+ if (process.versions.electron && !desktop) return false
992
1014
  const profile = detectProfile(ctx)
993
- if (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli)) return false
1015
+ if (!desktop && (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli))) return false
994
1016
  const env = { DSH_HOME: profile.home }
995
1017
  for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
996
1018
  if (process.env[key] !== undefined) env[key] = process.env[key]
997
1019
  }
998
- const args = [profile.cli, ...process.argv.slice(2)]
999
- if (!args.includes('--no-open')) args.push('--no-open')
1000
- const config = { version: 1, executable: process.execPath, args, cwd: process.cwd(),
1020
+ const args = desktop ? [] : [profile.cli, ...process.argv.slice(2)]
1021
+ if (!desktop && !args.includes('--no-open')) args.push('--no-open')
1022
+ const config = desktop || { version: 1, executable: process.execPath, args, cwd: process.cwd(),
1001
1023
  env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
1002
1024
  const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
1003
1025
  const tmp = `${file}.${process.pid}.tmp`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",