@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.
- package/dist/config-manager.js +55 -18
- package/dist/device/device-state.d.ts +2 -0
- package/dist/device/device-state.js +3 -1
- package/dist/device/device.js +63 -4
- package/dist/device/execution-engine.d.ts +1 -0
- package/dist/device/execution-engine.js +7 -1
- package/dist/device/gateway-channel.d.ts +13 -1
- package/dist/device/gateway-channel.js +91 -20
- package/dist/device/gateway-identity.d.ts +1 -0
- package/dist/device/gateway-identity.js +4 -0
- package/dist/device/linux-service.d.ts +2 -0
- package/dist/device/linux-service.js +2 -2
- package/dist/device/runtime-owner.d.ts +1 -0
- package/dist/device/runtime-owner.js +4 -4
- package/dist/device/self-update.d.ts +107 -12
- package/dist/device/self-update.js +660 -62
- package/dist/device/update-helper.cjs +343 -0
- package/dist/handlers/filesystem-handlers.js +3 -0
- package/dist/index.js +7 -3
- package/dist/npm-scripts/remote.d.ts +5 -0
- package/dist/npm-scripts/remote.js +50 -1
- package/dist/server.js +76 -70
- package/dist/terminal-manager.js +40 -0
- package/dist/utils/capture.js +1 -1
- package/dist/utils/files/factory.js +20 -0
- package/dist/utils/files/text.d.ts +1 -0
- package/dist/utils/files/text.js +78 -34
- package/dist/utils/trackTools.js +23 -2
- package/dist/utils/usageTracker.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/config-manager.js
CHANGED
|
@@ -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
|
-
|
|
53
|
+
let configData = null;
|
|
53
54
|
try {
|
|
54
|
-
await fs.
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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) {
|
package/dist/device/device.js
CHANGED
|
@@ -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 {
|
|
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
|
|
119
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
358
|
+
this.updateInFlight = requestId;
|
|
324
359
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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');
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
141
|
+
control = JSON.parse(await fs.readFile(runtimeOwnerControlFileFor(paths), 'utf8'));
|
|
142
142
|
}
|
|
143
143
|
catch (error) {
|
|
144
144
|
if (error?.code === 'ENOENT')
|