@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
package/README.md CHANGED
@@ -1,90 +1,26 @@
1
1
  # MCP Device
2
2
 
3
- `@hcu-lab.me/mcp-device` connects a user-owned computer to the authenticated MCP Gateway. The device opens the outbound connection; no inbound device port is required.
4
-
5
3
  The gateway owns accounts, OAuth, device ownership, MCP tool contracts, routing, and usage aggregation. MCP Device owns local device identity, pairing, reconnect lifecycle, bounded execution, background registration, and local status.
6
4
 
7
- ## Quick start
8
-
9
- Canonical production gateway: https://device.hcu-lab.me
10
-
11
- - Dashboard: https://device.hcu-lab.me/dashboard
12
- - Pair device: https://device.hcu-lab.me/pair
13
- - Live setup/help: https://device.hcu-lab.me/help
14
-
15
- Install the current release:
5
+ ## Install
16
6
 
17
7
  ```powershell
18
- npm install -g @hcu-lab.me/mcp-device
8
+ npm install -g @hcu-lab.me/mcp-device@1.0.7
19
9
  mcp-device login
20
10
  mcp-device install
21
11
  mcp-device status
22
12
  ```
23
13
 
24
- `mcp-device login` stays terminal-only: it prints the pairing URL, user code, and QR code, and never opens a browser automatically.
25
-
26
- ## Limited hosted access
27
-
28
- The public source package remains MIT licensed, but access to the hosted `device.hcu-lab.me` gateway is intentionally limited. New hosted accounts are admitted through a small, controlled supply of invite codes rather than open registration. Invite availability may be capped, paused, expired, or revoked as the service capacity and security policy change. Installing the package does not by itself grant a hosted-service account or guarantee that an invite code will be available.
29
-
30
- The hosted Pair/Help pages render their gateway URL and navigation from the deployment that served the page, so staging or custom deployments do not need a production-host rewrite. For the canonical package, `https://device.hcu-lab.me` is the default gateway; `MCP_GATEWAY_URL` is only needed when intentionally using another gateway.
31
-
32
14
  ## Commands
33
15
 
34
16
  ```text
35
17
  mcp-device start in the foreground
36
18
  mcp-device start start in the foreground
37
- mcp-device login explicitly link or switch the gateway account
19
+ mcp-device login link or switch the gateway account
38
20
  mcp-device logout unlink the gateway account from this device
39
- mcp-device status show runtime, manager, connection, security, proxy, usage, and schema state
40
- mcp-device install pair if needed, then install and start a background runtime
41
- mcp-device stop stop the current runtime without deleting identity/account state
42
- mcp-device uninstall remove background registration while preserving identity/account state
21
+ mcp-device status show device and runtime status
22
+ mcp-device install pair if needed, then install and start the background runtime
23
+ mcp-device stop stop the current runtime
24
+ mcp-device uninstall remove background registration while preserving device identity
43
25
  mcp-device --help show command help
44
26
  ```
