@hcu-lab.me/mcp-device 1.0.5 → 1.0.7

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 (57) hide show
  1. package/README.md +7 -71
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/config-manager.js +56 -19
  4. package/dist/config.js +1 -1
  5. package/dist/custom-stdio.js +1 -2
  6. package/dist/data/onboarding-prompts.json +22 -11
  7. package/dist/device/device-state.d.ts +2 -5
  8. package/dist/device/device-state.js +3 -85
  9. package/dist/device/device.d.ts +1 -1
  10. package/dist/device/device.js +69 -9
  11. package/dist/device/execution-engine.d.ts +11 -1
  12. package/dist/device/execution-engine.js +85 -126
  13. package/dist/device/gateway-channel.d.ts +19 -1
  14. package/dist/device/gateway-channel.js +107 -21
  15. package/dist/device/gateway-identity.d.ts +1 -0
  16. package/dist/device/gateway-identity.js +4 -0
  17. package/dist/device/gateway-tool-adapter.d.ts +3 -3
  18. package/dist/device/gateway-tool-adapter.js +12 -11
  19. package/dist/device/linux-service.d.ts +6 -0
  20. package/dist/device/linux-service.js +12 -5
  21. package/dist/device/runtime-owner.d.ts +1 -0
  22. package/dist/device/runtime-owner.js +6 -6
  23. package/dist/device/self-update.d.ts +107 -12
  24. package/dist/device/self-update.js +660 -62
  25. package/dist/device/update-helper.cjs +343 -0
  26. package/dist/device/windows-service.d.ts +3 -4
  27. package/dist/device/windows-service.js +5 -44
  28. package/dist/handlers/filesystem-handlers.js +3 -0
  29. package/dist/index.js +7 -3
  30. package/dist/npm-scripts/remote.d.ts +5 -0
  31. package/dist/npm-scripts/remote.js +50 -3
  32. package/dist/server.js +83 -167
  33. package/dist/terminal-manager.js +41 -1
  34. package/dist/tools/config.d.ts +1 -1
  35. package/dist/tools/filesystem.js +3 -3
  36. package/dist/tools/pdf/markdown.js +1 -1
  37. package/dist/tools/schemas.d.ts +2 -3
  38. package/dist/tools/schemas.js +0 -10
  39. package/dist/ui/config-editor/index.html +1 -1
  40. package/dist/ui/file-preview/index.html +1 -1
  41. package/dist/ui/file-preview/styles.css +2 -2
  42. package/dist/utils/capture.d.ts +5 -28
  43. package/dist/utils/capture.js +11 -455
  44. package/dist/utils/feature-flags.d.ts +0 -47
  45. package/dist/utils/feature-flags.js +5 -199
  46. package/dist/utils/files/docx.d.ts +2 -2
  47. package/dist/utils/files/docx.js +2 -2
  48. package/dist/utils/files/factory.js +20 -0
  49. package/dist/utils/files/text.d.ts +1 -0
  50. package/dist/utils/files/text.js +78 -34
  51. package/dist/utils/trackTools.js +23 -2
  52. package/dist/utils/usageTracker.js +2 -24
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +6 -7
  56. package/dist/tools/feedback.d.ts +0 -8
  57. package/dist/tools/feedback.js +0 -132
