@remcp/remcp 0.2.205 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.205",
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();
@@ -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 spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
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 };
@@ -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
- // Computer-use needs the signed-in interactive desktop. Create the task with /IT from the start
84
- // instead of changing it afterwards: schtasks /Change can request the account password even when
85
- // the task already belongs to the current user, which turns a one-command install into a prompt.
86
- run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/IT', '/F']);
87
- // The default Windows task policy expires long-running tasks and stops them on battery.
88
- // Apply guarded failure recovery without changing the interactive-user principal/session.
89
- run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsTaskReliabilityCommand(windowsTaskName)]);
90
- run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
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
- // installWindowsService already creates the task as HighestAvailable + InteractiveToken. Keep
123
- // post-install access setup non-interactive so a fresh install never asks for the Windows password.
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 configured with the highest available user privileges for interactive desktop access.');
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
- export function windowsServiceLauncherText(cliPath, nodePath) {
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
- const prefix = dirname(cliPath);
16
- const nodeDir = dirname(nodePath);
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=${windowsBatchValue(prefix)}"`,
21
- `set "PATH=${windowsBatchValue(nodeDir)};%PATH%"`,
53
+ `set "NPM_CONFIG_PREFIX=${windowsBatchLauncherValue(prefix)}"`,
54
+ `set "PATH=${windowsBatchLauncherValue(nodeDir)};%PATH%"`,
22
55
  ':remcp_restart',
23
- `call "${windowsBatchValue(cliPath)}" start --service`,
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