@livedesk/hub 0.1.42 → 0.1.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -11,7 +11,7 @@
11
11
  "scripts": {
12
12
  "dev": "node src/server.js",
13
13
  "start": "node src/server.js",
14
- "check": "node --check src/server.js && node --check src/remote-hub.js",
14
+ "check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/console-relay.js",
15
15
  "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
16
16
  },
17
17
  "dependencies": {
@@ -0,0 +1,66 @@
1
+ const DEFAULT_MAX_STATUS_AGE_MS = 30_000;
2
+
3
+ function asRecord(value) {
4
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
+ }
6
+
7
+ function cpuUsageRatio(value) {
8
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
9
+ ? value
10
+ : null;
11
+ }
12
+
13
+ function positiveInteger(value) {
14
+ const number = Number(value);
15
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
16
+ }
17
+
18
+ function statusAgeMs(device, now) {
19
+ const sampledAt = Date.parse(String(device?.lastStatusAt || ''));
20
+ return Number.isFinite(sampledAt) ? Math.max(0, now - sampledAt) : null;
21
+ }
22
+
23
+ export function enrichAgentTaskResults({
24
+ operation,
25
+ results,
26
+ devices,
27
+ now = Date.now(),
28
+ maxStatusAgeMs = DEFAULT_MAX_STATUS_AGE_MS
29
+ } = {}) {
30
+ const sourceResults = Array.isArray(results) ? results : [];
31
+ if (operation !== 'system.health') return sourceResults;
32
+
33
+ const devicesById = new Map(
34
+ (Array.isArray(devices) ? devices : []).map(device => [String(device?.deviceId || ''), device])
35
+ );
36
+
37
+ return sourceResults.map(result => {
38
+ if (!result || result.status !== 'completed') return result;
39
+ const data = asRecord(result.data);
40
+ const device = devicesById.get(String(result.deviceId || ''));
41
+ const status = asRecord(device?.status);
42
+ const statusCpu = asRecord(status.cpu);
43
+ const directRatio = cpuUsageRatio(data.cpuUsageRatio);
44
+ const latestRatio = cpuUsageRatio(statusCpu.usageRatio);
45
+ const latestAgeMs = statusAgeMs(device, now);
46
+ const useLatestStatus = directRatio === null
47
+ && latestRatio !== null
48
+ && latestAgeMs !== null
49
+ && latestAgeMs <= Math.max(0, Number(maxStatusAgeMs) || DEFAULT_MAX_STATUS_AGE_MS);
50
+ const cores = positiveInteger(data.cores) || positiveInteger(statusCpu.cores);
51
+ const nextData = { ...data };
52
+
53
+ if (cores !== null) nextData.cores = cores;
54
+ if (directRatio !== null) {
55
+ nextData.cpuUsageRatio = directRatio;
56
+ nextData.cpuUsageSource = 'system-health-task';
57
+ } else if (useLatestStatus) {
58
+ nextData.cpuUsageRatio = latestRatio;
59
+ nextData.cpuUsageSource = 'latest-client-status';
60
+ nextData.cpuUsageSampleAgeMs = latestAgeMs;
61
+ nextData.cpuUsageSampledAt = String(device.lastStatusAt);
62
+ }
63
+
64
+ return { ...result, data: nextData };
65
+ });
66
+ }
@@ -728,8 +728,10 @@ export function createCodexAgentRuntime({
728
728
  'Never use arbitrary MCP servers, change permissions, forge approvals, request credentials, or invent a tool result.',
729
729
  `The Hub has fixed this run to permission mode ${permissionPolicy?.mode || 'ask'} and enforces the policy independently of your instructions.`,
730
730
  'Use only the selected connected device IDs below. If the Hub asks for user approval, wait for that approval result and do not work around it.',
731
- 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
732
- `Selected device IDs: ${JSON.stringify(deviceIds)}`,
731
+ 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
732
+ 'If one read-only result omits a fact the user requested, do not treat that missing field as proof that the fact is unavailable. Try another applicable registered LiveDesk read-only tool.',
733
+ 'If only a high-risk registered tool such as livedesk.run_command can obtain the missing fact, use it only when necessary, one selected device per call, and let the Hub approval policy ask the user. If approval is denied or no applicable tool exists, explain that exact limit.',
734
+ `Selected device IDs: ${JSON.stringify(deviceIds)}`,
733
735
  'Return a concise Korean or English summary grounded only in tool results. Do not invent results.',
734
736
  `User request: ${safeText(instruction, 4000)}`
735
737
  ].join('\n');
@@ -0,0 +1,415 @@
1
+ import WebSocket from 'ws';
2
+
3
+ const DEFAULT_CONSOLE_RELAY_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
4
+ const DEFAULT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
5
+ const MAX_CHANNELS = 32;
6
+ const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
7
+ const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
8
+ const MAX_RELAY_BUFFERED_BYTES = 4 * 1024 * 1024;
9
+ const HTTP_TIMEOUT_MS = 15_000;
10
+ const BINARY_HEADER_BYTES = 73;
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+
13
+ function normalizeRelayUrl(value) {
14
+ const source = String(value || DEFAULT_CONSOLE_RELAY_URL).trim();
15
+ if (!source || /^(off|disabled|none)$/i.test(source)) return '';
16
+ try {
17
+ const url = new URL(source);
18
+ url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
19
+ url.pathname = '/v1/hub';
20
+ url.search = '';
21
+ url.hash = '';
22
+ return url;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function allowedHttpRequest(method, path) {
29
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
30
+ const pathname = String(path || '').split('?')[0];
31
+ if (pathname === '/api/health'
32
+ || pathname === '/api/runtime/status'
33
+ || pathname === '/api/runtime/restart'
34
+ || pathname === '/api/auth/status'
35
+ || pathname === '/api/remote/status'
36
+ || pathname === '/api/hub/status'
37
+ || pathname === '/api/update/status'
38
+ || pathname === '/api/update/apply') {
39
+ return true;
40
+ }
41
+ if (/^\/api\/settings(?:\/|$)/.test(pathname)
42
+ || /^\/api\/captures(?:\/|$)/.test(pathname)
43
+ || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
44
+ || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
45
+ || /^\/api\/remote\/frames$/.test(pathname)
46
+ || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
47
+ || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
48
+ || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
49
+ || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
50
+ return pathname !== '/api/remote/registry-credentials'
51
+ && pathname !== '/api/remote/pairing-pin'
52
+ && !pathname.startsWith('/api/remote/host-target')
53
+ && !pathname.startsWith('/api/remote/synthetic');
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function allowedWebSocketPath(path) {
59
+ const pathname = String(path || '').split('?')[0];
60
+ return pathname === '/api/remote/frames/ws'
61
+ || pathname === '/api/remote/atlas/ws'
62
+ || pathname === '/api/remote/input/ws'
63
+ || pathname === '/api/remote/audio/ws';
64
+ }
65
+
66
+ function channelKey(consoleId, channelId) {
67
+ return `${consoleId}\n${channelId}`;
68
+ }
69
+
70
+ function parseMessage(raw) {
71
+ try {
72
+ return JSON.parse(String(raw || ''));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function safeContentHeaders(headers) {
79
+ const result = {};
80
+ for (const name of ['accept', 'content-type', 'if-match', 'range']) {
81
+ const value = headers && typeof headers === 'object' ? headers[name] || headers[name.toUpperCase()] : '';
82
+ if (value) result[name] = String(value).slice(0, 512);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function createHubConsoleRelay(options = {}) {
88
+ const relayUrl = normalizeRelayUrl(options.url);
89
+ const deviceId = String(options.deviceId || '').trim();
90
+ const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
91
+ const WebSocketImpl = options.WebSocketImpl || WebSocket;
92
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
93
+ const getAccessToken = typeof options.getAccessToken === 'function'
94
+ ? options.getAccessToken
95
+ : async () => String(options.accessToken || '').trim();
96
+ const logger = options.logger || console;
97
+ const retryDelaysMs = options.retryDelaysMs || DEFAULT_RETRY_DELAYS_MS;
98
+ const localSockets = new Map();
99
+ const pendingHttp = new Set();
100
+ let relaySocket = null;
101
+ let retryTimer = null;
102
+ let retryAttempt = 0;
103
+ let generation = 0;
104
+ let stopped = true;
105
+ let state = relayUrl ? 'idle' : 'disabled';
106
+ let lastConnectedAt = '';
107
+ let lastError = relayUrl instanceof URL ? '' : relayUrl === '' ? '' : 'invalid-relay-url';
108
+ let droppedBinaryMessages = 0;
109
+
110
+ const sendRelay = payload => {
111
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
112
+ if (Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
113
+ relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
114
+ return true;
115
+ };
116
+
117
+ const sendError = (consoleId, channelId, error, type = 'relay-error') => {
118
+ sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
119
+ };
120
+
121
+ const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
122
+ const socket = localSockets.get(key);
123
+ if (!socket) return;
124
+ localSockets.delete(key);
125
+ try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
126
+ try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
127
+ };
128
+
129
+ const closeConsoleChannels = consoleId => {
130
+ for (const key of [...localSockets.keys()]) {
131
+ if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
132
+ }
133
+ };
134
+
135
+ const closeAllLocalSockets = reason => {
136
+ for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
137
+ };
138
+
139
+ const handleHttpRequest = async payload => {
140
+ const consoleId = String(payload?.consoleId || '');
141
+ const channelId = String(payload?.channelId || '');
142
+ const method = String(payload?.method || 'GET').toUpperCase();
143
+ const path = String(payload?.path || '');
144
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
145
+ sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
146
+ return;
147
+ }
148
+ if (pendingHttp.size >= MAX_CHANNELS) {
149
+ sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
150
+ return;
151
+ }
152
+ let body;
153
+ try {
154
+ body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
155
+ } catch {
156
+ sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
157
+ return;
158
+ }
159
+ if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
160
+ sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
161
+ return;
162
+ }
163
+
164
+ const requestKey = channelKey(consoleId, channelId);
165
+ pendingHttp.add(requestKey);
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
168
+ timeout.unref?.();
169
+ try {
170
+ const response = await fetchImpl(`${httpBaseUrl}${path}`, {
171
+ method,
172
+ headers: safeContentHeaders(payload.headers),
173
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
174
+ signal: controller.signal
175
+ });
176
+ const responseBytes = Buffer.from(await response.arrayBuffer());
177
+ if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
178
+ sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
179
+ return;
180
+ }
181
+ sendRelay({
182
+ type: 'http-response',
183
+ consoleId,
184
+ channelId,
185
+ status: response.status,
186
+ statusText: response.statusText,
187
+ headers: {
188
+ 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
189
+ 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
190
+ },
191
+ bodyBase64: responseBytes.toString('base64')
192
+ });
193
+ } catch (error) {
194
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
195
+ } finally {
196
+ clearTimeout(timeout);
197
+ pendingHttp.delete(requestKey);
198
+ }
199
+ };
200
+
201
+ const handleWebSocketOpen = payload => {
202
+ const consoleId = String(payload?.consoleId || '');
203
+ const channelId = String(payload?.channelId || '');
204
+ const path = String(payload?.path || '');
205
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
206
+ sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
207
+ return;
208
+ }
209
+ if (localSockets.size >= MAX_CHANNELS) {
210
+ sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
211
+ return;
212
+ }
213
+ const key = channelKey(consoleId, channelId);
214
+ closeLocalSocket(key, 1000, 'console-channel-replaced');
215
+ const localUrl = new URL(path, httpBaseUrl);
216
+ localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
217
+ let socket;
218
+ try {
219
+ socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
220
+ } catch (error) {
221
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
222
+ return;
223
+ }
224
+ localSockets.set(key, socket);
225
+ socket.once('open', () => {
226
+ if (localSockets.get(key) !== socket) return;
227
+ sendRelay({ type: 'ws-opened', consoleId, channelId });
228
+ });
229
+ socket.on('message', (data, isBinary) => {
230
+ if (localSockets.get(key) !== socket) return;
231
+ if (isBinary) {
232
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
233
+ || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
234
+ droppedBinaryMessages += 1;
235
+ return;
236
+ }
237
+ const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
238
+ if (header.byteLength !== BINARY_HEADER_BYTES) {
239
+ closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
240
+ return;
241
+ }
242
+ relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
243
+ return;
244
+ }
245
+ sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
246
+ });
247
+ socket.once('close', (code, reason) => {
248
+ if (localSockets.get(key) === socket) localSockets.delete(key);
249
+ sendRelay({ type: 'ws-closed', consoleId, channelId, code, reason: String(reason || '').slice(0, 120) });
250
+ });
251
+ socket.once('error', error => {
252
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
253
+ });
254
+ };
255
+
256
+ const handleMessage = raw => {
257
+ const payload = parseMessage(raw);
258
+ if (!payload?.type) return;
259
+ if (payload.type === 'relay-ready') {
260
+ state = 'connected';
261
+ lastConnectedAt = new Date().toISOString();
262
+ lastError = '';
263
+ return;
264
+ }
265
+ if (payload.type === 'console-detached') {
266
+ closeConsoleChannels(String(payload.consoleId || ''));
267
+ return;
268
+ }
269
+ if (payload.type === 'http-request') {
270
+ void handleHttpRequest(payload);
271
+ return;
272
+ }
273
+ if (payload.type === 'ws-open') {
274
+ handleWebSocketOpen(payload);
275
+ return;
276
+ }
277
+ const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
278
+ const socket = localSockets.get(key);
279
+ if (!socket) return;
280
+ if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
281
+ const data = String(payload.data || '');
282
+ if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
283
+ } else if (payload.type === 'ws-close') {
284
+ closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
285
+ }
286
+ };
287
+
288
+ const scheduleReconnect = connect => {
289
+ if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
290
+ const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
291
+ retryAttempt += 1;
292
+ state = 'waiting-retry';
293
+ retryTimer = setTimeout(() => {
294
+ retryTimer = null;
295
+ void connect();
296
+ }, delayMs);
297
+ retryTimer.unref?.();
298
+ };
299
+
300
+ const connect = async () => {
301
+ if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
302
+ const ownerGeneration = ++generation;
303
+ state = 'connecting';
304
+ let accessToken = '';
305
+ try {
306
+ accessToken = String(await getAccessToken() || '').trim();
307
+ } catch (error) {
308
+ lastError = error instanceof Error ? error.message : String(error);
309
+ }
310
+ if (stopped || ownerGeneration !== generation) return;
311
+ if (!accessToken) {
312
+ lastError = 'hub-session-required';
313
+ scheduleReconnect(connect);
314
+ return;
315
+ }
316
+ const url = new URL(relayUrl);
317
+ url.searchParams.set('deviceId', deviceId);
318
+ let socket;
319
+ try {
320
+ socket = new WebSocketImpl(url, {
321
+ headers: { Authorization: `Bearer ${accessToken}` },
322
+ perMessageDeflate: false
323
+ });
324
+ } catch (error) {
325
+ lastError = error instanceof Error ? error.message : String(error);
326
+ scheduleReconnect(connect);
327
+ return;
328
+ }
329
+ if (stopped || ownerGeneration !== generation) {
330
+ try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
331
+ return;
332
+ }
333
+ relaySocket = socket;
334
+ socket.once('open', () => {
335
+ if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
336
+ retryAttempt = 0;
337
+ state = 'authenticating';
338
+ });
339
+ socket.on('message', raw => {
340
+ if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
341
+ });
342
+ socket.once('close', () => {
343
+ if (relaySocket === socket) relaySocket = null;
344
+ if (ownerGeneration !== generation || stopped) return;
345
+ state = 'disconnected';
346
+ closeAllLocalSockets('relay-disconnected');
347
+ scheduleReconnect(connect);
348
+ });
349
+ socket.once('error', error => {
350
+ lastError = error instanceof Error ? error.message : String(error);
351
+ try { socket.terminate?.(); } catch { /* close handler owns retry */ }
352
+ });
353
+ };
354
+
355
+ const start = () => {
356
+ if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
357
+ stopped = false;
358
+ void connect();
359
+ };
360
+
361
+ const refresh = () => {
362
+ if (stopped) {
363
+ start();
364
+ return;
365
+ }
366
+ generation += 1;
367
+ if (retryTimer) {
368
+ clearTimeout(retryTimer);
369
+ retryTimer = null;
370
+ }
371
+ const socket = relaySocket;
372
+ relaySocket = null;
373
+ try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
374
+ closeAllLocalSockets('relay-refresh');
375
+ void connect();
376
+ };
377
+
378
+ const close = () => {
379
+ if (stopped) return;
380
+ stopped = true;
381
+ generation += 1;
382
+ if (retryTimer) clearTimeout(retryTimer);
383
+ retryTimer = null;
384
+ closeAllLocalSockets('hub-shutdown');
385
+ const socket = relaySocket;
386
+ relaySocket = null;
387
+ try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
388
+ try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
389
+ state = relayUrl ? 'closed' : 'disabled';
390
+ };
391
+
392
+ return {
393
+ start,
394
+ refresh,
395
+ close,
396
+ inspect: () => ({
397
+ enabled: relayUrl instanceof URL && Boolean(deviceId),
398
+ state,
399
+ connected: state === 'connected',
400
+ localWebSocketChannels: localSockets.size,
401
+ pendingHttpRequests: pendingHttp.size,
402
+ droppedBinaryMessages,
403
+ lastConnectedAt,
404
+ lastError
405
+ })
406
+ };
407
+ }
408
+
409
+ export const consoleRelayContract = Object.freeze({
410
+ maxChannels: MAX_CHANNELS,
411
+ maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
412
+ maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
413
+ maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
414
+ binaryHeaderBytes: BINARY_HEADER_BYTES
415
+ });
package/src/server.js CHANGED
@@ -7,8 +7,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
- import { WebSocketServer } from 'ws';
11
- import { createRemoteHub } from './remote-hub.js';
10
+ import { WebSocketServer } from 'ws';
11
+ import { createRemoteHub } from './remote-hub.js';
12
+ import { createHubConsoleRelay } from './console-relay.js';
12
13
  import {
13
14
  buildImmutableRemoteFramePacket,
14
15
  createRemoteFramePacketMetrics,
@@ -39,8 +40,9 @@ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-de
39
40
  import { AgentRuntimeError } from './agents/agent-runtime-error.js';
40
41
  import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
41
42
  import { createAgentPermissionStore } from './agents/agent-permission-store.js';
42
- import { createAgentAuditStore } from './agents/agent-audit-store.js';
43
- import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
43
+ import { createAgentAuditStore } from './agents/agent-audit-store.js';
44
+ import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
45
+ import { enrichAgentTaskResults } from './agents/agent-result-enrichment.js';
44
46
  import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
45
47
  import { effectiveDevicePolicy } from './settings/settings-schema.js';
46
48
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
@@ -202,15 +204,22 @@ let verifiedLicense = {
202
204
  let frameClientSeq = 0;
203
205
  let inputClientSeq = 0;
204
206
  let audioClientSeq = 0;
205
- let liveDeskUpdateManager = null;
206
- let hubTransferJobs = null;
207
+ let liveDeskUpdateManager = null;
208
+ let hubTransferJobs = null;
209
+ let hubConsoleRelay = null;
207
210
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
208
211
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
209
212
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
210
213
  Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
211
214
  ));
212
- const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
213
- const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
215
+ const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
216
+ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
217
+ const hubConsoleRelayBaseUrl = String(
218
+ process.env.LIVEDESK_CONSOLE_RELAY_URL
219
+ || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
220
+ ? 'off'
221
+ : 'https://livedesk-wake.lovecrdm.workers.dev')
222
+ ).trim();
214
223
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
215
224
  let hubHostTargetRenewTimer = null;
216
225
  let hubHostTargetRenewInFlight = false;
@@ -1261,10 +1270,16 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1261
1270
  remoteHub.cancelTaskBatch(result.batchId);
1262
1271
  return { ok: false, error: 'cancelled-by-user', batchId: result.batchId };
1263
1272
  }
1264
- const batch = remoteHub.getTaskBatch(result.batchId);
1265
- if (!batch) return { ok: false, error: 'task-not-found', batchId: result.batchId };
1266
- if (!['queued', 'running'].includes(batch.status)) {
1267
- return {
1273
+ const batch = remoteHub.getTaskBatch(result.batchId);
1274
+ if (!batch) return { ok: false, error: 'task-not-found', batchId: result.batchId };
1275
+ if (!['queued', 'running'].includes(batch.status)) {
1276
+ const taskResults = enrichAgentTaskResults({
1277
+ operation,
1278
+ results: batch.results,
1279
+ devices: remoteHub.listDevices({ includeDataUrl: false })
1280
+ .filter(device => targetIds.includes(device.deviceId))
1281
+ });
1282
+ return {
1268
1283
  ok: true,
1269
1284
  batchId: result.batchId,
1270
1285
  operation,
@@ -1272,8 +1287,8 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1272
1287
  total: batch.total,
1273
1288
  completed: batch.completed,
1274
1289
  failed: batch.failed,
1275
- results: batch.results.map(item => ({ deviceId: item.deviceId, deviceName: item.deviceName, status: item.status, stage: item.stage, result: String(item.result || '').slice(0, 3000), data: item.data, error: String(item.error || '').slice(0, 500) }))
1276
- };
1290
+ results: taskResults.map(item => ({ deviceId: item.deviceId, deviceName: item.deviceName, status: item.status, stage: item.stage, result: String(item.result || '').slice(0, 3000), data: item.data, error: String(item.error || '').slice(0, 500) }))
1291
+ };
1277
1292
  }
1278
1293
  await delayAgentMcp(200);
1279
1294
  }
@@ -1333,9 +1348,16 @@ const hubSharedFolders = createHubSharedFolders({
1333
1348
  dataDir: agentDataDir
1334
1349
  });
1335
1350
 
1336
- const app = express();
1337
- const httpServer = createServer(app);
1338
- const httpConnections = new Set();
1351
+ const app = express();
1352
+ const httpServer = createServer(app);
1353
+ hubConsoleRelay = createHubConsoleRelay({
1354
+ url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
1355
+ deviceId: runtimeDeviceId,
1356
+ httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1357
+ getAccessToken: () => getRuntimeAccessToken(),
1358
+ logger: console
1359
+ });
1360
+ const httpConnections = new Set();
1339
1361
  const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1340
1362
  const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1341
1363
  const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
@@ -4219,9 +4241,10 @@ app.get('/api/remote/status', (_req, res) => {
4219
4241
  runtimeRole,
4220
4242
  deviceId: runtimeDeviceId,
4221
4243
  deviceName: runtimeDeviceName,
4222
- roleSource: runtimeRoleSource,
4223
- agentPackage: '@livedesk/client',
4224
- frameLanes: snapshotFrameLaneResourceHealth(),
4244
+ roleSource: runtimeRoleSource,
4245
+ agentPackage: '@livedesk/client',
4246
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4247
+ frameLanes: snapshotFrameLaneResourceHealth(),
4225
4248
  update: getLiveDeskUpdateStatus()
4226
4249
  });
4227
4250
  });