@@ -0,0 +1,343 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const fsp = fs.promises;
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const net = require('net');
8
+ const { execFile } = require('child_process');
9
+
10
+ const statePath = process.argv[2];
11
+ const HANDOFF_WAIT_MS = 120000;
12
+ const PID_WAIT_MS = 30000;
13
+ const PACKAGE_UNLOCK_WAIT_MS = 20000;
14
+
15
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
16
+
17
+ function run(file, args) {
18
+ return new Promise((resolve, reject) => {
19
+ execFile(file, args, { windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
20
+ if (error) {
21
+ error.stdout = stdout;
22
+ error.stderr = stderr;
23
+ reject(error);
24
+ return;
25
+ }
26
+ resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
27
+ });
28
+ });
29
+ }
30
+
31
+ async function exists(filePath) {
32
+ try { await fsp.access(filePath); return true; } catch { return false; }
33
+ }
34
+
35
+ async function readState() {
36
+ return JSON.parse(await fsp.readFile(statePath, 'utf8'));
37
+ }
38
+
39
+ async function writeState(patch) {
40
+ const current = await readState();
41
+ const next = Object.assign({}, current, patch, { updatedAt: Date.now() });
42
+ const tmp =
43
+ statePath + '.' + process.pid + '.' + Date.now() + '.' +
44
+ crypto.randomBytes(6).toString('hex') + '.tmp';
45
+ await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
46
+ await fsp.rename(tmp, statePath);
47
+ try { await fsp.chmod(statePath, 0o600); } catch {}
48
+ return next;
49
+ }
50
+
51
+ function isAlive(pid) {
52
+ if (!pid || pid < 1) return false;
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ async function waitForFile(filePath, timeoutMs) {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ if (await exists(filePath)) return true;
65
+ await sleep(100);
66
+ }
67
+ return await exists(filePath);
68
+ }
69
+
70
+ async function waitForPids(pids, timeoutMs) {
71
+ const filtered = pids.filter(pid => Number.isInteger(pid) && pid > 0);
72
+ const deadline = Date.now() + timeoutMs;
73
+ while (Date.now() < deadline) {
74
+ if (filtered.every(pid => !isAlive(pid))) return true;
75
+ await sleep(100);
76
+ }
77
+ return filtered.every(pid => !isAlive(pid));
78
+ }
79
+
80
+ async function stopManager(state) {
81
+ if (state.managerKind === 'windows-task') {
82
+ // The update helper runs in a separate task, so ending the original device
83
+ // task cannot terminate the helper itself. "not running" is harmless.
84
+ await run(state.schtasksPath, ['/End', '/TN', state.windowsTaskName]).catch(() => {});
85
+ } else if (state.managerKind === 'systemd') {
86
+ await run(state.systemctlPath, ['--user', 'stop', state.systemdUnit]);
87
+ } else if (state.managerKind === 'pm2') {
88
+ await run(state.pm2Path, ['stop', state.pm2Name]);
89
+ }
90
+ }
91
+
92
+ async function startWindowsRunner(state) {
93
+ if (state.managerKind === 'windows-task') {
94
+ await run(state.schtasksPath, ['/Run', '/TN', state.windowsTaskName]);
95
+ return;
96
+ }
97
+
98
+ const escapePs = value => "'" + String(value).replace(/'/g, "''") + "'";
99
+ const command =
100
+ 'Start-Process -FilePath ' + escapePs(state.powershellPath) +
101
+ " -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File'," +
102
+ escapePs(state.windowsRunnerPath) + ') -WindowStyle Hidden';
103
+ await run(state.powershellPath, ['-NoProfile', '-NonInteractive', '-Command', command]);
104
+ }
105
+
106
+ async function startManager(state) {
107
+ if (String(state.managerKind).startsWith('windows-')) {
108
+ await startWindowsRunner(state);
109
+ return;
110
+ }
111
+ if (state.managerKind === 'systemd') {
112
+ await run(state.systemctlPath, ['--user', 'start', state.systemdUnit]);
113
+ return;
114
+ }
115
+ if (state.managerKind === 'pm2') {
116
+ await run(state.pm2Path, ['restart', state.pm2Name]);
117
+ await run(state.pm2Path, ['save']);
118
+ return;
119
+ }
120
+ throw new Error('Unsupported manager kind: ' + state.managerKind);
121
+ }
122
+
123
+ async function renameWithRetry(source, target, timeoutMs) {
124
+ const deadline = Date.now() + timeoutMs;
125
+ let lastError = null;
126
+ while (Date.now() < deadline) {
127
+ try {
128
+ await fsp.rename(source, target);
129
+ return;
130
+ } catch (error) {
131
+ lastError = error;
132
+ const code = String(error && error.code || '');
133
+ if (!['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(code)) throw error;
134
+ await sleep(150);
135
+ }
136
+ }
137
+ throw lastError || new Error('Timed out waiting for package directory to unlock.');
138
+ }
139
+
140
+ async function packageVersion(packageRoot) {
141
+ try {
142
+ const manifest = JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
143
+ return String(manifest.version || '');
144
+ } catch {
145
+ return '';
146
+ }
147
+ }
148
+
149
+ async function assertRuntimeOwnerReleased(state) {
150
+ const controlPath = String(state.runtimeOwnerControlPath || '');
151
+ if (!controlPath) {
152
+ const failure = new Error('Runtime owner control path is missing from update state.');
153
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
154
+ throw failure;
155
+ }
156
+ let control;
157
+ try {
158
+ control = JSON.parse(await fsp.readFile(controlPath, 'utf8'));
159
+ } catch (error) {
160
+ if (error && error.code === 'ENOENT') return;
161
+ const failure = new Error('Unable to verify MCP Device runtime ownership release.');
162
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
163
+ throw failure;
164
+ }
165
+
166
+ const endpoint = String(control && control.endpoint || '');
167
+ if (!endpoint) {
168
+ const failure = new Error('Runtime owner control state has no endpoint; refusing package replacement.');
169
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_UNVERIFIED';
170
+ throw failure;
171
+ }
172
+
173
+ const active = await new Promise(resolve => {
174
+ const socket = net.createConnection(endpoint);
175
+ let data = '';
176
+ let settled = false;
177
+ let connected = false;
178
+ const finish = value => {
179
+ if (settled) return;
180
+ settled = true;
181
+ socket.destroy();
182
+ resolve(value);
183
+ };
184
+ socket.setEncoding('utf8');
185
+ socket.setTimeout(800, () => finish(connected));
186
+ socket.once('error', () => finish(false));
187
+ socket.on('data', chunk => { data += chunk; });
188
+ socket.once('end', () => {
189
+ try {
190
+ const parsed = JSON.parse(data);
191
+ finish(parsed && parsed.ok === true && parsed.status && parsed.status.product === 'MCP Device');
192
+ } catch {
193
+ // A live endpoint with an unreadable response is not safe to replace.
194
+ finish(true);
195
+ }
196
+ });
197
+ socket.once('connect', () => {
198
+ connected = true;
199
+ socket.write(JSON.stringify({ action: 'status' }) + '\n');
200
+ });
201
+ });
202
+
203
+ if (active) {
204
+ const failure = new Error('A live MCP Device runtime owner still exists; refusing package replacement.');
205
+ failure.code = 'DEVICE_UPDATE_RUNTIME_OWNER_ACTIVE';
206
+ throw failure;
207
+ }
208
+
209
+ // A stale control file is harmless. Do not delete it here: a new owner could
210
+ // have acquired the endpoint between checks, and RuntimeOwner owns cleanup.
211
+ }
212
+
213
+ async function restoreRollback(state) {
214
+ if (await exists(state.rollbackPath)) {
215
+ await fsp.rm(state.packageRoot, { recursive: true, force: true }).catch(() => {});
216
+ await fsp.rename(state.rollbackPath, state.packageRoot);
217
+ } else {
218
+ const current = await packageVersion(state.packageRoot);
219
+ if (current === state.fromVersion) return;
220
+ if (!await exists(state.rollbackSnapshot)) {
221
+ throw new Error('Offline rollback package is unavailable.');
222
+ }
223
+ await fsp.rm(state.packageRoot, { recursive: true, force: true }).catch(() => {});
224
+ await fsp.cp(state.rollbackSnapshot, state.packageRoot, {
225
+ recursive: true,
226
+ force: true,
227
+ preserveTimestamps: true
228
+ });
229
+ }
230
+
231
+ const restored = await packageVersion(state.packageRoot);
232
+ if (restored !== state.fromVersion) {
233
+ throw new Error('Rollback package verification failed: expected ' + state.fromVersion + ', got ' + (restored || 'unknown') + '.');
234
+ }
235
+ }
236
+
237
+ async function cleanupWindowsUpdateTask(state) {
238
+ if (!state.windowsUpdateTaskName || !state.schtasksPath) return;
239
+ await run(state.schtasksPath, ['/Delete', '/F', '/TN', state.windowsUpdateTaskName]).catch(() => {});
240
+ }
241
+
242
+ async function main() {
243
+ let state = await readState();
244
+
245
+ try {
246
+ if (!await waitForFile(state.handoffPath, HANDOFF_WAIT_MS)) {
247
+ const error = new Error('Timed out waiting for MCP Device update handoff.');
248
+ error.code = 'DEVICE_UPDATE_HANDOFF_TIMEOUT';
249
+ throw error;
250
+ }
251
+
252
+ state = await writeState({ state: 'quiescing' });
253
+ await stopManager(state);
254
+
255
+ if (!await waitForPids([state.parentPid, state.childPid], PID_WAIT_MS)) {
256
+ const error = new Error('MCP Device parent/child did not exit before update.');
257
+ error.code = 'DEVICE_UPDATE_RUNTIME_STILL_ALIVE';
258
+ throw error;
259
+ }
260
+
261
+ await assertRuntimeOwnerReleased(state);
262
+
263
+ if (await exists(state.rollbackPath)) {
264
+ const error = new Error('Rollback path already exists; refusing to overwrite recovery evidence.');
265
+ error.code = 'DEVICE_UPDATE_ROLLBACK_PRESENT';
266
+ throw error;
267
+ }
268
+
269
+ await renameWithRetry(state.packageRoot, state.rollbackPath, PACKAGE_UNLOCK_WAIT_MS);
270
+ state = await writeState({ state: 'installing' });
271
+
272
+ await run(state.nodePath, [
273
+ state.npmCliPath,
274
+ 'install',
275
+ '-g',
276
+ state.targetTarball,
277
+ '--offline',
278
+ '--cache', state.cacheDir,
279
+ '--prefix', state.globalPrefix,
280
+ '--no-audit',
281
+ '--no-fund'
282
+ ]);
283
+
284
+ const actual = await packageVersion(state.packageRoot);
285
+ if (actual !== state.targetVersion) {
286
+ const error = new Error('Installed target verification failed: expected ' + state.targetVersion + ', got ' + (actual || 'unknown') + '.');
287
+ error.code = 'DEVICE_UPDATE_VERIFY_FAILED';
288
+ throw error;
289
+ }
290
+
291
+ state = await writeState({
292
+ state: 'installed_waiting_reconnect',
293
+ completedAt: Date.now(),
294
+ errorCode: null,
295
+ message: null
296
+ });
297
+
298
+ await startManager(state);
299
+ await cleanupWindowsUpdateTask(state);
300
+ process.exit(0);
301
+ } catch (error) {
302
+ const code = String(error && error.code || 'DEVICE_UPDATE_FAILED').slice(0, 64);
303
+ const message = String(error && error.message || error).slice(0, 240);
304
+
305
+ try {
306
+ state = await writeState({ state: 'rollback', errorCode: code, message });
307
+ await restoreRollback(state);
308
+ state = await writeState({
309
+ state: 'failed',
310
+ completedAt: Date.now(),
311
+ errorCode: code,
312
+ message
313
+ });
314
+
315
+ // Never create a second runtime while any tracked process or owner is
316
+ // still alive. If quiescence failed, preserve bounded failure evidence
317
+ // and leave the original runtime as the only authority.
318
+ const trackedRuntimeAlive = [state.parentPid, state.childPid]
319
+ .filter(pid => Number.isInteger(pid) && pid > 0)
320
+ .some(isAlive);
321
+ if (!trackedRuntimeAlive) {
322
+ try {
323
+ await assertRuntimeOwnerReleased(state);
324
+ await startManager(state);
325
+ } catch {
326
+ // Failure state remains durable for the next safe/manual recovery.
327
+ }
328
+ }
329
+ } catch (rollbackError) {
330
+ await writeState({
331
+ state: 'failed',
332
+ completedAt: Date.now(),
333
+ errorCode: 'DEVICE_UPDATE_ROLLBACK_FAILED',
334
+ message: String(rollbackError && rollbackError.message || rollbackError).slice(0, 240)
335
+ }).catch(() => {});
336
+ }
337
+
338
+ await cleanupWindowsUpdateTask(state).catch(() => {});
339
+ process.exit(1);
340
+ }
341
+ }
342
+
343
+ main().catch(() => process.exit(1));
@@ -9,6 +9,7 @@ export declare function buildWindowsRunnerScript(options: {
9
9
  allowedRoots?: string;
10
10
  nodePath: string;
11
11
  entrypoint: string;
12
+ runtimeDir?: string;
12
13
  }): string;
13
14
  export type WindowsDeviceServiceStatus = {
14
15
  installed: boolean;
@@ -21,24 +22,22 @@ export declare class WindowsDeviceService {
21
22
  private platform;
22
23
  private execFile;
23
24
  private runnerPath;
24
- private legacyRunnerPath;
25
25
  private nodePath;
26
26
  private entrypoint;
27
+ private runtimeDir;
27
28
  constructor(options?: {
28
29
  platform?: NodeJS.Platform | string;
29
30
  execFile?: ExecFileFn;
30
31
  runnerPath?: string;
31
- legacyRunnerPath?: string;
32
32
  nodePath?: string;
33
33
  entrypoint?: string;
34
+ runtimeDir?: string;
34
35
  });
35
36
  private assertWindows;
36
37
  private runnerExists;
37
38
  private hasUserRunEntry;
38
39
  private scheduledTaskState;
39
40
  private deleteRegistrations;
40
- private commandReferencesLegacyRunner;
41
- private deleteVerifiedLegacyRegistrations;
42
41
  private createAutostartRegistration;
43
42
  private startUserProcess;
44
43
  private userProcessRunning;
@@ -21,12 +21,11 @@ function isMissingRegistrationError(error) {
21
21
  export function buildWindowsTaskName(deviceId) {
22
22
  return `MCP-Device-${taskSafeDeviceId(deviceId)}`;
23
23
  }
24
- function legacyWindowsTaskName(deviceId) {
25
- return `HCU-Device-${taskSafeDeviceId(deviceId)}`;
26
- }
27
24
  export function buildWindowsRunnerScript(options) {
25
+ const runtimeDir = options.runtimeDir ? String(options.runtimeDir) : deviceStatePaths().runtime;
28
26
  return [
29
27
  "$ErrorActionPreference = 'Stop'",
28
+ `Set-Location -LiteralPath ${psQuote(runtimeDir)}`,
30
29
  `& ${psQuote(options.nodePath)} ${psQuote(options.entrypoint)} --service`,
31
30
  'exit $LASTEXITCODE',
32
31
  ''
@@ -40,9 +39,9 @@ export class WindowsDeviceService {
40
39
  return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
41
40
  });
42
41
  this.runnerPath = path.resolve(options.runnerPath || path.join(deviceStatePaths().root, 'run-device.ps1'));
43
- this.legacyRunnerPath = path.resolve(options.legacyRunnerPath || path.join(deviceStatePaths().legacyRoot, 'run-device.ps1'));
44
42
  this.nodePath = path.resolve(options.nodePath || process.execPath);
45
43
  this.entrypoint = path.resolve(options.entrypoint || process.argv[1]);
44
+ this.runtimeDir = options.runtimeDir ? String(options.runtimeDir) : deviceStatePaths().runtime;
46
45
  }
47
46
  assertWindows() {
48
47
  if (this.platform !== 'win32')
@@ -91,41 +90,6 @@ export class WindowsDeviceService {
91
90
  }
92
91
  catch { /* absent is fine */ }
93
92
  }
94
- commandReferencesLegacyRunner(value) {
95
- const normalize = (text) => String(text || '').replace(/\//g, '\\').toLowerCase();
96
- return normalize(value).includes(normalize(this.legacyRunnerPath));
97
- }
98
- async deleteVerifiedLegacyRegistrations(taskName) {
99
- let scheduledAction = null;
100
- try {
101
- const command = `(Get-ScheduledTask -TaskName ${psQuote(taskName)} -ErrorAction Stop).Actions | ForEach-Object { ($_.Execute + ' ' + $_.Arguments) }`;
102
- scheduledAction = String((await this.execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command])).stdout || '');
103
- }
104
- catch { /* absent scheduled task is fine */ }
105
- if (scheduledAction !== null && this.commandReferencesLegacyRunner(scheduledAction)) {
106
- try {
107
- await this.execFile('schtasks.exe', ['/Delete', '/F', '/TN', taskName]);
108
- }
109
- catch (error) {
110
- if (!isMissingRegistrationError(error))
111
- throw error;
112
- }
113
- }
114
- let runValue = null;
115
- try {
116
- runValue = String((await this.execFile('reg.exe', ['query', USER_RUN_KEY, '/v', taskName])).stdout || '');
117
- }
118
- catch { /* absent Run value is fine */ }
119
- if (runValue !== null && this.commandReferencesLegacyRunner(runValue)) {
120
- try {
121
- await this.execFile('reg.exe', ['delete', USER_RUN_KEY, '/v', taskName, '/f']);
122
- }
123
- catch (error) {
124
- if (!isMissingRegistrationError(error))
125
- throw error;
126
- }
127
- }
128
- }
129
93
  async createAutostartRegistration(taskName) {
130
94
  const taskAction = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${this.runnerPath}"`;
131
95
  try {
@@ -157,13 +121,12 @@ export class WindowsDeviceService {
157
121
  async install(options) {
158
122
  this.assertWindows();
159
123
  const taskName = buildWindowsTaskName(options.deviceId);
160
- const legacyTaskName = legacyWindowsTaskName(options.deviceId);
161
124
  const existing = await this.status(options.deviceId).catch(() => null);
162
125
  const preserveManual = existing?.installed === true && existing.autostart === false;
163
- const runner = buildWindowsRunnerScript({ nodePath: this.nodePath, entrypoint: this.entrypoint });
126
+ const runner = buildWindowsRunnerScript({ nodePath: this.nodePath, entrypoint: this.entrypoint, runtimeDir: this.runtimeDir });
127
+ await fs.mkdir(this.runtimeDir, { recursive: true, mode: 0o700 });
164
128
  await fs.mkdir(path.dirname(this.runnerPath), { recursive: true });
165
129
  await fs.writeFile(this.runnerPath, runner, { mode: 0o600 });
166
- await this.deleteVerifiedLegacyRegistrations(legacyTaskName);
167
130
  if (preserveManual) {
168
131
  await this.deleteRegistrations(taskName);
169
132
  return;
@@ -226,13 +189,11 @@ export class WindowsDeviceService {
226
189
  async uninstall(deviceId) {
227
190
  this.assertWindows();
228
191
  const taskName = buildWindowsTaskName(deviceId);
229
- const legacyTaskName = legacyWindowsTaskName(deviceId);
230
192
  try {
231
193
  await this.stop(deviceId);
232
194
  }
233
195
  catch { /* registration cleanup remains authoritative; runtime is never PID-killed. */ }
234
196
  await this.deleteRegistrations(taskName);
235
- await this.deleteVerifiedLegacyRegistrations(legacyTaskName);
236
197
  await fs.rm(this.runnerPath, { force: true });
237
198
  }
238
199
  }
@@ -53,6 +53,9 @@ export async function handleReadFile(args) {
53
53
  }
54
54
  const readFileOperation = async () => {
55
55
  const parsed = ReadFileArgsSchema.parse(args);
56
+ if (process.env.MCP_DEVICE_REMOTE === 'true' && parsed.isUrl) {
57
+ return createErrorResponse('REMOTE_URL_READ_DISABLED: URL reads are disabled for MCP Device remote execution.');
58
+ }
56
59
  // Get the configuration for file read limits
57
60
  const config = await configManager.getConfig();
58
61
  if (!config) {
package/dist/index.js CHANGED
@@ -38,9 +38,13 @@ async function runServer() {
38
38
  deferLog('info', 'Loading configuration...');
39
39
  await configManager.loadConfig();
40
40
  deferLog('info', 'Configuration loaded successfully');
41
- // Initialize feature flags (non-blocking)
42
- deferLog('info', 'Initializing feature flags...');
43
- await featureFlagManager.initialize();
41
+ // Remote MCP Device children must not depend on legacy host
42
+ // feature flags or cached A/B state. Their execution surface is fixed by
43
+ // GatewayToolAdapter and remote-mode behavior must be deterministic.
44
+ if (process.env.MCP_DEVICE_REMOTE !== 'true') {
45
+ deferLog('info', 'Initializing feature flags...');
46
+ await featureFlagManager.initialize();
47
+ }
44
48
  }
45
49
  catch (configError) {
46
50
  deferLog('error', `Failed to load configuration: ${configError instanceof Error ? configError.message : String(configError)}`);
@@ -1,3 +1,8 @@
1
+ import { GatewayDeviceIdentity } from '../device/gateway-identity.js';
1
2
  import { GatewayDeviceStatusStore } from '../device/device-status.js';
2
3
  export declare function applyServiceCommandStatus(store: GatewayDeviceStatusStore, command: 'install' | 'start' | 'stop' | 'uninstall'): Promise<void>;
4
+ export declare function resolvePairingIdentity(identity: GatewayDeviceIdentity, options?: {
5
+ forcePair?: boolean;
6
+ probeCurrentIdentity?: () => Promise<void>;
7
+ }): Promise<import("../device/gateway-identity.js").GatewayIdentityRecord>;
3
8
  export declare function runRemote(): Promise<void>;
@@ -111,13 +111,32 @@ async function withBootstrapOwner(action, allowTakeover = true) {
111
111
  await owner.release();
112
112
  }
113
113
  }
114
+ export async function resolvePairingIdentity(identity, options = {}) {
115
+ const current = await identity.loadOrCreate();
116
+ if (!options.forcePair || deviceIdentityNeedsPairing(current))
117
+ return current;
118
+ if (typeof options.probeCurrentIdentity !== 'function') {
119
+ throw new Error('Current device identity probe is required before forced pairing.');
120
+ }
121
+ try {
122
+ await options.probeCurrentIdentity();
123
+ return current;
124
+ }
125
+ catch (error) {
126
+ if (String(error?.code || '') !== 'DEVICE_FORGOTTEN')
127
+ throw error;
128
+ await identity.forget();
129
+ return await identity.loadOrCreate();
130
+ }
131
+ }
114
132
  async function ensureLinkedDeviceUnderOwner(forcePair = false) {
115
133
  const store = new GatewayDeviceStatusStore();
116
134
  const current = await store.load();
117
135
  const config = await captureGatewayConfigFromEnvironment(new GatewayDeviceConfigStore(), { fallbackGatewayUrl: current.gatewayUrl });
118
136
  const gatewayUrl = String(config.gatewayUrl || '').trim();
119
137
  const identity = new GatewayDeviceIdentity();
120
- const record = await identity.loadOrCreate();
138
+ let record = await identity.loadOrCreate();
139
+ const initialDeviceId = record.deviceId;
121
140
  await store.update({
122
141
  gatewayUrl,
123
142
  deviceId: record.deviceId,
@@ -130,6 +149,36 @@ async function ensureLinkedDeviceUnderOwner(forcePair = false) {
130
149
  const proxy = createGatewayProxyAgent(config);
131
150
  let channel;
132
151
  try {
152
+ record = await resolvePairingIdentity(identity, {
153
+ forcePair,
154
+ probeCurrentIdentity: async () => {
155
+ const probe = new GatewayDeviceChannel({
156
+ gatewayUrl,
157
+ identity,
158
+ proxyAgent: proxy.agent,
159
+ securityProtocolFloor: config.securityProtocolFloor,
160
+ appCaPem: config.appCaPem || undefined,
161
+ adapter: controlAdapter(),
162
+ agentVersion: process.env.npm_package_version
163
+ });
164
+ try {
165
+ await probe.start();
166
+ }
167
+ finally {
168
+ await probe.stop().catch(() => { });
169
+ }
170
+ }
171
+ });
172
+ if (record.deviceId !== initialDeviceId) {
173
+ await store.update({
174
+ gatewayUrl,
175
+ deviceId: record.deviceId,
176
+ deviceName: os.hostname(),
177
+ identityPresent: true,
178
+ account: { connected: false, label: null },
179
+ connection: { ...current.connection, online: false }
180
+ });
181
+ }
133
182
  const pairing = await pairGatewayDevice({ gatewayUrl, identity, proxyAgent: proxy.agent, deviceName: os.hostname() });
134
183
  await store.update({
135
184
  gatewayUrl,
@@ -276,8 +325,6 @@ async function serviceCommand(command) {
276
325
  }
277
326
  export async function runRemote() {
278
327
  // MCP Device owns its account-scoped usage telemetry at the gateway.
279
- // Never send inherited Desktop Commander analytics from the remote execution path.
280
- process.env.DESKTOP_COMMANDER_DISABLE_TELEMETRY = 'true';
281
328
  const disableNoSleep = process.argv.includes('--disable-no-sleep');
282
329
  const verbose = process.argv.includes('--debug');
283
330
  if (!verbose)