45
-
46
- `md` is an alias of `mcp-device` on POSIX shells. On Windows, use `mcp-device`; bare `md` has a shell collision with built-in directory-creation commands/aliases and is not the supported invocation.
47
-
48
- On Windows, `install` uses current-user background registration. On Linux, every `install` asks whether to use `systemd --user` or an already configured PM2 installation. MCP Device does not install PM2, run `pm2 startup`, or use `sudo` automatically.
49
-
50
- ## Dashboard updates
51
-
52
- Starting with MCP Device 1.0.5, an installed background device reports its package version to the authenticated gateway and can accept a per-device update requested by its owning account from the dashboard. The control is intentionally narrow: the gateway supplies only an exact stable version, and the device installs only `@hcu-lab.me/mcp-device@<version>`, verifies the installed version, then restarts through its existing Windows, systemd-user, or PM2 lifecycle.
53
-
54
- Installations older than 1.0.5 do not understand this update control. Upgrade those devices to 1.0.5 once with npm; subsequent releases can be applied from the dashboard. Offline devices remain unchanged until they are online and explicitly updated.
55
-
56
- ## First run and trust
57
-
58
- The official gateway base URL is `https://device.hcu-lab.me`. The official package pins the independently distributed application CA and requires protocol v2 before the first network connection. If that CA is absent or invalid, MCP Device fails closed instead of learning trust from the gateway it is about to contact or falling back to protocol v1.
59
-
60
- For a custom gateway, set `MCP_GATEWAY_URL` and provision its application CA through an independent trusted channel before enabling protocol v2. Proxy selection is captured during interactive provisioning and reused by the background runtime.
61
-
62
- ## Local state and 1.0.2 migration
63
-
64
- Canonical state is stored under `~/.mcp-device/`. Existing `~/.hcu-device/` identity/config/status state is migrated non-destructively under serialized runtime ownership. A conflicting canonical and legacy identity fails closed rather than creating a second device identity.
65
-
66
- The device identity is Ed25519. MCP Device 1.0.2 does not use Windows DPAPI for the device private key or proxy configuration. A legacy protected 1.0.1 identity is archived without decrypting it, a fresh local identity is generated, and the device must be paired once again. A legacy protected proxy configuration must be supplied again during `mcp-device login`. The gateway stores only the public device identity and account ownership.
67
-
68
- ## Development
69
-
70
- ```powershell
71
- git clone https://github.com/Hiroshimeow/agent-mcp-device.git
72
- cd agent-mcp-device
73
- npm ci
74
- npm run build
75
- node dist/mcp-device.js --help
76
- ```
77
-
78
- Normal development pushes do not publish npm. After reviewed development work is committed on a clean `main`, a release is intentionally created with:
79
-
80
- ```powershell
81
- npm run release
82
- ```
83
-
84
- That command runs the test suite, bumps the patch version, synchronizes project version files, creates the release commit and `vX.Y.Z` tag, then atomically pushes `main` and the tag. The tag-triggered GitHub Actions workflow performs the npm publish through trusted OIDC publishing. Use `npm run release:minor` or `npm run release:major` only when that version change is intentional.
85
-
86
- For a custom development gateway, set `MCP_GATEWAY_URL`. Supply `MCP_GATEWAY_APP_CA_PATH` from an independent trusted source when using protocol v2. Non-loopback plaintext gateway URLs are rejected.
87
-
88
- ## Upstream execution engine
89
-
90
- The local execution engine is derived from [Desktop Commander MCP](https://github.com/wonderwhy-er/DesktopCommanderMCP). Desktop Commander is MIT licensed; its copyright and MIT license are preserved in this repository's `LICENSE`. The MCP Device product identity, gateway protocol, account/device lifecycle, security layer, and packaging are maintained separately in this repository.
package/dist/bootstrap.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * slow or cloud-synced filesystem — 4 stalled operations exhaust the pool, and
8
8
  * because a stalled syscall keeps its thread until the OS returns (a JS-level
9
9
  * timeout does not cancel it), every subsequent fs op queues for minutes. That
10
- * surfaced as multi-minute tool-call hangs under parallel `claude -p` load.
10
+ * surfaced as multi-minute tool-call hangs under parallel agent load.
11
11
  *
12
12
  * Raising the pool size gives enough headroom that a burst of slow reads no
13
13
  * longer starves the rest. libuv reads UV_THREADPOOL_SIZE only when the pool is
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import fs from 'fs/promises';
2
3
  import path from 'path';
3
4
  import { existsSync } from 'fs';
@@ -49,29 +50,47 @@ class ConfigManager {
49
50
  if (!existsSync(configDir)) {
50
51
  await mkdir(configDir, { recursive: true });
51
52
  }
52
- // Check if config file exists
53
+ let configData = null;
53
54
  try {
54
- await fs.access(this.configPath);
55
- // Load existing config
56
- const configData = await fs.readFile(this.configPath, 'utf8');
57
- this.config = JSON.parse(configData);
58
- this._isFirstRun = false;
59
- // Configs created before this marker existed must not receive the
60
- // welcome page retroactively when client eligibility changes later.
61
- // New configs get this field from getDefaultConfig() and remain
62
- // eligible across restarts until their first initialization.
63
- if (this.config['welcomeOnboardingEligible'] === undefined) {
64
- this.config['welcomeOnboardingEligible'] = false;
65
- this.config['pendingWelcomeOnboarding'] = false;
66
- await this.saveConfig();
67
- }
55
+ configData = await fs.readFile(this.configPath, 'utf8');
68
56
  }
69
57
  catch (error) {
70
- // Config file doesn't exist, create default
58
+ if (error?.code !== 'ENOENT')
59
+ throw error;
60
+ }
61
+ if (configData === null) {
62
+ // Only a genuinely missing file creates and persists defaults.
71
63
  this.config = this.getDefaultConfig();
72
- this._isFirstRun = true; // This is a first run!
64
+ this._isFirstRun = true;
73
65
  await this.saveConfig();
74
66
  }
67
+ else {
68
+ try {
69
+ this.config = JSON.parse(configData);
70
+ this._isFirstRun = false;
71
+ }
72
+ catch (error) {
73
+ // Preserve corrupt evidence rather than silently replacing user
74
+ // settings with defaults. The remote execution plane can continue
75
+ // safely with in-memory defaults.
76
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
77
+ const corruptPath = path.join(path.dirname(this.configPath), `config.corrupt-${stamp}.json`);
78
+ await fs.rename(this.configPath, corruptPath);
79
+ console.warn(`Invalid MCP Device config preserved at ${corruptPath}: ${error?.message || error}`);
80
+ this.config = this.getDefaultConfig();
81
+ this._isFirstRun = false;
82
+ }
83
+ // Legacy onboarding-marker migration is local-client state. Remote
84
+ // device children normalize it in memory only so parent/child startup
85
+ // does not create a second writer for config.json.
86
+ if (this.config['welcomeOnboardingEligible'] === undefined) {
87
+ this.config['welcomeOnboardingEligible'] = false;
88
+ this.config['pendingWelcomeOnboarding'] = false;
89
+ if (process.env.MCP_DEVICE_REMOTE !== 'true') {
90
+ await this.saveConfig();
91
+ }
92
+ }
93
+ }
75
94
  this.config['version'] = VERSION;
76
95
  this.initialized = true;
77
96
  }
@@ -139,7 +158,7 @@ class ConfigManager {
139
158
  }
140
159
  // Use user's actual shell from environment
141
160
  // On macOS, default to zsh (default since Catalina) since process.env.SHELL
142
- // may not be set when running inside Claude Desktop
161
+ // may not be set when launched by a desktop host
143
162
  const fallbackShell = os.platform() === 'darwin' ? '/bin/zsh' : '/bin/sh';
144
163
  const userShell = process.env.SHELL || fallbackShell;
145
164
  // Return just the shell path - we'll handle login shell flag elsewhere
@@ -160,7 +179,25 @@ class ConfigManager {
160
179
  * own independent fs.writeFile of the same path.
161
180
  */
162
181
  async writeConfigToDisk() {
163
- await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
182
+ const directory = path.dirname(this.configPath);
183
+ await fs.mkdir(directory, { recursive: true });
184
+ const temporary = path.join(directory, `.${path.basename(this.configPath)}.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}.tmp`);
185
+ try {
186
+ await fs.writeFile(temporary, JSON.stringify(this.config, null, 2), {
187
+ encoding: 'utf8',
188
+ mode: 0o600,
189
+ flag: 'wx'
190
+ });
191
+ await fs.rename(temporary, this.configPath);
192
+ try {
193
+ await fs.chmod(this.configPath, 0o600);
194
+ }
195
+ catch { /* Windows ACLs inherit from the user profile. */ }
196
+ }
197
+ catch (error) {
198
+ await fs.rm(temporary, { force: true }).catch(() => { });
199
+ throw error;
200
+ }
164
201
  }
165
202
  /**
166
203
  * Awaitable save, serialized on writeChain. Use for explicit, user-driven
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  // MCP Device owns one canonical per-user state/config root.
4
4
  export const USER_HOME = os.homedir();
5
- export const CONFIG_DIR = path.resolve(process.env.MCP_DEVICE_CONFIG_DIR || process.env.DESKTOP_COMMANDER_CONFIG_DIR || path.join(USER_HOME, '.mcp-device'));
5
+ export const CONFIG_DIR = path.resolve(process.env.MCP_DEVICE_CONFIG_DIR || path.join(USER_HOME, '.mcp-device'));
6
6
  // Paths relative to the config directory.
7
7
  export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
8
  export const TOOL_CALL_FILE = path.join(CONFIG_DIR, 'logs', 'tool-calls.log');
@@ -64,8 +64,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
64
64
  this.clientName = clientName.toLowerCase();
65
65
  // Detect Cline and disable notifications
66
66
  if (this.clientName.includes('cline') ||
67
- this.clientName.includes('vscode') ||
68
- this.clientName === 'claude-dev') {
67
+ this.clientName.includes('vscode')) {
69
68
  this.disableNotifications = true;
70
69
  process.stderr.write(`[INFO] MCP Device: Notifications disabled for ${clientName}\n`);
71
70
  }
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "version": "2.0.0",
3
- "description": "Desktop Commander onboarding prompts - V2 simplified (5 prompts only)",
3
+ "description": "MCP Device local onboarding prompts",
4
4
  "prompts": [
5
5
  {
6
6
  "id": "onb2_01",
7
7
  "title": "Organize my Downloads folder",
8
8
  "description": "Clean up and organize your messy Downloads folder into relevant subfolders automatically.",
9
9
  "prompt": "Let's organize your Downloads folder! \n\nFirst, let me check what we're working with. I'll look at your Downloads folder to see how many files are there and what types.\n\nShould I start by analyzing your Downloads folder?",
10
- "categories": ["onboarding"],
10
+ "categories": [
11
+ "onboarding"
12
+ ],
11
13
  "secondaryTag": "Quick Start",
12
14
  "votes": 0,
13
15
  "gaClicks": 0,
14
16
  "icon": "FolderOpen",
15
- "author": "DC team",
17
+ "author": "MCP Device",
16
18
  "verified": true
17
19
  },
18
20
  {
@@ -20,11 +22,14 @@
20
22
  "title": "Explain codebase or repository to me",
21
23
  "description": "Analyze and explain any codebase - local project or GitHub repository - including architecture, dependencies, and how it works.",
22
24
  "prompt": "I'll analyze and explain any codebase for you! 🔍\n\n**What should I analyze?**\n\n*Local project:* `~/work/my-project` (replace with your path)\n*GitHub repo:* `https://github.com/user/repo`\n\nI'll break down the architecture, dependencies, and how everything works together!",
23
- "categories": ["onboarding"], "secondaryTag": "Code Analysis",
25
+ "categories": [
26
+ "onboarding"
27
+ ],
28
+ "secondaryTag": "Code Analysis",
24
29
  "votes": 0,
25
30
  "gaClicks": 0,
26
31
  "icon": "Code",
27
- "author": "DC team",
32
+ "author": "MCP Device",
28
33
  "verified": true
29
34
  },
30
35
  {
@@ -32,12 +37,14 @@
32
37
  "title": "Create organized knowledge/documents folder",
33
38
  "description": "Set up a well-structured knowledge base or document organization system with templates and suggested categories.",
34
39
  "prompt": "Let's create an organized knowledge base! 📚\n\n**Where should I set it up?**\n\n*I suggest: `~/Documents/Knowledge-Base` (replace with your path) or give me a different location.*\n\nI'll create a clean folder structure with templates and organize any existing documents you have!",
35
- "categories": ["onboarding"],
40
+ "categories": [
41
+ "onboarding"
42
+ ],
36
43
  "secondaryTag": "Quick Start",
37
44
  "votes": 0,
38
45
  "gaClicks": 0,
39
46
  "icon": "BookOpen",
40
- "author": "DC team",
47
+ "author": "MCP Device",
41
48
  "verified": true
42
49
  },
43
50
  {
@@ -45,12 +52,14 @@
45
52
  "title": "Analyze my data file",
46
53
  "description": "Upload or point to any data file (CSV, JSON, Excel, etc.) and get comprehensive analysis including patterns, insights, and summary reports.",
47
54
  "prompt": "I'll help you analyze your data file! \n\nWhat's the path to your data file? (e.g., `/Users/yourname/data.csv`)\n\nOnce you give me the path, I'll start by checking what type of file it is and show you a quick preview, then we can dive deeper step by step.",
48
- "categories": ["onboarding"],
55
+ "categories": [
56
+ "onboarding"
57
+ ],
49
58
  "secondaryTag": "Quick Start",
50
59
  "votes": 0,
51
60
  "gaClicks": 0,
52
61
  "icon": "BarChart3",
53
- "author": "DC team",
62
+ "author": "MCP Device",
54
63
  "verified": true
55
64
  },
56
65
  {
@@ -58,12 +67,14 @@
58
67
  "title": "Check system health and resources",
59
68
  "description": "Analyze your system's health, resource usage, running processes, and generate a comprehensive system status report.",
60
69
  "prompt": "Let me check your system health and resources!\n\nI'll start by looking at your CPU, memory, and disk usage, then check for any performance issues.\n\nShould I begin the system analysis?",
61
- "categories": ["onboarding"],
70
+ "categories": [
71
+ "onboarding"
72
+ ],
62
73
  "secondaryTag": "Quick Start",
63
74
  "votes": 0,
64
75
  "gaClicks": 0,
65
76
  "icon": "Activity",
66
- "author": "DC team",
77
+ "author": "MCP Device",
67
78
  "verified": true
68
79
  }
69
80
  ]
@@ -1,13 +1,10 @@
1
1
  export declare const DEVICE_STATE_FILES: readonly string[];
2
2
  export interface DeviceStatePaths {
3
3
  root: string;
4
- legacyRoot: string;
5
4
  identity: string;
6
5
  config: string;
7
6
  status: string;
7
+ runtime: string;
8
+ update: string;
8
9
  }
9
10
  export declare function deviceStatePaths(home?: string): DeviceStatePaths;
10
- export declare function migrateLegacyDeviceState(paths?: DeviceStatePaths): Promise<{
11
- migrated: string[];
12
- legacyStateSeen: boolean;
13
- }>;
@@ -1,4 +1,3 @@
1
- import fs from 'fs/promises';
2
1
  import os from 'os';
3
2
  import path from 'path';
4
3
  export const DEVICE_STATE_FILES = Object.freeze([
@@ -8,93 +7,12 @@ export const DEVICE_STATE_FILES = Object.freeze([
8
7
  ]);
9
8
  export function deviceStatePaths(home = os.homedir()) {
10
9
  const root = path.resolve(home, '.mcp-device');
11
- const legacyRoot = path.resolve(home, '.hcu-device');
12
10
  return {
13
11
  root,
14
- legacyRoot,
15
12
  identity: path.join(root, 'gateway-identity.json'),
16
13
  config: path.join(root, 'gateway-config.json'),
17
- status: path.join(root, 'gateway-status.json')
14
+ status: path.join(root, 'gateway-status.json'),
15
+ runtime: path.join(root, 'runtime'),
16
+ update: path.join(root, 'update')
18
17
  };
19
18
  }
20
- async function readJsonIfExists(filePath) {
21
- try {
22
- return JSON.parse(await fs.readFile(filePath, 'utf8'));
23
- }
24
- catch (error) {
25
- if (error?.code === 'ENOENT')
26
- return null;
27
- throw new Error(`Invalid MCP Device state file ${filePath}: ${error?.message || error}`);
28
- }
29
- }
30
- async function copyAtomic(source, target) {
31
- const content = await fs.readFile(source);
32
- await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
33
- const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
34
- await fs.writeFile(temporary, content, { mode: 0o600, flag: 'wx' });
35
- await fs.rename(temporary, target);
36
- try {
37
- await fs.chmod(target, 0o600);
38
- }
39
- catch { /* Windows ACLs inherit from the user profile. */ }
40
- }
41
- function identityFingerprint(value) {
42
- if (!value)
43
- return null;
44
- const deviceId = String(value.deviceId || '').trim();
45
- const publicKeyPem = String(value.publicKeyPem || '').trim();
46
- return deviceId && publicKeyPem ? `${deviceId}\n${publicKeyPem}` : null;
47
- }
48
- export async function migrateLegacyDeviceState(paths = deviceStatePaths()) {
49
- await fs.mkdir(paths.root, { recursive: true, mode: 0o700 });
50
- const legacyIdentityPath = path.join(paths.legacyRoot, 'gateway-identity.json');
51
- const canonicalIdentity = await readJsonIfExists(paths.identity);
52
- const legacyIdentity = await readJsonIfExists(legacyIdentityPath);
53
- const legacyPresence = await Promise.all(DEVICE_STATE_FILES.map(async (name) => {
54
- try {
55
- await fs.access(path.join(paths.legacyRoot, name));
56
- return true;
57
- }
58
- catch {
59
- return false;
60
- }
61
- }));
62
- const legacyStateSeen = legacyPresence.some(Boolean);
63
- if (canonicalIdentity && legacyIdentity) {
64
- const canonicalFingerprint = identityFingerprint(canonicalIdentity);
65
- const legacyFingerprint = identityFingerprint(legacyIdentity);
66
- if (!canonicalFingerprint || !legacyFingerprint || canonicalFingerprint !== legacyFingerprint) {
67
- throw new Error('MCP Device migration conflict: canonical and legacy device identities differ.');
68
- }
69
- }
70
- if (legacyStateSeen && !legacyIdentity && !canonicalIdentity) {
71
- throw new Error('MCP Device migration failed closed: legacy state exists without a device identity.');
72
- }
73
- const migrated = [];
74
- for (const name of DEVICE_STATE_FILES) {
75
- const source = path.join(paths.legacyRoot, name);
76
- const target = path.join(paths.root, name);
77
- let sourceExists = true;
78
- try {
79
- await fs.access(source);
80
- }
81
- catch {
82
- sourceExists = false;
83
- }
84
- if (!sourceExists)
85
- continue;
86
- await readJsonIfExists(source);
87
- let targetExists = true;
88
- try {
89
- await fs.access(target);
90
- }
91
- catch {
92
- targetExists = false;
93
- }
94
- if (!targetExists) {
95
- await copyAtomic(source, target);
96
- migrated.push(name);
97
- }
98
- }
99
- return { migrated, legacyStateSeen };
100
- }
@@ -2,7 +2,7 @@
2
2
  import { type RuntimeOwnerStatus } from './runtime-owner.js';