@@ -4453,10 +4476,11 @@ app.post('/api/auth/session', async (req, res) => {
4453
4476
  csrfToken: uiSession.csrfToken,
4454
4477
  expiresAt: uiSession.expiresAt
4455
4478
  }
4456
- });
4457
- if (runtimeRole === 'hub') {
4458
- scheduleAuthenticatedHubHostTargetPublication('session-received');
4459
- }
4479
+ });
4480
+ if (runtimeRole === 'hub') {
4481
+ hubConsoleRelay?.refresh();
4482
+ scheduleAuthenticatedHubHostTargetPublication('session-received');
4483
+ }
4460
4484
  } catch (error) {
4461
4485
  const message = error instanceof Error ? error.message : String(error);
4462
4486
  const status = authVerificationHttpStatus(message);
@@ -4476,10 +4500,11 @@ function getHubHostTargetLeaseStatus() {
4476
4500
  ...hubHostTargetLeaseState,
4477
4501
  renewing: hubHostTargetRenewInFlight,
4478
4502
  renewalIntervalMs: HUB_HOST_TARGET_RENEW_MS,
4479
- leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4480
- authenticated: Boolean(runtimeAccessToken),
4481
- timerActive: Boolean(hubHostTargetRenewTimer),
4482
- wake: { ...hubWakeNotificationState }
4503
+ leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4504
+ authenticated: Boolean(runtimeAccessToken),
4505
+ timerActive: Boolean(hubHostTargetRenewTimer),
4506
+ wake: { ...hubWakeNotificationState },
4507
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4483
4508
  };
4484
4509
  }
