dsh-remote-plugin 0.7.1 → 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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +322 -19
- package/index.mjs +41 -9
- package/insights.mjs +73 -0
- package/package.json +3 -2
- package/plugin-center.mjs +118 -21
- package/public/admin.html +3 -0
- package/public/admin.js +1 -0
- package/public/app.js +403 -470
- package/public/desktop/desktop.css +101 -0
- package/public/desktop/desktop.html +63 -20
- package/public/desktop/desktop.js +324 -99
- package/public/history.js +160 -0
- package/public/index.html +73 -84
- package/public/insights.css +14 -0
- package/public/insights.js +228 -0
- package/public/link-check.js +109 -0
- package/public/plugin-center.css +100 -1
- package/public/plugin-center.js +222 -60
- package/public/plugin.js +7 -1
- package/public/styles.css +107 -95
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
|
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
|
|
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
|
|
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)
|
|
@@ -4504,6 +4786,8 @@ async function proxyLegacyRpcWithModernFallback(req, res, url) {
|
|
|
4504
4786
|
|
|
4505
4787
|
async function proxyModernApi(req, res, url) {
|
|
4506
4788
|
if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
|
|
4789
|
+
// dsh-context is a Connection Fetch route with its own JSON contract, not RPC.
|
|
4790
|
+
if (url.pathname === '/api/dsh-context/detail') return false
|
|
4507
4791
|
if (await detectUpstreamApiFlavor() !== 'modern') return false
|
|
4508
4792
|
let raw = ''
|
|
4509
4793
|
try {
|
|
@@ -4624,7 +4908,13 @@ function proxyApi(req, res, url) {
|
|
|
4624
4908
|
res.end()
|
|
4625
4909
|
return
|
|
4626
4910
|
}
|
|
4627
|
-
|
|
4911
|
+
// Credential exchange is restricted to named client endpoints. Administrative
|
|
4912
|
+
// plugin routes must authenticate the master token before reaching DSH.
|
|
4913
|
+
const remoteClientPath = ['/remote/api/command', '/remote/api/command-status', '/remote/admin/api/dsh'].includes(url.pathname)
|
|
4914
|
+
|| (url.pathname === '/remote/api/insights/cost' && req.method === 'GET')
|
|
4915
|
+
|| /^\/remote\/api\/plugins\/(state|details|market|operations|recover)$/.test(url.pathname)
|
|
4916
|
+
const remoteAdminPath = url.pathname.startsWith('/remote/') && !remoteClientPath
|
|
4917
|
+
const ok = remoteAdminPath ? adminAuthorized(req, url) : remoteClientPath ? controlAuthorized(req, url) : authorized(req, url)
|
|
4628
4918
|
touchDevice(req, ok ? {} : { failedAuth: true })
|
|
4629
4919
|
if (!ok) {
|
|
4630
4920
|
authFailures++
|
|
@@ -4735,6 +5025,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
4735
5025
|
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
|
|
4736
5026
|
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
4737
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
|
+
}
|
|
4738
5036
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
4739
5037
|
if (url.pathname === '/handoff') return serveHandoff(req, res, url)
|
|
4740
5038
|
if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
|
|
@@ -4765,19 +5063,20 @@ server.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
|
|
|
4765
5063
|
server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS
|
|
4766
5064
|
server.timeout = 0
|
|
4767
5065
|
|
|
4768
|
-
//
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
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'))
|
|
4781
5080
|
|
|
4782
5081
|
function wsPingFrame(masked) {
|
|
4783
5082
|
if (!masked) return Buffer.from([0x89, 0x00])
|
|
@@ -5125,6 +5424,8 @@ server.on('clientError', (err, socket) => {
|
|
|
5125
5424
|
})
|
|
5126
5425
|
|
|
5127
5426
|
server.listen(PORT, HOST, () => {
|
|
5427
|
+
recordCrash('gateway','startup')
|
|
5428
|
+
const crashWatch=setInterval(()=>{void checkDesktopCrash()},5000);crashWatch.unref?.()
|
|
5128
5429
|
const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
|
|
5129
5430
|
console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
|
|
5130
5431
|
console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
|
|
@@ -5145,3 +5446,5 @@ server.listen(PORT, HOST, () => {
|
|
|
5145
5446
|
scanStatsOnce(2000)
|
|
5146
5447
|
setInterval(() => scanStatsOnce(0), 5 * 60 * 1000)
|
|
5147
5448
|
})
|
|
5449
|
+
|
|
5450
|
+
} // gateway worker
|
package/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
|
13
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { createPluginCenter, detectProfile } from './plugin-center.mjs'
|
|
16
|
+
import { readCostInsights } from './insights.mjs'
|
|
16
17
|
|
|
17
18
|
export const name = 'dsh-remote'
|
|
18
19
|
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
@@ -233,6 +234,7 @@ const GATEWAY_ENV_KEYS = [
|
|
|
233
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',
|
|
234
235
|
'DSH_REMOTE_DSH_COOKIE_FILE',
|
|
235
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',
|
|
236
238
|
'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
|
|
237
239
|
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
238
240
|
'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
|
|
@@ -388,7 +390,7 @@ async function startGateway() {
|
|
|
388
390
|
'--user', '--unit=dsh-remote-gateway', '--service-type=exec',
|
|
389
391
|
...gatewaySystemdEnvArgs(),
|
|
390
392
|
'--setenv=PORT=' + port, '--setenv=HOST=' + host, '--setenv=DSH_UPSTREAM=' + upstream,
|
|
391
|
-
'--', process.execPath, script,
|
|
393
|
+
'--', process.execPath, script, '--supervise',
|
|
392
394
|
])) === 0
|
|
393
395
|
} catch {}
|
|
394
396
|
if (sysd) {
|
|
@@ -404,11 +406,14 @@ async function startGateway() {
|
|
|
404
406
|
try {
|
|
405
407
|
logFd = openSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, 'a')
|
|
406
408
|
} catch {}
|
|
407
|
-
const
|
|
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'], {
|
|
408
413
|
cwd: dirname(script),
|
|
409
414
|
detached: true,
|
|
410
415
|
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
411
|
-
env:
|
|
416
|
+
env: gatewayEnv,
|
|
412
417
|
})
|
|
413
418
|
child.unref()
|
|
414
419
|
writeGatewayPid(child.pid)
|
|
@@ -637,6 +642,15 @@ async function resolveFile(pathname) {
|
|
|
637
642
|
|
|
638
643
|
async function serveStatic(req, res, ctx) {
|
|
639
644
|
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
645
|
+
if (pathname === `${MOUNT}/api/insights/cost`) {
|
|
646
|
+
if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, code: 'unauthorized' })
|
|
647
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, code: 'read-only' })
|
|
648
|
+
const query = new URL(req.url, 'http://x').searchParams
|
|
649
|
+
if ([...query.keys()].some(key => key !== 'sessionId') || query.getAll('sessionId').length > 1) return sendJson(res, 400, { ok: false, code: 'invalid-query' })
|
|
650
|
+
const result = await readCostInsights(ctx, query.get('sessionId') || '')
|
|
651
|
+
res.setHeader('cache-control', 'no-store')
|
|
652
|
+
return sendJson(res, result.status, result.body)
|
|
653
|
+
}
|
|
640
654
|
if (pathname.startsWith(`${MOUNT}/api/plugins/`)) {
|
|
641
655
|
if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, message: 'unauthorized' })
|
|
642
656
|
try {
|
|
@@ -976,18 +990,36 @@ async function serveStatic(req, res, ctx) {
|
|
|
976
990
|
}
|
|
977
991
|
|
|
978
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
|
+
|
|
979
1010
|
export function saveDshLaunchConfig(ctx) {
|
|
980
|
-
|
|
981
|
-
|
|
1011
|
+
if (process.platform !== 'win32') return false
|
|
1012
|
+
const desktop = desktopLaunchConfig(ctx)
|
|
1013
|
+
if (process.versions.electron && !desktop) return false
|
|
982
1014
|
const profile = detectProfile(ctx)
|
|
983
|
-
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
|
|
984
1016
|
const env = { DSH_HOME: profile.home }
|
|
985
1017
|
for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
986
1018
|
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
987
1019
|
}
|
|
988
|
-
const args = [profile.cli, ...process.argv.slice(2)]
|
|
989
|
-
if (!args.includes('--no-open')) args.push('--no-open')
|
|
990
|
-
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(),
|
|
991
1023
|
env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
|
|
992
1024
|
const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
|
|
993
1025
|
const tmp = `${file}.${process.pid}.tmp`
|