@hcu-lab.me/mcp-device 1.0.5 → 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.
@@ -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,8 +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 { 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;
@@ -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')
@@ -113,10 +117,37 @@ export class MCPDevice {
113
117
  appCaPem: gatewayConfig.appCaPem || undefined,
114
118
  adapter: new GatewayToolAdapter(this.desktop, { allowedRoots: gatewayConfig.allowedRoots }),
115
119
  agentVersion: process.env.npm_package_version,
116
- onUpdateRequest: async (targetVersion) => {
120
+ onUpdateRequest: async (targetVersion, { requestId }) => {
117
121
  const record = await identity.loadOrCreate();
118
- await installDevicePackageUpdate(targetVersion);
119
- scheduleDeviceRuntimeRestart({ deviceId: record.deviceId });
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
+ };
120
151
  },
121
152
  onStatus: async (payload) => {
122
153
  const record = await identity.loadOrCreate();
@@ -138,6 +169,34 @@ export class MCPDevice {
138
169
  }
139
170
  });
140
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
+ }
141
200
  console.log('✓ Device ready through direct Gateway channel');
142
201
  }
143
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,7 +16,9 @@ export interface GatewayChannelOptions {
12
16
  adapter: GatewayToolAdapter;
13
17
  agentVersion?: string;
14
18
  onStatus?: (payload: any) => void | Promise<void>;
15
- onUpdateRequest?: (targetVersion: string) => void | Promise<void>;
19
+ onUpdateRequest?: (targetVersion: string, context: {
20
+ requestId: string;
21
+ }) => GatewayUpdateRequestOutcome | Promise<GatewayUpdateRequestOutcome>;
16
22
  }
17
23
  export declare class GatewayDeviceChannel {
18
24
  private options;
@@ -32,6 +38,7 @@ export declare class GatewayDeviceChannel {
32
38
  private protocolVersion;
33
39
  private secureSocket?;
34
40
  private authMode;
41
+ private updateInFlight?;
35
42
  constructor(options: GatewayChannelOptions);
36
43
  start(): Promise<void>;
37
44
  private startV2Transport;
@@ -40,11 +47,16 @@ export declare class GatewayDeviceChannel {
40
47
  private sendHelloV2;
41
48
  private handleMessage;
42
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>;
43
54
  private handleUpdateRequest;
44
55
  private handleToolCall;
45
56
  private scheduleReconnect;
46
57
  private startHeartbeat;
47
58
  private stopHeartbeat;
48
59
  private send;
60
+ private sendFlushed;
49
61
  private fail;
50
62
  }
@@ -55,10 +55,15 @@ export class GatewayDeviceChannel {
55
55
  socket.once('error', error => this.fail(error));
56
56
  socket.once('close', (code, reason) => {
57
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';
58
63
  if (!this.shuttingDown && this.readyReject) {
59
- this.fail(new Error(`Gateway connection closed (${code}): ${reason.toString()}`));
64
+ this.fail(error);
60
65
  }
61
- else if (!this.shuttingDown) {
66
+ else if (!this.shuttingDown && !forgotten) {
62
67
  this.scheduleReconnect();
63
68
  }
64
69
  });
@@ -297,40 +302,83 @@ export class GatewayDeviceChannel {
297
302
  clearTimeout(timer);
298
303
  }
299
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
+ }
300
337
  async handleUpdateRequest(message) {
301
338
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
302
339
  return;
303
340
  const requestId = String(message.request_id || '').trim();
304
341
  const targetVersion = String(message.payload?.target_version || '').trim();
305
- const sendStatus = (state, extra = {}) => {
306
- try {
307
- this.send({
308
- protocol_version: this.protocolVersion,
309
- type: 'device_update_status',
310
- request_id: requestId,
311
- device_id: this.authenticatedDeviceId,
312
- connection_epoch: this.connectionEpoch,
313
- timestamp: Date.now(),
314
- payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
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.'
315
354
  });
316
355
  }
317
- catch { /* reconnect state will make the final version authoritative */ }
318
- };
319
- if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
320
- sendStatus('failed', { code: 'DEVICE_UPDATE_UNSUPPORTED', message: 'Device update request is unsupported or invalid.' });
321
356
  return;
322
357
  }
323
- sendStatus('accepted');
358
+ this.updateInFlight = requestId;
324
359
  try {
325
- await this.options.onUpdateRequest(targetVersion);
326
- sendStatus('installed');
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;
327
371
  }
328
372
  catch (error) {
329
- sendStatus('failed', {
373
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
330
374
  code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
331
375
  message: String(error?.message || error).slice(0, 240)
332
376
  });
333
377
  }
378
+ finally {
379
+ if (this.updateInFlight === requestId)
380
+ this.updateInFlight = undefined;
381
+ }
334
382
  }
335
383
  async handleToolCall(message) {
336
384
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
@@ -411,6 +459,29 @@ export class GatewayDeviceChannel {
411
459
  }
412
460
  this.socket.send(encoded);
413
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
+ }
414
485
  fail(error) {
415
486
  const reject = this.readyReject;
416
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')