4485
4510
 
@@ -4783,10 +4808,11 @@ app.get('/api/hub/status', (_req, res) => {
4783
4808
  ...remoteHub.getStatus({ includeSecrets: false }),
4784
4809
  role: 'hub',
4785
4810
  deviceId: runtimeDeviceId,
4786
- deviceName: runtimeDeviceName,
4787
- roleSource: runtimeRoleSource,
4788
- runtimeStarted: true,
4789
- hostTargetLease: getHubHostTargetLeaseStatus(),
4811
+ deviceName: runtimeDeviceName,
4812
+ roleSource: runtimeRoleSource,
4813
+ runtimeStarted: true,
4814
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4815
+ hostTargetLease: getHubHostTargetLeaseStatus(),
4790
4816
  update: getLiveDeskUpdateStatus()
4791
4817
  });
4792
4818
  });
@@ -5793,13 +5819,14 @@ hubSharedFolders.startAutoSync(
5793
5819
  () => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
5794
5820
  () => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
5795
5821
  );
5796
- httpServer.listen(httpPort, httpHost, () => {
5822
+ httpServer.listen(httpPort, httpHost, () => {
5797
5823
  const status = remoteHub.getStatus({ includeSecrets: true });
5798
5824
  const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
5799
5825
  console.log(`[LiveDesk Hub] Version ${managerVersion}`);
5800
- console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5801
- console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5802
- });
5826
+ console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5827
+ console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5828
+ hubConsoleRelay?.start();
5829
+ });
5803
5830
 
5804
5831
  const roleWatchTimer = runtimeRole === 'hub'
5805
5832
  ? setInterval(() => { void watchAuthoritativeRuntimeRole(); }, 5000)
@@ -5880,9 +5907,10 @@ function shutdownHub(signal) {
5880
5907
  const startedAt = Date.now();
5881
5908
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
5882
5909
  if (roleWatchTimer) clearInterval(roleWatchTimer);
5883
- clearInterval(browserWebSocketHeartbeatTimer);
5884
- runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5885
- atlasClients.clear();
5910
+ clearInterval(browserWebSocketHeartbeatTimer);
5911
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5912
+ runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
5913
+ atlasClients.clear();
5886
5914
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
5887
5915
 
5888
5916
  const httpClosed = new Promise(resolveClose => {