@hcu-lab.me/mcp-device 1.0.4 → 1.0.5
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/device/device.js +6 -0
- package/dist/device/gateway-channel.d.ts +2 -0
- package/dist/device/gateway-channel.js +47 -1
- package/dist/device/self-update.d.ts +20 -0
- package/dist/device/self-update.js +87 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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/device/device.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
15
|
import { captureRemote } from '../utils/capture.js';
|
|
15
16
|
export class MCPDevice {
|
|
16
17
|
constructor() {
|
|
@@ -112,6 +113,11 @@ export class MCPDevice {
|
|
|
112
113
|
appCaPem: gatewayConfig.appCaPem || undefined,
|
|
113
114
|
adapter: new GatewayToolAdapter(this.desktop, { allowedRoots: gatewayConfig.allowedRoots }),
|
|
114
115
|
agentVersion: process.env.npm_package_version,
|
|
116
|
+
onUpdateRequest: async (targetVersion) => {
|
|
117
|
+
const record = await identity.loadOrCreate();
|
|
118
|
+
await installDevicePackageUpdate(targetVersion);
|
|
119
|
+
scheduleDeviceRuntimeRestart({ deviceId: record.deviceId });
|
|
120
|
+
},
|
|
115
121
|
onStatus: async (payload) => {
|
|
116
122
|
const record = await identity.loadOrCreate();
|
|
117
123
|
await gatewayStatus.update({
|
|
@@ -12,6 +12,7 @@ export interface GatewayChannelOptions {
|
|
|
12
12
|
adapter: GatewayToolAdapter;
|
|
13
13
|
agentVersion?: string;
|
|
14
14
|
onStatus?: (payload: any) => void | Promise<void>;
|
|
15
|
+
onUpdateRequest?: (targetVersion: string) => void | Promise<void>;
|
|
15
16
|
}
|
|
16
17
|
export declare class GatewayDeviceChannel {
|
|
17
18
|
private options;
|
|
@@ -39,6 +40,7 @@ export declare class GatewayDeviceChannel {
|
|
|
39
40
|
private sendHelloV2;
|
|
40
41
|
private handleMessage;
|
|
41
42
|
logoutAccount(timeoutMs?: number): Promise<any>;
|
|
43
|
+
private handleUpdateRequest;
|
|
42
44
|
private handleToolCall;
|
|
43
45
|
private scheduleReconnect;
|
|
44
46
|
private startHeartbeat;
|
|
@@ -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;
|
|
@@ -138,6 +139,7 @@ export class GatewayDeviceChannel {
|
|
|
138
139
|
timestamp: Date.now(),
|
|
139
140
|
payload: {
|
|
140
141
|
agent_version: this.options.agentVersion || 'mcp-device-1',
|
|
142
|
+
package_version: VERSION,
|
|
141
143
|
hostname: os.hostname(),
|
|
142
144
|
platform: process.platform,
|
|
143
145
|
arch: process.arch,
|
|
@@ -160,6 +162,7 @@ export class GatewayDeviceChannel {
|
|
|
160
162
|
timestamp: Date.now(),
|
|
161
163
|
payload: {
|
|
162
164
|
agent_version: this.options.agentVersion || 'mcp-device-1',
|
|
165
|
+
package_version: VERSION,
|
|
163
166
|
hostname: os.hostname(),
|
|
164
167
|
platform: process.platform,
|
|
165
168
|
arch: process.arch,
|
|
@@ -173,7 +176,7 @@ export class GatewayDeviceChannel {
|
|
|
173
176
|
const message = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
174
177
|
if (message?.protocol_version !== this.protocolVersion)
|
|
175
178
|
throw new Error('Unsupported gateway protocol version');
|
|
176
|
-
if (['auth_challenge', 'auth_ok', 'tool_call'].includes(String(message?.type || ''))) {
|
|
179
|
+
if (['auth_challenge', 'auth_ok', 'tool_call', 'device_update'].includes(String(message?.type || ''))) {
|
|
177
180
|
const record = await this.identity.loadOrCreate();
|
|
178
181
|
if (String(message?.device_id || '') !== record.deviceId)
|
|
179
182
|
throw new Error('Gateway message device_id mismatch');
|
|
@@ -245,6 +248,14 @@ export class GatewayDeviceChannel {
|
|
|
245
248
|
}
|
|
246
249
|
return;
|
|
247
250
|
}
|
|
251
|
+
if (message.type === 'device_update') {
|
|
252
|
+
if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
|
|
253
|
+
throw new Error('Gateway device_update arrived before authentication');
|
|
254
|
+
if (String(message.connection_epoch) !== String(this.connectionEpoch))
|
|
255
|
+
throw new Error('Gateway device_update connection_epoch mismatch');
|
|
256
|
+
void this.handleUpdateRequest(message);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
248
259
|
if (message.type !== 'tool_call')
|
|
249
260
|
return;
|
|
250
261
|
if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
|
|
@@ -286,6 +297,41 @@ export class GatewayDeviceChannel {
|
|
|
286
297
|
clearTimeout(timer);
|
|
287
298
|
}
|
|
288
299
|
}
|
|
300
|
+
async handleUpdateRequest(message) {
|
|
301
|
+
if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
|
|
302
|
+
return;
|
|
303
|
+
const requestId = String(message.request_id || '').trim();
|
|
304
|
+
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 }
|
|
315
|
+
});
|
|
316
|
+
}
|
|
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
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
sendStatus('accepted');
|
|
324
|
+
try {
|
|
325
|
+
await this.options.onUpdateRequest(targetVersion);
|
|
326
|
+
sendStatus('installed');
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
sendStatus('failed', {
|
|
330
|
+
code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
|
|
331
|
+
message: String(error?.message || error).slice(0, 240)
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
289
335
|
async handleToolCall(message) {
|
|
290
336
|
if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
|
|
291
337
|
throw new Error('Gateway tool_call arrived before authentication');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawn as spawnCallback } from 'child_process';
|
|
2
|
+
export declare const MCP_DEVICE_PACKAGE = "@hcu-lab.me/mcp-device";
|
|
3
|
+
export declare function normalizeUpdateVersion(value: string): string;
|
|
4
|
+
export declare function installDevicePackageUpdate(targetVersion: string, options?: {
|
|
5
|
+
platform?: NodeJS.Platform | string;
|
|
6
|
+
execFile?: (file: string, args: string[]) => Promise<{
|
|
7
|
+
stdout?: string;
|
|
8
|
+
stderr?: string;
|
|
9
|
+
}>;
|
|
10
|
+
npmPath?: string;
|
|
11
|
+
}): Promise<string>;
|
|
12
|
+
export declare function buildWindowsRestartHelper(deviceId: string, currentPid?: number): string;
|
|
13
|
+
export declare function scheduleDeviceRuntimeRestart(options: {
|
|
14
|
+
deviceId: string;
|
|
15
|
+
platform?: NodeJS.Platform | string;
|
|
16
|
+
argv?: string[];
|
|
17
|
+
delayMs?: number;
|
|
18
|
+
spawn?: typeof spawnCallback;
|
|
19
|
+
exit?: (code?: number) => never | void;
|
|
20
|
+
}): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn as spawnCallback } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { deviceStatePaths } from './device-state.js';
|
|
5
|
+
import { buildWindowsTaskName } from './windows-service.js';
|
|
6
|
+
const execFileAsync = promisify(execFileCallback);
|
|
7
|
+
export const MCP_DEVICE_PACKAGE = '@hcu-lab.me/mcp-device';
|
|
8
|
+
export function normalizeUpdateVersion(value) {
|
|
9
|
+
const version = String(value || '').trim();
|
|
10
|
+
if (!/^\d+\.\d+\.\d+$/.test(version))
|
|
11
|
+
throw new Error('Update target must be an exact stable semantic version.');
|
|
12
|
+
return version;
|
|
13
|
+
}
|
|
14
|
+
export async function installDevicePackageUpdate(targetVersion, options = {}) {
|
|
15
|
+
const version = normalizeUpdateVersion(targetVersion);
|
|
16
|
+
const platform = options.platform || process.platform;
|
|
17
|
+
const execFile = options.execFile || (async (file, args) => {
|
|
18
|
+
const result = await execFileAsync(file, args, { windowsHide: true, encoding: 'utf8' });
|
|
19
|
+
return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
20
|
+
});
|
|
21
|
+
const npmPath = options.npmPath || (platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
22
|
+
await execFile(npmPath, ['install', '-g', `${MCP_DEVICE_PACKAGE}@${version}`, '--no-audit', '--no-fund']);
|
|
23
|
+
const listed = await execFile(npmPath, ['list', '-g', MCP_DEVICE_PACKAGE, '--depth=0', '--json']);
|
|
24
|
+
let actual = '';
|
|
25
|
+
try {
|
|
26
|
+
actual = String(JSON.parse(String(listed.stdout || '{}'))?.dependencies?.[MCP_DEVICE_PACKAGE]?.version || '');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('Updated package version could not be verified.');
|
|
30
|
+
}
|
|
31
|
+
if (actual !== version)
|
|
32
|
+
throw new Error(`Updated package verification failed: expected ${version}, got ${actual || 'unknown'}.`);
|
|
33
|
+
return actual;
|
|
34
|
+
}
|
|
35
|
+
function serviceManager(argv) {
|
|
36
|
+
const value = argv.find(item => String(item).startsWith('--manager='));
|
|
37
|
+
return value ? String(value).slice('--manager='.length).trim().toLowerCase() : '';
|
|
38
|
+
}
|
|
39
|
+
export function buildWindowsRestartHelper(deviceId, currentPid = process.pid) {
|
|
40
|
+
const runnerPath = path.join(deviceStatePaths().root, 'run-device.ps1');
|
|
41
|
+
const taskName = buildWindowsTaskName(deviceId);
|
|
42
|
+
const psQuote = (value) => `'${String(value).replace(/'/g, "''")}'`;
|
|
43
|
+
return [
|
|
44
|
+
`$oldPid = ${Number(currentPid)}`,
|
|
45
|
+
'for ($i = 0; $i -lt 80; $i++) {',
|
|
46
|
+
" if (-not (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { break }",
|
|
47
|
+
' Start-Sleep -Milliseconds 250',
|
|
48
|
+
'}',
|
|
49
|
+
`$taskName = ${psQuote(taskName)}`,
|
|
50
|
+
`$runner = ${psQuote(runnerPath)}`,
|
|
51
|
+
'if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {',
|
|
52
|
+
' Start-ScheduledTask -TaskName $taskName',
|
|
53
|
+
'} else {',
|
|
54
|
+
" Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',$runner) -WindowStyle Hidden",
|
|
55
|
+
'}'
|
|
56
|
+
].join('; ');
|
|
57
|
+
}
|
|
58
|
+
export function scheduleDeviceRuntimeRestart(options) {
|
|
59
|
+
const platform = options.platform || process.platform;
|
|
60
|
+
const argv = options.argv || process.argv;
|
|
61
|
+
const delayMs = Math.max(100, Number(options.delayMs ?? 700));
|
|
62
|
+
const exit = options.exit || process.exit;
|
|
63
|
+
if (!argv.includes('--service'))
|
|
64
|
+
throw new Error('Dashboard updates require an installed background MCP Device service.');
|
|
65
|
+
if (platform === 'linux') {
|
|
66
|
+
const manager = serviceManager(argv);
|
|
67
|
+
if (!['systemd', 'pm2'].includes(manager))
|
|
68
|
+
throw new Error('Linux dashboard update requires systemd or PM2 service ownership.');
|
|
69
|
+
const timer = setTimeout(() => exit(75), delayMs);
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
const spawn = options.spawn || spawnCallback;
|
|
75
|
+
const helper = buildWindowsRestartHelper(options.deviceId);
|
|
76
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-Command', helper], {
|
|
77
|
+
detached: true,
|
|
78
|
+
stdio: 'ignore',
|
|
79
|
+
windowsHide: true
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
const timer = setTimeout(() => exit(0), delayMs);
|
|
83
|
+
timer.unref?.();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw new Error('Dashboard self-update is supported only on installed Windows and Linux devices.');
|
|
87
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.0.
|
|
1
|
+
export declare const VERSION = "1.0.5";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.0.
|
|
1
|
+
export const VERSION = '1.0.5';
|
package/package.json
CHANGED