@hcu-lab.me/mcp-device 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,12 @@ mcp-device --help show command help
47
47
 
48
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
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
+
50
56
  ## First run and trust
51
57
 
52
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.
@@ -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
  }
@@ -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
@@ -5,6 +5,8 @@ export interface DeviceStatePaths {
5
5
  identity: string;
6
6
  config: string;
7
7
  status: string;
8
+ runtime: string;
9
+ update: string;
8
10
  }
9
11
  export declare function deviceStatePaths(home?: string): DeviceStatePaths;
10
12
  export declare function migrateLegacyDeviceState(paths?: DeviceStatePaths): Promise<{
@@ -14,7 +14,9 @@ export function deviceStatePaths(home = os.homedir()) {
14
14
  legacyRoot,
15
15
  identity: path.join(root, 'gateway-identity.json'),
16
16
  config: path.join(root, 'gateway-config.json'),
17
- status: path.join(root, 'gateway-status.json')
17
+ status: path.join(root, 'gateway-status.json'),
18
+ runtime: path.join(root, 'runtime'),
19
+ update: path.join(root, 'update')
18
20
  };
19
21
  }
20
22
  async function readJsonIfExists(filePath) {
@@ -11,7 +11,9 @@ 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 { assertDeviceStartupAllowedDuringUpdate, launchDeviceUpdateHelper, prepareDevicePackageUpdate, reconcileDeviceUpdateAfterStart, retireDeviceUpdateState, signalDeviceUpdateHandoff } from './self-update.js';
14
15
  import { captureRemote } from '../utils/capture.js';
16
+ import { VERSION } from '../version.js';
15
17
  export class MCPDevice {
16
18
  constructor() {
17
19
  this.isShuttingDown = false;
@@ -61,6 +63,9 @@ export class MCPDevice {
61
63
  console.log('🚀 Starting MCP Device...');
62
64
  if (process.env.DEBUG_MODE === 'true')
63
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);
64
69
  const managerArg = process.argv.find(value => value.startsWith('--manager='));
65
70
  const manager = managerArg?.slice('--manager='.length);
66
71
  const mode = process.argv.includes('--service')
@@ -112,6 +117,38 @@ export class MCPDevice {
112
117
  appCaPem: gatewayConfig.appCaPem || undefined,
113
118
  adapter: new GatewayToolAdapter(this.desktop, { allowedRoots: gatewayConfig.allowedRoots }),
114
119
  agentVersion: process.env.npm_package_version,
120
+ onUpdateRequest: async (targetVersion, { requestId }) => {
121
+ const record = await identity.loadOrCreate();
122
+ const prepared = await prepareDevicePackageUpdate({
123
+ targetVersion,
124
+ requestId,
125
+ deviceId: record.deviceId,
126
+ childPid: this.desktop.getChildPid(),
127
+ fromVersion: VERSION
128
+ });
129
+ try {
130
+ // Launch the helper while the service is healthy. The
131
+ // helper remains inert until the explicit handoff marker.
132
+ await launchDeviceUpdateHelper(prepared);
133
+ }
134
+ catch (error) {
135
+ await retireDeviceUpdateState(prepared.state).catch(() => { });
136
+ throw error;
137
+ }
138
+ return {
139
+ mode: 'detached',
140
+ handoff: async () => {
141
+ // Capture the last live child PID, then complete graceful
142
+ // shutdown and RuntimeOwner release before arming the helper.
143
+ // This preserves G5.6: no destructive update work may begin
144
+ // until the old runtime has finished releasing package handles.
145
+ const childPid = this.desktop.getChildPid();
146
+ await this.shutdown();
147
+ await signalDeviceUpdateHandoff({ childPid });
148
+ process.exit(0);
149
+ }
150
+ };
151
+ },
115
152
  onStatus: async (payload) => {
116
153
  const record = await identity.loadOrCreate();
117
154
  await gatewayStatus.update({
@@ -132,6 +169,34 @@ export class MCPDevice {
132
169
  }
133
170
  });
134
171
  await this.gatewayChannel.start();
172
+ const updateReconciliation = await reconcileDeviceUpdateAfterStart(VERSION);
173
+ if (updateReconciliation.action === 'success') {
174
+ // Successful reconnect already advertised the target package_version
175
+ // in auth_hello; that reconnect is the authoritative completion proof.
176
+ // Do not send a late "installed" status: the gateway completes the
177
+ // update from the authenticated reconnect itself.
178
+ try {
179
+ await retireDeviceUpdateState(updateReconciliation.state);
180
+ }
181
+ catch (error) {
182
+ // A transient Windows EPERM/EBUSY while the detached helper is
183
+ // exiting must not turn a healthy target reconnect into startup failure.
184
+ console.warn('Update cleanup deferred:', error?.message || error);
185
+ }
186
+ }
187
+ else if (updateReconciliation.action === 'report-failed') {
188
+ try {
189
+ await this.gatewayChannel.reportUpdateFailure(updateReconciliation.state.requestId, updateReconciliation.state.targetVersion, updateReconciliation.state.errorCode || 'DEVICE_UPDATE_FAILED', updateReconciliation.state.message || 'MCP Device update rolled back.');
190
+ await retireDeviceUpdateState(updateReconciliation.state).catch((error) => {
191
+ console.warn('Failed update cleanup deferred:', error?.message || error);
192
+ });
193
+ }
194
+ catch (error) {
195
+ // Keep durable failed state for a later reporting attempt; do not
196
+ // make the restored runtime unavailable because reporting raced.
197
+ console.warn('Failed update report deferred:', error?.message || error);
198
+ }
199
+ }
135
200
  console.log('✓ Device ready through direct Gateway channel');
136
201
  }
137
202
  catch (error) {
@@ -10,6 +10,7 @@ export declare class DesktopCommanderIntegration {
10
10
  private isReady;
11
11
  initialize(): Promise<void>;
12
12
  resolveMcpConfig(): Promise<McpConfig | null>;
13
+ getChildPid(): number | null;
13
14
  callClientTool(toolName: string, args: any, metadata?: any): Promise<{
14
15
  [x: string]: unknown;
15
16
  content: ({
@@ -5,6 +5,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
5
  import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { captureRemote } from '../utils/capture.js';
8
+ import { deviceStatePaths } from './device-state.js';
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
11
  export class DesktopCommanderIntegration {
@@ -68,10 +69,12 @@ export class DesktopCommanderIntegration {
68
69
  try {
69
70
  await fs.access(devPath);
70
71
  console.debug(' - 🔍 Found local MCP server at:', devPath);
72
+ const runtimeDir = deviceStatePaths().runtime;
73
+ await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
71
74
  return {
72
75
  command: process.execPath, // Use the current node executable
73
76
  args: [devPath],
74
- cwd: path.dirname(devPath)
77
+ cwd: runtimeDir
75
78
  };
76
79
  }
77
80
  catch {
@@ -110,6 +113,9 @@ export class DesktopCommanderIntegration {
110
113
  console.debug('[DEBUG] No MCP config resolved');
111
114
  return null;
112
115
  }
116
+ getChildPid() {
117
+ return this.mcpTransport?.pid ?? null;
118
+ }
113
119
  async callClientTool(toolName, args, metadata) {
114
120
  if (!this.isReady || !this.mcpClient) {
115
121
  console.debug('[DEBUG] callClientTool() failed - not ready or no client');
@@ -1,6 +1,10 @@
1
1
  import type { Agent } from 'http';
2
2
  import { GatewayDeviceIdentity } from './gateway-identity.js';
3
3
  import { GatewayToolAdapter } from './gateway-tool-adapter.js';
4
+ export type GatewayUpdateRequestOutcome = {
5
+ mode: 'detached';
6
+ handoff: () => Promise<void>;
7
+ };
4
8
  export interface GatewayChannelOptions {
5
9
  gatewayUrl: string;
6
10
  enrollmentToken?: string;
@@ -12,6 +16,9 @@ export interface GatewayChannelOptions {
12
16
  adapter: GatewayToolAdapter;
13
17
  agentVersion?: string;
14
18
  onStatus?: (payload: any) => void | Promise<void>;
19
+ onUpdateRequest?: (targetVersion: string, context: {
20
+ requestId: string;
21
+ }) => GatewayUpdateRequestOutcome | Promise<GatewayUpdateRequestOutcome>;
15
22
  }
16
23
  export declare class GatewayDeviceChannel {
17
24
  private options;
@@ -31,6 +38,7 @@ export declare class GatewayDeviceChannel {
31
38
  private protocolVersion;
32
39
  private secureSocket?;
33
40
  private authMode;
41
+ private updateInFlight?;
34
42
  constructor(options: GatewayChannelOptions);
35
43
  start(): Promise<void>;
36
44
  private startV2Transport;
@@ -39,10 +47,16 @@ export declare class GatewayDeviceChannel {
39
47
  private sendHelloV2;
40
48
  private handleMessage;
41
49
  logoutAccount(timeoutMs?: number): Promise<any>;
50
+ private updateStatusMessage;
51
+ private sendUpdateStatus;
52
+ private sendUpdateStatusFlushed;
53
+ reportUpdateFailure(requestId: string, targetVersion: string, code: string, message: string): Promise<void>;
54
+ private handleUpdateRequest;
42
55
  private handleToolCall;
43
56
  private scheduleReconnect;
44
57
  private startHeartbeat;
45
58
  private stopHeartbeat;
46
59
  private send;
60
+ private sendFlushed;
47
61
  private fail;
48
62
  }
@@ -4,6 +4,7 @@ import { GatewayDeviceIdentity } from './gateway-identity.js';
4
4
  import { DEVICE_INNER_TLS_SUBPROTOCOL, createClientInnerTls, createJsonFrameParser, encodeJsonFrame, exportDeviceAuthKeyingMaterial } from './gateway-secure-transport.js';
5
5
  import { GATEWAY_CAPABILITIES } from './gateway-tool-adapter.js';
6
6
  import { gatewaySocketUrl } from './gateway-url-policy.js';
7
+ import { VERSION } from '../version.js';
7
8
  const PROTOCOL_VERSION = 1;
8
9
  const PROTOCOL_VERSION_V2 = 2;
9
10
  const HEARTBEAT_MS = 20000;
@@ -54,10 +55,15 @@ export class GatewayDeviceChannel {
54
55
  socket.once('error', error => this.fail(error));
55
56
  socket.once('close', (code, reason) => {
56
57
  this.stopHeartbeat();
58
+ const reasonText = reason.toString();
59
+ const forgotten = code === 4003 && /(?:unknown or revoked device|revoked device|device revoked)/i.test(reasonText);
60
+ const error = new Error(`Gateway connection closed (${code}): ${reasonText}`);
61
+ if (forgotten)
62
+ error.code = 'DEVICE_FORGOTTEN';
57
63
  if (!this.shuttingDown && this.readyReject) {
58
- this.fail(new Error(`Gateway connection closed (${code}): ${reason.toString()}`));
64
+ this.fail(error);
59
65
  }
60
- else if (!this.shuttingDown) {
66
+ else if (!this.shuttingDown && !forgotten) {
61
67
  this.scheduleReconnect();
62
68
  }
63
69
  });
@@ -138,6 +144,7 @@ export class GatewayDeviceChannel {
138
144
  timestamp: Date.now(),
139
145
  payload: {
140
146
  agent_version: this.options.agentVersion || 'mcp-device-1',
147
+ package_version: VERSION,
141
148
  hostname: os.hostname(),
142
149
  platform: process.platform,
143
150
  arch: process.arch,
@@ -160,6 +167,7 @@ export class GatewayDeviceChannel {
160
167
  timestamp: Date.now(),
161
168
  payload: {
162
169
  agent_version: this.options.agentVersion || 'mcp-device-1',
170
+ package_version: VERSION,
163
171
  hostname: os.hostname(),
164
172
  platform: process.platform,
165
173
  arch: process.arch,
@@ -173,7 +181,7 @@ export class GatewayDeviceChannel {
173
181
  const message = typeof raw === 'string' ? JSON.parse(raw) : raw;
174
182
  if (message?.protocol_version !== this.protocolVersion)
175
183
  throw new Error('Unsupported gateway protocol version');
176
- if (['auth_challenge', 'auth_ok', 'tool_call'].includes(String(message?.type || ''))) {
184
+ if (['auth_challenge', 'auth_ok', 'tool_call', 'device_update'].includes(String(message?.type || ''))) {
177
185
  const record = await this.identity.loadOrCreate();
178
186
  if (String(message?.device_id || '') !== record.deviceId)
179
187
  throw new Error('Gateway message device_id mismatch');
@@ -245,6 +253,14 @@ export class GatewayDeviceChannel {
245
253
  }
246
254
  return;
247
255
  }
256
+ if (message.type === 'device_update') {
257
+ if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
258
+ throw new Error('Gateway device_update arrived before authentication');
259
+ if (String(message.connection_epoch) !== String(this.connectionEpoch))
260
+ throw new Error('Gateway device_update connection_epoch mismatch');
261
+ void this.handleUpdateRequest(message);
262
+ return;
263
+ }
248
264
  if (message.type !== 'tool_call')
249
265
  return;
250
266
  if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
@@ -286,6 +302,84 @@ export class GatewayDeviceChannel {
286
302
  clearTimeout(timer);
287
303
  }
288
304
  }
305
+ updateStatusMessage(requestId, targetVersion, state, extra = {}) {
306
+ if (this.connectionEpoch === undefined || !this.authenticatedDeviceId) {
307
+ throw new Error('Gateway update status cannot be sent before authentication.');
308
+ }
309
+ return {
310
+ protocol_version: this.protocolVersion,
311
+ type: 'device_update_status',
312
+ request_id: requestId,
313
+ device_id: this.authenticatedDeviceId,
314
+ connection_epoch: this.connectionEpoch,
315
+ timestamp: Date.now(),
316
+ payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
317
+ };
318
+ }
319
+ sendUpdateStatus(requestId, targetVersion, state, extra = {}) {
320
+ try {
321
+ this.send(this.updateStatusMessage(requestId, targetVersion, state, extra));
322
+ }
323
+ catch {
324
+ // Best-effort path used only when the socket may already be closing.
325
+ }
326
+ }
327
+ async sendUpdateStatusFlushed(requestId, targetVersion, state, extra = {}) {
328
+ await this.sendFlushed(this.updateStatusMessage(requestId, targetVersion, state, extra));
329
+ }
330
+ async reportUpdateFailure(requestId, targetVersion, code, message) {
331
+ // Failed update state is retired only after this awaited send succeeds.
332
+ await this.sendUpdateStatusFlushed(requestId, targetVersion, 'failed', {
333
+ code: String(code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
334
+ message: String(message || 'Device update failed.').slice(0, 240)
335
+ });
336
+ }
337
+ async handleUpdateRequest(message) {
338
+ if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
339
+ return;
340
+ const requestId = String(message.request_id || '').trim();
341
+ const targetVersion = String(message.payload?.target_version || '').trim();
342
+ if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
343
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
344
+ code: 'DEVICE_UPDATE_UNSUPPORTED',
345
+ message: 'Device update request is unsupported or invalid.'
346
+ });
347
+ return;
348
+ }
349
+ if (this.updateInFlight) {
350
+ if (this.updateInFlight !== requestId) {
351
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
352
+ code: 'DEVICE_UPDATE_IN_PROGRESS',
353
+ message: 'Another MCP Device update is already in progress.'
354
+ });
355
+ }
356
+ return;
357
+ }
358
+ this.updateInFlight = requestId;
359
+ try {
360
+ // Pre-stage and verify the target before acknowledging the update.
361
+ // If this throws, the currently running device stays healthy.
362
+ const outcome = await this.options.onUpdateRequest(targetVersion, { requestId });
363
+ if (!outcome || outcome.mode !== 'detached') {
364
+ throw Object.assign(new Error('Device update handler did not provide a detached handoff.'), { code: 'DEVICE_UPDATE_INVALID_HANDOFF' });
365
+ }
366
+ // Do not tear down the socket until the gateway has accepted
367
+ // the frame into the transport write path.
368
+ await this.sendUpdateStatusFlushed(requestId, targetVersion, 'accepted');
369
+ await outcome.handoff();
370
+ return;
371
+ }
372
+ catch (error) {
373
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
374
+ code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
375
+ message: String(error?.message || error).slice(0, 240)
376
+ });
377
+ }
378
+ finally {
379
+ if (this.updateInFlight === requestId)
380
+ this.updateInFlight = undefined;
381
+ }
382
+ }
289
383
  async handleToolCall(message) {
290
384
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
291
385
  throw new Error('Gateway tool_call arrived before authentication');
@@ -365,6 +459,29 @@ export class GatewayDeviceChannel {
365
459
  }
366
460
  this.socket.send(encoded);
367
461
  }
462
+ async sendFlushed(message) {
463
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
464
+ throw new Error('Gateway socket is not open');
465
+ const encoded = JSON.stringify(message);
466
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_OUTBOUND_MESSAGE_BYTES) {
467
+ const error = new Error('Gateway device response exceeds the outbound message limit');
468
+ error.code = 'DEVICE_OUTPUT_TOO_LARGE';
469
+ throw error;
470
+ }
471
+ if (this.protocolVersion === PROTOCOL_VERSION_V2) {
472
+ if (!this.secureSocket || this.secureSocket.destroyed)
473
+ throw new Error('Gateway v2 inner TLS socket is not open');
474
+ const secureSocket = this.secureSocket;
475
+ await new Promise((resolve, reject) => {
476
+ secureSocket.write(encodeJsonFrame(message), error => error ? reject(error) : resolve());
477
+ });
478
+ return;
479
+ }
480
+ const socket = this.socket;
481
+ await new Promise((resolve, reject) => {
482
+ socket.send(encoded, error => error ? reject(error) : resolve());
483
+ });
484
+ }
368
485
  fail(error) {
369
486
  const reject = this.readyReject;
370
487
  this.readyResolve = undefined;
@@ -13,6 +13,7 @@ export declare class GatewayDeviceIdentity {
13
13
  });
14
14
  loadOrCreate(): Promise<GatewayIdentityRecord>;
15
15
  markEnrolled(): Promise<void>;
16
+ forget(): Promise<void>;
16
17
  signChallenge(nonce: string): Promise<string>;
17
18
  signChallengeV2(options: {
18
19
  mode: 'reconnect' | 'enroll' | 'pair';
@@ -82,6 +82,10 @@ export class GatewayDeviceIdentity {
82
82
  this.record = { ...current, enrolled: true };
83
83
  await this.persist();
84
84
  }
85
+ async forget() {
86
+ this.record = undefined;
87
+ await fs.rm(this.identityPath, { force: true });
88
+ }
85
89
  async signChallenge(nonce) {
86
90
  const current = await this.loadOrCreate();
87
91
  const challenge = Buffer.from(`mcp-device-auth-v1\n${current.deviceId}\n${nonce}`, 'utf8');
@@ -1,3 +1,5 @@
1
+ export declare const SYSTEMD_UNIT = "mcp-device.service";
2
+ export declare const PM2_NAME = "mcp-device";
1
3
  type ExecFileResult = {
2
4
  stdout: string;
3
5
  stderr: string;
@@ -5,9 +5,9 @@ import path from 'path';
5
5
  import readline from 'readline/promises';
6
6
  import { promisify } from 'util';
7
7
  const execFileAsync = promisify(execFileCallback);
8
- const SYSTEMD_UNIT = 'mcp-device.service';
8
+ export const SYSTEMD_UNIT = 'mcp-device.service';
9
9
  const SYSTEMD_OWNER_MARKER = '# Managed by MCP Device';
10
- const PM2_NAME = 'mcp-device';
10
+ export const PM2_NAME = 'mcp-device';
11
11
  async function defaultExecFile(file, args) {
12
12
  const result = await execFileAsync(file, args, { windowsHide: true, encoding: 'utf8' });
13
13
  return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
@@ -9,6 +9,7 @@ export interface RuntimeOwnerStatus {
9
9
  entrypoint: string;
10
10
  deviceId: string | null;
11
11
  }
12
+ export declare function runtimeOwnerControlFileFor(paths: DeviceStatePaths): string;
12
13
  export declare function probeRuntimeOwner(paths?: DeviceStatePaths, platform?: NodeJS.Platform | string): Promise<RuntimeOwnerStatus | null>;
13
14
  export declare class RuntimeOwner {
14
15
  readonly paths: DeviceStatePaths;
@@ -9,7 +9,7 @@ function endpointFor(paths, platform = process.platform) {
9
9
  return `\\\\.\\pipe\\mcp-device-${digest}`;
10
10
  return path.join(paths.root, `runtime-${digest}.sock`);
11
11
  }
12
- function controlFileFor(paths) {
12
+ export function runtimeOwnerControlFileFor(paths) {
13
13
  return path.join(paths.root, 'runtime-control.json');
14
14
  }
15
15
  async function request(endpoint, message, timeoutMs = 800) {
@@ -103,7 +103,7 @@ export class RuntimeOwner {
103
103
  this.server.once('error', reject);
104
104
  this.server.listen(this.endpoint, resolve);
105
105
  });
106
- await fs.writeFile(controlFileFor(this.paths), JSON.stringify({ endpoint: this.endpoint, nonce: this.nonce }, null, 2), { mode: 0o600 });
106
+ await fs.writeFile(runtimeOwnerControlFileFor(this.paths), JSON.stringify({ endpoint: this.endpoint, nonce: this.nonce }, null, 2), { mode: 0o600 });
107
107
  }
108
108
  async bootstrap() {
109
109
  await migrateLegacyDeviceState(this.paths);
@@ -118,7 +118,7 @@ export class RuntimeOwner {
118
118
  await new Promise(resolve => server.close(() => resolve()));
119
119
  }
120
120
  try {
121
- await fs.unlink(controlFileFor(this.paths));
121
+ await fs.unlink(runtimeOwnerControlFileFor(this.paths));
122
122
  }
123
123
  catch (error) {
124
124
  if (error?.code !== 'ENOENT')
@@ -138,7 +138,7 @@ export class RuntimeOwner {
138
138
  export async function stopRuntimeOwner(paths = deviceStatePaths(), platform = process.platform) {
139
139
  let control;
140
140
  try {
141
- control = JSON.parse(await fs.readFile(controlFileFor(paths), 'utf8'));
141
+ control = JSON.parse(await fs.readFile(runtimeOwnerControlFileFor(paths), 'utf8'));
142
142
  }
143
143
  catch (error) {
144
144
  if (error?.code === 'ENOENT')