@remcp/remcp 0.2.204 → 0.2.206
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/package.json +2 -2
- package/src/cli/env.mjs +6 -0
- package/src/cli/mac-native-temperature.mjs +12 -2
- package/src/cli/service-inventory.mjs +9 -2
- package/src/cli/service.mjs +122 -13
- package/src/cli/windows-supervisor.mjs +104 -6
- package/src/cli/windows-user-supervisor.mjs +253 -0
- package/src/cli.mjs +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.206",
|
|
4
4
|
"description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"check": "node --check bin/remcp.mjs && node --check src/agent-deploy-reconnect.mjs && node --check src/agent-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/mac-native-temperature.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fetch-update-manifest.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
21
|
+
"check": "node --check bin/remcp.mjs && node --check src/agent-deploy-reconnect.mjs && node --check src/agent-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/mac-native-temperature.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/cli/windows-user-supervisor.mjs && node --check src/fetch-update-manifest.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
22
22
|
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
package/src/cli/env.mjs
CHANGED
|
@@ -17,6 +17,12 @@ export const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macS
|
|
|
17
17
|
export const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
|
|
18
18
|
export const windowsTaskName = 'ReMCP Agent';
|
|
19
19
|
export const windowsServiceLauncherFile = path.join(configDir, 'remcp-agent.cmd');
|
|
20
|
+
// Task Scheduler may refuse to create a task for a standard Windows account.
|
|
21
|
+
// This per-user Startup folder runs without administrator privileges.
|
|
22
|
+
export const windowsStartupScriptFile = path.join(
|
|
23
|
+
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
|
24
|
+
'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'ReMCP Agent.vbs'
|
|
25
|
+
);
|
|
20
26
|
// How npm is invoked is resolved from the running node when possible: a background service has a
|
|
21
27
|
// minimal PATH, which is why auto-update used to find no npm on macOS. See src/npm.mjs.
|
|
22
28
|
export const npm = resolveNpm();
|
|
@@ -20,7 +20,10 @@ function checkedDirectory(directory) {
|
|
|
20
20
|
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('unsafe addon directory');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function buildVettedMacTemperatureAddon({
|
|
23
|
+
export function buildVettedMacTemperatureAddon({
|
|
24
|
+
runtimeRoot, resolved, runCommand, warn = console.error,
|
|
25
|
+
nodeExecutable = process.execPath, environment = process.env,
|
|
26
|
+
}) {
|
|
24
27
|
try {
|
|
25
28
|
checkedDirectory(runtimeRoot);
|
|
26
29
|
const runtime = checkedManifest(path.join(runtimeRoot, 'package.json'));
|
|
@@ -51,10 +54,17 @@ export function buildVettedMacTemperatureAddon({ runtimeRoot, resolved, runComma
|
|
|
51
54
|
}
|
|
52
55
|
// Execute precisely this verified package's known node-gyp script. Do not
|
|
53
56
|
// re-enable arbitrary transitive lifecycle scripts in global npm install.
|
|
57
|
+
// launchd uses a minimal PATH: npm itself is invoked by absolute node path,
|
|
58
|
+
// but node-gyp's nested executable uses `#!/usr/bin/env node`. Without the
|
|
59
|
+
// running Node binary's directory, the verified native addon never builds.
|
|
60
|
+
const nativeBuildEnv = {
|
|
61
|
+
...environment,
|
|
62
|
+
PATH: [path.dirname(nodeExecutable), environment.PATH].filter(Boolean).join(path.delimiter),
|
|
63
|
+
};
|
|
54
64
|
runCommand(resolved.command, [
|
|
55
65
|
...resolved.args, '--prefix', addonRoot, 'run', 'install',
|
|
56
66
|
'--foreground-scripts', '--ignore-scripts=false', '--no-audit', '--no-fund',
|
|
57
|
-
], { timeout:90_000 });
|
|
67
|
+
], { timeout:90_000, env:nativeBuildEnv });
|
|
58
68
|
if (!fs.existsSync(nativeBinary) || !fs.lstatSync(nativeBinary).isFile())
|
|
59
69
|
throw new Error('native build did not create its module');
|
|
60
70
|
return true;
|
|
@@ -4,8 +4,9 @@ import process from 'node:process';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
6
6
|
import { saveConfig } from './config.mjs';
|
|
7
|
-
import { linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsTaskName } from './env.mjs';
|
|
7
|
+
import { linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsStartupScriptFile, windowsTaskName } from './env.mjs';
|
|
8
8
|
import { servicePlatform, globalCliPath, npmGlobalUpdateForNode } from './npm-global.mjs';
|
|
9
|
+
import { windowsStartupScriptInstalled } from './windows-supervisor.mjs';
|
|
9
10
|
|
|
10
11
|
export function persistentServiceExpected(config) {
|
|
11
12
|
if (config?.serviceInstalled === false) return false;
|
|
@@ -14,7 +15,8 @@ export function persistentServiceExpected(config) {
|
|
|
14
15
|
if (platform === 'linux') return fs.existsSync(linuxServiceFile);
|
|
15
16
|
if (platform === 'darwin') return fs.existsSync(macServiceFile);
|
|
16
17
|
if (platform === 'win32') {
|
|
17
|
-
return
|
|
18
|
+
return windowsStartupScriptInstalled(windowsStartupScriptFile) ||
|
|
19
|
+
spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
|
|
18
20
|
}
|
|
19
21
|
return false;
|
|
20
22
|
}
|
|
@@ -38,6 +40,11 @@ export function persistentServiceState(config) {
|
|
|
38
40
|
const installed = query.status === 0;
|
|
39
41
|
const outputText = String(query.stdout || '');
|
|
40
42
|
const active = installed && /(?:Status|Состояние):\s*Running/i.test(outputText);
|
|
43
|
+
if (!installed && windowsStartupScriptInstalled(windowsStartupScriptFile)) {
|
|
44
|
+
// Startup has no OS status query comparable to Task Scheduler; do not report a
|
|
45
|
+
// running process without evidence. Device presence is tracked separately.
|
|
46
|
+
return { expected, installed:true, active:null, manager:'startup-user', name:windowsTaskName };
|
|
47
|
+
}
|
|
41
48
|
return { expected, installed, active, manager:'schtasks', name:windowsTaskName };
|
|
42
49
|
}
|
|
43
50
|
return { expected:false, installed:false, active:false, manager:null, name:null };
|
package/src/cli/service.mjs
CHANGED
|
@@ -15,9 +15,10 @@ export { macLaunchDomain, ensureMacCliCommand, installMacService } from './servi
|
|
|
15
15
|
import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
16
16
|
|
|
17
17
|
import { saveConfig } from './config.mjs';
|
|
18
|
-
import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsServiceLauncherFile, windowsTaskName } from './env.mjs';
|
|
18
|
+
import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsServiceLauncherFile, windowsStartupScriptFile, windowsTaskName } from './env.mjs';
|
|
19
19
|
import { run } from './shell.mjs';
|
|
20
|
-
import { windowsServiceLauncherText, windowsTaskReliabilityCommand } from './windows-supervisor.mjs';
|
|
20
|
+
import { windowsServiceLauncherText, windowsTaskReliabilityCommand, windowsTaskAbsenceCommand, windowsBatchUnsafeProfile, windowsStartupScriptText, windowsStartupScriptInstalled } from './windows-supervisor.mjs';
|
|
21
|
+
import { ensureWindowsSupervisorKey, windowsSupervisorCommand } from './windows-user-supervisor.mjs';
|
|
21
22
|
|
|
22
23
|
export function quoteSystemd(value) {
|
|
23
24
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
@@ -77,17 +78,106 @@ export function writeWindowsServiceLauncher(cliPath = globalCliPath(), nodePath
|
|
|
77
78
|
return windowsServiceLauncherFile;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function installWindowsUserStartup(cliPath, nodePath) {
|
|
82
|
+
const existed = fs.existsSync(windowsStartupScriptFile);
|
|
83
|
+
if (existed && !windowsStartupScriptInstalled(windowsStartupScriptFile)) {
|
|
84
|
+
throw new Error('The ReMCP Startup entry is not owned by ReMCP; refusing to overwrite it');
|
|
85
|
+
}
|
|
86
|
+
// Windows npm exposes a .cmd shim; the child must execute the actual Node ESM entry.
|
|
87
|
+
const cliEntry = path.join(path.dirname(cliPath), 'node_modules', '@remcp', 'remcp', 'bin', 'remcp.mjs');
|
|
88
|
+
const supervisorEntry = path.resolve(path.dirname(cliEntry), '../src/cli/windows-user-supervisor.mjs');
|
|
89
|
+
if (!fs.existsSync(cliEntry)) throw new Error('Windows ReMCP Node entry is missing');
|
|
90
|
+
if (!fs.existsSync(supervisorEntry)) throw new Error('Windows supervisor entry is missing from the installed ReMCP package');
|
|
91
|
+
ensureWindowsSupervisorKey();
|
|
92
|
+
const script = windowsStartupScriptText(nodePath, supervisorEntry, cliEntry);
|
|
93
|
+
fs.mkdirSync(path.dirname(windowsStartupScriptFile), { recursive:true });
|
|
94
|
+
const temporary = `${windowsStartupScriptFile}.${process.pid}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
// WSH requires a Unicode BOM to preserve non-ASCII Windows account names.
|
|
97
|
+
fs.writeFileSync(temporary, '\uFEFF' + script, { flag:'wx', mode:0o600, encoding:'utf16le' });
|
|
98
|
+
fs.renameSync(temporary, windowsStartupScriptFile);
|
|
99
|
+
} finally { try { fs.unlinkSync(temporary); } catch {} }
|
|
100
|
+
const startAndVerify = () => {
|
|
101
|
+
run('wscript.exe', ['//B', '//Nologo', windowsStartupScriptFile]);
|
|
102
|
+
const state = windowsSupervisorCommand('wait');
|
|
103
|
+
if (!state.running) throw new Error('Windows Startup agent did not launch');
|
|
104
|
+
};
|
|
105
|
+
// Reuse a running singleton. A missing OS-held pipe means the old supervisor
|
|
106
|
+
// exited: relaunch the same verified entry without touching device credentials.
|
|
107
|
+
if (existed) {
|
|
108
|
+
try {
|
|
109
|
+
const state = windowsSupervisorCommand('status');
|
|
110
|
+
if (!state.running) throw new Error('The existing Windows Startup agent is not running');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const reason = String(error.message || '');
|
|
113
|
+
if (!reason.includes('ENOENT') && !reason.includes('ECONNREFUSED')) throw error;
|
|
114
|
+
startAndVerify();
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
try { startAndVerify(); }
|
|
118
|
+
catch (error) {
|
|
119
|
+
// A launched supervisor can be alive when readiness fails. Stop it before cleanup.
|
|
120
|
+
try { windowsSupervisorCommand('stop'); } catch {}
|
|
121
|
+
try { fs.unlinkSync(windowsStartupScriptFile); } catch {}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.log('ReMCP uses this Windows account\'s Startup folder (no administrator rights required).');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function requireWindowsScheduledTaskAbsent() {
|
|
129
|
+
// A failed schtasks /Query is ambiguous across Windows languages and ACLs.
|
|
130
|
+
// Fail closed if COM cannot prove absence before choosing another startup route.
|
|
131
|
+
const existing = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive',
|
|
132
|
+
'-Command', windowsTaskAbsenceCommand(windowsTaskName)],
|
|
133
|
+
{ encoding:'utf8', windowsHide:true, timeout:10000, maxBuffer:4096, input:'' });
|
|
134
|
+
if (existing.error || existing.status !== 0 || String(existing.stdout || '').trim() !== 'ABSENT') {
|
|
135
|
+
throw new Error('Could not prove that the ReMCP scheduled task is absent; refusing to start a competing agent');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
export function installWindowsService(cliPath = globalCliPath(), { nodePath = process.execPath } = {}) {
|
|
140
|
+
if (windowsStartupScriptInstalled(windowsStartupScriptFile)) {
|
|
141
|
+
installWindowsUserStartup(cliPath, nodePath);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (windowsBatchUnsafeProfile(cliPath, nodePath, windowsServiceLauncherFile)) {
|
|
145
|
+
requireWindowsScheduledTaskAbsent();
|
|
146
|
+
console.log('ReMCP: Unicode or shell-sensitive Windows profile; using the per-user Startup supervisor.');
|
|
147
|
+
installWindowsUserStartup(cliPath, nodePath);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
81
150
|
const launcherFile = writeWindowsServiceLauncher(cliPath, nodePath);
|
|
82
151
|
const command = `"${launcherFile}"`;
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
152
|
+
// Current-user interactive tasks need /IT. /RL LIMITED avoids requiring elevation.
|
|
153
|
+
// An organization may still disable creation of all tasks by standard users.
|
|
154
|
+
const created = spawnSync('schtasks.exe',
|
|
155
|
+
['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/IT', '/F'],
|
|
156
|
+
{ encoding:'utf8', timeout:15000, maxBuffer:64 * 1024, windowsHide:true, input:'' });
|
|
157
|
+
if (created.error || created.status !== 0) {
|
|
158
|
+
requireWindowsScheduledTaskAbsent();
|
|
159
|
+
console.warn('Windows Task Scheduler rejected task creation; switching to per-user Startup.');
|
|
160
|
+
installWindowsUserStartup(cliPath, nodePath);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Recovery tuning is optional; a policy denying Set-ScheduledTask must not invalidate
|
|
164
|
+
// a successfully created and runnable interactive task.
|
|
165
|
+
try {
|
|
166
|
+
run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsTaskReliabilityCommand(windowsTaskName)]);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.warn(`ReMCP task recovery settings were not applied: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
run('schtasks.exe', ['/Run', '/TN', windowsTaskName], { timeout:15000 });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// We just created this task. Remove it before registering another launcher, so
|
|
174
|
+
// a delayed logon cannot start competing agents under two supervisors.
|
|
175
|
+
const removed = spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'],
|
|
176
|
+
{ stdio:'ignore', timeout:10000 });
|
|
177
|
+
if (removed.status !== 0) throw error;
|
|
178
|
+
console.warn('Windows Task Scheduler could not start ReMCP; using per-user Startup.');
|
|
179
|
+
installWindowsUserStartup(cliPath, nodePath);
|
|
180
|
+
}
|
|
91
181
|
}
|
|
92
182
|
|
|
93
183
|
// `serviceInstalled` was added after background services already existed in the wild. Treat an
|
|
@@ -119,13 +209,13 @@ export function configureMacWriteAccess() {
|
|
|
119
209
|
}
|
|
120
210
|
|
|
121
211
|
export function configureWindowsWriteAccess() {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
212
|
+
// The interactive task runs at LIMITED privilege, or the current-user Startup folder
|
|
213
|
+
// provides a non-elevated fallback. Neither installation requires a Windows password.
|
|
124
214
|
try {
|
|
125
215
|
const workspaceDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ReMCP');
|
|
126
216
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
127
217
|
} catch {}
|
|
128
|
-
console.log('ReMCP
|
|
218
|
+
console.log('ReMCP uses this account\'s interactive desktop privileges.');
|
|
129
219
|
}
|
|
130
220
|
|
|
131
221
|
export function configureLinuxWriteAccess() {
|
|
@@ -268,6 +358,11 @@ export function restartPersistentServiceIfInstalled(config, { deferLinux = false
|
|
|
268
358
|
return macServiceLabel;
|
|
269
359
|
}
|
|
270
360
|
if (platform === 'win32') {
|
|
361
|
+
if (windowsStartupScriptInstalled(windowsStartupScriptFile)) {
|
|
362
|
+
const state = windowsSupervisorCommand('restart');
|
|
363
|
+
if (state.restartScheduled !== true) throw new Error('Windows user supervisor did not schedule a restart');
|
|
364
|
+
return 'ReMCP user startup supervisor (restart scheduled)';
|
|
365
|
+
}
|
|
271
366
|
const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
|
|
272
367
|
if (result.status === 0) {
|
|
273
368
|
// /Run does not restart a task that is already running; stop the previous
|
|
@@ -292,6 +387,20 @@ export function uninstallPersistentService() {
|
|
|
292
387
|
try { fs.unlinkSync(macServiceFile); } catch {}
|
|
293
388
|
removeMacCliCommand();
|
|
294
389
|
} else if (platform === 'win32') {
|
|
390
|
+
if (windowsStartupScriptInstalled(windowsStartupScriptFile)) {
|
|
391
|
+
// Removing Startup alone leaves the already spawned agent alive. Stop the
|
|
392
|
+
// exact child owned by the authenticated singleton, then remove registration.
|
|
393
|
+
try {
|
|
394
|
+
const result = windowsSupervisorCommand('stop');
|
|
395
|
+
if (!result.stopped) throw new Error('Windows user agent could not be stopped');
|
|
396
|
+
} catch (error) {
|
|
397
|
+
// An already-exited supervisor has no listening pipe. That is the one safe
|
|
398
|
+
// case where removal may proceed without an acknowledged stop. Never ignore
|
|
399
|
+
// a bad MAC, access denied, timeout, or a failed owned-child termination.
|
|
400
|
+
if (!/\bconnect (?:ENOENT|ECONNREFUSED)\b/.test(String(error?.message || ''))) throw error;
|
|
401
|
+
}
|
|
402
|
+
fs.unlinkSync(windowsStartupScriptFile);
|
|
403
|
+
}
|
|
295
404
|
spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
|
|
296
405
|
spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
|
|
297
406
|
try { fs.unlinkSync(windowsServiceLauncherFile); } catch {}
|
|
@@ -1,26 +1,59 @@
|
|
|
1
1
|
// Recovery for the signed-in user's interactive ReMCP agent on Windows.
|
|
2
2
|
// The desktop UI cannot run in Session 0: this intentionally retains /IT and ONLOGON.
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import process from 'node:process';
|
|
4
6
|
|
|
5
7
|
function windowsBatchValue(value) {
|
|
6
8
|
// cmd.exe expands % even inside quoted strings. Escape user-controlled prefix paths.
|
|
7
9
|
return String(value).replaceAll('%', '%%');
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
function windowsBatchLauncherValue(value) {
|
|
13
|
+
// This trusted variable reference must expand at runtime, not become literal %%.
|
|
14
|
+
const runtime = '%LOCALAPPDATA%\\ReMCP\\runtime';
|
|
15
|
+
return value === runtime || value === runtime + '\\remcp.cmd'
|
|
16
|
+
? value : windowsBatchValue(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// CMD cannot reliably resolve paths containing characters outside the active OEM
|
|
20
|
+
// codepage. Use the Unicode Windows Script Host supervisor instead of batch tasks
|
|
21
|
+
// whenever any launcher path contains non-ASCII or command metacharacters.
|
|
22
|
+
export function windowsBatchUnsafeProfile(...paths) {
|
|
23
|
+
return paths.some(value => /[^\x20-\x7e]|[&!%^]/.test(String(value || '')));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function windowsServiceLauncherText(cliPath, nodePath, { localAppData = process.env.LOCALAPPDATA } = {}) {
|
|
11
27
|
// Tests can simulate win32 from a POSIX runner with injected temporary paths.
|
|
12
28
|
// Native Windows paths always use win32 parsing; POSIX-shaped fixtures keep their roots.
|
|
13
29
|
const dirname = file => /^[A-Za-z]:[\\/]/.test(file) || file.includes('\\')
|
|
14
30
|
? path.win32.dirname(file) : path.dirname(file);
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
let prefix = dirname(cliPath);
|
|
32
|
+
let nodeDir = dirname(nodePath);
|
|
33
|
+
// .cmd is decoded using the active Windows codepage, not necessarily UTF-8.
|
|
34
|
+
// Keep the body ASCII if the CLI/Node live under this user's managed runtime.
|
|
35
|
+
// This also handles all locale-specific account names and literal %/! in paths.
|
|
36
|
+
if (typeof localAppData === 'string' && localAppData.trim()) {
|
|
37
|
+
const canonical = path.win32.resolve(localAppData, 'ReMCP', 'runtime');
|
|
38
|
+
if (path.win32.normalize(prefix).toLowerCase() === canonical.toLowerCase()) {
|
|
39
|
+
prefix = '%LOCALAPPDATA%\\ReMCP\\runtime';
|
|
40
|
+
}
|
|
41
|
+
if (path.win32.normalize(nodeDir).toLowerCase() === canonical.toLowerCase()) {
|
|
42
|
+
nodeDir = '%LOCALAPPDATA%\\ReMCP\\runtime';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const safeCliPath = prefix === '%LOCALAPPDATA%\\ReMCP\\runtime'
|
|
46
|
+
? '%LOCALAPPDATA%\\ReMCP\\runtime\\remcp.cmd' : cliPath;
|
|
47
|
+
if ([prefix, nodeDir, safeCliPath].some(s => /[^\x20-\x7e]/.test(s))) {
|
|
48
|
+
throw new Error('Windows batch launcher cannot safely encode this Unicode path outside the managed runtime');
|
|
49
|
+
}
|
|
17
50
|
return [
|
|
18
51
|
'@echo off',
|
|
19
52
|
'setlocal DisableDelayedExpansion',
|
|
20
|
-
`set "NPM_CONFIG_PREFIX=${
|
|
21
|
-
`set "PATH=${
|
|
53
|
+
`set "NPM_CONFIG_PREFIX=${windowsBatchLauncherValue(prefix)}"`,
|
|
54
|
+
`set "PATH=${windowsBatchLauncherValue(nodeDir)};%PATH%"`,
|
|
22
55
|
':remcp_restart',
|
|
23
|
-
`call "${
|
|
56
|
+
`call "${windowsBatchLauncherValue(safeCliPath)}" start --service`,
|
|
24
57
|
// Restart even after a clean unexpected exit. A deliberate stop ends the Scheduled Task
|
|
25
58
|
// process tree, so this supervisor cannot resurrect an intentionally stopped service.
|
|
26
59
|
'rem Reconnect after an interrupted upgrade or a transient crash without logging in again.',
|
|
@@ -32,6 +65,71 @@ export function windowsServiceLauncherText(cliPath, nodePath) {
|
|
|
32
65
|
].join('\r\n');
|
|
33
66
|
}
|
|
34
67
|
|
|
68
|
+
export const WINDOWS_STARTUP_MARKER = "' ReMCP managed user startup (do not edit)";
|
|
69
|
+
|
|
70
|
+
// Start the existing private launcher in the interactive user's session without displaying
|
|
71
|
+
// a permanent console window. Do not interpolate the path into cmd.exe or PowerShell.
|
|
72
|
+
export function windowsStartupScriptText(nodePath, supervisorPath, cliEntry) {
|
|
73
|
+
for (const value of [nodePath, supervisorPath, cliEntry]) {
|
|
74
|
+
if (typeof value !== 'string' || !value || /["\0\r\n]/.test(value) ||
|
|
75
|
+
!(path.isAbsolute(value) || path.win32.isAbsolute(value))) {
|
|
76
|
+
throw new Error('Invalid ReMCP Windows supervisor path');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// VBScript quotes each trusted absolute executable/argument path, with no cmd.exe
|
|
80
|
+
// or PowerShell interpolation. The supervisor owns its agent child and IPC endpoint.
|
|
81
|
+
return [
|
|
82
|
+
WINDOWS_STARTUP_MARKER,
|
|
83
|
+
'Set remcpShell = CreateObject("WScript.Shell")',
|
|
84
|
+
`remcpShell.Run Chr(34) & "${nodePath}" & Chr(34) & " " & Chr(34) & "${supervisorPath}" & Chr(34) & " serve " & Chr(34) & "${cliEntry}" & Chr(34), 0, False`,
|
|
85
|
+
'',
|
|
86
|
+
].join('\r\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function windowsStartupScriptInstalled(scriptFile) {
|
|
90
|
+
try {
|
|
91
|
+
const text = fs.readFileSync(scriptFile, 'utf16le');
|
|
92
|
+
const lines = text.replace(/^\uFEFF/, '').split('\r\n');
|
|
93
|
+
if (!text.startsWith('\uFEFF') || lines.length !== 4 ||
|
|
94
|
+
lines[0] !== WINDOWS_STARTUP_MARKER ||
|
|
95
|
+
lines[1] !== 'Set remcpShell = CreateObject("WScript.Shell")' ||
|
|
96
|
+
lines[3] !== '') return false;
|
|
97
|
+
const match = lines[2].match(/^remcpShell\.Run Chr\(34\) & "([^"]+)" & Chr\(34\) & " " & Chr\(34\) & "([^"]+)" & Chr\(34\) & " serve " & Chr\(34\) & "([^"]+)" & Chr\(34\), 0, False$/);
|
|
98
|
+
if (!match) return false;
|
|
99
|
+
const [, node, supervisor, cli] = match;
|
|
100
|
+
const absolute = value => path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
101
|
+
const basename = value => path.posix.basename(value.replaceAll('\\', '/'));
|
|
102
|
+
return [node, supervisor, cli].every(absolute) &&
|
|
103
|
+
/^(node|node\.exe)$/i.test(basename(node)) &&
|
|
104
|
+
basename(supervisor) === 'windows-user-supervisor.mjs' &&
|
|
105
|
+
basename(cli) === 'remcp.mjs';
|
|
106
|
+
} catch { return false; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function windowsTaskAbsenceCommand(taskName) {
|
|
110
|
+
if (typeof taskName !== 'string' || !/^[A-Za-z0-9 _-]{1,80}$/.test(taskName)) {
|
|
111
|
+
throw new Error('Invalid Windows task name');
|
|
112
|
+
}
|
|
113
|
+
// Task Scheduler COM gives locale-independent HRESULTs. A failed schtasks /Query
|
|
114
|
+
// is ambiguous: it may mean "access denied" rather than "does not exist".
|
|
115
|
+
const name = taskName.replaceAll("'", "''");
|
|
116
|
+
return [
|
|
117
|
+
"$ErrorActionPreference = 'Stop'",
|
|
118
|
+
'$scheduler = New-Object -ComObject Schedule.Service',
|
|
119
|
+
'$scheduler.Connect()',
|
|
120
|
+
`try { $null = $scheduler.GetFolder('\\').GetTask('${name}'); Write-Output 'PRESENT' }`,
|
|
121
|
+
'catch {',
|
|
122
|
+
' $e = $_.Exception',
|
|
123
|
+
' $missing = $false',
|
|
124
|
+
' while ($null -ne $e) {',
|
|
125
|
+
' if ($e.HResult -eq -2147024894 -or $e.HResult -eq -2147216625) { $missing = $true; break }',
|
|
126
|
+
' $e = $e.InnerException',
|
|
127
|
+
' }',
|
|
128
|
+
" if ($missing) { Write-Output 'ABSENT' } else { throw }",
|
|
129
|
+
'}',
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
35
133
|
export function windowsTaskReliabilityCommand(taskName) {
|
|
36
134
|
if (typeof taskName !== 'string' || !/^[A-Za-z0-9 _-]{1,80}$/.test(taskName)) {
|
|
37
135
|
throw new Error('Invalid Windows task name');
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Single-instance, current-user Windows Startup supervisor with authenticated local control.
|
|
2
|
+
// Owns its child PID; never enumerates or kills unrelated node.exe/cmd.exe processes.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
13
|
+
const root = process.env.REMCP_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
|
|
14
|
+
const keyFile = path.join(root, 'windows-startup-control.key');
|
|
15
|
+
const actions = new Set(['status', 'stop', 'restart']);
|
|
16
|
+
|
|
17
|
+
export function windowsSupervisorPath(configDir = root, platform = process.platform) {
|
|
18
|
+
const id = crypto.createHash('sha256').update(path.resolve(configDir)).digest('hex').slice(0, 32);
|
|
19
|
+
return platform === 'win32' ? '\\\\.\\pipe\\remcp-user-' + id
|
|
20
|
+
: path.join(os.tmpdir(), 'remcp-user-' + id + '.sock');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ensureWindowsSupervisorKey() {
|
|
24
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
25
|
+
try { fs.writeFileSync(keyFile, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 }); }
|
|
26
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
27
|
+
const stat = fs.lstatSync(keyFile);
|
|
28
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== 32) {
|
|
29
|
+
throw new Error('Invalid ReMCP supervisor control key');
|
|
30
|
+
}
|
|
31
|
+
return fs.readFileSync(keyFile);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function signature(secret, action, nonce) {
|
|
35
|
+
return crypto.createHmac('sha256', secret).update(action + ':' + nonce).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function windowsSupervisorRequest(action, { timeoutMs = action === 'stop' ? 14500 : 4000 } = {}) {
|
|
39
|
+
if (!actions.has(action)) throw new Error('Invalid Windows supervisor action');
|
|
40
|
+
const secret = fs.readFileSync(keyFile);
|
|
41
|
+
if (secret.length !== 32) throw new Error('Invalid supervisor key');
|
|
42
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const socket = net.createConnection(windowsSupervisorPath());
|
|
45
|
+
let output = '';
|
|
46
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error('Supervisor timed out')); }, timeoutMs);
|
|
47
|
+
socket.on('connect', () => socket.write(JSON.stringify({
|
|
48
|
+
action, nonce, mac: signature(secret, action, nonce),
|
|
49
|
+
}) + '\n'));
|
|
50
|
+
socket.on('data', chunk => {
|
|
51
|
+
output += chunk.toString('utf8');
|
|
52
|
+
if (Buffer.byteLength(output) > 2048) socket.destroy(new Error('Supervisor reply too large'));
|
|
53
|
+
});
|
|
54
|
+
socket.on('error', error => { clearTimeout(timer); reject(error); });
|
|
55
|
+
socket.on('end', () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
try {
|
|
58
|
+
const result = JSON.parse(output.trim());
|
|
59
|
+
const { mac, nonce: replyNonce, ...body } = result;
|
|
60
|
+
if (replyNonce !== nonce || !/^[a-f0-9]{64}$/.test(String(mac))) {
|
|
61
|
+
throw new Error('Unauthenticated supervisor reply');
|
|
62
|
+
}
|
|
63
|
+
const expected = signature(secret, 'reply', nonce + ':' + JSON.stringify(body));
|
|
64
|
+
if (!crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
65
|
+
throw new Error('Untrusted supervisor reply');
|
|
66
|
+
}
|
|
67
|
+
if (!body.ok) throw new Error(body.error || 'Supervisor rejected command');
|
|
68
|
+
resolve(body);
|
|
69
|
+
} catch (error) { reject(error); }
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Synchronous callers in the installed CLI use a separate Node client.
|
|
75
|
+
// Never pass device tokens or control keys in command-line arguments.
|
|
76
|
+
export function windowsSupervisorCommand(action, { timeoutMs = action === 'stop' ? 20000 : 15000 } = {}) {
|
|
77
|
+
if (!actions.has(action) && action !== 'wait') throw new Error('Invalid supervisor action');
|
|
78
|
+
const proc = spawnSync(process.execPath, [currentFile, 'control', action], {
|
|
79
|
+
encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 8192,
|
|
80
|
+
});
|
|
81
|
+
if (proc.error || proc.status !== 0) throw new Error(
|
|
82
|
+
'ReMCP user supervisor ' + action + ' failed: ' +
|
|
83
|
+
String(proc.stderr || proc.error?.message || proc.stdout || 'unavailable').trim().slice(0, 300));
|
|
84
|
+
try { return JSON.parse(proc.stdout.trim()); }
|
|
85
|
+
catch { throw new Error('Invalid supervisor status'); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function serve(cliEntry) {
|
|
89
|
+
if (!path.isAbsolute(cliEntry) || !fs.existsSync(cliEntry)) throw new Error('Invalid ReMCP CLI entry');
|
|
90
|
+
const secret = ensureWindowsSupervisorKey();
|
|
91
|
+
let child = null;
|
|
92
|
+
let stopped = false;
|
|
93
|
+
let restartTimer = null;
|
|
94
|
+
let requestedRestart = null;
|
|
95
|
+
let startedAt = 0;
|
|
96
|
+
let lastExit = null;
|
|
97
|
+
const usedNonces = new Set();
|
|
98
|
+
|
|
99
|
+
function start() {
|
|
100
|
+
if (stopped || child) return;
|
|
101
|
+
const logFile = path.join(root, 'windows-startup-agent.log');
|
|
102
|
+
try { if (fs.statSync(logFile).size > 4 * 1024 * 1024) fs.truncateSync(logFile, 0); } catch {}
|
|
103
|
+
const log = fs.openSync(logFile, 'a', 0o600);
|
|
104
|
+
const prefix = path.resolve(path.dirname(cliEntry), '../../../../');
|
|
105
|
+
try {
|
|
106
|
+
child = spawn(process.execPath, [cliEntry, 'start', '--service'], {
|
|
107
|
+
cwd: root, windowsHide: true, stdio: ['ignore', log, log, 'ipc'],
|
|
108
|
+
env: { ...process.env, REMCP_USER_STARTUP_SUPERVISOR:'1', NPM_CONFIG_PREFIX: prefix,
|
|
109
|
+
PATH: path.dirname(process.execPath) + path.delimiter + (process.env.PATH || '') },
|
|
110
|
+
});
|
|
111
|
+
} finally { fs.closeSync(log); }
|
|
112
|
+
const ownChild = child;
|
|
113
|
+
startedAt = Date.now();
|
|
114
|
+
ownChild.on('error', error => {
|
|
115
|
+
lastExit = error.message;
|
|
116
|
+
if (child === ownChild) {
|
|
117
|
+
child = null;
|
|
118
|
+
if (!stopped) restartTimer = setTimeout(start, 3000);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
ownChild.on('exit', (code, signal) => {
|
|
122
|
+
if (child !== ownChild) return;
|
|
123
|
+
child = null;
|
|
124
|
+
lastExit = code === null ? String(signal || 'unknown') : String(code);
|
|
125
|
+
if (!stopped) restartTimer = setTimeout(start, 3000);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function endChild() {
|
|
130
|
+
clearTimeout(restartTimer);
|
|
131
|
+
const ownChild = child;
|
|
132
|
+
if (!ownChild) return;
|
|
133
|
+
if (ownChild.exitCode === null && !ownChild.killed) {
|
|
134
|
+
if (process.platform === 'win32') {
|
|
135
|
+
const result = spawnSync('taskkill.exe', ['/PID', String(ownChild.pid), '/T', '/F'], {
|
|
136
|
+
windowsHide: true, encoding: 'utf8', timeout: 7000, maxBuffer: 2048,
|
|
137
|
+
});
|
|
138
|
+
if (result.status !== 0 && ownChild.exitCode === null) {
|
|
139
|
+
throw new Error('Could not terminate owned agent process tree');
|
|
140
|
+
}
|
|
141
|
+
} else ownChild.kill('SIGTERM');
|
|
142
|
+
}
|
|
143
|
+
if (ownChild.exitCode === null) await Promise.race([
|
|
144
|
+
new Promise(resolve => ownChild.once('exit', resolve)),
|
|
145
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Agent exit timed out')), 4500)),
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const server = net.createServer(socket => {
|
|
150
|
+
socket.on('error', () => {}); // A disconnected local client must not crash the agent.
|
|
151
|
+
socket.setTimeout(15000, () => socket.destroy());
|
|
152
|
+
let input = '';
|
|
153
|
+
socket.on('data', chunk => {
|
|
154
|
+
input += chunk.toString('utf8');
|
|
155
|
+
if (Buffer.byteLength(input) > 1024) { socket.destroy(); return; }
|
|
156
|
+
if (!input.includes('\n')) return;
|
|
157
|
+
const raw = input.slice(0, input.indexOf('\n'));
|
|
158
|
+
input = '';
|
|
159
|
+
void (async () => {
|
|
160
|
+
try {
|
|
161
|
+
const request = JSON.parse(raw);
|
|
162
|
+
const reply = body => socket.end(JSON.stringify({ ...body, nonce: request.nonce,
|
|
163
|
+
mac: signature(secret, 'reply', request.nonce + ':' + JSON.stringify(body)) }) + '\n');
|
|
164
|
+
if (!actions.has(request.action) ||
|
|
165
|
+
!/^[a-f0-9]{32}$/.test(String(request.nonce)) ||
|
|
166
|
+
!/^[a-f0-9]{64}$/.test(String(request.mac)) ||
|
|
167
|
+
usedNonces.has(request.nonce) ||
|
|
168
|
+
!crypto.timingSafeEqual(Buffer.from(request.mac, 'hex'),
|
|
169
|
+
Buffer.from(signature(secret, request.action, request.nonce), 'hex'))) {
|
|
170
|
+
throw new Error('Unauthorized supervisor request');
|
|
171
|
+
}
|
|
172
|
+
usedNonces.add(request.nonce);
|
|
173
|
+
if (usedNonces.size > 128) usedNonces.clear();
|
|
174
|
+
if (request.action === 'stop') {
|
|
175
|
+
clearTimeout(requestedRestart);
|
|
176
|
+
requestedRestart = null;
|
|
177
|
+
stopped = true;
|
|
178
|
+
await endChild();
|
|
179
|
+
reply({ ok: true, stopped: true });
|
|
180
|
+
server.close(() => {
|
|
181
|
+
if (process.platform !== 'win32') {
|
|
182
|
+
try { fs.unlinkSync(windowsSupervisorPath()); } catch {}
|
|
183
|
+
}
|
|
184
|
+
process.exit(0);
|
|
185
|
+
});
|
|
186
|
+
} else if (request.action === 'restart') {
|
|
187
|
+
// Agent-initiated npm updates call this from a child of the current agent.
|
|
188
|
+
// Killing that tree before the update CLI returns can abort the updater itself.
|
|
189
|
+
// Coalesce requests and schedule the owned-child handoff after acknowledgement.
|
|
190
|
+
if (!requestedRestart) {
|
|
191
|
+
requestedRestart = setTimeout(() => {
|
|
192
|
+
requestedRestart = null;
|
|
193
|
+
void (async () => {
|
|
194
|
+
stopped = true;
|
|
195
|
+
try { await endChild(); }
|
|
196
|
+
catch (error) { console.error('ReMCP supervisor restart failed:', error.message); }
|
|
197
|
+
finally { stopped = false; if (!child) start(); }
|
|
198
|
+
})();
|
|
199
|
+
}, 2500);
|
|
200
|
+
}
|
|
201
|
+
reply({ ok: true, restartScheduled: true });
|
|
202
|
+
} else reply({
|
|
203
|
+
ok: true, childPid: child?.pid || null,
|
|
204
|
+
running: Boolean(child?.pid && child.exitCode === null),
|
|
205
|
+
restartPending: Boolean(requestedRestart),
|
|
206
|
+
uptimeMs: child ? Date.now() - startedAt : null, lastExit,
|
|
207
|
+
});
|
|
208
|
+
} catch (error) { socket.end(JSON.stringify({ ok: false, error: error.message }) + '\n'); }
|
|
209
|
+
})();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
server.maxConnections = 16;
|
|
213
|
+
server.on('error', error => {
|
|
214
|
+
if (error.code === 'EADDRINUSE') {
|
|
215
|
+
// A second logon or repeated Startup invocation must never create a second agent.
|
|
216
|
+
process.exitCode = 0;
|
|
217
|
+
} else { console.error('ReMCP supervisor:', error.message); process.exitCode = 1; }
|
|
218
|
+
});
|
|
219
|
+
server.listen(windowsSupervisorPath(), start);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function control(action) {
|
|
223
|
+
if (action === 'stop') {
|
|
224
|
+
const result = await windowsSupervisorRequest('stop');
|
|
225
|
+
const deadline = Date.now() + 3500;
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
try { await windowsSupervisorRequest('status', { timeoutMs: 450 }); }
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (['ENOENT', 'ECONNREFUSED', 'EPIPE'].includes(error?.code)) return result;
|
|
230
|
+
}
|
|
231
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
232
|
+
}
|
|
233
|
+
throw new Error('Windows Startup supervisor has not exited after stop');
|
|
234
|
+
}
|
|
235
|
+
if (action !== 'wait') return windowsSupervisorRequest(action);
|
|
236
|
+
const deadline = Date.now() + 11000;
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
try {
|
|
239
|
+
const current = await windowsSupervisorRequest('status', { timeoutMs: 1000 });
|
|
240
|
+
if (current.running && current.uptimeMs >= 700) return current;
|
|
241
|
+
} catch {}
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
243
|
+
}
|
|
244
|
+
throw new Error('Supervisor did not start');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
|
|
248
|
+
if (process.argv[2] === 'serve') void serve(process.argv[3])
|
|
249
|
+
.catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
250
|
+
else if (process.argv[2] === 'control') void control(process.argv[3])
|
|
251
|
+
.then(result => process.stdout.write(JSON.stringify(result) + '\n'))
|
|
252
|
+
.catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
253
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -58,6 +58,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
58
|
// the workspace-generated command and for CI, and `--server` for self-hosted deployments.
|
|
59
59
|
const previous = loadConfig(false);
|
|
60
60
|
const alreadyPaired = Boolean(previous?.deviceId && previous?.deviceToken);
|
|
61
|
+
// The official Windows bootstrap may be rerun after approval succeeded but OS service
|
|
62
|
+
// registration failed. Repair the existing credential in place, never initiate another
|
|
63
|
+
// device-authorization flow or replace an already owned computer.
|
|
64
|
+
if (alreadyPaired && flags['repair-if-paired'] === true &&
|
|
65
|
+
flags['re-pair'] !== true && !flags.code && flags.install !== false) {
|
|
66
|
+
installPersistentAgent(previous);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
61
69
|
if (alreadyPaired && flags['re-pair'] !== true) {
|
|
62
70
|
throw new Error('This computer is already paired. Use `remcp doctor` or `remcp install` to repair it without changing credentials. To intentionally replace the pairing (possibly moving it to another account), add --re-pair.');
|
|
63
71
|
}
|
|
@@ -149,7 +157,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
149
157
|
return;
|
|
150
158
|
}
|
|
151
159
|
const telemetry = telemetryState();
|
|
152
|
-
await runAgent({
|
|
160
|
+
const agent = await runAgent({
|
|
153
161
|
...cfg,
|
|
154
162
|
autoUpdate: autoUpdateEnabled(cfg),
|
|
155
163
|
trustRuntime: cfg.trustRuntime === true
|
|
@@ -160,6 +168,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
160
168
|
installSpec: `${PACKAGE_NAME}@${VERSION}`,
|
|
161
169
|
persistState: patch => saveConfig({ ...cfg, ...patch }),
|
|
162
170
|
});
|
|
171
|
+
// The per-user Windows supervisor owns this child through a private Node IPC
|
|
172
|
+
// handle. If the supervisor dies, the agent must close its runtime rather than
|
|
173
|
+
// remain orphaned and create duplicates at the next interactive logon.
|
|
174
|
+
if (supervised && process.env.REMCP_USER_STARTUP_SUPERVISOR === '1') {
|
|
175
|
+
const closeOrphan = () => void agent.stop().finally(() => process.exit(0));
|
|
176
|
+
process.once('disconnect', closeOrphan);
|
|
177
|
+
if (!process.connected) closeOrphan();
|
|
178
|
+
}
|
|
163
179
|
return;
|
|
164
180
|
}
|
|
165
181
|
|