@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 +6 -0
- 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 +65 -0
- package/dist/device/execution-engine.d.ts +1 -0
- package/dist/device/execution-engine.js +7 -1
- package/dist/device/gateway-channel.d.ts +14 -0
- package/dist/device/gateway-channel.js +120 -3
- 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 +115 -0
- package/dist/device/self-update.js +685 -0
- 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/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.
|
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,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:
|
|
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(
|
|
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');
|
|
@@ -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')
|