@justlovepl/desktop-bridge 0.0.0-stage → 0.2.6

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -3
  3. package/THIRD_PARTY_NOTICES.md +33 -0
  4. package/bin/desktop-bridge.mjs +114 -0
  5. package/bin/update.mjs +92 -0
  6. package/dist/client/Install.cmd +3 -0
  7. package/dist/client/Install.command +3 -0
  8. package/dist/client/Install.sh +137 -0
  9. package/dist/client/Pair-again.cmd +3 -0
  10. package/dist/client/RePair.cmd +9 -0
  11. package/dist/client/Start.cmd +4 -0
  12. package/dist/client/Start.command +3 -0
  13. package/dist/client/Stop.cmd +4 -0
  14. package/dist/client/Stop.command +3 -0
  15. package/dist/client/Uninstall.cmd +3 -0
  16. package/dist/client/Uninstall.command +3 -0
  17. package/dist/client/Uninstall.sh +3 -0
  18. package/dist/client/admin-elevate.mjs +48 -0
  19. package/dist/client/admin-runtime.mjs +119 -0
  20. package/dist/client/admin-service.mjs +152 -0
  21. package/dist/client/agent/agent.mjs +264 -0
  22. package/dist/client/agent/pair.mjs +31 -0
  23. package/dist/client/connection.json +1 -0
  24. package/dist/client/control.mjs +75 -0
  25. package/dist/client/control.ps1 +12 -0
  26. package/dist/client/environment-probe.mjs +5 -0
  27. package/dist/client/environment.mjs +99 -0
  28. package/dist/client/install-options.mjs +43 -0
  29. package/dist/client/install.mjs +84 -0
  30. package/dist/client/install.ps1 +108 -0
  31. package/dist/client/launch.sh +17 -0
  32. package/dist/client/lifecycle.mjs +42 -0
  33. package/dist/client/node-runtime.json +30 -0
  34. package/dist/client/package-lock.json +8457 -0
  35. package/dist/client/package.json +23 -0
  36. package/dist/client/runner.mjs +38 -0
  37. package/dist/client/start.sh +3 -0
  38. package/dist/client/startup.mjs +60 -0
  39. package/dist/client/stop.sh +3 -0
  40. package/dist/client/uninstall.ps1 +6 -0
  41. package/package.json +28 -6
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import {spawnSync} from 'node:child_process';
5
+
6
+ export const SERVICE_ID='de.desktopbridge.admin';
7
+ export const WINDOWS_TASK='DesktopBridgeAdministrator';
8
+
9
+ export function protectedRoot(platform=process.platform) {
10
+ if(platform==='win32') {
11
+ const programData=process.env.ProgramData??'C:\\ProgramData';
12
+ return path.win32.join(programData,'DesktopBridge','Administrator');
13
+ }
14
+ if(platform==='darwin')return '/Library/Application Support/DesktopBridge/Administrator';
15
+ if(platform==='linux')return '/var/lib/desktop-bridge-administrator';
16
+ throw new Error('Unsupported platform: '+platform);
17
+ }
18
+ export function quoteXml(value) {
19
+ return String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
20
+ }
21
+ export function serviceSpec(platform,root,node) {
22
+ if(platform==='darwin')return '<?xml version="1.0" encoding="UTF-8"?>\n'+
23
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'+
24
+ '<plist version="1.0"><dict><key>Label</key><string>'+SERVICE_ID+'</string>'+
25
+ '<key>ProgramArguments</key><array>'+[node,path.join(root,'runner.mjs'),root].map(s=>'<string>'+quoteXml(s)+'</string>').join('')+
26
+ '</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/>'+
27
+ '<key>WorkingDirectory</key><string>'+quoteXml(root)+'</string>'+
28
+ '<key>StandardErrorPath</key><string>'+quoteXml(path.join(root,'service.stderr.log'))+'</string>'+
29
+ '</dict></plist>\n';
30
+ if(platform==='linux') {
31
+ const escape=s=>String(s).replace(/\\/g,'\\\\').replace(/"/g,'\\"').replace(/%/g,'%%').replace(/\n/g,'');
32
+ return '[Unit]\nDescription=Desktop Bridge administrator agent (explicit opt-in)\nAfter=network-online.target\nWants=network-online.target\n\n'+
33
+ '[Service]\nType=simple\nUser=root\nGroup=root\nWorkingDirectory="'+escape(root)+'"\nExecStart="'+escape(node)+'" "'+escape(path.join(root,'runner.mjs'))+'" "'+escape(root)+'"\n'+
34
+ 'Restart=always\nRestartSec=10\nUMask=0077\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=full\n\n[Install]\nWantedBy=multi-user.target\n';
35
+ }
36
+ throw new Error('No text service specification for '+platform);
37
+ }
38
+ export function isAdministrator(platform=process.platform) {
39
+ if(platform==='win32') {
40
+ const result=spawnSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',
41
+ '[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'],{encoding:'utf8'});
42
+ return result.status===0&&result.stdout.trim()==='True';
43
+ }
44
+ return typeof process.getuid==='function'&&process.getuid()===0;
45
+ }
46
+ export function assertProtectedParents(root,platform=process.platform) {
47
+ const absolute=path.resolve(root);
48
+ if(absolute!==path.resolve(protectedRoot(platform)))throw new Error('Administrative directory must be the fixed protected path.');
49
+ if(platform!=='win32'){
50
+ let current=absolute,checked=0;
51
+ // The dedicated installation and its own parent must be protected.
52
+ // macOS may legitimately mark /Library/Application Support group-admin writable.
53
+ while(current!==path.dirname(current)&&checked++<2){
54
+ if(!fs.existsSync(current)){current=path.dirname(current);continue;}
55
+ const st=fs.lstatSync(current);
56
+ if(st.isSymbolicLink()||!st.isDirectory())throw new Error('Administrative directory contains an unsafe symlink.');
57
+ if(st.uid!==0||(st.mode&0o022)!==0)throw new Error('Administrative path must be root-owned and not group/world writable: '+current);
58
+ current=path.dirname(current);
59
+ }
60
+ }
61
+ }
62
+ export function validateSource(source) {
63
+ const base=fs.realpathSync(source);
64
+ const required=['.desktop-bridge-client','launch.json','agent-config.json','runner.mjs','lifecycle.mjs','releases','runtime'];
65
+ for(const name of required)if(!fs.existsSync(path.join(base,name)))throw new Error('Missing installed client component: '+name);
66
+ const marker=fs.readFileSync(path.join(base,'.desktop-bridge-client'),'utf8');
67
+ if(marker.trim()!=='desktop-bridge-client')throw new Error('Unrecognized installation marker.');
68
+ const config=JSON.parse(fs.readFileSync(path.join(base,'agent-config.json'),'utf8'));
69
+ if(!/^https:\/\//.test(config.serverUrl)||!config.deviceId||!config.deviceToken)throw new Error('Pair the client over HTTPS before installing administrator mode.');
70
+ const launch=JSON.parse(fs.readFileSync(path.join(base,'launch.json'),'utf8'));
71
+ if(!path.resolve(launch.agent).startsWith(base+path.sep)||!path.resolve(launch.config).startsWith(base+path.sep))throw new Error('Unsafe launch manifest.');
72
+ for(const name of ['runtime','releases','agent-config.json','launch.json','runner.mjs']){
73
+ const entry=fs.lstatSync(path.join(base,name));if(entry.isSymbolicLink())throw new Error('Top-level symlink not allowed: '+name);
74
+ }
75
+ return {base,config,launch};
76
+ }
77
+ export function copyTreeSafe(source,destination) {
78
+ const base=fs.realpathSync(source);
79
+ const boundary=fs.statSync(base).isDirectory()?base:path.dirname(base);
80
+ fs.cpSync(source,destination,{recursive:true,force:true,errorOnExist:false,dereference:true,filter:(entry)=>{
81
+ const resolved=fs.realpathSync(entry);
82
+ if(resolved!==boundary&&!resolved.startsWith(boundary+path.sep))throw new Error('Symlink escapes administrative payload: '+entry);
83
+ const lstat=fs.lstatSync(entry);
84
+ if(lstat.isSymbolicLink()&&fs.statSync(entry).isDirectory())throw new Error('Symlinked directories not allowed: '+entry);
85
+ return true;
86
+ }});
87
+ }
88
+ export function rewritePaths(value,oldRoot,newRoot) {
89
+ if(typeof value==='string') {
90
+ return value===oldRoot?newRoot:value.startsWith(oldRoot+path.sep)?newRoot+value.slice(oldRoot.length):value;
91
+ }
92
+ if(Array.isArray(value))return value.map(x=>rewritePaths(x,oldRoot,newRoot));
93
+ if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,x])=>[key,rewritePaths(x,oldRoot,newRoot)]));
94
+ return value;
95
+ }
96
+ export function secureTree(root,platform=process.platform) {
97
+ if(platform==='win32') {
98
+ const result=spawnSync('icacls.exe',[root,'/inheritance:r','/grant:r','*S-1-5-18:(OI)(CI)F','*S-1-5-32-544:(OI)(CI)F','/T','/Q'],{encoding:'utf8'});
99
+ if(result.status!==0)throw new Error('Failed to protect Windows administrative files: '+result.stderr);
100
+ } else {
101
+ for(const entry of fs.readdirSync(root,{withFileTypes:true})){
102
+ const filename=path.join(root,entry.name);
103
+ const st=fs.lstatSync(filename);
104
+ if(st.isSymbolicLink())throw new Error('Unexpected symlink in privileged tree.');
105
+ fs.chownSync(filename,0,0);
106
+ if(st.isDirectory()){fs.chmodSync(filename,0o700);secureTree(filename,platform);}
107
+ else fs.chmodSync(filename,(st.mode&0o111)?0o700:0o600);
108
+ }
109
+ fs.chownSync(root,0,0);fs.chmodSync(root,0o700);
110
+ for(const bin of findNodeBinaries(root))fs.chmodSync(bin,0o700);
111
+ }
112
+ }
113
+ function findNodeBinaries(root) {
114
+ const runtime=path.join(root,'runtime');if(!fs.existsSync(runtime))return [];
115
+ const out=[];
116
+ for(const entry of fs.readdirSync(runtime)){const candidate=path.join(runtime,entry,'bin','node');if(fs.existsSync(candidate))out.push(candidate);}
117
+ return out;
118
+ }
119
+ export function writePrivateJson(file,data){fs.writeFileSync(file,JSON.stringify(data,null,2),{mode:0o600,flag:'w'});if(process.platform!=='win32')fs.chmodSync(file,0o600);}
@@ -0,0 +1,152 @@
1
+ // Privileged entry point. Never run automatically from a network request.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {spawnSync} from 'node:child_process';
5
+ import {protectedRoot,SERVICE_ID,WINDOWS_TASK,isAdministrator,assertProtectedParents,validateSource,copyTreeSafe,secureTree,rewritePaths,serviceSpec,writePrivateJson} from './admin-runtime.mjs';
6
+
7
+ const platform=process.platform;
8
+ const root=protectedRoot();
9
+ const serviceFile=platform==='darwin'?'/Library/LaunchDaemons/'+SERVICE_ID+'.plist':
10
+ platform==='linux'?'/etc/systemd/system/desktop-bridge-administrator.service':null;
11
+
12
+ function cmd(program,args,optional=false) {
13
+ const result=spawnSync(program,args,{stdio:'inherit',env:{...process.env,NODE_ENV:'production'}});
14
+ if(result.error||(!optional&&result.status!==0))throw new Error(program+' failed: '+(result.error?.message??result.status));
15
+ return result.status===0;
16
+ }
17
+ function winPs(text,optional=false) {
18
+ return cmd('powershell.exe',['-NoProfile','-NonInteractive','-Command',text],optional);
19
+ }
20
+ const ps=s=>"'"+String(s).replace(/'/g,"''")+"'";
21
+ const nodeIn=root=> {
22
+ const runtime=path.join(root,'runtime');
23
+ const entries=fs.readdirSync(runtime).filter(s=>s.startsWith('node-v24.')&&s.endsWith('-'+platform+'-'+process.arch));
24
+ if(entries.length!==1)throw new Error('Expected exactly one verified private Node runtime.');
25
+ const filename=path.join(runtime,entries[0],platform==='win32'?'node.exe':'bin/node');
26
+ if(!fs.statSync(filename).isFile())throw new Error('Protected Node executable is not a file.');
27
+ return filename;
28
+ };
29
+ function taskInstall(node) {
30
+ const arg='"'+path.join(root,'runner.mjs')+'" "'+root+'"';
31
+ const script="$ErrorActionPreference='Stop';"+
32
+ "$a=New-ScheduledTaskAction -Execute "+ps(node)+" -Argument "+ps(arg)+" -WorkingDirectory "+ps(root)+";"+
33
+ "$t=New-ScheduledTaskTrigger -AtStartup;"+
34
+ "$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;"+
35
+ "$s=New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable;"+
36
+ "Register-ScheduledTask -TaskName "+ps(WINDOWS_TASK)+" -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null;"+
37
+ "Start-ScheduledTask -TaskName "+ps(WINDOWS_TASK);
38
+ winPs(script);
39
+ }
40
+ function stopService() {
41
+ if(platform==='win32'){
42
+ winPs("$t=Get-ScheduledTask -TaskName "+ps(WINDOWS_TASK)+" -ErrorAction SilentlyContinue;if($t){Stop-ScheduledTask -TaskName "+ps(WINDOWS_TASK)+" -ErrorAction SilentlyContinue;Unregister-ScheduledTask -TaskName "+ps(WINDOWS_TASK)+" -Confirm:$false}",true);
43
+ }else if(platform==='darwin'){
44
+ if(fs.existsSync(serviceFile))cmd('launchctl',['bootout','system',serviceFile],true);
45
+ fs.rmSync(serviceFile,{force:true});
46
+ }else if(fs.existsSync(serviceFile)){
47
+ cmd('systemctl',['disable','--now','desktop-bridge-administrator.service'],true);
48
+ fs.rmSync(serviceFile,{force:true});
49
+ cmd('systemctl',['daemon-reload']);
50
+ }
51
+ }
52
+ function startService() {
53
+ const node=nodeIn(root);
54
+ if(platform==='win32')return taskInstall(node);
55
+ fs.writeFileSync(serviceFile,serviceSpec(platform,root,node),{mode:0o644});
56
+ fs.chownSync(serviceFile,0,0);fs.chmodSync(serviceFile,0o644);
57
+ if(platform==='darwin')cmd('launchctl',['bootstrap','system',serviceFile]);
58
+ else {cmd('systemctl',['daemon-reload']);cmd('systemctl',['enable','--now','desktop-bridge-administrator.service']);}
59
+ }
60
+ function flag(state) {
61
+ fs.writeFileSync(path.join(root,'maintenance.flag'),state,{mode:0o600});
62
+ }
63
+ function settings(mode) {
64
+ writePrivateJson(path.join(root,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.6'});
65
+ }
66
+ function relocate(source,mode) {
67
+ const {base}=validateSource(source);
68
+ const parent=path.dirname(root);
69
+ // Parent folders must be created by an explicitly authorized elevated installer.
70
+ fs.mkdirSync(parent,{recursive:true,mode:0o700});
71
+ if(platform!=='win32'){
72
+ const info=fs.lstatSync(parent);
73
+ if(!info.isDirectory()||info.isSymbolicLink())throw new Error('Unsafe administrative parent directory.');
74
+ fs.chownSync(parent,0,0);fs.chmodSync(parent,0o700);
75
+ }else{
76
+ const acl=spawnSync('icacls.exe',[parent,'/inheritance:r','/grant:r','*S-1-5-18:(OI)(CI)F','*S-1-5-32-544:(OI)(CI)F','/Q'],{encoding:'utf8'});
77
+ if(acl.status!==0)throw new Error('Unable to protect administrative parent directory.');
78
+ }
79
+ if(fs.existsSync(root))assertProtectedParents(root);
80
+ const stage=path.join(parent,'.desktop-bridge-admin-stage-'+process.pid);
81
+ const backup=path.join(parent,'.desktop-bridge-admin-backup-'+process.pid);
82
+ fs.mkdirSync(stage,{mode:0o700});
83
+ const selected=['.desktop-bridge-client','agent-config.json','launch.json','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','runtime','releases'];
84
+ try{
85
+ for(const entry of selected){const file=path.join(base,entry);if(fs.existsSync(file))copyTreeSafe(file,path.join(stage,entry));}
86
+ const oldConfig=fs.existsSync(path.join(root,'agent-config.json'))?JSON.parse(fs.readFileSync(path.join(root,'agent-config.json'),'utf8')):null;
87
+ const config=JSON.parse(fs.readFileSync(path.join(stage,'agent-config.json'),'utf8'));
88
+ const merged=oldConfig?.deviceId&&oldConfig?.deviceToken?{...config,deviceId:oldConfig.deviceId,deviceToken:oldConfig.deviceToken}:config;
89
+ writePrivateJson(path.join(stage,'agent-config.json'),rewritePaths(merged,base,root));
90
+ const launch=JSON.parse(fs.readFileSync(path.join(stage,'launch.json'),'utf8'));
91
+ writePrivateJson(path.join(stage,'launch.json'),rewritePaths(launch,base,root));
92
+ for(const name of ['journal.json','agent.log','agent.log.1']){
93
+ const previous=path.join(root,name);if(fs.existsSync(previous))copyTreeSafe(previous,path.join(stage,name));
94
+ else if(fs.existsSync(path.join(base,name)))copyTreeSafe(path.join(base,name),path.join(stage,name));
95
+ }
96
+ writePrivateJson(path.join(stage,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.6'});
97
+ fs.writeFileSync(path.join(stage,'maintenance.flag'),'installing',{mode:0o600});
98
+ secureTree(stage);
99
+ if(fs.existsSync(root))fs.renameSync(root,backup);
100
+ try{fs.renameSync(stage,root);}
101
+ catch(e){if(fs.existsSync(backup))fs.renameSync(backup,root);throw e;}
102
+ assertProtectedParents(root);
103
+ fs.rmSync(backup,{recursive:true,force:true});
104
+ }finally{fs.rmSync(stage,{recursive:true,force:true});}
105
+ }
106
+ async function stopRunner() {
107
+ flag('stopping');
108
+ const {stopClient}=await import('./lifecycle.mjs');
109
+ await stopClient(root);
110
+ }
111
+ const [action,value,...rest]=process.argv.slice(2);
112
+ if(!isAdministrator())throw new Error('Administrator authorization is required; use admin-elevate.mjs instead.');
113
+ if(!['install','start','stop','mode','uninstall','status'].includes(action)||(action==='install'?rest.length!==1:rest.length!==0))throw new Error('Usage: admin-service.mjs install <source> manual|background | start | stop | status | mode manual|background | uninstall');
114
+ if(action==='install'){
115
+ // install receives two positional values.
116
+ const source=value;const mode=rest[0];
117
+ if(!source||!['manual','background'].includes(mode))throw new Error('install requires source and manual|background');
118
+ if(fs.existsSync(root)){stopService();await stopRunner();}
119
+ relocate(source,mode);
120
+ fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
121
+ if(mode==='background')startService();
122
+ console.log('Administrator '+mode+' mode installed. System privacy controls and ChatGPT tool approvals remain in force.');
123
+ }else if(action==='status'){
124
+ if(!fs.existsSync(root))throw new Error('Administrator client has not been installed.');
125
+ const mode=JSON.parse(fs.readFileSync(path.join(root,'client-settings.json'),'utf8')).mode;
126
+ console.log(JSON.stringify({root,mode,privilege:'administrator'}));
127
+ }else if(action==='start'){
128
+ assertProtectedParents(root);
129
+ const mode=JSON.parse(fs.readFileSync(path.join(root,'client-settings.json'),'utf8')).mode;
130
+ if(mode==='background'){startService();}else{
131
+ fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
132
+ process.env.BRIDGE_ADMIN_CONTEXT='1';
133
+ cmd(nodeIn(root),[path.join(root,'control.mjs'),'start','--root',root]);
134
+ }
135
+ }else if(action==='stop'){
136
+ assertProtectedParents(root);stopService();await stopRunner();console.log('Administrator client stopped.');
137
+ }else if(action==='mode'){
138
+ if(!['manual','background'].includes(value))throw new Error('mode must be manual or background');
139
+ assertProtectedParents(root);stopService();await stopRunner();settings(value);
140
+ fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
141
+ if(value==='background')startService();
142
+ console.log('Administrator client mode changed: '+value);
143
+ }else if(action==='uninstall'){
144
+ assertProtectedParents(root);stopService();await stopRunner();
145
+ const purge=value==='--purge';
146
+ if(value&&value!=='--purge')throw new Error('Only --purge is accepted for uninstall');
147
+ if(purge)fs.rmSync(root,{recursive:true,force:true});
148
+ else{
149
+ for(const entry of ['runtime','releases','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','launch.json'])fs.rmSync(path.join(root,entry),{recursive:true,force:true});
150
+ }
151
+ console.log('Administrator service uninstalled. Device revocation is separate.');
152
+ }
@@ -0,0 +1,264 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { createHash } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { pairDevice,saveConfig } from './pair.mjs';
8
+
9
+ const configPath=process.env.BRIDGE_AGENT_CONFIG??process.argv[2];
10
+ if(!configPath) throw new Error('Usage: node agent.mjs path/to/agent-config.json');
11
+ const config=JSON.parse(fs.readFileSync(configPath,'utf8').replace(/^\uFEFF/,''));
12
+
13
+ const directory=path.dirname(path.resolve(configPath));
14
+ if(fs.existsSync(path.join(directory,'maintenance.flag'))) process.exit(0);
15
+
16
+ const lockPath=path.join(directory,'agent.lock');
17
+ try {
18
+ const fd=fs.openSync(lockPath,'wx',0o600);fs.writeFileSync(fd,String(process.pid));fs.closeSync(fd);
19
+ } catch(e) {
20
+ if(e.code!=='EEXIST') throw e;
21
+ // A concurrent process can create the file before writing its PID.
22
+ if(fs.statSync(lockPath).size===0 && Date.now()-fs.statSync(lockPath).mtimeMs<30000)process.exit(0);
23
+ const old=Number(fs.readFileSync(lockPath,'utf8'));
24
+ let alive=false;
25
+ if(Number.isInteger(old) && old>0 && old!==process.pid) {try{process.kill(old,0);alive=true;}catch(err){if(err.code!=='ESRCH')alive=true;}}
26
+ if(alive) process.exit(0);
27
+ fs.unlinkSync(lockPath);
28
+ const fd=fs.openSync(lockPath,'wx',0o600);fs.writeFileSync(fd,String(process.pid));fs.closeSync(fd);
29
+ }
30
+ const logPath=path.join(directory,'agent.log');
31
+ function log(event,fields={}) {
32
+ try {
33
+ if(fs.existsSync(logPath) && fs.statSync(logPath).size>1024*1024) fs.renameSync(logPath,logPath+'.1');
34
+ fs.appendFileSync(logPath,JSON.stringify({time:new Date().toISOString(),event,...fields})+'\n',{mode:0o600});
35
+ } catch {}
36
+ }
37
+ function unlock(){try{if(fs.readFileSync(lockPath,'utf8')===String(process.pid))fs.unlinkSync(lockPath);}catch{}}
38
+ process.on('exit',unlock);
39
+
40
+ const authStatusPath=path.join(directory,'auth-status.json');
41
+ function saveAuthStatus(status) {
42
+ const temporary=authStatusPath+'.tmp';
43
+ fs.writeFileSync(temporary,JSON.stringify(status,null,2),{mode:0o600});
44
+ fs.renameSync(temporary,authStatusPath);
45
+ }
46
+ function notifyRevokedWindows() {
47
+ if(process.platform!=='win32'||process.env.DESKTOP_BRIDGE_SUPPRESS_NOTIFICATIONS==='1'||process.env.USERNAME?.toUpperCase()==='SYSTEM')return;
48
+ let state;try{state=JSON.parse(fs.readFileSync(authStatusPath,'utf8'));}catch{return;}
49
+ if(state.authStatus!=='re_pair_required'||state.notificationShown)return;
50
+ // The warning is informative only. Re-pairing still requires an explicit local
51
+ // click followed by a browser login and device approval.
52
+ const rePair=path.join(directory,'RePair.cmd');
53
+ const quote=value=>"'"+String(value).replace(/'/g,"''")+"'";
54
+ const message='Desktop Bridge 的设备授权已撤销,旧凭据已失效。\n\n是否现在重新配对?(需要网页登录确认)';
55
+ const script="Add-Type -AssemblyName System.Windows.Forms;"+
56
+ "$answer=[System.Windows.Forms.MessageBox]::Show("+quote(message)+",'Desktop Bridge',[System.Windows.Forms.MessageBoxButtons]::YesNo,[System.Windows.Forms.MessageBoxIcon]::Warning);"+
57
+ "if($answer -eq [System.Windows.Forms.DialogResult]::Yes){Start-Process -FilePath 'cmd.exe' -ArgumentList @('/k',"+quote('"' + rePair + '"')+")}";
58
+ try {
59
+ const child=spawn('powershell.exe',['-NoProfile','-Sta','-WindowStyle','Hidden','-Command',script],
60
+ {detached:true,stdio:'ignore',windowsHide:true});
61
+ child.on('error',()=>{});child.unref();
62
+ saveAuthStatus({...state,notificationShown:true});
63
+ } catch { /* The next local status/start command still shows persistent notice. */ }
64
+ }
65
+ function clearRevokedCredentials(reason,{stop=true}={}) {
66
+ delete config.deviceToken;delete config.deviceId;
67
+ saveConfig(configPath,config);
68
+ let previous={};try{previous=JSON.parse(fs.readFileSync(authStatusPath,'utf8'));}catch{}
69
+ saveAuthStatus({authStatus:'re_pair_required',reason:'device_revoked',
70
+ detectedAt:previous.authStatus==='re_pair_required'?previous.detectedAt??new Date().toISOString():new Date().toISOString(),
71
+ notificationShown:previous.authStatus==='re_pair_required'&&previous.notificationShown===true});
72
+ if(stop)fs.writeFileSync(path.join(directory,'maintenance.flag'),'device authorization revoked',{mode:0o600});
73
+ log('device_authorization_revoked',{reason});
74
+ console.error('设备授权已撤销,旧凭据已清除。请运行 RePair.cmd 重新配对。');
75
+ if(stop)notifyRevokedWindows();
76
+ }
77
+ async function remoteRevoked() {
78
+ if(!config.deviceId||!config.deviceToken)return false;
79
+ const url=new URL('/api/agent/revocation-status',config.serverUrl);
80
+ if(url.protocol!=='https:'&&!['localhost','127.0.0.1'].includes(url.hostname))return false;
81
+ try {
82
+ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},
83
+ body:JSON.stringify({deviceId:config.deviceId}),signal:AbortSignal.timeout(5000)});
84
+ return response.ok&&(await response.json()).revoked===true;
85
+ } catch {return false;} // Never erase credentials on a timeout or ordinary network failure.
86
+ }
87
+ let revokedAtStartup=false;
88
+ if(process.argv.includes('--pair')){delete config.deviceToken;delete config.deviceId;saveConfig(configPath,config);}
89
+ else if(await remoteRevoked()) {
90
+ const installing=process.argv.includes('--setup-only');
91
+ clearRevokedCredentials('startup_preflight',{stop:!installing});
92
+ if(!installing){revokedAtStartup=true;process.exitCode=78;}
93
+ }
94
+ if(!revokedAtStartup&&(!config.deviceToken||!config.deviceId)) {
95
+ await pairDevice(config,configPath,{openBrowser:!process.argv.includes('--no-browser')});
96
+ fs.rmSync(authStatusPath,{force:true}); // New server-approved credentials clear the notice.
97
+ }
98
+ if(revokedAtStartup){
99
+ // An offline device's credentials were revoked. Let fetch handles close
100
+ // normally on Windows; do not start tools or silently auto-pair.
101
+ } else if(process.argv.includes('--setup-only')) {
102
+ // Let active fetch/undici handles close naturally on Windows.
103
+ process.exitCode=0;
104
+ } else {
105
+ const requireRuntime=createRequire(path.join(config.runtimeDir,'package.json'));
106
+ const {Client}=await import(pathToFileURL(requireRuntime.resolve('@modelcontextprotocol/sdk/client/index.js')));
107
+ const {StdioClientTransport}=await import(pathToFileURL(requireRuntime.resolve('@modelcontextprotocol/sdk/client/stdio.js')));
108
+ const WebSocket=requireRuntime('ws');
109
+ const client=new Client({name:'personal-desktop-bridge-agent',version:'0.2.6'},{capabilities:{}});
110
+ const transport=new StdioClientTransport({command:process.execPath,args:[config.mcpEntry],cwd:config.workingDirectory??directory,env:{...process.env},stderr:'pipe'});
111
+ let ws,retryTimer,stopping=false,attempt=0,localHealthy=false,checkingHealth=false,healthFailures=0,remoteStopping=false;
112
+ const heartbeatPath=path.join(directory,'heartbeat.json');
113
+ function writeHeartbeat(){try{fs.writeFileSync(heartbeatPath,JSON.stringify({pid:process.pid,time:Date.now(),connected:ws?.readyState===1,localToolsAvailable:localHealthy}),{mode:0o600});}catch{}}
114
+ writeHeartbeat();
115
+ const processHeartbeat=setInterval(writeHeartbeat,15000);processHeartbeat.unref();
116
+ const journalPath=path.join(directory,'journal.json');
117
+ let journal={};
118
+ try{journal=JSON.parse(fs.readFileSync(journalPath,'utf8'));}catch(e){if(e.code!=='ENOENT'){log('journal_corrupt');process.exit(78);}}
119
+ function persist() {
120
+ const entries=Object.entries(journal).filter(([,v])=>v.status==='executing'||Date.now()-(v.time??0)<30*86400000).sort((a,b)=>(b[1].time??0)-(a[1].time??0));
121
+ let bytes=0;
122
+ journal=Object.fromEntries(entries.map(([id,value])=>{
123
+ bytes+=JSON.stringify(value).length;
124
+ return [id,bytes>12*1024*1024?{status:'unknown',time:value.time}:value];
125
+ }));
126
+ fs.writeFileSync(journalPath+'.tmp',JSON.stringify(journal),{mode:0o600});
127
+ fs.renameSync(journalPath+'.tmp',journalPath);
128
+ }
129
+ for(const entry of Object.values(journal)) if(entry.status==='executing') entry.status='unknown';
130
+ persist();
131
+ const running=new Map();
132
+ async function execute(message,socket) {
133
+ if(!/^[0-9a-f-]{36}$/.test(message.id) || typeof message.name!=='string' || !message.arguments || typeof message.arguments!=='object' || Array.isArray(message.arguments)) return socket.close(1008,'Invalid call');
134
+ const cached=journal[message.id];
135
+ if(cached) {
136
+ if(cached.status==='completed') return send(socket,{type:'result',id:message.id,result:cached.result});
137
+ if(cached.status==='executing') return; // Original execution will answer; never run a duplicate.
138
+ return send(socket,{type:'result',id:message.id,status:'unknown',error:'An earlier execution has an unknown outcome. Inspect the computer before retrying.'});
139
+ }
140
+ if(remoteStopping||(message.name==='shutdown'&&running.size>0))return send(socket,{type:'result',id:message.id,error:'Agent is stopping or has an executing call; no shutdown was performed.'});
141
+ if(running.size>=4) return send(socket,{type:'result',id:message.id,error:'Agent is busy; no command was executed.'});
142
+ journal[message.id]={status:'executing',time:Date.now()};
143
+ persist();
144
+ running.set(message.id,true);
145
+ log('call_started',{id:message.id,tool:message.name});
146
+ try {
147
+ if(message.name==='shutdown') {
148
+ if(Object.keys(message.arguments).length)throw new Error('shutdown accepts no local arguments');
149
+ remoteStopping=true;
150
+ // Persist the stop intent so a runner/service restart cannot immediately reconnect.
151
+ fs.writeFileSync(path.join(directory,'maintenance.flag'),'remote shutdown',{mode:0o600});
152
+ const result={content:[{type:'text',text:JSON.stringify({status:'stopping',computerShutdown:false,restart:'Start Desktop Bridge locally to reconnect.'})}]};
153
+ journal[message.id]={status:'completed',time:Date.now(),result};persist();
154
+ await new Promise(resolve=>{
155
+ const timer=setTimeout(resolve,1000);
156
+ if(socket.readyState!==WebSocket.OPEN){clearTimeout(timer);return resolve();}
157
+ socket.send(JSON.stringify({type:'result',id:message.id,result}),()=>{clearTimeout(timer);resolve();});
158
+ });
159
+ await shutdown();return;
160
+ }
161
+ const args={...message.arguments};
162
+ if(process.platform==='win32' && message.name==='start_process') {
163
+ args.shell??=config.defaultShell;
164
+ if(typeof args.command==='string' && /(?:^|[\\/])(powershell|pwsh)(?:\.exe)?$/i.test(args.shell??'')) {
165
+ args.command='[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Text.UTF8Encoding]::new($false);'+args.command;
166
+ }
167
+ }
168
+ let result=await client.callTool({name:message.name,arguments:args},undefined,{timeout:110000,maxTotalTimeout:110000});
169
+ if(Buffer.byteLength(JSON.stringify(result))>8*1024*1024) result={isError:true,content:[{type:'text',text:'Tool ran, but its result exceeded the transfer limit. Use smaller pages or save output to a file.'}]};
170
+ journal[message.id]={status:'completed',time:Date.now(),result};persist();
171
+ send(ws,{type:'result',id:message.id,result});
172
+ log('call_finished',{id:message.id,isError:!!result.isError});
173
+ } catch(e) {
174
+ journal[message.id]={status:'unknown',time:Date.now()};persist();
175
+ send(ws,{type:'result',id:message.id,status:'unknown',error:'Local tool request failed; its execution outcome may be unknown. '+String(e.message).slice(0,500)});
176
+ log('call_failed',{id:message.id});
177
+ } finally {running.delete(message.id);}
178
+ }
179
+ function send(socket,message) {if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(message));}
180
+ async function shutdown(code=0) {
181
+ if(stopping)return;stopping=true;clearTimeout(retryTimer);
182
+ clearInterval(processHeartbeat);clearInterval(maintenanceWatch);
183
+ ws?.close();
184
+ const timeout=setTimeout(()=>process.exit(code),8000);timeout.unref();
185
+ try{await client.close();}catch{}
186
+ process.exit(code);
187
+ }
188
+ process.on('message',message=>{if(message?.type==='stop')void shutdown();});
189
+ const maintenanceWatch=setInterval(()=>{if(fs.existsSync(path.join(directory,'maintenance.flag')))void shutdown();},1000);maintenanceWatch.unref();
190
+ process.on('SIGINT',()=>void shutdown());process.on('SIGTERM',()=>void shutdown());
191
+ process.on('uncaughtException',error=>{log('fatal',{type:error.name});void shutdown(1);});
192
+ process.on('unhandledRejection',error=>{log('fatal_rejection',{type:error?.name});void shutdown(1);});
193
+ client.onclose=()=>{if(!stopping){log('local_mcp_closed');void shutdown(1);}};
194
+ client.onerror=error=>log('local_mcp_error',{type:error.name});
195
+ try{await client.connect(transport,{timeout:30000});}catch(error){log('local_mcp_start_failed',{message:String(error.message).slice(0,300)});await shutdown(1);}
196
+ // Drain stderr to prevent a full pipe from blocking the local MCP process.
197
+ transport.stderr?.on('data',()=>{});
198
+ let tools=[],cursor;
199
+ do {const page=await client.listTools(cursor?{cursor}:{});tools.push(...page.tools);cursor=page.nextCursor;}while(cursor);
200
+ log('local_mcp_ready',{toolCount:tools.length,pid:process.pid,childPid:transport.pid});
201
+ function recover(id,socket) {
202
+ const entry=journal[id];
203
+ if(entry?.status==='completed')send(socket,{type:'result',id,result:entry.result});
204
+ else if(entry?.status==='executing')send(socket,{type:'result',id,status:'executing'});
205
+ else send(socket,{type:'result',id,status:'unknown',error:'客户端未保留确定结果。请检查电脑上的实际状态;不会自动重试。'});
206
+ }
207
+ async function checkHealth(socket) {
208
+ if(checkingHealth)return;
209
+ checkingHealth=true;
210
+ try{await client.ping({timeout:5000});const probe=tools.find(t=>['get_config','list_sessions'].includes(t.name));if(probe){const result=await client.callTool({name:probe.name,arguments:{}},undefined,{timeout:5000});if(result.isError)throw new Error('Probe failed');}else await client.listTools({}, {timeout:5000});localHealthy=true;healthFailures=0;}
211
+ catch{localHealthy=false;if(++healthFailures>=3){log('local_health_failed');void shutdown(1);}}
212
+ finally{checkingHealth=false;send(socket,{type:'health',ok:localHealthy});writeHeartbeat();}
213
+ }
214
+ function connect() {
215
+ if(stopping)return;
216
+ const url=new URL('/agent',config.serverUrl);
217
+ url.protocol=url.protocol==='https:'?'wss:':'ws:';
218
+ if(url.protocol!=='wss:' && !['localhost','127.0.0.1'].includes(url.hostname)) throw new Error('Remote connection requires HTTPS');
219
+ const socket=new WebSocket(url,{headers:{Authorization:'Bearer '+config.deviceToken},handshakeTimeout:15000,perMessageDeflate:false,maxPayload:10*1024*1024});
220
+ ws=socket;
221
+ let lastSeen=Date.now(),welcomed=false;
222
+ const heartbeat=setInterval(()=>{if(Date.now()-lastSeen>65000)socket.terminate();},15000);
223
+ socket.on('ping',()=>lastSeen=Date.now());
224
+ socket.on('open',()=>{send(socket,{type:'hello',protocol:2,deviceId:config.deviceId,tools,capabilities:{shutdown:true},version:'0.2.6',platform:process.platform});});
225
+ socket.on('message',data=>{
226
+ lastSeen=Date.now();
227
+ try {
228
+ const message=JSON.parse(data.toString());
229
+ if(message.type==='welcome'){welcomed=true;attempt=0;log('connected');for(const id of message.recover??[])recover(id,socket);}
230
+ else if(message.type==='recover'&&welcomed){for(const id of message.ids??[])recover(id,socket);}
231
+ else if(message.type==='health_check'&&welcomed)void checkHealth(socket);
232
+ else if(message.type==='rotate'&&welcomed){if(!/^[A-Za-z0-9_-]{43}$/.test(message.token))throw new Error('Invalid token');config.deviceToken=message.token;saveConfig(configPath,config);send(socket,{type:'rotate_ack',tokenHash:createHash('sha256').update(message.token).digest('hex')});}
233
+ else if(message.type==='revoked'&&welcomed) {
234
+ clearRevokedCredentials('server_revocation');
235
+ send(socket,{type:'revoked_ack'});
236
+ void shutdown(78);
237
+ }
238
+ else if(message.type==='call' && welcomed) void execute(message,socket);
239
+ else socket.close(1008,'Invalid server message');
240
+ } catch {socket.close(1008,'Invalid server message');}
241
+ });
242
+ socket.on('unexpected-response',(_request,response)=>{
243
+ const status=response.statusCode;response.resume();
244
+ // ws delegates handshake failures to this listener: explicitly finish it
245
+ // so the normal reconnect/stop lifecycle can proceed.
246
+ socket.terminate();
247
+ if(status!==403||stopping)return; // Pauses and other auth failures are not revocations.
248
+ void remoteRevoked().then(revoked=>{
249
+ if(!revoked||stopping)return;
250
+ clearRevokedCredentials('reconnect_rejected');
251
+ void shutdown(78);
252
+ }).catch(()=>{}); // Preserve credentials if status cannot be verified.
253
+ });
254
+ socket.on('error',error=>log('connection_error',{message:String(error.message).slice(0,240)}));
255
+ socket.on('close',(code)=>{
256
+ clearInterval(heartbeat);
257
+ if(stopping)return;
258
+ const delay=Math.min(60000,1000*2**Math.min(attempt++,6))+Math.floor(Math.random()*1000);
259
+ log('disconnected',{code,retryInMs:delay});
260
+ retryTimer=setTimeout(connect,delay);
261
+ });
262
+ }
263
+ connect();
264
+ }
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import { spawn } from 'node:child_process';
4
+ export function saveConfig(filename,config) {
5
+ fs.writeFileSync(filename+'.tmp',JSON.stringify(config,null,2),{mode:0o600});
6
+ fs.renameSync(filename+'.tmp',filename);
7
+ }
8
+ export async function pairDevice(config,filename,{openBrowser=true,intervalMs}={}) {
9
+ const server=new URL(config.serverUrl);
10
+ if(server.protocol!=='https:'&&!['127.0.0.1','localhost'].includes(server.hostname))throw new Error('配对需要 HTTPS');
11
+ const response=await fetch(new URL('/api/pair/start',server),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:config.deviceName??os.hostname(),platform:process.platform,version:'0.2.6'}),signal:AbortSignal.timeout(15000)});
12
+ if(!response.ok)throw new Error('无法开始配对,请检查网络');
13
+ const p=await response.json();
14
+ console.log('请在网页登录并确认这台电脑。配对码:'+p.userCode+'\n'+p.verificationUri);
15
+ if(openBrowser) {
16
+ const command=process.platform==='win32'?'rundll32.exe':process.platform==='darwin'?'open':'xdg-open';
17
+ const args=process.platform==='win32'?['url.dll,FileProtocolHandler',p.verificationUri]:[p.verificationUri];
18
+ const child=spawn(command,args,{stdio:'ignore',detached:true});child.on('error',()=>{});child.unref();
19
+ }
20
+ const deadline=Date.now()+p.expiresIn*1000;
21
+ while(Date.now()<deadline) {
22
+ await new Promise(r=>setTimeout(r,intervalMs??p.interval*1000));
23
+ let poll;
24
+ try{poll=await fetch(new URL('/api/pair/poll',server),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({deviceCode:p.deviceCode}),signal:AbortSignal.timeout(15000)});}catch{continue;}
25
+ if(poll.status===202||poll.status===429||poll.status>=500)continue;
26
+ if(!poll.ok)throw new Error('配对被拒绝或已过期,请重新运行安装程序');
27
+ const credentials=await poll.json();config.deviceId=credentials.deviceId;config.deviceToken=credentials.deviceToken;
28
+ saveConfig(filename,config);console.log('电脑已绑定,凭据已自动保存。');return config;
29
+ }
30
+ throw new Error('配对超时,请重新运行安装程序');
31
+ }
@@ -0,0 +1 @@
1
+ {"serverUrl":"https://mcp.ilovestudy.de"}