3
3
  export declare class MCPDevice {
4
4
  private isShuttingDown;
5
- private desktop;
5
+ private executionEngine;
6
6
  private gatewayChannel?;
7
7
  private gatewayProxyAgent?;
8
8
  private runtimeOwner?;
@@ -2,7 +2,7 @@
2
2
  import { fileURLToPath } from 'url';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { DesktopCommanderIntegration } from './execution-engine.js';
5
+ import { LocalExecutionEngine } from './execution-engine.js';
6
6
  import { GatewayDeviceChannel } from './gateway-channel.js';
7
7
  import { GatewayDeviceConfigStore, createGatewayProxyAgent } from './gateway-config.js';
8
8
  import { GatewayDeviceIdentity } from './gateway-identity.js';
@@ -11,12 +11,13 @@ import { bootstrapOfficialGatewayTrust } from './official-trust.js';
11
11
  import { GatewayDeviceStatusStore } from './device-status.js';
12
12
  import { GatewayToolAdapter } from './gateway-tool-adapter.js';
13
13
  import { RuntimeOwner, acquireRuntimeOwner } from './runtime-owner.js';
14
- import { installDevicePackageUpdate, scheduleDeviceRuntimeRestart } from './self-update.js';
14
+ import { assertDeviceStartupAllowedDuringUpdate, launchDeviceUpdateHelper, prepareDevicePackageUpdate, reconcileDeviceUpdateAfterStart, retireDeviceUpdateState, signalDeviceUpdateHandoff } from './self-update.js';
15
15
  import { captureRemote } from '../utils/capture.js';
16
+ import { VERSION } from '../version.js';
16
17
  export class MCPDevice {
17
18
  constructor() {
18
19
  this.isShuttingDown = false;
19
- this.desktop = new DesktopCommanderIntegration();
20
+ this.executionEngine = new LocalExecutionEngine();
20
21
  this.setupShutdownHandlers();
21
22
  }
22
23
  setupShutdownHandlers() {
@@ -62,6 +63,9 @@ export class MCPDevice {
62
63
  console.log('🚀 Starting MCP Device...');
63
64
  if (process.env.DEBUG_MODE === 'true')
64
65
  console.log(' - 🐞 DEBUG_MODE');
66
+ // A fresh external update helper owns the package lifecycle.
67
+ // Refuse a competing runtime until it completes or becomes stale.
68
+ await assertDeviceStartupAllowedDuringUpdate(VERSION);
65
69
  const managerArg = process.argv.find(value => value.startsWith('--manager='));
66
70
  const manager = managerArg?.slice('--manager='.length);
67
71
  const mode = process.argv.includes('--service')
@@ -71,7 +75,7 @@ export class MCPDevice {
71
75
  await acquireRuntimeOwner(this.runtimeOwner, { confirmTakeover: options.confirmTakeover });
72
76
  await this.runtimeOwner.bootstrap();
73
77
  await bootstrapOfficialGatewayTrust(new GatewayDeviceConfigStore());
74
- await this.desktop.initialize();
78
+ await this.executionEngine.initialize();
75
79
  const gatewayStatus = new GatewayDeviceStatusStore();
76
80
  const storedGatewayStatus = await gatewayStatus.load();
77
81
  const gatewayConfig = await new GatewayDeviceConfigStore().load();
@@ -111,12 +115,40 @@ export class MCPDevice {
111
115
  proxyAgent: proxy.agent,
112
116
  securityProtocolFloor: gatewayConfig.securityProtocolFloor,
113
117
  appCaPem: gatewayConfig.appCaPem || undefined,
114
- adapter: new GatewayToolAdapter(this.desktop, { allowedRoots: gatewayConfig.allowedRoots }),
118
+ adapter: new GatewayToolAdapter(this.executionEngine, { allowedRoots: gatewayConfig.allowedRoots }),
119
+ runtimeState: () => this.executionEngine.getRuntimeState(),
115
120
  agentVersion: process.env.npm_package_version,
116
- onUpdateRequest: async (targetVersion) => {
121
+ onUpdateRequest: async (targetVersion, { requestId }) => {
117
122
  const record = await identity.loadOrCreate();
118
- await installDevicePackageUpdate(targetVersion);
119
- scheduleDeviceRuntimeRestart({ deviceId: record.deviceId });
123
+ const prepared = await prepareDevicePackageUpdate({
124
+ targetVersion,
125
+ requestId,
126
+ deviceId: record.deviceId,
127
+ childPid: this.executionEngine.getChildPid(),
128
+ fromVersion: VERSION
129
+ });
130
+ try {
131
+ // Launch the helper while the service is healthy. The
132
+ // helper remains inert until the explicit handoff marker.
133
+ await launchDeviceUpdateHelper(prepared);
134
+ }
135
+ catch (error) {
136
+ await retireDeviceUpdateState(prepared.state).catch(() => { });
137
+ throw error;
138
+ }
139
+ return {
140
+ mode: 'detached',
141
+ handoff: async () => {
142
+ // Capture the last live child PID, then complete graceful
143
+ // shutdown and RuntimeOwner release before arming the helper.
144
+ // This preserves G5.6: no destructive update work may begin
145
+ // until the old runtime has finished releasing package handles.
146
+ const childPid = this.executionEngine.getChildPid();
147
+ await this.shutdown();
148
+ await signalDeviceUpdateHandoff({ childPid });
149
+ process.exit(0);
150
+ }
151
+ };
120
152
  },
121
153
  onStatus: async (payload) => {
122
154
  const record = await identity.loadOrCreate();
@@ -138,6 +170,34 @@ export class MCPDevice {
138
170
  }
139
171
  });
140
172
  await this.gatewayChannel.start();
173
+ const updateReconciliation = await reconcileDeviceUpdateAfterStart(VERSION);
174
+ if (updateReconciliation.action === 'success') {
175
+ // Successful reconnect already advertised the target package_version
176
+ // in auth_hello; that reconnect is the authoritative completion proof.
177
+ // Do not send a late "installed" status: the gateway completes the
178
+ // update from the authenticated reconnect itself.
179
+ try {
180
+ await retireDeviceUpdateState(updateReconciliation.state);
181
+ }
182
+ catch (error) {
183
+ // A transient Windows EPERM/EBUSY while the detached helper is
184
+ // exiting must not turn a healthy target reconnect into startup failure.
185
+ console.warn('Update cleanup deferred:', error?.message || error);
186
+ }
187
+ }
188
+ else if (updateReconciliation.action === 'report-failed') {
189
+ try {
190
+ await this.gatewayChannel.reportUpdateFailure(updateReconciliation.state.requestId, updateReconciliation.state.targetVersion, updateReconciliation.state.errorCode || 'DEVICE_UPDATE_FAILED', updateReconciliation.state.message || 'MCP Device update rolled back.');
191
+ await retireDeviceUpdateState(updateReconciliation.state).catch((error) => {
192
+ console.warn('Failed update cleanup deferred:', error?.message || error);
193
+ });
194
+ }
195
+ catch (error) {
196
+ // Keep durable failed state for a later reporting attempt; do not
197
+ // make the restored runtime unavailable because reporting raced.
198
+ console.warn('Failed update report deferred:', error?.message || error);
199
+ }
200
+ }
141
201
  console.log('✓ Device ready through direct Gateway channel');
142
202
  }
143
203
  catch (error) {
@@ -165,7 +225,7 @@ export class MCPDevice {
165
225
  this.gatewayProxyAgent = undefined;
166
226
  }
167
227
  await new GatewayDeviceStatusStore().markOffline().catch(() => { });
168
- await this.desktop.shutdown();
228
+ await this.executionEngine.shutdown();
169
229
  if (this.runtimeOwner) {
170
230
  await this.runtimeOwner.release().catch(() => { });
171
231
  this.runtimeOwner = undefined;
@@ -4,12 +4,22 @@ interface McpConfig {
4
4
  cwd?: string;
5
5
  env?: Record<string, string>;
6
6
  }
7
- export declare class DesktopCommanderIntegration {
7
+ export declare class LocalExecutionEngine {
8
8
  private mcpClient;
9
9
  private mcpTransport;
10
10
  private isReady;
11
+ private runtimeReason;
12
+ private runtimeGeneration;
13
+ private markUnavailable;
11
14
  initialize(): Promise<void>;
12
15
  resolveMcpConfig(): Promise<McpConfig | null>;
16
+ getChildPid(): number | null;
17
+ getRuntimeState(): {
18
+ runtime_ready: boolean;
19
+ runtime_reason: string | null;
20
+ execution_runtime_generation: string | null;
21
+ };
22
+ assertReady(): void;
13
23
  callClientTool(toolName: string, args: any, metadata?: any): Promise<{
14
24
  [x: string]: unknown;
15
25
  content: ({