@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 +2 -2
- package/src/agents/agent-result-enrichment.js +66 -0
- package/src/agents/codex-agent-runtime.js +4 -2
- package/src/console-relay.js +415 -0
- package/src/server.js +67 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|