@livedesk/client 0.1.233 → 0.1.235

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.
@@ -1,1046 +1,1046 @@
1
- import { createServer } from 'node:http';
2
- import { randomBytes } from 'node:crypto';
3
- import { execFile } from 'node:child_process';
4
- import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
- import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { execFile } from 'node:child_process';
4
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
+ import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
6
6
  import os from 'node:os';
7
- import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
- import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../../runtime-core/src/os-secret-store.js';
9
-
10
- const DEFAULT_HOST = '127.0.0.1';
11
- const DEFAULT_PORT = 5179;
12
- const CLIENT_RUNTIME_BIND_TIMEOUT_MS = 10_000;
13
- const CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS = 150;
14
- const CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS = 750;
15
- const CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS = 1_500;
16
- const CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS = 15_000;
17
- const CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS = 60_000;
18
- const CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS = 300_000;
19
- const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
20
- const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co';
21
- const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
22
- const CLIENT_STATE_DIR = process.env.LIVEDESK_STATE_DIR || (process.env.LIVEDESK_UNIFIED_RUNTIME === '1' ? join(os.homedir(), '.livedesk') : join(os.homedir(), '.livedesk-client'));
23
- const CLIENT_AUTH_PATH = join(CLIENT_STATE_DIR, 'auth.json');
24
- const CLIENT_REFRESH_SECRET_STORE = createOsSecretStore({
25
- service: 'LiveDesk',
26
- account: 'client-refresh-token',
27
- dataDir: CLIENT_STATE_DIR
28
- });
29
- const DEFAULT_TRUSTED_WEB_ORIGINS = Object.freeze([
30
- 'https://livedesk.pages.dev',
31
- 'http://127.0.0.1:5173',
32
- 'http://localhost:5173',
33
- 'http://127.0.0.1:4173',
34
- 'http://localhost:4173'
35
- ]);
36
- const LIVE_DESK_PAGES_PREVIEW_HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.livedesk\.pages\.dev$/;
37
- const READ_ONLY_CLIENT_API_PATHS = new Set([
38
- '/api/health',
39
- '/api/runtime',
40
- '/api/runtime/status',
41
- '/api/runtime/events',
42
- '/api/client/status',
43
- '/api/client/computer',
44
- '/api/client/system',
45
- '/api/client/permissions',
46
- '/api/client/diagnostics',
47
- '/api/auth/status'
48
- ]);
49
-
50
- function normalizeString(value, maxLength = 1024) {
51
- return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
52
- }
53
-
54
- function normalizeAccountAvatarUrl(value) {
55
- const candidate = normalizeString(value, 2048);
56
- if (!candidate) return '';
57
- try {
58
- const url = new URL(candidate);
59
- return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : '';
60
- } catch {
61
- return '';
62
- }
63
- }
64
-
65
- function readAccessTokenProfile(accessToken) {
66
- const token = normalizeString(accessToken, 8192);
67
- const payloadSegment = token.split('.')[1] || '';
68
- if (!payloadSegment) return {};
69
- try {
70
- const payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
71
- return payload && typeof payload === 'object' ? payload : {};
72
- } catch {
73
- return {};
74
- }
75
- }
76
-
77
- function readClientAccountProfile(sessionOrUser) {
78
- const tokenProfile = readAccessTokenProfile(sessionOrUser?.access_token);
79
- const user = sessionOrUser?.user && typeof sessionOrUser.user === 'object'
80
- ? sessionOrUser.user
81
- : sessionOrUser && typeof sessionOrUser === 'object'
82
- ? sessionOrUser
83
- : {};
84
- const metadata = user.user_metadata && typeof user.user_metadata === 'object'
85
- ? user.user_metadata
86
- : {};
87
- const tokenMetadata = tokenProfile.user_metadata && typeof tokenProfile.user_metadata === 'object'
88
- ? tokenProfile.user_metadata
89
- : {};
90
- const email = normalizeString(user.email || tokenProfile.email, 320);
91
- const name = normalizeString(
92
- metadata.full_name
93
- || metadata.name
94
- || metadata.preferred_username
95
- || tokenMetadata.full_name
96
- || tokenMetadata.name
97
- || tokenMetadata.preferred_username
98
- || user.name
99
- || tokenProfile.name
100
- || email
101
- || user.id,
102
- 160
103
- );
104
- const avatarUrl = normalizeAccountAvatarUrl(
105
- metadata.avatar_url
106
- || metadata.picture
107
- || tokenMetadata.avatar_url
108
- || tokenMetadata.picture
109
- || user.avatar_url
110
- || user.picture
111
- );
112
- return { email, name, avatarUrl };
113
- }
114
-
115
- function mergeClientAccountProfile(session, sourceUser) {
116
- const profile = readClientAccountProfile(sourceUser);
117
- const existingUserMetadata = session?.user?.user_metadata && typeof session.user.user_metadata === 'object'
118
- ? session.user.user_metadata
119
- : {};
120
- const userMetadata = {
121
- ...existingUserMetadata,
122
- ...(profile.name ? { full_name: profile.name } : {}),
123
- ...(profile.avatarUrl ? { avatar_url: profile.avatarUrl } : {})
124
- };
125
- return {
126
- ...session,
127
- user: {
128
- ...(session?.user || {}),
129
- ...(Object.keys(userMetadata).length > 0 ? { user_metadata: userMetadata } : {})
130
- }
131
- };
132
- }
133
-
134
- async function hydrateClientAccountProfile(session) {
135
- const accessToken = normalizeString(session?.access_token, 8192);
136
- if (!accessToken) return session;
137
- try {
138
- const response = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
139
- headers: {
140
- apikey: SUPABASE_PUBLISHABLE_KEY,
141
- Authorization: `Bearer ${accessToken}`,
142
- Accept: 'application/json'
143
- },
144
- signal: AbortSignal.timeout(5_000)
145
- });
146
- if (!response.ok) return session;
147
- const verifiedUser = await response.json().catch(() => null);
148
- return verifiedUser ? mergeClientAccountProfile(session, verifiedUser) : session;
149
- } catch {
150
- return session;
151
- }
152
- }
153
-
154
- function normalizePort(value, fallback = DEFAULT_PORT) {
155
- const port = Number(value);
156
- return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
157
- }
158
-
159
- function normalizeSlotNumber(value) {
160
- const slotNumber = Number(String(value ?? '').trim());
161
- return Number.isInteger(slotNumber) && slotNumber >= 1 && slotNumber <= 999
162
- ? slotNumber
163
- : 0;
164
- }
165
-
166
- function normalizeRoleVersion(value) {
167
- const roleVersion = Number(String(value ?? '').trim());
168
- return Number.isInteger(roleVersion) && roleVersion >= 0 ? roleVersion : 0;
169
- }
170
-
171
- function readCpuTotals() {
172
- return os.cpus().reduce((totals, cpu) => {
173
- const times = Object.values(cpu.times || {}).map(Number);
174
- totals.idle += Number(cpu.times?.idle || 0);
175
- totals.total += times.reduce((sum, value) => sum + value, 0);
176
- return totals;
177
- }, { idle: 0, total: 0 });
178
- }
179
-
180
- function finiteNumber(value) {
181
- if (value === null
182
- || value === undefined
183
- || typeof value === 'boolean'
184
- || (typeof value === 'string' && value.trim() === '')) {
185
- return null;
186
- }
187
- const number = Number(value);
188
- return Number.isFinite(number) && number >= 0 ? number : null;
189
- }
190
-
191
- function percentFromRatio(value) {
192
- const number = finiteNumber(value);
193
- if (number === null) return null;
194
- return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
195
- }
196
-
197
- function destroyChildRedirectedIo(child) {
198
- for (const stream of [child?.stdin, child?.stdout, child?.stderr]) {
199
- try { stream?.destroy(); } catch { /* an exact owned stream already closed */ }
200
- }
201
- }
202
-
203
- function sanitizeDiagnosticCommandName(value) {
204
- return normalizeString(value, 200) || 'unknown-diagnostic-command';
205
- }
206
-
207
- function defaultDiagnosticMonotonicNow() {
208
- return Number(process.hrtime.bigint() / 1_000_000n);
209
- }
210
-
211
- /**
212
- * Contract:
213
- * - A probe is idle until an explicit diagnostic request calls read().
214
- * - Every successful, unavailable, empty, or failed result is retained for at
215
- * least 15 monotonic seconds measured from refresh completion.
216
- * - Concurrent readers share one refresh promise. A failed tool therefore
217
- * cannot respawn once per status heartbeat.
218
- */
219
- export function createClientDiagnosticProbeCache(options = {}) {
220
- if (typeof options.refresh !== 'function') {
221
- throw new TypeError('client-diagnostic-probe-refresh-required');
222
- }
223
- const probeId = normalizeString(options.probeId, 80) || 'client-diagnostic-probe';
224
- const monotonicNow = typeof options.monotonicNow === 'function'
225
- ? options.monotonicNow
226
- : defaultDiagnosticMonotonicNow;
227
- const ttlMs = Math.max(
228
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
229
- Number(options.ttlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
230
- );
231
- const negativeTtlMs = Math.max(
232
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
233
- Number(options.negativeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS
234
- );
235
- const fallbackValue = options.fallbackValue;
236
- let initialized = false;
237
- let sampledAtMonotonicMs = 0;
238
- let generation = 0;
239
- let refreshCount = 0;
240
- let cacheHitCount = 0;
241
- let sharedReadCount = 0;
242
- let record = {
243
- ok: false,
244
- value: fallbackValue,
245
- error: 'not-requested'
246
- };
247
- let inFlight = null;
248
-
249
- const snapshot = () => {
250
- const now = Number(monotonicNow());
251
- const ageMs = initialized && Number.isFinite(now)
252
- ? Math.max(0, now - sampledAtMonotonicMs)
253
- : null;
254
- return {
255
- probeId,
256
- initialized,
257
- inFlight: Boolean(inFlight),
258
- ok: initialized ? record.ok === true : false,
259
- error: initialized ? normalizeString(record.error, 160) : 'not-requested',
260
- ttlMs,
261
- negativeTtlMs,
262
- ageMs,
263
- generation,
264
- refreshCount,
265
- cacheHitCount,
266
- sharedReadCount
267
- };
268
- };
269
-
270
- const read = () => {
271
- const now = Number(monotonicNow());
272
- const activeTtlMs = record.ok === true ? ttlMs : negativeTtlMs;
273
- if (initialized
274
- && Number.isFinite(now)
275
- && Math.max(0, now - sampledAtMonotonicMs) < activeTtlMs) {
276
- cacheHitCount += 1;
277
- return Promise.resolve({
278
- ...record,
279
- cached: true,
280
- shared: false,
281
- generation
282
- });
283
- }
284
- if (inFlight) {
285
- sharedReadCount += 1;
286
- return inFlight.then(result => ({ ...result, shared: true }));
287
- }
288
-
289
- inFlight = (async () => {
290
- let next;
291
- try {
292
- const candidate = await options.refresh();
293
- next = candidate && typeof candidate === 'object'
294
- ? {
295
- ok: candidate.ok === true,
296
- value: candidate.value === undefined ? fallbackValue : candidate.value,
297
- error: candidate.ok === true ? '' : normalizeString(candidate.error, 160) || 'probe-unavailable'
298
- }
299
- : {
300
- ok: false,
301
- value: fallbackValue,
302
- error: 'probe-invalid-result'
303
- };
304
- } catch (error) {
305
- next = {
306
- ok: false,
307
- value: fallbackValue,
308
- error: normalizeString(error?.message || error, 160) || 'probe-failed'
309
- };
310
- }
311
- record = next;
312
- initialized = true;
313
- generation += 1;
314
- refreshCount += 1;
315
- sampledAtMonotonicMs = Number(monotonicNow());
316
- return {
317
- ...record,
318
- cached: false,
319
- shared: false,
320
- generation
321
- };
322
- })().finally(() => {
323
- inFlight = null;
324
- });
325
- return inFlight;
326
- };
327
-
328
- return {
329
- read,
330
- getSnapshot: snapshot
331
- };
332
- }
333
-
334
- function isExecutablePath(path) {
335
- try {
336
- accessSync(path, fsConstants.X_OK);
337
- return true;
338
- } catch {
339
- return false;
340
- }
341
- }
342
-
343
- export function resolveNvidiaSmiCommand(options = {}) {
344
- const platform = normalizeString(options.platform || process.platform, 20);
345
- const executablePath = normalizeString(
346
- options.explicitPath ?? process.env.LIVEDESK_NVIDIA_SMI_PATH,
347
- 1000
348
- );
349
- const executableCheck = typeof options.isExecutable === 'function'
350
- ? options.isExecutable
351
- : isExecutablePath;
352
- if (platform === 'win32') {
353
- return executablePath || 'nvidia-smi.exe';
354
- }
355
-
356
- const pathValue = String(options.pathValue ?? process.env.PATH ?? '');
357
- const pathDelimiter = normalizeString(options.pathDelimiter, 4) || delimiter;
358
- const joinExecutablePath = platform === 'win32' ? join : posix.join;
359
- const candidates = [
360
- executablePath,
361
- ...pathValue
362
- .split(pathDelimiter)
363
- .map(directory => directory.trim())
364
- .filter(Boolean)
365
- .map(directory => joinExecutablePath(directory, 'nvidia-smi')),
366
- ...(platform === 'linux' ? ['/usr/bin/nvidia-smi', '/usr/local/bin/nvidia-smi'] : [])
367
- ].filter(Boolean);
368
- for (const candidate of [...new Set(candidates)]) {
369
- try {
370
- if (executableCheck(candidate) === true) return candidate;
371
- } catch {
372
- // An unreadable PATH entry is negative probe evidence.
373
- }
374
- }
375
- return '';
376
- }
377
-
378
- /**
379
- * Contract:
380
- * - Owns only ChildProcess objects spawned through this instance.
381
- * - Identity is ownerId + commandId + the immutable ChildProcess handle; a PID
382
- * is diagnostic evidence and is never rediscovered to authorize termination.
383
- * - A command deadline and owner close both abort, terminate, drain redirected
384
- * stdio, and wait for the exact child's terminal `close` event.
385
- * - Concurrent identical probes share the same command/output/terminal owner.
386
- * - Output, close, and active-ledger retention are bounded independently so an
387
- * inherited pipe can never keep the Client event loop alive without evidence.
388
- */
389
- export function createClientDiagnosticCommandOwner(options = {}) {
390
- const execFileImpl = typeof options.execFileImpl === 'function' ? options.execFileImpl : execFile;
391
- const ownerId = normalizeString(options.ownerId, 160)
392
- || `client-diagnostics-${process.pid}-${randomBytes(8).toString('hex')}`;
393
- const forceKillDelayMs = Math.max(
394
- 10,
395
- Number(options.forceKillDelayMs) || CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS
396
- );
397
- const commandDrainMs = Math.max(
398
- forceKillDelayMs + 10,
399
- Number(options.commandDrainMs) || CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS
400
- );
401
- const active = new Map();
402
- const activeByKey = new Map();
403
- let commandSequence = 0;
404
- let spawnedCount = 0;
405
- let joinedCount = 0;
406
- let timedOutCount = 0;
407
- let sharedRequestCount = 0;
408
- let closePromise = null;
409
- let closed = false;
410
-
411
- const snapshotRecord = record => ({
412
- ownerId,
413
- commandId: record.commandId,
414
- pid: record.pid,
415
- command: record.command,
416
- startedAt: record.startedAt,
417
- terminationReason: record.terminationReason || ''
418
- });
419
-
420
- const getSnapshot = () => ({
421
- ownerId,
422
- closed,
423
- spawnedCount,
424
- joinedCount,
425
- timedOutCount,
426
- sharedRequestCount,
427
- activeCount: active.size,
428
- redirectedStreamCount: [...active.values()].reduce(
429
- (count, record) => count + [record.child?.stdin, record.child?.stdout, record.child?.stderr]
430
- .filter(stream => stream && !stream.destroyed).length,
431
- 0
432
- ),
433
- active: [...active.values()].map(snapshotRecord)
434
- });
435
-
436
- const settleOutput = (record, output = '') => {
437
- if (record.outputSettled) return;
438
- record.outputSettled = true;
439
- record.resolveOutput(String(output || ''));
440
- };
441
-
442
- const markTerminal = record => {
443
- if (record.terminal) return;
444
- record.terminal = true;
445
- clearTimeout(record.commandTimer);
446
- clearTimeout(record.forceKillTimer);
447
- clearTimeout(record.outputDrainTimer);
448
- destroyChildRedirectedIo(record.child);
449
- active.delete(record.commandId);
450
- if (activeByKey.get(record.commandKey) === record) {
451
- activeByKey.delete(record.commandKey);
452
- }
453
- joinedCount += 1;
454
- settleOutput(record, record.error ? '' : record.stdout);
455
- record.resolveTerminal();
456
- };
457
-
458
- const terminateOwnedRecord = (record, reason) => {
459
- if (!record || record.terminal || record.terminationRequested) return;
460
- record.terminationRequested = true;
461
- record.terminationReason = normalizeString(reason, 80) || 'diagnostic-owner-close';
462
- if (record.terminationReason === 'command-timeout') timedOutCount += 1;
463
- clearTimeout(record.commandTimer);
464
- try { record.abortController.abort(new Error(record.terminationReason)); } catch { /* already aborted */ }
465
- try { record.child?.kill('SIGTERM'); } catch { /* exact child already exited */ }
466
- record.forceKillTimer = setTimeout(() => {
467
- if (record.terminal) return;
468
- try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
469
- // An inherited writer can keep execFile's callback pending even after the
470
- // root exits. Closing our exact pipe ends the Client-side handle owner.
471
- destroyChildRedirectedIo(record.child);
472
- }, forceKillDelayMs);
473
- record.forceKillTimer.unref?.();
474
- record.outputDrainTimer = setTimeout(() => {
475
- if (record.terminal) return;
476
- settleOutput(record, '');
477
- destroyChildRedirectedIo(record.child);
478
- record.child?.unref?.();
479
- }, commandDrainMs);
480
- record.outputDrainTimer.unref?.();
481
- };
482
-
483
- const capture = (command, args = [], timeout = 3500) => {
484
- if (closed) return Promise.resolve('');
485
- const normalizedArgs = Array.isArray(args) ? args : [];
486
- const commandKey = JSON.stringify([
487
- String(command || ''),
488
- ...normalizedArgs.map(value => String(value ?? ''))
489
- ]);
490
- const sharedRecord = activeByKey.get(commandKey);
491
- if (sharedRecord) {
492
- sharedRequestCount += 1;
493
- return sharedRecord.outputPromise;
494
- }
495
- const commandId = `${ownerId}:${++commandSequence}`;
496
- const abortController = new AbortController();
497
- let resolveOutput;
498
- let resolveTerminal;
499
- const outputPromise = new Promise(resolve => { resolveOutput = resolve; });
500
- const terminalPromise = new Promise(resolve => { resolveTerminal = resolve; });
501
- const record = {
502
- ownerId,
503
- commandId,
504
- commandKey,
505
- command: sanitizeDiagnosticCommandName(command),
506
- pid: 0,
507
- startedAt: new Date().toISOString(),
508
- child: null,
509
- abortController,
510
- resolveOutput,
511
- resolveTerminal,
512
- outputPromise,
513
- terminalPromise,
514
- stdout: '',
515
- error: null,
516
- outputSettled: false,
517
- terminal: false,
518
- terminationRequested: false,
519
- terminationReason: '',
520
- commandTimer: null,
521
- forceKillTimer: null,
522
- outputDrainTimer: null
523
- };
524
- active.set(commandId, record);
525
- activeByKey.set(commandKey, record);
526
-
527
- try {
528
- record.child = execFileImpl(command, normalizedArgs, {
529
- encoding: 'utf8',
530
- maxBuffer: 512 * 1024,
531
- windowsHide: true,
532
- signal: abortController.signal
533
- }, (error, stdout) => {
534
- record.error = error || null;
535
- record.stdout = error ? '' : String(stdout || '');
536
- settleOutput(record, record.stdout);
537
- });
538
- record.pid = Number(record.child?.pid || 0);
539
- spawnedCount += 1;
540
- record.child.once('close', () => markTerminal(record));
541
- record.child.once('error', error => {
542
- record.error = record.error || error;
543
- });
544
- const commandTimeoutMs = Math.max(10, Number(timeout) || 3500);
545
- record.commandTimer = setTimeout(
546
- () => terminateOwnedRecord(record, 'command-timeout'),
547
- commandTimeoutMs
548
- );
549
- record.commandTimer.unref?.();
550
- } catch (error) {
551
- record.error = error;
552
- settleOutput(record, '');
553
- markTerminal(record);
554
- }
555
-
556
- return outputPromise;
557
- };
558
-
559
- const close = (timeoutMs = CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS) => {
560
- if (closePromise) return closePromise;
561
- closed = true;
562
- const boundedTimeoutMs = Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS);
563
- closePromise = (async () => {
564
- const records = [...active.values()];
565
- for (const record of records) terminateOwnedRecord(record, 'diagnostic-owner-close');
566
- if (records.length > 0) {
567
- let deadlineTimer;
568
- await Promise.race([
569
- Promise.allSettled(records.map(record => record.terminalPromise)),
570
- new Promise(resolve => {
571
- deadlineTimer = setTimeout(resolve, boundedTimeoutMs);
572
- })
573
- ]);
574
- clearTimeout(deadlineTimer);
575
- }
576
- for (const record of active.values()) {
577
- settleOutput(record, '');
578
- try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
579
- destroyChildRedirectedIo(record.child);
580
- record.child?.unref?.();
581
- }
582
- const snapshot = getSnapshot();
583
- return {
584
- ok: snapshot.activeCount === 0 && snapshot.redirectedStreamCount === 0,
585
- ...snapshot
586
- };
587
- })();
588
- return closePromise;
589
- };
590
-
591
- return {
592
- ownerId,
593
- capture,
594
- close,
595
- getSnapshot
596
- };
597
- }
598
-
599
- export function parseNvidiaSmiOutput(output) {
600
- return String(output || '')
601
- .split(/\r?\n/)
602
- .map(line => line.trim())
603
- .filter(Boolean)
604
- .map(line => {
605
- const [name = '', usage = '', totalMemoryMiB = '', usedMemoryMiB = ''] = line.split(',').map(value => value.trim());
606
- const memoryTotalBytes = finiteNumber(totalMemoryMiB);
607
- const memoryUsedBytes = finiteNumber(usedMemoryMiB);
608
- return {
609
- name: normalizeString(name, 160),
610
- usagePercent: percentFromRatio(usage),
611
- memoryTotalBytes: memoryTotalBytes === null ? null : Math.round(memoryTotalBytes * 1024 ** 2),
612
- memoryUsedBytes: memoryUsedBytes === null ? null : Math.round(memoryUsedBytes * 1024 ** 2)
613
- };
614
- })
615
- .filter(gpu => gpu.name);
616
- }
617
-
618
- function parseCounter(value) {
619
- const number = Number(String(value || '').replaceAll(',', ''));
620
- return Number.isFinite(number) && number >= 0 ? number : 0;
621
- }
622
-
623
- export function parseWindowsNetworkOutput(output) {
624
- for (const line of String(output || '').split(/\r?\n/)) {
625
- const match = line.match(/(?:^|\s)(\d[\d,]*)\s+(\d[\d,]*)\s*$/);
626
- if (match) return { receivedBytes: parseCounter(match[1]), sentBytes: parseCounter(match[2]) };
627
- }
628
- return { receivedBytes: 0, sentBytes: 0 };
629
- }
630
-
631
- export function parseMacNetworkOutput(output) {
632
- const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
633
- const header = lines.find(line => /\bIbytes\b/i.test(line) && /\bObytes\b/i.test(line));
634
- if (!header) return { receivedBytes: 0, sentBytes: 0 };
635
- const columns = header.split(/\s+/);
636
- const receivedIndex = columns.findIndex(column => column.toLowerCase() === 'ibytes');
637
- const sentIndex = columns.findIndex(column => column.toLowerCase() === 'obytes');
638
- const byInterface = new Map();
639
- for (const line of lines.slice(lines.indexOf(header) + 1)) {
640
- const fields = line.split(/\s+/);
641
- const name = normalizeString(fields[0], 80);
642
- if (!name || name === 'lo0' || fields.length <= Math.max(receivedIndex, sentIndex)) continue;
643
- const receivedBytes = parseCounter(fields[receivedIndex]);
644
- const sentBytes = parseCounter(fields[sentIndex]);
645
- const previous = byInterface.get(name) || { receivedBytes: 0, sentBytes: 0 };
646
- byInterface.set(name, {
647
- receivedBytes: Math.max(previous.receivedBytes, receivedBytes),
648
- sentBytes: Math.max(previous.sentBytes, sentBytes)
649
- });
650
- }
651
- return [...byInterface.values()].reduce((total, current) => ({
652
- receivedBytes: total.receivedBytes + current.receivedBytes,
653
- sentBytes: total.sentBytes + current.sentBytes
654
- }), { receivedBytes: 0, sentBytes: 0 });
655
- }
656
-
657
- export function parseLinuxNetworkOutput(output) {
658
- let receivedBytes = 0;
659
- let sentBytes = 0;
660
- for (const line of String(output || '').split(/\r?\n/)) {
661
- const separator = line.indexOf(':');
662
- if (separator < 0) continue;
663
- const name = line.slice(0, separator).trim();
664
- if (!name || name === 'lo') continue;
665
- const fields = line.slice(separator + 1).trim().split(/\s+/);
666
- receivedBytes += parseCounter(fields[0]);
667
- sentBytes += parseCounter(fields[8]);
668
- }
669
- return { receivedBytes, sentBytes };
670
- }
671
-
672
- function activeNetworkInterfaces() {
673
- const interfaces = [];
674
- for (const [name, addresses] of Object.entries(os.networkInterfaces())) {
675
- for (const address of addresses || []) {
676
- if (address.internal) continue;
677
- interfaces.push({
678
- name: normalizeString(name, 80),
679
- address: normalizeString(address.address, 100),
680
- family: normalizeString(address.family, 20)
681
- });
682
- }
683
- }
684
- return interfaces;
685
- }
686
-
687
- async function readNetworkTotalsProbe(commandOwner, platform = process.platform) {
688
- if (platform === 'win32') {
689
- const output = await commandOwner.capture('netstat.exe', ['-e'], 2500);
690
- return String(output || '').trim()
691
- ? { ok: true, value: parseWindowsNetworkOutput(output) }
692
- : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
693
- }
694
- if (platform === 'darwin') {
695
- const output = await commandOwner.capture('netstat', ['-ibn'], 2500);
696
- return String(output || '').trim()
697
- ? { ok: true, value: parseMacNetworkOutput(output) }
698
- : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
699
- }
700
- try {
701
- return {
702
- ok: true,
703
- value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
704
- };
705
- } catch {
706
- return {
707
- ok: false,
708
- value: { receivedBytes: 0, sentBytes: 0 },
709
- error: 'network-counters-unavailable'
710
- };
711
- }
712
- }
713
-
714
- function normalizeGpu(value) {
715
- if (!value || typeof value !== 'object') return null;
716
- const name = normalizeString(value.name, 160);
717
- if (!name) return null;
718
- return {
719
- name,
720
- usagePercent: percentFromRatio(value.usagePercent),
721
- memoryTotalBytes: finiteNumber(value.memoryTotalBytes),
722
- memoryUsedBytes: finiteNumber(value.memoryUsedBytes)
723
- };
724
- }
725
-
726
- export function normalizeGpuIdentityName(value) {
727
- return normalizeString(value, 160)
728
- .normalize('NFKC')
729
- .replace(/\((?:r|tm)\)/giu, '')
730
- .replace(/[®™]/gu, '')
731
- .toLocaleLowerCase('en-US')
732
- .replace(/[^\p{L}\p{N}]+/gu, ' ')
733
- .trim();
734
- }
735
-
736
- export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
737
- const merged = [];
738
- const indexesByIdentity = new Map();
739
- for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
740
- const gpu = normalizeGpu(value);
741
- const identity = normalizeGpuIdentityName(gpu?.name);
742
- if (!gpu || !identity) continue;
743
- const index = merged.length;
744
- merged.push(gpu);
745
- const indexes = indexesByIdentity.get(identity) || [];
746
- indexes.push(index);
747
- indexesByIdentity.set(identity, indexes);
748
- }
749
-
750
- const matchedIndexes = new Set();
751
- for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
752
- const gpu = normalizeGpu(value);
753
- const identity = normalizeGpuIdentityName(gpu?.name);
754
- if (!gpu || !identity) continue;
755
- const existingIndex = (indexesByIdentity.get(identity) || [])
756
- .find(index => !matchedIndexes.has(index));
757
- if (existingIndex === undefined) {
758
- merged.push(gpu);
759
- continue;
760
- }
761
- matchedIndexes.add(existingIndex);
762
- const existing = merged[existingIndex];
763
- merged[existingIndex] = {
764
- ...existing,
765
- usagePercent: gpu.usagePercent ?? existing.usagePercent,
766
- memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
767
- memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
768
- };
769
- }
770
- return merged;
771
- }
772
-
773
- function normalizeDisk(value) {
774
- if (!value || typeof value !== 'object') return null;
775
- const mount = normalizeString(value.mount, 80);
776
- const totalBytes = finiteNumber(value.totalBytes);
777
- const freeBytes = finiteNumber(value.freeBytes);
778
- if (!mount || totalBytes === null || totalBytes <= 0 || freeBytes === null) return null;
779
- const boundedFreeBytes = Math.min(totalBytes, freeBytes);
780
- const usedBytes = Math.max(0, totalBytes - boundedFreeBytes);
781
- return {
782
- name: normalizeString(value.name, 120),
783
- mount,
784
- totalBytes,
785
- freeBytes: boundedFreeBytes,
786
- usedBytes,
787
- usedPercent: Math.round((usedBytes / totalBytes) * 1000) / 10
788
- };
789
- }
790
-
791
- export function parseWindowsHardwareOutput(output) {
792
- try {
793
- const payload = JSON.parse(String(output || '').trim());
794
- const rawGpus = Array.isArray(payload?.gpus) ? payload.gpus : payload?.gpus ? [payload.gpus] : [];
795
- const rawDisks = Array.isArray(payload?.disks) ? payload.disks : payload?.disks ? [payload.disks] : [];
796
- return {
797
- gpus: rawGpus.map(normalizeGpu).filter(Boolean),
798
- disks: rawDisks.map(normalizeDisk).filter(Boolean)
799
- };
800
- } catch {
801
- return { gpus: [], disks: [] };
802
- }
803
- }
804
-
805
- function readRootDiskSnapshot() {
806
- try {
807
- const mount = parse(os.homedir()).root || '/';
808
- const stats = statfsSync(mount, { bigint: true });
809
- return normalizeDisk({
810
- name: process.platform === 'darwin' ? 'Macintosh HD' : 'System',
811
- mount,
812
- totalBytes: Number(stats.bsize * stats.blocks),
813
- freeBytes: Number(stats.bsize * stats.bavail)
814
- });
815
- } catch {
816
- return null;
817
- }
818
- }
819
-
820
- function readFilesystemDiskSnapshot(mount, name) {
821
- try {
822
- const stats = statfsSync(mount, { bigint: true });
823
- return normalizeDisk({
824
- name,
825
- mount,
826
- totalBytes: Number(stats.bsize * stats.blocks),
827
- freeBytes: Number(stats.bsize * stats.bavail)
828
- });
829
- } catch {
830
- return null;
831
- }
832
- }
833
-
834
- export function readMacDiskSnapshots(options = {}) {
835
- const rootMount = String(options.rootMount || '/');
836
- const volumesRoot = String(options.volumesRoot || '/Volumes');
837
- const disks = [];
838
- const seenPaths = new Set();
839
- const addDisk = (mount, name) => {
840
- try {
841
- const resolvedPath = realpathSync(mount);
842
- if (seenPaths.has(resolvedPath)) return;
843
- const disk = readFilesystemDiskSnapshot(mount, name);
844
- if (!disk) return;
845
- seenPaths.add(resolvedPath);
846
- disks.push(disk);
847
- } catch {
848
- // A removable volume may disappear while the snapshot is being read.
849
- }
850
- };
851
-
852
- addDisk(rootMount, 'Macintosh HD');
853
- try {
854
- for (const entry of readdirSync(volumesRoot, { withFileTypes: true })) {
855
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
856
- addDisk(join(volumesRoot, entry.name), entry.name);
857
- }
858
- } catch {
859
- // /Volumes can be unavailable in restricted environments; keep the root disk.
860
- }
861
- return disks;
862
- }
863
-
864
- async function readWindowsHardwareSnapshot(commandOwner) {
865
- const script = [
866
- '$ErrorActionPreference="Stop"',
867
- '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
868
- '$physicalDriveIds=@(Get-CimInstance Win32_DiskDrive | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_DiskDriveToDiskPartition | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_LogicalDiskToPartition } } | Select-Object -ExpandProperty DeviceID -Unique)',
869
- '$gpus=@(Get-CimInstance Win32_VideoController | Where-Object { $_.Name } | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; usagePercent=$null; memoryTotalBytes=if ($_.AdapterRAM) { [double]$_.AdapterRAM } else { $null }; memoryUsedBytes=$null } })',
870
- '$disks=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $physicalDriveIds -contains $_.DeviceID } | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{ name=[string]$_.VolumeName; mount=[string]$_.DeviceID; totalBytes=[double]$_.Size; freeBytes=[double]$_.FreeSpace } })',
871
- '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
872
- ].join('; ');
873
- return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
874
- }
875
-
876
- function parseMacGpuOutput(output) {
877
- try {
878
- const payload = JSON.parse(String(output || '').trim());
879
- return (Array.isArray(payload?.SPDisplaysDataType) ? payload.SPDisplaysDataType : [])
880
- .map(item => normalizeGpu({ name: item?.sppci_model || item?._name }))
881
- .filter(Boolean);
882
- } catch {
883
- return [];
884
- }
885
- }
886
-
887
- async function collectPlatformHardwareProbe(commandOwner, platform = process.platform) {
888
- if (platform === 'win32') {
889
- const snapshot = await readWindowsHardwareSnapshot(commandOwner);
890
- return snapshot.gpus.length > 0 || snapshot.disks.length > 0
891
- ? { ok: true, value: snapshot }
892
- : { ok: false, value: snapshot, error: 'windows-hardware-unavailable' };
893
- }
894
- if (platform === 'darwin') {
895
- const output = await commandOwner.capture('system_profiler', ['SPDisplaysDataType', '-json'], 5000);
896
- const snapshot = {
897
- gpus: parseMacGpuOutput(output),
898
- disks: readMacDiskSnapshots()
899
- };
900
- return String(output || '').trim()
901
- ? { ok: true, value: snapshot }
902
- : { ok: false, value: snapshot, error: 'system-profiler-unavailable' };
903
- }
904
- const disk = readRootDiskSnapshot();
905
- return {
906
- ok: Boolean(disk),
907
- value: { gpus: [], disks: disk ? [disk] : [] },
908
- error: disk ? '' : 'root-disk-unavailable'
909
- };
910
- }
911
-
912
- async function collectNvidiaGpuProbe(commandOwner, platform = process.platform) {
913
- const command = resolveNvidiaSmiCommand({ platform });
914
- if (!command) {
915
- return { ok: false, value: [], error: 'nvidia-smi-unavailable' };
916
- }
917
- const output = await commandOwner.capture(
918
- command,
919
- ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
920
- 3000
921
- );
922
- const gpus = parseNvidiaSmiOutput(output);
923
- return gpus.length > 0
924
- ? { ok: true, value: gpus }
925
- : { ok: false, value: [], error: 'nvidia-smi-empty-or-failed' };
926
- }
927
-
928
- function normalizeOrigin(value) {
929
- const candidate = normalizeString(value, 300);
930
- if (!candidate) return '';
931
- try {
932
- const parsed = new URL(candidate);
933
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
934
- return parsed.origin.toLowerCase();
935
- } catch {
936
- return '';
937
- }
938
- }
939
-
940
- function createTrustedWebOrigins(port) {
941
- const origins = new Set([
942
- ...DEFAULT_TRUSTED_WEB_ORIGINS,
943
- `http://127.0.0.1:${port}`,
944
- `http://localhost:${port}`
945
- ]);
946
- for (const value of [process.env.LIVEDESK_WEB_ORIGINS, process.env.LIVEDESK_SITE_URL]) {
947
- for (const candidate of String(value || '').split(/[\s,]+/)) {
948
- const origin = normalizeOrigin(candidate);
949
- if (origin) origins.add(origin);
950
- }
951
- }
952
- return origins;
953
- }
954
-
955
- function readSavedSession() {
956
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') return null;
957
- try {
7
+ import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../../runtime-core/src/os-secret-store.js';
9
+
10
+ const DEFAULT_HOST = '127.0.0.1';
11
+ const DEFAULT_PORT = 5179;
12
+ const CLIENT_RUNTIME_BIND_TIMEOUT_MS = 10_000;
13
+ const CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS = 150;
14
+ const CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS = 750;
15
+ const CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS = 1_500;
16
+ const CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS = 15_000;
17
+ const CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS = 60_000;
18
+ const CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS = 300_000;
19
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
20
+ const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co';
21
+ const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
22
+ const CLIENT_STATE_DIR = process.env.LIVEDESK_STATE_DIR || (process.env.LIVEDESK_UNIFIED_RUNTIME === '1' ? join(os.homedir(), '.livedesk') : join(os.homedir(), '.livedesk-client'));
23
+ const CLIENT_AUTH_PATH = join(CLIENT_STATE_DIR, 'auth.json');
24
+ const CLIENT_REFRESH_SECRET_STORE = createOsSecretStore({
25
+ service: 'LiveDesk',
26
+ account: 'client-refresh-token',
27
+ dataDir: CLIENT_STATE_DIR
28
+ });
29
+ const DEFAULT_TRUSTED_WEB_ORIGINS = Object.freeze([
30
+ 'https://livedesk.pages.dev',
31
+ 'http://127.0.0.1:5173',
32
+ 'http://localhost:5173',
33
+ 'http://127.0.0.1:4173',
34
+ 'http://localhost:4173'
35
+ ]);
36
+ const LIVE_DESK_PAGES_PREVIEW_HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.livedesk\.pages\.dev$/;
37
+ const READ_ONLY_CLIENT_API_PATHS = new Set([
38
+ '/api/health',
39
+ '/api/runtime',
40
+ '/api/runtime/status',
41
+ '/api/runtime/events',
42
+ '/api/client/status',
43
+ '/api/client/computer',
44
+ '/api/client/system',
45
+ '/api/client/permissions',
46
+ '/api/client/diagnostics',
47
+ '/api/auth/status'
48
+ ]);
49
+
50
+ function normalizeString(value, maxLength = 1024) {
51
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
52
+ }
53
+
54
+ function normalizeAccountAvatarUrl(value) {
55
+ const candidate = normalizeString(value, 2048);
56
+ if (!candidate) return '';
57
+ try {
58
+ const url = new URL(candidate);
59
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : '';
60
+ } catch {
61
+ return '';
62
+ }
63
+ }
64
+
65
+ function readAccessTokenProfile(accessToken) {
66
+ const token = normalizeString(accessToken, 8192);
67
+ const payloadSegment = token.split('.')[1] || '';
68
+ if (!payloadSegment) return {};
69
+ try {
70
+ const payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
71
+ return payload && typeof payload === 'object' ? payload : {};
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+
77
+ function readClientAccountProfile(sessionOrUser) {
78
+ const tokenProfile = readAccessTokenProfile(sessionOrUser?.access_token);
79
+ const user = sessionOrUser?.user && typeof sessionOrUser.user === 'object'
80
+ ? sessionOrUser.user
81
+ : sessionOrUser && typeof sessionOrUser === 'object'
82
+ ? sessionOrUser
83
+ : {};
84
+ const metadata = user.user_metadata && typeof user.user_metadata === 'object'
85
+ ? user.user_metadata
86
+ : {};
87
+ const tokenMetadata = tokenProfile.user_metadata && typeof tokenProfile.user_metadata === 'object'
88
+ ? tokenProfile.user_metadata
89
+ : {};
90
+ const email = normalizeString(user.email || tokenProfile.email, 320);
91
+ const name = normalizeString(
92
+ metadata.full_name
93
+ || metadata.name
94
+ || metadata.preferred_username
95
+ || tokenMetadata.full_name
96
+ || tokenMetadata.name
97
+ || tokenMetadata.preferred_username
98
+ || user.name
99
+ || tokenProfile.name
100
+ || email
101
+ || user.id,
102
+ 160
103
+ );
104
+ const avatarUrl = normalizeAccountAvatarUrl(
105
+ metadata.avatar_url
106
+ || metadata.picture
107
+ || tokenMetadata.avatar_url
108
+ || tokenMetadata.picture
109
+ || user.avatar_url
110
+ || user.picture
111
+ );
112
+ return { email, name, avatarUrl };
113
+ }
114
+
115
+ function mergeClientAccountProfile(session, sourceUser) {
116
+ const profile = readClientAccountProfile(sourceUser);
117
+ const existingUserMetadata = session?.user?.user_metadata && typeof session.user.user_metadata === 'object'
118
+ ? session.user.user_metadata
119
+ : {};
120
+ const userMetadata = {
121
+ ...existingUserMetadata,
122
+ ...(profile.name ? { full_name: profile.name } : {}),
123
+ ...(profile.avatarUrl ? { avatar_url: profile.avatarUrl } : {})
124
+ };
125
+ return {
126
+ ...session,
127
+ user: {
128
+ ...(session?.user || {}),
129
+ ...(Object.keys(userMetadata).length > 0 ? { user_metadata: userMetadata } : {})
130
+ }
131
+ };
132
+ }
133
+
134
+ async function hydrateClientAccountProfile(session) {
135
+ const accessToken = normalizeString(session?.access_token, 8192);
136
+ if (!accessToken) return session;
137
+ try {
138
+ const response = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
139
+ headers: {
140
+ apikey: SUPABASE_PUBLISHABLE_KEY,
141
+ Authorization: `Bearer ${accessToken}`,
142
+ Accept: 'application/json'
143
+ },
144
+ signal: AbortSignal.timeout(5_000)
145
+ });
146
+ if (!response.ok) return session;
147
+ const verifiedUser = await response.json().catch(() => null);
148
+ return verifiedUser ? mergeClientAccountProfile(session, verifiedUser) : session;
149
+ } catch {
150
+ return session;
151
+ }
152
+ }
153
+
154
+ function normalizePort(value, fallback = DEFAULT_PORT) {
155
+ const port = Number(value);
156
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
157
+ }
158
+
159
+ function normalizeSlotNumber(value) {
160
+ const slotNumber = Number(String(value ?? '').trim());
161
+ return Number.isInteger(slotNumber) && slotNumber >= 1 && slotNumber <= 999
162
+ ? slotNumber
163
+ : 0;
164
+ }
165
+
166
+ function normalizeRoleVersion(value) {
167
+ const roleVersion = Number(String(value ?? '').trim());
168
+ return Number.isInteger(roleVersion) && roleVersion >= 0 ? roleVersion : 0;
169
+ }
170
+
171
+ function readCpuTotals() {
172
+ return os.cpus().reduce((totals, cpu) => {
173
+ const times = Object.values(cpu.times || {}).map(Number);
174
+ totals.idle += Number(cpu.times?.idle || 0);
175
+ totals.total += times.reduce((sum, value) => sum + value, 0);
176
+ return totals;
177
+ }, { idle: 0, total: 0 });
178
+ }
179
+
180
+ function finiteNumber(value) {
181
+ if (value === null
182
+ || value === undefined
183
+ || typeof value === 'boolean'
184
+ || (typeof value === 'string' && value.trim() === '')) {
185
+ return null;
186
+ }
187
+ const number = Number(value);
188
+ return Number.isFinite(number) && number >= 0 ? number : null;
189
+ }
190
+
191
+ function percentFromRatio(value) {
192
+ const number = finiteNumber(value);
193
+ if (number === null) return null;
194
+ return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
195
+ }
196
+
197
+ function destroyChildRedirectedIo(child) {
198
+ for (const stream of [child?.stdin, child?.stdout, child?.stderr]) {
199
+ try { stream?.destroy(); } catch { /* an exact owned stream already closed */ }
200
+ }
201
+ }
202
+
203
+ function sanitizeDiagnosticCommandName(value) {
204
+ return normalizeString(value, 200) || 'unknown-diagnostic-command';
205
+ }
206
+
207
+ function defaultDiagnosticMonotonicNow() {
208
+ return Number(process.hrtime.bigint() / 1_000_000n);
209
+ }
210
+
211
+ /**
212
+ * Contract:
213
+ * - A probe is idle until an explicit diagnostic request calls read().
214
+ * - Every successful, unavailable, empty, or failed result is retained for at
215
+ * least 15 monotonic seconds measured from refresh completion.
216
+ * - Concurrent readers share one refresh promise. A failed tool therefore
217
+ * cannot respawn once per status heartbeat.
218
+ */
219
+ export function createClientDiagnosticProbeCache(options = {}) {
220
+ if (typeof options.refresh !== 'function') {
221
+ throw new TypeError('client-diagnostic-probe-refresh-required');
222
+ }
223
+ const probeId = normalizeString(options.probeId, 80) || 'client-diagnostic-probe';
224
+ const monotonicNow = typeof options.monotonicNow === 'function'
225
+ ? options.monotonicNow
226
+ : defaultDiagnosticMonotonicNow;
227
+ const ttlMs = Math.max(
228
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
229
+ Number(options.ttlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
230
+ );
231
+ const negativeTtlMs = Math.max(
232
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
233
+ Number(options.negativeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS
234
+ );
235
+ const fallbackValue = options.fallbackValue;
236
+ let initialized = false;
237
+ let sampledAtMonotonicMs = 0;
238
+ let generation = 0;
239
+ let refreshCount = 0;
240
+ let cacheHitCount = 0;
241
+ let sharedReadCount = 0;
242
+ let record = {
243
+ ok: false,
244
+ value: fallbackValue,
245
+ error: 'not-requested'
246
+ };
247
+ let inFlight = null;
248
+
249
+ const snapshot = () => {
250
+ const now = Number(monotonicNow());
251
+ const ageMs = initialized && Number.isFinite(now)
252
+ ? Math.max(0, now - sampledAtMonotonicMs)
253
+ : null;
254
+ return {
255
+ probeId,
256
+ initialized,
257
+ inFlight: Boolean(inFlight),
258
+ ok: initialized ? record.ok === true : false,
259
+ error: initialized ? normalizeString(record.error, 160) : 'not-requested',
260
+ ttlMs,
261
+ negativeTtlMs,
262
+ ageMs,
263
+ generation,
264
+ refreshCount,
265
+ cacheHitCount,
266
+ sharedReadCount
267
+ };
268
+ };
269
+
270
+ const read = () => {
271
+ const now = Number(monotonicNow());
272
+ const activeTtlMs = record.ok === true ? ttlMs : negativeTtlMs;
273
+ if (initialized
274
+ && Number.isFinite(now)
275
+ && Math.max(0, now - sampledAtMonotonicMs) < activeTtlMs) {
276
+ cacheHitCount += 1;
277
+ return Promise.resolve({
278
+ ...record,
279
+ cached: true,
280
+ shared: false,
281
+ generation
282
+ });
283
+ }
284
+ if (inFlight) {
285
+ sharedReadCount += 1;
286
+ return inFlight.then(result => ({ ...result, shared: true }));
287
+ }
288
+
289
+ inFlight = (async () => {
290
+ let next;
291
+ try {
292
+ const candidate = await options.refresh();
293
+ next = candidate && typeof candidate === 'object'
294
+ ? {
295
+ ok: candidate.ok === true,
296
+ value: candidate.value === undefined ? fallbackValue : candidate.value,
297
+ error: candidate.ok === true ? '' : normalizeString(candidate.error, 160) || 'probe-unavailable'
298
+ }
299
+ : {
300
+ ok: false,
301
+ value: fallbackValue,
302
+ error: 'probe-invalid-result'
303
+ };
304
+ } catch (error) {
305
+ next = {
306
+ ok: false,
307
+ value: fallbackValue,
308
+ error: normalizeString(error?.message || error, 160) || 'probe-failed'
309
+ };
310
+ }
311
+ record = next;
312
+ initialized = true;
313
+ generation += 1;
314
+ refreshCount += 1;
315
+ sampledAtMonotonicMs = Number(monotonicNow());
316
+ return {
317
+ ...record,
318
+ cached: false,
319
+ shared: false,
320
+ generation
321
+ };
322
+ })().finally(() => {
323
+ inFlight = null;
324
+ });
325
+ return inFlight;
326
+ };
327
+
328
+ return {
329
+ read,
330
+ getSnapshot: snapshot
331
+ };
332
+ }
333
+
334
+ function isExecutablePath(path) {
335
+ try {
336
+ accessSync(path, fsConstants.X_OK);
337
+ return true;
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ export function resolveNvidiaSmiCommand(options = {}) {
344
+ const platform = normalizeString(options.platform || process.platform, 20);
345
+ const executablePath = normalizeString(
346
+ options.explicitPath ?? process.env.LIVEDESK_NVIDIA_SMI_PATH,
347
+ 1000
348
+ );
349
+ const executableCheck = typeof options.isExecutable === 'function'
350
+ ? options.isExecutable
351
+ : isExecutablePath;
352
+ if (platform === 'win32') {
353
+ return executablePath || 'nvidia-smi.exe';
354
+ }
355
+
356
+ const pathValue = String(options.pathValue ?? process.env.PATH ?? '');
357
+ const pathDelimiter = normalizeString(options.pathDelimiter, 4) || delimiter;
358
+ const joinExecutablePath = platform === 'win32' ? join : posix.join;
359
+ const candidates = [
360
+ executablePath,
361
+ ...pathValue
362
+ .split(pathDelimiter)
363
+ .map(directory => directory.trim())
364
+ .filter(Boolean)
365
+ .map(directory => joinExecutablePath(directory, 'nvidia-smi')),
366
+ ...(platform === 'linux' ? ['/usr/bin/nvidia-smi', '/usr/local/bin/nvidia-smi'] : [])
367
+ ].filter(Boolean);
368
+ for (const candidate of [...new Set(candidates)]) {
369
+ try {
370
+ if (executableCheck(candidate) === true) return candidate;
371
+ } catch {
372
+ // An unreadable PATH entry is negative probe evidence.
373
+ }
374
+ }
375
+ return '';
376
+ }
377
+
378
+ /**
379
+ * Contract:
380
+ * - Owns only ChildProcess objects spawned through this instance.
381
+ * - Identity is ownerId + commandId + the immutable ChildProcess handle; a PID
382
+ * is diagnostic evidence and is never rediscovered to authorize termination.
383
+ * - A command deadline and owner close both abort, terminate, drain redirected
384
+ * stdio, and wait for the exact child's terminal `close` event.
385
+ * - Concurrent identical probes share the same command/output/terminal owner.
386
+ * - Output, close, and active-ledger retention are bounded independently so an
387
+ * inherited pipe can never keep the Client event loop alive without evidence.
388
+ */
389
+ export function createClientDiagnosticCommandOwner(options = {}) {
390
+ const execFileImpl = typeof options.execFileImpl === 'function' ? options.execFileImpl : execFile;
391
+ const ownerId = normalizeString(options.ownerId, 160)
392
+ || `client-diagnostics-${process.pid}-${randomBytes(8).toString('hex')}`;
393
+ const forceKillDelayMs = Math.max(
394
+ 10,
395
+ Number(options.forceKillDelayMs) || CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS
396
+ );
397
+ const commandDrainMs = Math.max(
398
+ forceKillDelayMs + 10,
399
+ Number(options.commandDrainMs) || CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS
400
+ );
401
+ const active = new Map();
402
+ const activeByKey = new Map();
403
+ let commandSequence = 0;
404
+ let spawnedCount = 0;
405
+ let joinedCount = 0;
406
+ let timedOutCount = 0;
407
+ let sharedRequestCount = 0;
408
+ let closePromise = null;
409
+ let closed = false;
410
+
411
+ const snapshotRecord = record => ({
412
+ ownerId,
413
+ commandId: record.commandId,
414
+ pid: record.pid,
415
+ command: record.command,
416
+ startedAt: record.startedAt,
417
+ terminationReason: record.terminationReason || ''
418
+ });
419
+
420
+ const getSnapshot = () => ({
421
+ ownerId,
422
+ closed,
423
+ spawnedCount,
424
+ joinedCount,
425
+ timedOutCount,
426
+ sharedRequestCount,
427
+ activeCount: active.size,
428
+ redirectedStreamCount: [...active.values()].reduce(
429
+ (count, record) => count + [record.child?.stdin, record.child?.stdout, record.child?.stderr]
430
+ .filter(stream => stream && !stream.destroyed).length,
431
+ 0
432
+ ),
433
+ active: [...active.values()].map(snapshotRecord)
434
+ });
435
+
436
+ const settleOutput = (record, output = '') => {
437
+ if (record.outputSettled) return;
438
+ record.outputSettled = true;
439
+ record.resolveOutput(String(output || ''));
440
+ };
441
+
442
+ const markTerminal = record => {
443
+ if (record.terminal) return;
444
+ record.terminal = true;
445
+ clearTimeout(record.commandTimer);
446
+ clearTimeout(record.forceKillTimer);
447
+ clearTimeout(record.outputDrainTimer);
448
+ destroyChildRedirectedIo(record.child);
449
+ active.delete(record.commandId);
450
+ if (activeByKey.get(record.commandKey) === record) {
451
+ activeByKey.delete(record.commandKey);
452
+ }
453
+ joinedCount += 1;
454
+ settleOutput(record, record.error ? '' : record.stdout);
455
+ record.resolveTerminal();
456
+ };
457
+
458
+ const terminateOwnedRecord = (record, reason) => {
459
+ if (!record || record.terminal || record.terminationRequested) return;
460
+ record.terminationRequested = true;
461
+ record.terminationReason = normalizeString(reason, 80) || 'diagnostic-owner-close';
462
+ if (record.terminationReason === 'command-timeout') timedOutCount += 1;
463
+ clearTimeout(record.commandTimer);
464
+ try { record.abortController.abort(new Error(record.terminationReason)); } catch { /* already aborted */ }
465
+ try { record.child?.kill('SIGTERM'); } catch { /* exact child already exited */ }
466
+ record.forceKillTimer = setTimeout(() => {
467
+ if (record.terminal) return;
468
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
469
+ // An inherited writer can keep execFile's callback pending even after the
470
+ // root exits. Closing our exact pipe ends the Client-side handle owner.
471
+ destroyChildRedirectedIo(record.child);
472
+ }, forceKillDelayMs);
473
+ record.forceKillTimer.unref?.();
474
+ record.outputDrainTimer = setTimeout(() => {
475
+ if (record.terminal) return;
476
+ settleOutput(record, '');
477
+ destroyChildRedirectedIo(record.child);
478
+ record.child?.unref?.();
479
+ }, commandDrainMs);
480
+ record.outputDrainTimer.unref?.();
481
+ };
482
+
483
+ const capture = (command, args = [], timeout = 3500) => {
484
+ if (closed) return Promise.resolve('');
485
+ const normalizedArgs = Array.isArray(args) ? args : [];
486
+ const commandKey = JSON.stringify([
487
+ String(command || ''),
488
+ ...normalizedArgs.map(value => String(value ?? ''))
489
+ ]);
490
+ const sharedRecord = activeByKey.get(commandKey);
491
+ if (sharedRecord) {
492
+ sharedRequestCount += 1;
493
+ return sharedRecord.outputPromise;
494
+ }
495
+ const commandId = `${ownerId}:${++commandSequence}`;
496
+ const abortController = new AbortController();
497
+ let resolveOutput;
498
+ let resolveTerminal;
499
+ const outputPromise = new Promise(resolve => { resolveOutput = resolve; });
500
+ const terminalPromise = new Promise(resolve => { resolveTerminal = resolve; });
501
+ const record = {
502
+ ownerId,
503
+ commandId,
504
+ commandKey,
505
+ command: sanitizeDiagnosticCommandName(command),
506
+ pid: 0,
507
+ startedAt: new Date().toISOString(),
508
+ child: null,
509
+ abortController,
510
+ resolveOutput,
511
+ resolveTerminal,
512
+ outputPromise,
513
+ terminalPromise,
514
+ stdout: '',
515
+ error: null,
516
+ outputSettled: false,
517
+ terminal: false,
518
+ terminationRequested: false,
519
+ terminationReason: '',
520
+ commandTimer: null,
521
+ forceKillTimer: null,
522
+ outputDrainTimer: null
523
+ };
524
+ active.set(commandId, record);
525
+ activeByKey.set(commandKey, record);
526
+
527
+ try {
528
+ record.child = execFileImpl(command, normalizedArgs, {
529
+ encoding: 'utf8',
530
+ maxBuffer: 512 * 1024,
531
+ windowsHide: true,
532
+ signal: abortController.signal
533
+ }, (error, stdout) => {
534
+ record.error = error || null;
535
+ record.stdout = error ? '' : String(stdout || '');
536
+ settleOutput(record, record.stdout);
537
+ });
538
+ record.pid = Number(record.child?.pid || 0);
539
+ spawnedCount += 1;
540
+ record.child.once('close', () => markTerminal(record));
541
+ record.child.once('error', error => {
542
+ record.error = record.error || error;
543
+ });
544
+ const commandTimeoutMs = Math.max(10, Number(timeout) || 3500);
545
+ record.commandTimer = setTimeout(
546
+ () => terminateOwnedRecord(record, 'command-timeout'),
547
+ commandTimeoutMs
548
+ );
549
+ record.commandTimer.unref?.();
550
+ } catch (error) {
551
+ record.error = error;
552
+ settleOutput(record, '');
553
+ markTerminal(record);
554
+ }
555
+
556
+ return outputPromise;
557
+ };
558
+
559
+ const close = (timeoutMs = CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS) => {
560
+ if (closePromise) return closePromise;
561
+ closed = true;
562
+ const boundedTimeoutMs = Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS);
563
+ closePromise = (async () => {
564
+ const records = [...active.values()];
565
+ for (const record of records) terminateOwnedRecord(record, 'diagnostic-owner-close');
566
+ if (records.length > 0) {
567
+ let deadlineTimer;
568
+ await Promise.race([
569
+ Promise.allSettled(records.map(record => record.terminalPromise)),
570
+ new Promise(resolve => {
571
+ deadlineTimer = setTimeout(resolve, boundedTimeoutMs);
572
+ })
573
+ ]);
574
+ clearTimeout(deadlineTimer);
575
+ }
576
+ for (const record of active.values()) {
577
+ settleOutput(record, '');
578
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
579
+ destroyChildRedirectedIo(record.child);
580
+ record.child?.unref?.();
581
+ }
582
+ const snapshot = getSnapshot();
583
+ return {
584
+ ok: snapshot.activeCount === 0 && snapshot.redirectedStreamCount === 0,
585
+ ...snapshot
586
+ };
587
+ })();
588
+ return closePromise;
589
+ };
590
+
591
+ return {
592
+ ownerId,
593
+ capture,
594
+ close,
595
+ getSnapshot
596
+ };
597
+ }
598
+
599
+ export function parseNvidiaSmiOutput(output) {
600
+ return String(output || '')
601
+ .split(/\r?\n/)
602
+ .map(line => line.trim())
603
+ .filter(Boolean)
604
+ .map(line => {
605
+ const [name = '', usage = '', totalMemoryMiB = '', usedMemoryMiB = ''] = line.split(',').map(value => value.trim());
606
+ const memoryTotalBytes = finiteNumber(totalMemoryMiB);
607
+ const memoryUsedBytes = finiteNumber(usedMemoryMiB);
608
+ return {
609
+ name: normalizeString(name, 160),
610
+ usagePercent: percentFromRatio(usage),
611
+ memoryTotalBytes: memoryTotalBytes === null ? null : Math.round(memoryTotalBytes * 1024 ** 2),
612
+ memoryUsedBytes: memoryUsedBytes === null ? null : Math.round(memoryUsedBytes * 1024 ** 2)
613
+ };
614
+ })
615
+ .filter(gpu => gpu.name);
616
+ }
617
+
618
+ function parseCounter(value) {
619
+ const number = Number(String(value || '').replaceAll(',', ''));
620
+ return Number.isFinite(number) && number >= 0 ? number : 0;
621
+ }
622
+
623
+ export function parseWindowsNetworkOutput(output) {
624
+ for (const line of String(output || '').split(/\r?\n/)) {
625
+ const match = line.match(/(?:^|\s)(\d[\d,]*)\s+(\d[\d,]*)\s*$/);
626
+ if (match) return { receivedBytes: parseCounter(match[1]), sentBytes: parseCounter(match[2]) };
627
+ }
628
+ return { receivedBytes: 0, sentBytes: 0 };
629
+ }
630
+
631
+ export function parseMacNetworkOutput(output) {
632
+ const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
633
+ const header = lines.find(line => /\bIbytes\b/i.test(line) && /\bObytes\b/i.test(line));
634
+ if (!header) return { receivedBytes: 0, sentBytes: 0 };
635
+ const columns = header.split(/\s+/);
636
+ const receivedIndex = columns.findIndex(column => column.toLowerCase() === 'ibytes');
637
+ const sentIndex = columns.findIndex(column => column.toLowerCase() === 'obytes');
638
+ const byInterface = new Map();
639
+ for (const line of lines.slice(lines.indexOf(header) + 1)) {
640
+ const fields = line.split(/\s+/);
641
+ const name = normalizeString(fields[0], 80);
642
+ if (!name || name === 'lo0' || fields.length <= Math.max(receivedIndex, sentIndex)) continue;
643
+ const receivedBytes = parseCounter(fields[receivedIndex]);
644
+ const sentBytes = parseCounter(fields[sentIndex]);
645
+ const previous = byInterface.get(name) || { receivedBytes: 0, sentBytes: 0 };
646
+ byInterface.set(name, {
647
+ receivedBytes: Math.max(previous.receivedBytes, receivedBytes),
648
+ sentBytes: Math.max(previous.sentBytes, sentBytes)
649
+ });
650
+ }
651
+ return [...byInterface.values()].reduce((total, current) => ({
652
+ receivedBytes: total.receivedBytes + current.receivedBytes,
653
+ sentBytes: total.sentBytes + current.sentBytes
654
+ }), { receivedBytes: 0, sentBytes: 0 });
655
+ }
656
+
657
+ export function parseLinuxNetworkOutput(output) {
658
+ let receivedBytes = 0;
659
+ let sentBytes = 0;
660
+ for (const line of String(output || '').split(/\r?\n/)) {
661
+ const separator = line.indexOf(':');
662
+ if (separator < 0) continue;
663
+ const name = line.slice(0, separator).trim();
664
+ if (!name || name === 'lo') continue;
665
+ const fields = line.slice(separator + 1).trim().split(/\s+/);
666
+ receivedBytes += parseCounter(fields[0]);
667
+ sentBytes += parseCounter(fields[8]);
668
+ }
669
+ return { receivedBytes, sentBytes };
670
+ }
671
+
672
+ function activeNetworkInterfaces() {
673
+ const interfaces = [];
674
+ for (const [name, addresses] of Object.entries(os.networkInterfaces())) {
675
+ for (const address of addresses || []) {
676
+ if (address.internal) continue;
677
+ interfaces.push({
678
+ name: normalizeString(name, 80),
679
+ address: normalizeString(address.address, 100),
680
+ family: normalizeString(address.family, 20)
681
+ });
682
+ }
683
+ }
684
+ return interfaces;
685
+ }
686
+
687
+ async function readNetworkTotalsProbe(commandOwner, platform = process.platform) {
688
+ if (platform === 'win32') {
689
+ const output = await commandOwner.capture('netstat.exe', ['-e'], 2500);
690
+ return String(output || '').trim()
691
+ ? { ok: true, value: parseWindowsNetworkOutput(output) }
692
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
693
+ }
694
+ if (platform === 'darwin') {
695
+ const output = await commandOwner.capture('netstat', ['-ibn'], 2500);
696
+ return String(output || '').trim()
697
+ ? { ok: true, value: parseMacNetworkOutput(output) }
698
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
699
+ }
700
+ try {
701
+ return {
702
+ ok: true,
703
+ value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
704
+ };
705
+ } catch {
706
+ return {
707
+ ok: false,
708
+ value: { receivedBytes: 0, sentBytes: 0 },
709
+ error: 'network-counters-unavailable'
710
+ };
711
+ }
712
+ }
713
+
714
+ function normalizeGpu(value) {
715
+ if (!value || typeof value !== 'object') return null;
716
+ const name = normalizeString(value.name, 160);
717
+ if (!name) return null;
718
+ return {
719
+ name,
720
+ usagePercent: percentFromRatio(value.usagePercent),
721
+ memoryTotalBytes: finiteNumber(value.memoryTotalBytes),
722
+ memoryUsedBytes: finiteNumber(value.memoryUsedBytes)
723
+ };
724
+ }
725
+
726
+ export function normalizeGpuIdentityName(value) {
727
+ return normalizeString(value, 160)
728
+ .normalize('NFKC')
729
+ .replace(/\((?:r|tm)\)/giu, '')
730
+ .replace(/[®™]/gu, '')
731
+ .toLocaleLowerCase('en-US')
732
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
733
+ .trim();
734
+ }
735
+
736
+ export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
737
+ const merged = [];
738
+ const indexesByIdentity = new Map();
739
+ for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
740
+ const gpu = normalizeGpu(value);
741
+ const identity = normalizeGpuIdentityName(gpu?.name);
742
+ if (!gpu || !identity) continue;
743
+ const index = merged.length;
744
+ merged.push(gpu);
745
+ const indexes = indexesByIdentity.get(identity) || [];
746
+ indexes.push(index);
747
+ indexesByIdentity.set(identity, indexes);
748
+ }
749
+
750
+ const matchedIndexes = new Set();
751
+ for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
752
+ const gpu = normalizeGpu(value);
753
+ const identity = normalizeGpuIdentityName(gpu?.name);
754
+ if (!gpu || !identity) continue;
755
+ const existingIndex = (indexesByIdentity.get(identity) || [])
756
+ .find(index => !matchedIndexes.has(index));
757
+ if (existingIndex === undefined) {
758
+ merged.push(gpu);
759
+ continue;
760
+ }
761
+ matchedIndexes.add(existingIndex);
762
+ const existing = merged[existingIndex];
763
+ merged[existingIndex] = {
764
+ ...existing,
765
+ usagePercent: gpu.usagePercent ?? existing.usagePercent,
766
+ memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
767
+ memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
768
+ };
769
+ }
770
+ return merged;
771
+ }
772
+
773
+ function normalizeDisk(value) {
774
+ if (!value || typeof value !== 'object') return null;
775
+ const mount = normalizeString(value.mount, 80);
776
+ const totalBytes = finiteNumber(value.totalBytes);
777
+ const freeBytes = finiteNumber(value.freeBytes);
778
+ if (!mount || totalBytes === null || totalBytes <= 0 || freeBytes === null) return null;
779
+ const boundedFreeBytes = Math.min(totalBytes, freeBytes);
780
+ const usedBytes = Math.max(0, totalBytes - boundedFreeBytes);
781
+ return {
782
+ name: normalizeString(value.name, 120),
783
+ mount,
784
+ totalBytes,
785
+ freeBytes: boundedFreeBytes,
786
+ usedBytes,
787
+ usedPercent: Math.round((usedBytes / totalBytes) * 1000) / 10
788
+ };
789
+ }
790
+
791
+ export function parseWindowsHardwareOutput(output) {
792
+ try {
793
+ const payload = JSON.parse(String(output || '').trim());
794
+ const rawGpus = Array.isArray(payload?.gpus) ? payload.gpus : payload?.gpus ? [payload.gpus] : [];
795
+ const rawDisks = Array.isArray(payload?.disks) ? payload.disks : payload?.disks ? [payload.disks] : [];
796
+ return {
797
+ gpus: rawGpus.map(normalizeGpu).filter(Boolean),
798
+ disks: rawDisks.map(normalizeDisk).filter(Boolean)
799
+ };
800
+ } catch {
801
+ return { gpus: [], disks: [] };
802
+ }
803
+ }
804
+
805
+ function readRootDiskSnapshot() {
806
+ try {
807
+ const mount = parse(os.homedir()).root || '/';
808
+ const stats = statfsSync(mount, { bigint: true });
809
+ return normalizeDisk({
810
+ name: process.platform === 'darwin' ? 'Macintosh HD' : 'System',
811
+ mount,
812
+ totalBytes: Number(stats.bsize * stats.blocks),
813
+ freeBytes: Number(stats.bsize * stats.bavail)
814
+ });
815
+ } catch {
816
+ return null;
817
+ }
818
+ }
819
+
820
+ function readFilesystemDiskSnapshot(mount, name) {
821
+ try {
822
+ const stats = statfsSync(mount, { bigint: true });
823
+ return normalizeDisk({
824
+ name,
825
+ mount,
826
+ totalBytes: Number(stats.bsize * stats.blocks),
827
+ freeBytes: Number(stats.bsize * stats.bavail)
828
+ });
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+
834
+ export function readMacDiskSnapshots(options = {}) {
835
+ const rootMount = String(options.rootMount || '/');
836
+ const volumesRoot = String(options.volumesRoot || '/Volumes');
837
+ const disks = [];
838
+ const seenPaths = new Set();
839
+ const addDisk = (mount, name) => {
840
+ try {
841
+ const resolvedPath = realpathSync(mount);
842
+ if (seenPaths.has(resolvedPath)) return;
843
+ const disk = readFilesystemDiskSnapshot(mount, name);
844
+ if (!disk) return;
845
+ seenPaths.add(resolvedPath);
846
+ disks.push(disk);
847
+ } catch {
848
+ // A removable volume may disappear while the snapshot is being read.
849
+ }
850
+ };
851
+
852
+ addDisk(rootMount, 'Macintosh HD');
853
+ try {
854
+ for (const entry of readdirSync(volumesRoot, { withFileTypes: true })) {
855
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
856
+ addDisk(join(volumesRoot, entry.name), entry.name);
857
+ }
858
+ } catch {
859
+ // /Volumes can be unavailable in restricted environments; keep the root disk.
860
+ }
861
+ return disks;
862
+ }
863
+
864
+ async function readWindowsHardwareSnapshot(commandOwner) {
865
+ const script = [
866
+ '$ErrorActionPreference="Stop"',
867
+ '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
868
+ '$physicalDriveIds=@(Get-CimInstance Win32_DiskDrive | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_DiskDriveToDiskPartition | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_LogicalDiskToPartition } } | Select-Object -ExpandProperty DeviceID -Unique)',
869
+ '$gpus=@(Get-CimInstance Win32_VideoController | Where-Object { $_.Name } | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; usagePercent=$null; memoryTotalBytes=if ($_.AdapterRAM) { [double]$_.AdapterRAM } else { $null }; memoryUsedBytes=$null } })',
870
+ '$disks=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $physicalDriveIds -contains $_.DeviceID } | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{ name=[string]$_.VolumeName; mount=[string]$_.DeviceID; totalBytes=[double]$_.Size; freeBytes=[double]$_.FreeSpace } })',
871
+ '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
872
+ ].join('; ');
873
+ return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
874
+ }
875
+
876
+ function parseMacGpuOutput(output) {
877
+ try {
878
+ const payload = JSON.parse(String(output || '').trim());
879
+ return (Array.isArray(payload?.SPDisplaysDataType) ? payload.SPDisplaysDataType : [])
880
+ .map(item => normalizeGpu({ name: item?.sppci_model || item?._name }))
881
+ .filter(Boolean);
882
+ } catch {
883
+ return [];
884
+ }
885
+ }
886
+
887
+ async function collectPlatformHardwareProbe(commandOwner, platform = process.platform) {
888
+ if (platform === 'win32') {
889
+ const snapshot = await readWindowsHardwareSnapshot(commandOwner);
890
+ return snapshot.gpus.length > 0 || snapshot.disks.length > 0
891
+ ? { ok: true, value: snapshot }
892
+ : { ok: false, value: snapshot, error: 'windows-hardware-unavailable' };
893
+ }
894
+ if (platform === 'darwin') {
895
+ const output = await commandOwner.capture('system_profiler', ['SPDisplaysDataType', '-json'], 5000);
896
+ const snapshot = {
897
+ gpus: parseMacGpuOutput(output),
898
+ disks: readMacDiskSnapshots()
899
+ };
900
+ return String(output || '').trim()
901
+ ? { ok: true, value: snapshot }
902
+ : { ok: false, value: snapshot, error: 'system-profiler-unavailable' };
903
+ }
904
+ const disk = readRootDiskSnapshot();
905
+ return {
906
+ ok: Boolean(disk),
907
+ value: { gpus: [], disks: disk ? [disk] : [] },
908
+ error: disk ? '' : 'root-disk-unavailable'
909
+ };
910
+ }
911
+
912
+ async function collectNvidiaGpuProbe(commandOwner, platform = process.platform) {
913
+ const command = resolveNvidiaSmiCommand({ platform });
914
+ if (!command) {
915
+ return { ok: false, value: [], error: 'nvidia-smi-unavailable' };
916
+ }
917
+ const output = await commandOwner.capture(
918
+ command,
919
+ ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
920
+ 3000
921
+ );
922
+ const gpus = parseNvidiaSmiOutput(output);
923
+ return gpus.length > 0
924
+ ? { ok: true, value: gpus }
925
+ : { ok: false, value: [], error: 'nvidia-smi-empty-or-failed' };
926
+ }
927
+
928
+ function normalizeOrigin(value) {
929
+ const candidate = normalizeString(value, 300);
930
+ if (!candidate) return '';
931
+ try {
932
+ const parsed = new URL(candidate);
933
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
934
+ return parsed.origin.toLowerCase();
935
+ } catch {
936
+ return '';
937
+ }
938
+ }
939
+
940
+ function createTrustedWebOrigins(port) {
941
+ const origins = new Set([
942
+ ...DEFAULT_TRUSTED_WEB_ORIGINS,
943
+ `http://127.0.0.1:${port}`,
944
+ `http://localhost:${port}`
945
+ ]);
946
+ for (const value of [process.env.LIVEDESK_WEB_ORIGINS, process.env.LIVEDESK_SITE_URL]) {
947
+ for (const candidate of String(value || '').split(/[\s,]+/)) {
948
+ const origin = normalizeOrigin(candidate);
949
+ if (origin) origins.add(origin);
950
+ }
951
+ }
952
+ return origins;
953
+ }
954
+
955
+ function readSavedSession() {
956
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') return null;
957
+ try {
958
958
  const raw = JSON.parse(readFileSync(CLIENT_AUTH_PATH, 'utf8'))?.[CLIENT_AUTH_STORAGE_KEY];
959
- const session = typeof raw === 'string' ? JSON.parse(raw) : null;
960
- if (!session?.access_token) return null;
961
- const plaintextRefreshToken = normalizeString(session.refresh_token, 8192);
962
- const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
963
- ? CLIENT_REFRESH_SECRET_STORE.read()
964
- : '');
965
- if (!refreshToken) return null;
966
- if (plaintextRefreshToken) {
967
- if (!CLIENT_REFRESH_SECRET_STORE.write(plaintextRefreshToken)) {
968
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
969
- return null;
970
- }
971
- const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
972
- delete migrated.refresh_token;
973
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(migrated) });
974
- }
975
- return { ...session, refresh_token: refreshToken };
959
+ const session = typeof raw === 'string' ? JSON.parse(raw) : null;
960
+ if (!session?.access_token) return null;
961
+ const plaintextRefreshToken = normalizeString(session.refresh_token, 8192);
962
+ const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
963
+ ? CLIENT_REFRESH_SECRET_STORE.read()
964
+ : '');
965
+ if (!refreshToken) return null;
966
+ if (plaintextRefreshToken) {
967
+ if (!CLIENT_REFRESH_SECRET_STORE.write(plaintextRefreshToken)) {
968
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
969
+ return null;
970
+ }
971
+ const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
972
+ delete migrated.refresh_token;
973
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(migrated) });
974
+ }
975
+ return { ...session, refresh_token: refreshToken };
976
976
  } catch {
977
977
  return null;
978
978
  }
979
- }
980
-
981
- function resolveSavedSessionSecret(session) {
982
- if (!session || typeof session !== 'object') return null;
983
- const refreshToken = normalizeString(session.refresh_token, 8192)
984
- || (session.refresh_token_ref === OS_SECRET_REFERENCE ? CLIENT_REFRESH_SECRET_STORE.read() : '');
985
- return session.access_token && refreshToken ? { ...session, refresh_token: refreshToken } : null;
986
- }
987
-
988
- function writePrivateJsonAtomic(path, value) {
989
- mkdirSync(dirname(path), { recursive: true });
990
- const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
991
- try {
992
- writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
993
- try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
994
- renameSync(temporaryPath, path);
995
- try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
996
- } catch (error) {
997
- try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
998
- throw error;
999
- }
1000
- }
1001
-
1002
- function writeSavedSession(session) {
1003
- if (!session?.access_token || !session?.refresh_token) return false;
1004
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') return true;
1005
- if (!CLIENT_REFRESH_SECRET_STORE.write(session.refresh_token)) return false;
1006
- const persisted = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
1007
- delete persisted.refresh_token;
1008
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(persisted) });
1009
- return true;
1010
- }
1011
-
1012
- function clearSavedSession() {
1013
- CLIENT_REFRESH_SECRET_STORE.clear();
1014
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
1015
- }
1016
-
1017
- export async function revokeClientProviderSession(session, options = {}) {
1018
- const accessToken = normalizeString(session?.access_token, 8192);
1019
- if (!accessToken) return { ok: true, skipped: true, reason: 'no-access-token' };
1020
- try {
1021
- if (typeof options.revokeProviderSession === 'function') {
1022
- const result = await options.revokeProviderSession(session);
1023
- return result && typeof result === 'object' ? result : { ok: result !== false };
1024
- }
1025
- const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : fetch;
1026
- const response = await fetchImpl(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/logout?scope=local`, {
1027
- method: 'POST',
1028
- headers: {
1029
- apikey: SUPABASE_PUBLISHABLE_KEY,
1030
- Authorization: `Bearer ${accessToken}`,
1031
- Accept: 'application/json'
1032
- },
1033
- signal: AbortSignal.timeout(10_000)
1034
- });
1035
- if (response.ok) return { ok: true, revoked: true };
1036
- if ([401, 403, 404].includes(response.status)) {
1037
- return { ok: true, alreadyInvalid: true, status: response.status };
1038
- }
1039
- return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
1040
- } catch (error) {
1041
- return { ok: false, error: normalizeString(error?.message || error, 240) || 'provider-session-revoke-failed' };
1042
- }
1043
- }
979
+ }
980
+
981
+ function resolveSavedSessionSecret(session) {
982
+ if (!session || typeof session !== 'object') return null;
983
+ const refreshToken = normalizeString(session.refresh_token, 8192)
984
+ || (session.refresh_token_ref === OS_SECRET_REFERENCE ? CLIENT_REFRESH_SECRET_STORE.read() : '');
985
+ return session.access_token && refreshToken ? { ...session, refresh_token: refreshToken } : null;
986
+ }
987
+
988
+ function writePrivateJsonAtomic(path, value) {
989
+ mkdirSync(dirname(path), { recursive: true });
990
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
991
+ try {
992
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
993
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
994
+ renameSync(temporaryPath, path);
995
+ try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
996
+ } catch (error) {
997
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
998
+ throw error;
999
+ }
1000
+ }
1001
+
1002
+ function writeSavedSession(session) {
1003
+ if (!session?.access_token || !session?.refresh_token) return false;
1004
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') return true;
1005
+ if (!CLIENT_REFRESH_SECRET_STORE.write(session.refresh_token)) return false;
1006
+ const persisted = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
1007
+ delete persisted.refresh_token;
1008
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(persisted) });
1009
+ return true;
1010
+ }
1011
+
1012
+ function clearSavedSession() {
1013
+ CLIENT_REFRESH_SECRET_STORE.clear();
1014
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
1015
+ }
1016
+
1017
+ export async function revokeClientProviderSession(session, options = {}) {
1018
+ const accessToken = normalizeString(session?.access_token, 8192);
1019
+ if (!accessToken) return { ok: true, skipped: true, reason: 'no-access-token' };
1020
+ try {
1021
+ if (typeof options.revokeProviderSession === 'function') {
1022
+ const result = await options.revokeProviderSession(session);
1023
+ return result && typeof result === 'object' ? result : { ok: result !== false };
1024
+ }
1025
+ const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : fetch;
1026
+ const response = await fetchImpl(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/logout?scope=local`, {
1027
+ method: 'POST',
1028
+ headers: {
1029
+ apikey: SUPABASE_PUBLISHABLE_KEY,
1030
+ Authorization: `Bearer ${accessToken}`,
1031
+ Accept: 'application/json'
1032
+ },
1033
+ signal: AbortSignal.timeout(10_000)
1034
+ });
1035
+ if (response.ok) return { ok: true, revoked: true };
1036
+ if ([401, 403, 404].includes(response.status)) {
1037
+ return { ok: true, alreadyInvalid: true, status: response.status };
1038
+ }
1039
+ return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
1040
+ } catch (error) {
1041
+ return { ok: false, error: normalizeString(error?.message || error, 240) || 'provider-session-revoke-failed' };
1042
+ }
1043
+ }
1044
1044
 
1045
1045
  function readBody(req) {
1046
1046
  return new Promise((resolveBody, reject) => {
@@ -1077,82 +1077,82 @@ function contentType(pathname) {
1077
1077
  return 'text/html; charset=utf-8';
1078
1078
  }
1079
1079
 
1080
- function isLoopbackRequest(req) {
1081
- const address = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
1082
- return address === '127.0.0.1' || address === '::1' || address === 'localhost' || !address;
1083
- }
1084
-
1085
- function isTrustedApiOrigin(value, trustedWebOrigins) {
1086
- const rawOrigin = normalizeString(value, 300);
1087
- if (rawOrigin === 'null') return true;
1088
- const origin = normalizeOrigin(rawOrigin);
1089
- if (!origin) return false;
1090
- if (trustedWebOrigins.has(origin)) return true;
1091
- try {
1092
- const parsed = new URL(origin);
1093
- // The unified local shell has existed at both an explicit runtime port and
1094
- // the default loopback origin during Hub/Client module transitions. Treat
1095
- // every HTTP loopback origin as local. The API still requires a per-process
1096
- // CSRF token for mutations, and non-browser local processes already share
1097
- // the same machine trust boundary.
1098
- if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname.toLowerCase())) {
1099
- return true;
1100
- }
1101
- return parsed.protocol === 'https:' && LIVE_DESK_PAGES_PREVIEW_HOST.test(parsed.hostname);
1102
- } catch {
1103
- return false;
1104
- }
1105
- }
1106
-
1107
- function isTrustedLocalRequest(req, host, port, options = {}) {
1108
- if (!isLoopbackRequest(req)) return false;
1109
- const requestHost = normalizeString(req.headers.host, 300).split(':')[0].toLowerCase();
1110
- if (requestHost && requestHost !== host.toLowerCase() && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
1111
-
1112
- // Chromium can mark modulepreload/stylesheet requests as cross-site, or
1113
- // send Origin: null when the page was opened from a local shell. Static
1114
- // assets are not privileged, and the local shell still needs to read the
1115
- // loopback runtime API to determine the active role. Mutating API routes
1116
- // remain protected by the CSRF token check below.
1117
- if (options.allowStaticCrossSite === true) {
1118
- return true;
1119
- }
1120
-
1121
- const origin = normalizeString(req.headers.origin, 300);
1122
- if (isTrustedApiOrigin(origin, options.trustedWebOrigins || createTrustedWebOrigins(port))) return true;
1123
- if (origin) return false;
1124
- // Chrome can label a loopback fetch as cross-site while omitting Origin.
1125
- // Permit only explicitly read-only status routes in that shape. The response
1126
- // still uses the loopback ACAO value, and all mutations continue to require
1127
- // both a trusted Origin and the per-process CSRF token.
1128
- if (options.allowReadOnlyCrossSite === true && ['GET', 'HEAD'].includes(req.method || '')) return true;
1129
- const fetchSite = normalizeString(req.headers['sec-fetch-site'], 40).toLowerCase();
1130
- return !fetchSite
1131
- || fetchSite === 'same-origin'
1132
- || fetchSite === 'same-site'
1133
- || fetchSite === 'none';
1134
- }
1135
-
1136
- function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1137
- const origin = normalizeString(requestOrigin, 300);
1138
- if (origin === 'null') return 'null';
1139
- return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1140
- ? normalizeOrigin(origin)
1141
- : `http://127.0.0.1:${port}`;
1142
- }
1143
-
1144
- function sendJson(res, status, payload, requestOrigin = '', port = DEFAULT_PORT, trustedWebOrigins = createTrustedWebOrigins(port)) {
1145
- const body = JSON.stringify(payload);
1146
- const allowedOrigin = resolveCorsOrigin(requestOrigin, port, trustedWebOrigins);
1147
- res.writeHead(status, {
1148
- 'Content-Type': 'application/json; charset=utf-8',
1149
- 'Cache-Control': 'no-store',
1150
- 'Access-Control-Allow-Origin': allowedOrigin,
1151
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1152
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1153
- 'Access-Control-Allow-Private-Network': 'true',
1154
- Vary: 'Origin'
1155
- });
1080
+ function isLoopbackRequest(req) {
1081
+ const address = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
1082
+ return address === '127.0.0.1' || address === '::1' || address === 'localhost' || !address;
1083
+ }
1084
+
1085
+ function isTrustedApiOrigin(value, trustedWebOrigins) {
1086
+ const rawOrigin = normalizeString(value, 300);
1087
+ if (rawOrigin === 'null') return true;
1088
+ const origin = normalizeOrigin(rawOrigin);
1089
+ if (!origin) return false;
1090
+ if (trustedWebOrigins.has(origin)) return true;
1091
+ try {
1092
+ const parsed = new URL(origin);
1093
+ // The unified local shell has existed at both an explicit runtime port and
1094
+ // the default loopback origin during Hub/Client module transitions. Treat
1095
+ // every HTTP loopback origin as local. The API still requires a per-process
1096
+ // CSRF token for mutations, and non-browser local processes already share
1097
+ // the same machine trust boundary.
1098
+ if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname.toLowerCase())) {
1099
+ return true;
1100
+ }
1101
+ return parsed.protocol === 'https:' && LIVE_DESK_PAGES_PREVIEW_HOST.test(parsed.hostname);
1102
+ } catch {
1103
+ return false;
1104
+ }
1105
+ }
1106
+
1107
+ function isTrustedLocalRequest(req, host, port, options = {}) {
1108
+ if (!isLoopbackRequest(req)) return false;
1109
+ const requestHost = normalizeString(req.headers.host, 300).split(':')[0].toLowerCase();
1110
+ if (requestHost && requestHost !== host.toLowerCase() && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
1111
+
1112
+ // Chromium can mark modulepreload/stylesheet requests as cross-site, or
1113
+ // send Origin: null when the page was opened from a local shell. Static
1114
+ // assets are not privileged, and the local shell still needs to read the
1115
+ // loopback runtime API to determine the active role. Mutating API routes
1116
+ // remain protected by the CSRF token check below.
1117
+ if (options.allowStaticCrossSite === true) {
1118
+ return true;
1119
+ }
1120
+
1121
+ const origin = normalizeString(req.headers.origin, 300);
1122
+ if (isTrustedApiOrigin(origin, options.trustedWebOrigins || createTrustedWebOrigins(port))) return true;
1123
+ if (origin) return false;
1124
+ // Chrome can label a loopback fetch as cross-site while omitting Origin.
1125
+ // Permit only explicitly read-only status routes in that shape. The response
1126
+ // still uses the loopback ACAO value, and all mutations continue to require
1127
+ // both a trusted Origin and the per-process CSRF token.
1128
+ if (options.allowReadOnlyCrossSite === true && ['GET', 'HEAD'].includes(req.method || '')) return true;
1129
+ const fetchSite = normalizeString(req.headers['sec-fetch-site'], 40).toLowerCase();
1130
+ return !fetchSite
1131
+ || fetchSite === 'same-origin'
1132
+ || fetchSite === 'same-site'
1133
+ || fetchSite === 'none';
1134
+ }
1135
+
1136
+ function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1137
+ const origin = normalizeString(requestOrigin, 300);
1138
+ if (origin === 'null') return 'null';
1139
+ return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1140
+ ? normalizeOrigin(origin)
1141
+ : `http://127.0.0.1:${port}`;
1142
+ }
1143
+
1144
+ function sendJson(res, status, payload, requestOrigin = '', port = DEFAULT_PORT, trustedWebOrigins = createTrustedWebOrigins(port)) {
1145
+ const body = JSON.stringify(payload);
1146
+ const allowedOrigin = resolveCorsOrigin(requestOrigin, port, trustedWebOrigins);
1147
+ res.writeHead(status, {
1148
+ 'Content-Type': 'application/json; charset=utf-8',
1149
+ 'Cache-Control': 'no-store',
1150
+ 'Access-Control-Allow-Origin': allowedOrigin,
1151
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1152
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1153
+ 'Access-Control-Allow-Private-Network': 'true',
1154
+ Vary: 'Origin'
1155
+ });
1156
1156
  res.end(body);
1157
1157
  }
1158
1158
 
@@ -1161,122 +1161,125 @@ function sendText(res, status, body, type = 'text/plain; charset=utf-8') {
1161
1161
  res.end(body);
1162
1162
  }
1163
1163
 
1164
- export function createClientRuntimeServer(options = {}) {
1165
- const host = normalizeString(options.host, 80) || DEFAULT_HOST;
1166
- const port = normalizePort(options.port, DEFAULT_PORT);
1167
- const initialChoice = options.initialChoice && typeof options.initialChoice === 'object'
1168
- ? options.initialChoice
1169
- : null;
1170
- const initialChoiceMessage = normalizeString(
1171
- options.initialChoiceMessage,
1172
- 1000
1173
- ) || 'Existing LiveDesk credentials accepted. Starting the Client.';
1174
- const trustedWebOrigins = createTrustedWebOrigins(port);
1175
- const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
1164
+ export function createClientRuntimeServer(options = {}) {
1165
+ const host = normalizeString(options.host, 80) || DEFAULT_HOST;
1166
+ const port = normalizePort(options.port, DEFAULT_PORT);
1167
+ const initialChoice = options.initialChoice && typeof options.initialChoice === 'object'
1168
+ ? options.initialChoice
1169
+ : null;
1170
+ const initialChoiceMessage = normalizeString(
1171
+ options.initialChoiceMessage,
1172
+ 1000
1173
+ ) || 'Existing LiveDesk credentials accepted. Starting the Client.';
1174
+ const trustedWebOrigins = createTrustedWebOrigins(port);
1175
+ const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
1176
1176
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
1177
- const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1178
- const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
1179
- const providedSavedSession = resolveSavedSessionSecret(options.savedSession);
1180
- const savedSession = options.loadSavedSession === false
1181
- ? null
1182
- : providedSavedSession
1183
- ? providedSavedSession
1184
- : readSavedSession();
1185
- const savedSessionPersisted = Boolean(savedSession?.refresh_token);
1186
- const savedAccountProfile = readClientAccountProfile(savedSession);
1187
- const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
1188
- const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
1189
- ? options.diagnosticMonotonicNow
1190
- : defaultDiagnosticMonotonicNow;
1191
- const diagnosticProbeTtlMs = Math.max(
1192
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1193
- Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
1194
- );
1195
- let previousCpuTotals = readCpuTotals();
1196
- let hardwareSnapshot = {
1197
- gpus: [],
1198
- disks: [],
1199
- collectedAt: '',
1200
- collecting: false,
1201
- lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
1202
- };
1203
- let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
1204
- let previousNetworkTotals = null;
1205
- let lastDiagnosticRequestAt = '';
1206
- let lastDiagnosticCompletedAt = '';
1207
- let diagnosticRefreshInFlight = null;
1208
- let closed = false;
1209
- let runtimeClosePromise = null;
1210
- const diagnosticCommandOwner = options.diagnosticCommandOwner
1211
- || createClientDiagnosticCommandOwner();
1212
- if (typeof diagnosticCommandOwner.capture !== 'function'
1213
- || typeof diagnosticCommandOwner.close !== 'function'
1214
- || typeof diagnosticCommandOwner.getSnapshot !== 'function') {
1215
- throw new TypeError('invalid-client-diagnostic-command-owner');
1216
- }
1217
- const diagnosticTasks = new Set();
1218
- const trackDiagnosticTask = operation => {
1219
- if (closed) return Promise.resolve();
1220
- const task = Promise.resolve()
1221
- .then(operation)
1222
- .finally(() => diagnosticTasks.delete(task));
1223
- diagnosticTasks.add(task);
1224
- return task;
1225
- };
1226
- const hardwareProbeCache = createClientDiagnosticProbeCache({
1227
- probeId: 'platform-hardware',
1228
- ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
1229
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1230
- monotonicNow: diagnosticMonotonicNow,
1231
- fallbackValue: { gpus: [], disks: [] },
1232
- refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
1233
- });
1234
- const nvidiaProbeCache = createClientDiagnosticProbeCache({
1235
- probeId: 'nvidia-gpu',
1236
- ttlMs: diagnosticProbeTtlMs,
1237
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1238
- monotonicNow: diagnosticMonotonicNow,
1239
- fallbackValue: [],
1240
- refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
1241
- });
1242
- const networkProbeCache = createClientDiagnosticProbeCache({
1243
- probeId: 'network-totals',
1244
- ttlMs: diagnosticProbeTtlMs,
1245
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1246
- monotonicNow: diagnosticMonotonicNow,
1247
- fallbackValue: { receivedBytes: 0, sentBytes: 0 },
1248
- refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
1249
- });
1250
- const csrfToken = randomBytes(32).toString('hex');
1251
- const state = {
1252
- route: '/computer',
1253
- manager: '',
1254
- pairToken: '',
1255
- slotNumber: normalizeSlotNumber(options.slot),
1256
- assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
1257
- endpointCandidates: [],
1258
- roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
1177
+ const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1178
+ const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
1179
+ const providedSavedSession = resolveSavedSessionSecret(options.savedSession);
1180
+ const savedSession = options.loadSavedSession === false
1181
+ ? null
1182
+ : providedSavedSession
1183
+ ? providedSavedSession
1184
+ : readSavedSession();
1185
+ const savedSessionPersisted = Boolean(savedSession?.refresh_token);
1186
+ const savedAccountProfile = readClientAccountProfile(savedSession);
1187
+ const hydrateAccountProfile = typeof options.hydrateAccountProfile === 'function'
1188
+ ? options.hydrateAccountProfile
1189
+ : hydrateClientAccountProfile;
1190
+ const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
1191
+ const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
1192
+ ? options.diagnosticMonotonicNow
1193
+ : defaultDiagnosticMonotonicNow;
1194
+ const diagnosticProbeTtlMs = Math.max(
1195
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1196
+ Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
1197
+ );
1198
+ let previousCpuTotals = readCpuTotals();
1199
+ let hardwareSnapshot = {
1200
+ gpus: [],
1201
+ disks: [],
1202
+ collectedAt: '',
1203
+ collecting: false,
1204
+ lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
1205
+ };
1206
+ let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
1207
+ let previousNetworkTotals = null;
1208
+ let lastDiagnosticRequestAt = '';
1209
+ let lastDiagnosticCompletedAt = '';
1210
+ let diagnosticRefreshInFlight = null;
1211
+ let closed = false;
1212
+ let runtimeClosePromise = null;
1213
+ const diagnosticCommandOwner = options.diagnosticCommandOwner
1214
+ || createClientDiagnosticCommandOwner();
1215
+ if (typeof diagnosticCommandOwner.capture !== 'function'
1216
+ || typeof diagnosticCommandOwner.close !== 'function'
1217
+ || typeof diagnosticCommandOwner.getSnapshot !== 'function') {
1218
+ throw new TypeError('invalid-client-diagnostic-command-owner');
1219
+ }
1220
+ const diagnosticTasks = new Set();
1221
+ const trackDiagnosticTask = operation => {
1222
+ if (closed) return Promise.resolve();
1223
+ const task = Promise.resolve()
1224
+ .then(operation)
1225
+ .finally(() => diagnosticTasks.delete(task));
1226
+ diagnosticTasks.add(task);
1227
+ return task;
1228
+ };
1229
+ const hardwareProbeCache = createClientDiagnosticProbeCache({
1230
+ probeId: 'platform-hardware',
1231
+ ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
1232
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1233
+ monotonicNow: diagnosticMonotonicNow,
1234
+ fallbackValue: { gpus: [], disks: [] },
1235
+ refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
1236
+ });
1237
+ const nvidiaProbeCache = createClientDiagnosticProbeCache({
1238
+ probeId: 'nvidia-gpu',
1239
+ ttlMs: diagnosticProbeTtlMs,
1240
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1241
+ monotonicNow: diagnosticMonotonicNow,
1242
+ fallbackValue: [],
1243
+ refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
1244
+ });
1245
+ const networkProbeCache = createClientDiagnosticProbeCache({
1246
+ probeId: 'network-totals',
1247
+ ttlMs: diagnosticProbeTtlMs,
1248
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1249
+ monotonicNow: diagnosticMonotonicNow,
1250
+ fallbackValue: { receivedBytes: 0, sentBytes: 0 },
1251
+ refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
1252
+ });
1253
+ const csrfToken = randomBytes(32).toString('hex');
1254
+ const state = {
1255
+ route: '/computer',
1256
+ manager: '',
1257
+ pairToken: '',
1258
+ slotNumber: normalizeSlotNumber(options.slot),
1259
+ assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
1260
+ endpointCandidates: [],
1261
+ roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
1259
1262
  connectedAt: '',
1260
1263
  message: 'Sign in with Google or enter a Hub PIN to start this Client.',
1261
- startup: false,
1262
- agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
1263
- videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
1264
- ? { ...options.videoAcceleration, busy: false }
1265
- : null,
1266
- lastError: '',
1267
- auth: {
1268
- lastAttemptAt: '',
1269
- lastResult: '',
1270
- lastError: '',
1271
- persisted: savedSessionPersisted,
1272
- email: savedAccountProfile.email,
1273
- name: savedAccountProfile.name,
1274
- avatarUrl: savedAccountProfile.avatarUrl
1275
- }
1264
+ startup: false,
1265
+ agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
1266
+ videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
1267
+ ? { ...options.videoAcceleration, busy: false }
1268
+ : null,
1269
+ lastError: '',
1270
+ auth: {
1271
+ lastAttemptAt: '',
1272
+ lastResult: '',
1273
+ lastError: '',
1274
+ persisted: savedSessionPersisted,
1275
+ email: savedAccountProfile.email,
1276
+ name: savedAccountProfile.name,
1277
+ avatarUrl: savedAccountProfile.avatarUrl
1278
+ }
1276
1279
  };
1277
- let completed = false;
1278
- let loggedOut = false;
1279
- let lastChoice = null;
1280
+ let completed = false;
1281
+ let loggedOut = false;
1282
+ let lastChoice = null;
1280
1283
  let settleChoice;
1281
1284
  let rejectChoice;
1282
1285
  let server;
@@ -1287,219 +1290,219 @@ export function createClientRuntimeServer(options = {}) {
1287
1290
  const runtime = createRuntimeManager({
1288
1291
  role: 'client',
1289
1292
  state: RuntimeState.WAITING_AUTH,
1290
- authenticated: Boolean(savedSession?.access_token),
1291
- userId: savedSession?.user?.id || '',
1293
+ authenticated: Boolean(savedSession?.access_token),
1294
+ userId: savedSession?.user?.id || '',
1292
1295
  deviceId,
1293
1296
  appVersion,
1294
1297
  startedAt: new Date().toISOString(),
1295
1298
  runtimePid: process.pid
1296
1299
  });
1297
- runtime.emit('runtime.waiting-auth', { role: 'client' });
1298
-
1299
- const requestDiagnosticRefresh = () => {
1300
- if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1301
- if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1302
- lastDiagnosticRequestAt = new Date().toISOString();
1303
- hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
1304
- diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
1305
- const [platformResult, nvidiaResult, networkResult] = await Promise.all([
1306
- hardwareProbeCache.read(),
1307
- nvidiaProbeCache.read(),
1308
- networkProbeCache.read()
1309
- ]);
1310
- if (closed) return;
1311
-
1312
- const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
1313
- ? platformResult.value
1314
- : { gpus: [], disks: [] };
1315
- const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
1316
- const errors = [platformResult, nvidiaResult, networkResult]
1317
- .filter(result => result.ok !== true && result.error)
1318
- .map(result => normalizeString(result.error, 160));
1319
- hardwareSnapshot = {
1320
- // Preserve the platform adapter inventory on hybrid-GPU computers. The
1321
- // NVIDIA sampler enriches its matching adapter instead of replacing
1322
- // every non-NVIDIA display adapter with the telemetry subset.
1323
- gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
1324
- disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
1325
- collectedAt: new Date().toISOString(),
1326
- collecting: false,
1327
- lastError: errors.join(', ')
1328
- };
1329
-
1330
- const totals = networkResult.value && typeof networkResult.value === 'object'
1331
- ? networkResult.value
1332
- : { receivedBytes: 0, sentBytes: 0 };
1333
- if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
1334
- const sampledAtMs = Date.now();
1335
- const elapsedSeconds = previousNetworkTotals
1336
- ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
1337
- : 0;
1338
- networkSnapshot = {
1339
- interfaces: activeNetworkInterfaces(),
1340
- receivedBytes: totals.receivedBytes,
1341
- sentBytes: totals.sentBytes,
1342
- receiveBytesPerSecond: elapsedSeconds > 0
1343
- ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
1344
- : 0,
1345
- sendBytesPerSecond: elapsedSeconds > 0
1346
- ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
1347
- : 0,
1348
- sampledAt: new Date(sampledAtMs).toISOString()
1349
- };
1350
- previousNetworkTotals = { ...totals, sampledAtMs };
1351
- }
1352
- lastDiagnosticCompletedAt = new Date().toISOString();
1353
- runtime.emit('client.system.diagnostics', {
1354
- gpuCount: hardwareSnapshot.gpus.length,
1355
- diskCount: hardwareSnapshot.disks.length,
1356
- platformHardwareGeneration: platformResult.generation,
1357
- nvidiaGeneration: nvidiaResult.generation,
1358
- networkGeneration: networkResult.generation
1359
- });
1360
- });
1361
- void diagnosticRefreshInFlight.finally(() => {
1362
- diagnosticRefreshInFlight = null;
1363
- if (!closed && hardwareSnapshot.collecting) {
1364
- hardwareSnapshot = {
1365
- ...hardwareSnapshot,
1366
- collecting: false,
1367
- lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
1368
- };
1369
- }
1370
- });
1371
- return diagnosticRefreshInFlight;
1372
- };
1373
-
1374
- const recordAuthAttempt = (result, error = '') => {
1375
- const message = normalizeString(error, 240);
1376
- state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
1377
- state.lastError = message;
1378
- if (message) state.message = `Client authentication needs attention: ${message}`;
1379
- runtime.emit('client.auth.session', { result: state.auth.lastResult, error: message });
1380
- };
1381
-
1382
- const complete = (choice, message = 'Client credentials accepted. Finding the Hub.') => {
1383
- lastChoice = choice || lastChoice;
1384
- if (completed) {
1385
- if (choice?.session?.access_token) {
1386
- const accountProfile = readClientAccountProfile(choice.session);
1387
- runtime.setAuthenticated(true, choice.session.user?.id || '');
1388
- state.auth = {
1389
- ...state.auth,
1390
- lastAttemptAt: new Date().toISOString(),
1391
- lastResult: 'accepted',
1392
- lastError: '',
1393
- persisted: true,
1394
- email: accountProfile.email || state.auth.email,
1395
- name: accountProfile.name || state.auth.name,
1396
- avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1397
- };
1398
- state.lastError = '';
1399
- if (state.message.startsWith('Client authentication needs attention:')) {
1400
- state.message = state.agent.state === 'running'
1401
- ? 'Connected to the LiveDesk Hub.'
1402
- : normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
1403
- }
1404
- }
1405
- return;
1406
- }
1300
+ runtime.emit('runtime.waiting-auth', { role: 'client' });
1301
+
1302
+ const requestDiagnosticRefresh = () => {
1303
+ if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1304
+ if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1305
+ lastDiagnosticRequestAt = new Date().toISOString();
1306
+ hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
1307
+ diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
1308
+ const [platformResult, nvidiaResult, networkResult] = await Promise.all([
1309
+ hardwareProbeCache.read(),
1310
+ nvidiaProbeCache.read(),
1311
+ networkProbeCache.read()
1312
+ ]);
1313
+ if (closed) return;
1314
+
1315
+ const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
1316
+ ? platformResult.value
1317
+ : { gpus: [], disks: [] };
1318
+ const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
1319
+ const errors = [platformResult, nvidiaResult, networkResult]
1320
+ .filter(result => result.ok !== true && result.error)
1321
+ .map(result => normalizeString(result.error, 160));
1322
+ hardwareSnapshot = {
1323
+ // Preserve the platform adapter inventory on hybrid-GPU computers. The
1324
+ // NVIDIA sampler enriches its matching adapter instead of replacing
1325
+ // every non-NVIDIA display adapter with the telemetry subset.
1326
+ gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
1327
+ disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
1328
+ collectedAt: new Date().toISOString(),
1329
+ collecting: false,
1330
+ lastError: errors.join(', ')
1331
+ };
1332
+
1333
+ const totals = networkResult.value && typeof networkResult.value === 'object'
1334
+ ? networkResult.value
1335
+ : { receivedBytes: 0, sentBytes: 0 };
1336
+ if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
1337
+ const sampledAtMs = Date.now();
1338
+ const elapsedSeconds = previousNetworkTotals
1339
+ ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
1340
+ : 0;
1341
+ networkSnapshot = {
1342
+ interfaces: activeNetworkInterfaces(),
1343
+ receivedBytes: totals.receivedBytes,
1344
+ sentBytes: totals.sentBytes,
1345
+ receiveBytesPerSecond: elapsedSeconds > 0
1346
+ ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
1347
+ : 0,
1348
+ sendBytesPerSecond: elapsedSeconds > 0
1349
+ ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
1350
+ : 0,
1351
+ sampledAt: new Date(sampledAtMs).toISOString()
1352
+ };
1353
+ previousNetworkTotals = { ...totals, sampledAtMs };
1354
+ }
1355
+ lastDiagnosticCompletedAt = new Date().toISOString();
1356
+ runtime.emit('client.system.diagnostics', {
1357
+ gpuCount: hardwareSnapshot.gpus.length,
1358
+ diskCount: hardwareSnapshot.disks.length,
1359
+ platformHardwareGeneration: platformResult.generation,
1360
+ nvidiaGeneration: nvidiaResult.generation,
1361
+ networkGeneration: networkResult.generation
1362
+ });
1363
+ });
1364
+ void diagnosticRefreshInFlight.finally(() => {
1365
+ diagnosticRefreshInFlight = null;
1366
+ if (!closed && hardwareSnapshot.collecting) {
1367
+ hardwareSnapshot = {
1368
+ ...hardwareSnapshot,
1369
+ collecting: false,
1370
+ lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
1371
+ };
1372
+ }
1373
+ });
1374
+ return diagnosticRefreshInFlight;
1375
+ };
1376
+
1377
+ const recordAuthAttempt = (result, error = '') => {
1378
+ const message = normalizeString(error, 240);
1379
+ state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
1380
+ state.lastError = message;
1381
+ if (message) state.message = `Client authentication needs attention: ${message}`;
1382
+ runtime.emit('client.auth.session', { result: state.auth.lastResult, error: message });
1383
+ };
1384
+
1385
+ const complete = (choice, message = 'Client credentials accepted. Finding the Hub.') => {
1386
+ lastChoice = choice || lastChoice;
1387
+ if (completed) {
1388
+ if (choice?.session?.access_token) {
1389
+ const accountProfile = readClientAccountProfile(choice.session);
1390
+ runtime.setAuthenticated(true, choice.session.user?.id || '');
1391
+ state.auth = {
1392
+ ...state.auth,
1393
+ lastAttemptAt: new Date().toISOString(),
1394
+ lastResult: 'accepted',
1395
+ lastError: '',
1396
+ persisted: true,
1397
+ email: accountProfile.email || state.auth.email,
1398
+ name: accountProfile.name || state.auth.name,
1399
+ avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1400
+ };
1401
+ state.lastError = '';
1402
+ if (state.message.startsWith('Client authentication needs attention:')) {
1403
+ state.message = state.agent.state === 'running'
1404
+ ? 'Connected to the LiveDesk Hub.'
1405
+ : normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
1406
+ }
1407
+ }
1408
+ return;
1409
+ }
1407
1410
  completed = true;
1408
- loggedOut = false;
1409
- state.connectedAt = new Date().toISOString();
1410
- state.manager = normalizeString(choice?.manager, 256);
1411
- if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
1412
- state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
1413
- }
1414
- state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
1415
- ? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
1416
- : [];
1417
- state.message = message;
1418
- state.lastError = '';
1419
- const accountProfile = readClientAccountProfile(choice?.session);
1420
- state.auth = {
1421
- ...state.auth,
1422
- lastAttemptAt: state.connectedAt,
1423
- lastResult: 'accepted',
1424
- lastError: '',
1425
- persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
1426
- email: accountProfile.email || state.auth.email,
1427
- name: accountProfile.name || state.auth.name,
1428
- avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1429
- };
1411
+ loggedOut = false;
1412
+ state.connectedAt = new Date().toISOString();
1413
+ state.manager = normalizeString(choice?.manager, 256);
1414
+ if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
1415
+ state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
1416
+ }
1417
+ state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
1418
+ ? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
1419
+ : [];
1420
+ state.message = message;
1421
+ state.lastError = '';
1422
+ const accountProfile = readClientAccountProfile(choice?.session);
1423
+ state.auth = {
1424
+ ...state.auth,
1425
+ lastAttemptAt: state.connectedAt,
1426
+ lastResult: 'accepted',
1427
+ lastError: '',
1428
+ persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
1429
+ email: accountProfile.email || state.auth.email,
1430
+ name: accountProfile.name || state.auth.name,
1431
+ avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1432
+ };
1430
1433
  runtime.setAuthenticated(Boolean(choice?.session?.access_token), choice?.session?.user?.id || '');
1431
1434
  runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'credentials-accepted');
1432
1435
  settleChoice?.(choice);
1433
1436
  settleChoice = null;
1434
1437
  };
1435
1438
 
1436
- const getState = () => {
1437
- const snapshot = runtime.getSnapshot();
1438
- const currentCpuTotals = readCpuTotals();
1439
- const cpuTotalDelta = Math.max(0, currentCpuTotals.total - previousCpuTotals.total);
1440
- const cpuIdleDelta = Math.max(0, currentCpuTotals.idle - previousCpuTotals.idle);
1441
- const cpuUsagePercent = cpuTotalDelta > 0 ? Math.max(0, Math.min(100, (1 - cpuIdleDelta / cpuTotalDelta) * 100)) : 0;
1442
- previousCpuTotals = currentCpuTotals;
1443
- const totalMemoryBytes = os.totalmem();
1444
- const freeMemoryBytes = os.freemem();
1445
- return {
1439
+ const getState = () => {
1440
+ const snapshot = runtime.getSnapshot();
1441
+ const currentCpuTotals = readCpuTotals();
1442
+ const cpuTotalDelta = Math.max(0, currentCpuTotals.total - previousCpuTotals.total);
1443
+ const cpuIdleDelta = Math.max(0, currentCpuTotals.idle - previousCpuTotals.idle);
1444
+ const cpuUsagePercent = cpuTotalDelta > 0 ? Math.max(0, Math.min(100, (1 - cpuIdleDelta / cpuTotalDelta) * 100)) : 0;
1445
+ previousCpuTotals = currentCpuTotals;
1446
+ const totalMemoryBytes = os.totalmem();
1447
+ const freeMemoryBytes = os.freemem();
1448
+ return {
1446
1449
  ok: true,
1447
1450
  role: 'client',
1448
1451
  state: snapshot.state,
1449
1452
  deviceId,
1450
1453
  deviceName,
1451
1454
  runtimeStarted: completed,
1452
- authenticated: snapshot.authenticated,
1453
- userId: snapshot.userId || '',
1454
- userEmail: state.auth.email || '',
1455
- userName: state.auth.name || '',
1456
- userAvatarUrl: state.auth.avatarUrl || '',
1457
- appVersion,
1458
- runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
1459
- runtimePid: snapshot.runtimePid,
1455
+ authenticated: snapshot.authenticated,
1456
+ userId: snapshot.userId || '',
1457
+ userEmail: state.auth.email || '',
1458
+ userName: state.auth.name || '',
1459
+ userAvatarUrl: state.auth.avatarUrl || '',
1460
+ appVersion,
1461
+ runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
1462
+ runtimePid: snapshot.runtimePid,
1460
1463
  startedAt: snapshot.startedAt,
1461
- route: state.route,
1462
- manager: state.manager,
1463
- slotNumber: state.slotNumber,
1464
- assignedHubId: state.assignedHubId,
1465
- endpointCandidates: state.endpointCandidates,
1464
+ route: state.route,
1465
+ manager: state.manager,
1466
+ slotNumber: state.slotNumber,
1467
+ assignedHubId: state.assignedHubId,
1468
+ endpointCandidates: state.endpointCandidates,
1466
1469
  roleVersion: state.roleVersion,
1467
1470
  connectedAt: state.connectedAt,
1468
- connectionState: state.agent.state === 'running' ? 'connected' : completed ? 'connecting' : 'waiting-auth',
1469
- needsAuth: !snapshot.authenticated,
1470
- statusLabel: loggedOut ? 'Signed out' : state.agent.state === 'running' ? 'Agent running' : completed ? 'Finding Hub' : 'Waiting for auth',
1471
- message: state.message,
1472
- lastError: state.lastError,
1473
- auth: { ...state.auth },
1474
- startup: state.startup,
1475
- agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
1476
- videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
1477
- system: {
1478
- platform: process.platform,
1479
- arch: process.arch,
1480
- osVersion: os.release(),
1481
- pid: process.pid,
1482
- cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
1483
- cpuLogicalCores: Math.max(1, os.cpus().length),
1484
- memoryTotalBytes: totalMemoryBytes,
1485
- memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
1486
- uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
1487
- gpus: hardwareSnapshot.gpus,
1488
- disks: hardwareSnapshot.disks,
1489
- network: networkSnapshot,
1490
- hardwareCollectedAt: hardwareSnapshot.collectedAt,
1491
- hardwareCollecting: hardwareSnapshot.collecting,
1492
- hardwareLastError: hardwareSnapshot.lastError || '',
1493
- diagnosticProbes: {
1494
- requestDriven: true,
1495
- minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1496
- requestedAt: lastDiagnosticRequestAt,
1497
- completedAt: lastDiagnosticCompletedAt,
1498
- platformHardware: hardwareProbeCache.getSnapshot(),
1499
- nvidiaGpu: nvidiaProbeCache.getSnapshot(),
1500
- networkTotals: networkProbeCache.getSnapshot()
1501
- }
1502
- },
1471
+ connectionState: state.agent.state === 'running' ? 'connected' : completed ? 'connecting' : 'waiting-auth',
1472
+ needsAuth: !snapshot.authenticated,
1473
+ statusLabel: loggedOut ? 'Signed out' : state.agent.state === 'running' ? 'Agent running' : completed ? 'Finding Hub' : 'Waiting for auth',
1474
+ message: state.message,
1475
+ lastError: state.lastError,
1476
+ auth: { ...state.auth },
1477
+ startup: state.startup,
1478
+ agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
1479
+ videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
1480
+ system: {
1481
+ platform: process.platform,
1482
+ arch: process.arch,
1483
+ osVersion: os.release(),
1484
+ pid: process.pid,
1485
+ cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
1486
+ cpuLogicalCores: Math.max(1, os.cpus().length),
1487
+ memoryTotalBytes: totalMemoryBytes,
1488
+ memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
1489
+ uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
1490
+ gpus: hardwareSnapshot.gpus,
1491
+ disks: hardwareSnapshot.disks,
1492
+ network: networkSnapshot,
1493
+ hardwareCollectedAt: hardwareSnapshot.collectedAt,
1494
+ hardwareCollecting: hardwareSnapshot.collecting,
1495
+ hardwareLastError: hardwareSnapshot.lastError || '',
1496
+ diagnosticProbes: {
1497
+ requestDriven: true,
1498
+ minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1499
+ requestedAt: lastDiagnosticRequestAt,
1500
+ completedAt: lastDiagnosticCompletedAt,
1501
+ platformHardware: hardwareProbeCache.getSnapshot(),
1502
+ nvidiaGpu: nvidiaProbeCache.getSnapshot(),
1503
+ networkTotals: networkProbeCache.getSnapshot()
1504
+ }
1505
+ },
1503
1506
  permissions: {
1504
1507
  screenCapture: state.agent.state === 'running',
1505
1508
  remoteInput: state.agent.state === 'running',
@@ -1528,385 +1531,385 @@ export function createClientRuntimeServer(options = {}) {
1528
1531
  }
1529
1532
  };
1530
1533
 
1531
- const handleRequest = async (req, res) => {
1532
- const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
1533
- const pathname = requestUrl.pathname;
1534
- const requestOrigin = normalizeString(req.headers.origin, 300);
1535
- const respondJson = (status, payload) => sendJson(res, status, payload, requestOrigin, port, trustedWebOrigins);
1536
- const isStaticAppRequest = !pathname.startsWith('/api/');
1537
- const allowReadOnlyCrossSite = READ_ONLY_CLIENT_API_PATHS.has(pathname);
1538
- if (!isTrustedLocalRequest(req, host, port, { allowStaticCrossSite: isStaticAppRequest, allowReadOnlyCrossSite, trustedWebOrigins })) {
1539
- runtime.emit('client.http.rejected', {
1540
- method: normalizeString(req.method, 12),
1541
- pathname: normalizeString(pathname, 160),
1542
- reason: 'untrusted-local-origin',
1543
- origin: requestOrigin,
1544
- fetchSite: normalizeString(req.headers['sec-fetch-site'], 40)
1545
- });
1546
- if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'untrusted-local-origin');
1547
- respondJson(403, { ok: false, error: 'untrusted-local-origin' });
1548
- return;
1549
- }
1550
- if (req.method === 'OPTIONS') {
1551
- res.writeHead(204, {
1552
- 'Access-Control-Allow-Origin': resolveCorsOrigin(requestOrigin, port, trustedWebOrigins),
1553
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1554
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1555
- 'Access-Control-Allow-Private-Network': 'true',
1556
- Vary: 'Origin'
1557
- });
1558
- res.end();
1559
- return;
1534
+ const handleRequest = async (req, res) => {
1535
+ const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
1536
+ const pathname = requestUrl.pathname;
1537
+ const requestOrigin = normalizeString(req.headers.origin, 300);
1538
+ const respondJson = (status, payload) => sendJson(res, status, payload, requestOrigin, port, trustedWebOrigins);
1539
+ const isStaticAppRequest = !pathname.startsWith('/api/');
1540
+ const allowReadOnlyCrossSite = READ_ONLY_CLIENT_API_PATHS.has(pathname);
1541
+ if (!isTrustedLocalRequest(req, host, port, { allowStaticCrossSite: isStaticAppRequest, allowReadOnlyCrossSite, trustedWebOrigins })) {
1542
+ runtime.emit('client.http.rejected', {
1543
+ method: normalizeString(req.method, 12),
1544
+ pathname: normalizeString(pathname, 160),
1545
+ reason: 'untrusted-local-origin',
1546
+ origin: requestOrigin,
1547
+ fetchSite: normalizeString(req.headers['sec-fetch-site'], 40)
1548
+ });
1549
+ if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'untrusted-local-origin');
1550
+ respondJson(403, { ok: false, error: 'untrusted-local-origin' });
1551
+ return;
1560
1552
  }
1561
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '') && req.headers['x-livedesk-csrf'] !== csrfToken) {
1562
- if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'csrf-token-required');
1563
- respondJson(403, { ok: false, error: 'csrf-token-required' });
1553
+ if (req.method === 'OPTIONS') {
1554
+ res.writeHead(204, {
1555
+ 'Access-Control-Allow-Origin': resolveCorsOrigin(requestOrigin, port, trustedWebOrigins),
1556
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1557
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1558
+ 'Access-Control-Allow-Private-Network': 'true',
1559
+ Vary: 'Origin'
1560
+ });
1561
+ res.end();
1562
+ return;
1563
+ }
1564
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '') && req.headers['x-livedesk-csrf'] !== csrfToken) {
1565
+ if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'csrf-token-required');
1566
+ respondJson(403, { ok: false, error: 'csrf-token-required' });
1564
1567
  return;
1565
1568
  }
1566
1569
  if (pathname === '/api/health') {
1567
- respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
1570
+ respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
1571
+ return;
1572
+ }
1573
+ if (pathname === '/api/client/diagnostics') {
1574
+ await requestDiagnosticRefresh();
1575
+ respondJson(200, getState());
1576
+ return;
1577
+ }
1578
+ if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
1579
+ respondJson(200, getState());
1580
+ return;
1581
+ }
1582
+ if (pathname === '/api/runtime/events') {
1583
+ respondJson(200, { ok: true, events: runtime.getEvents() });
1568
1584
  return;
1569
1585
  }
1570
- if (pathname === '/api/client/diagnostics') {
1571
- await requestDiagnosticRefresh();
1572
- respondJson(200, getState());
1573
- return;
1574
- }
1575
- if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
1576
- respondJson(200, getState());
1577
- return;
1578
- }
1579
- if (pathname === '/api/runtime/events') {
1580
- respondJson(200, { ok: true, events: runtime.getEvents() });
1586
+ if (pathname === '/api/auth/status') {
1587
+ const snapshot = runtime.getSnapshot();
1588
+ respondJson(200, {
1589
+ ok: true,
1590
+ authenticated: snapshot.authenticated,
1591
+ userId: snapshot.userId || null,
1592
+ userEmail: state.auth.email || null,
1593
+ userName: state.auth.name || null,
1594
+ userAvatarUrl: state.auth.avatarUrl || null,
1595
+ role: 'client'
1596
+ });
1581
1597
  return;
1582
1598
  }
1583
- if (pathname === '/api/auth/status') {
1584
- const snapshot = runtime.getSnapshot();
1585
- respondJson(200, {
1586
- ok: true,
1587
- authenticated: snapshot.authenticated,
1588
- userId: snapshot.userId || null,
1589
- userEmail: state.auth.email || null,
1590
- userName: state.auth.name || null,
1591
- userAvatarUrl: state.auth.avatarUrl || null,
1592
- role: 'client'
1593
- });
1594
- return;
1595
- }
1596
- if (pathname === '/auth/google' && req.method === 'GET') {
1597
- try {
1598
- if (runtime.getSnapshot().authenticated) {
1599
- res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1600
- res.end();
1601
- return;
1602
- }
1603
- if (typeof options.beginGoogleSignIn !== 'function') {
1604
- throw new Error('client-google-sign-in-unavailable');
1605
- }
1606
- recordAuthAttempt('oauth-starting');
1607
- const redirectTo = `http://${host}:${port}/callback`;
1608
- const result = await options.beginGoogleSignIn(redirectTo);
1609
- const authorizationUrl = normalizeString(result?.url || result, 4000);
1610
- if (!authorizationUrl) throw new Error(result?.error || 'google-authorization-url-missing');
1611
- recordAuthAttempt('oauth-waiting');
1612
- res.writeHead(302, {
1613
- Location: authorizationUrl,
1614
- 'Cache-Control': 'no-store',
1615
- 'Referrer-Policy': 'no-referrer'
1616
- });
1617
- res.end();
1618
- } catch (error) {
1619
- const message = normalizeString(error?.message || error, 240);
1620
- recordAuthAttempt('rejected', message);
1621
- res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1622
- res.end();
1623
- }
1624
- return;
1625
- }
1626
- if (pathname === '/callback' && req.method === 'GET') {
1627
- const providerError = normalizeString(
1628
- requestUrl.searchParams.get('error_description') || requestUrl.searchParams.get('error'),
1629
- 240
1630
- );
1631
- const code = normalizeString(requestUrl.searchParams.get('code'), 2000);
1632
- try {
1633
- if (providerError) throw new Error(providerError);
1634
- if (!code) throw new Error('google-auth-code-missing');
1635
- if (typeof options.exchangeGoogleCode !== 'function') {
1636
- throw new Error('client-google-callback-unavailable');
1637
- }
1638
- recordAuthAttempt('oauth-exchanging');
1639
- const exchanged = await options.exchangeGoogleCode(code);
1640
- const sessionCandidate = exchanged?.session || exchanged;
1641
- const normalized = normalizeRuntimeAuthSession(sessionCandidate, { requireRefreshToken: true });
1642
- if (!normalized.ok) throw new Error(normalized.error);
1643
- const session = {
1644
- ...sessionCandidate,
1645
- ...mergeClientAccountProfile(normalized.session, sessionCandidate?.user)
1646
- };
1647
- if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1648
- state.auth.persisted = true;
1649
- complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1650
- res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1651
- res.end();
1652
- } catch (error) {
1653
- const message = normalizeString(error?.message || error, 240);
1654
- recordAuthAttempt('rejected', message);
1655
- res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1656
- res.end();
1657
- }
1658
- return;
1659
- }
1660
- if (pathname === '/api/auth/session' && req.method === 'POST') {
1661
- const body = parseJsonBody(await readBody(req));
1662
- const normalized = normalizeRuntimeAuthSession(body, { requireRefreshToken: true });
1663
- if (!normalized.ok) {
1664
- recordAuthAttempt('rejected', normalized.error);
1665
- respondJson(400, { ok: false, error: normalized.error });
1666
- return;
1667
- }
1668
- const accessToken = normalized.session.access_token;
1669
- recordAuthAttempt('verifying');
1670
- const verifyResponse = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
1599
+ if (pathname === '/auth/google' && req.method === 'GET') {
1600
+ try {
1601
+ if (runtime.getSnapshot().authenticated) {
1602
+ res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1603
+ res.end();
1604
+ return;
1605
+ }
1606
+ if (typeof options.beginGoogleSignIn !== 'function') {
1607
+ throw new Error('client-google-sign-in-unavailable');
1608
+ }
1609
+ recordAuthAttempt('oauth-starting');
1610
+ const redirectTo = `http://${host}:${port}/callback`;
1611
+ const result = await options.beginGoogleSignIn(redirectTo);
1612
+ const authorizationUrl = normalizeString(result?.url || result, 4000);
1613
+ if (!authorizationUrl) throw new Error(result?.error || 'google-authorization-url-missing');
1614
+ recordAuthAttempt('oauth-waiting');
1615
+ res.writeHead(302, {
1616
+ Location: authorizationUrl,
1617
+ 'Cache-Control': 'no-store',
1618
+ 'Referrer-Policy': 'no-referrer'
1619
+ });
1620
+ res.end();
1621
+ } catch (error) {
1622
+ const message = normalizeString(error?.message || error, 240);
1623
+ recordAuthAttempt('rejected', message);
1624
+ res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1625
+ res.end();
1626
+ }
1627
+ return;
1628
+ }
1629
+ if (pathname === '/callback' && req.method === 'GET') {
1630
+ const providerError = normalizeString(
1631
+ requestUrl.searchParams.get('error_description') || requestUrl.searchParams.get('error'),
1632
+ 240
1633
+ );
1634
+ const code = normalizeString(requestUrl.searchParams.get('code'), 2000);
1635
+ try {
1636
+ if (providerError) throw new Error(providerError);
1637
+ if (!code) throw new Error('google-auth-code-missing');
1638
+ if (typeof options.exchangeGoogleCode !== 'function') {
1639
+ throw new Error('client-google-callback-unavailable');
1640
+ }
1641
+ recordAuthAttempt('oauth-exchanging');
1642
+ const exchanged = await options.exchangeGoogleCode(code);
1643
+ const sessionCandidate = exchanged?.session || exchanged;
1644
+ const normalized = normalizeRuntimeAuthSession(sessionCandidate, { requireRefreshToken: true });
1645
+ if (!normalized.ok) throw new Error(normalized.error);
1646
+ const session = {
1647
+ ...sessionCandidate,
1648
+ ...mergeClientAccountProfile(normalized.session, sessionCandidate?.user)
1649
+ };
1650
+ if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1651
+ state.auth.persisted = true;
1652
+ complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1653
+ res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1654
+ res.end();
1655
+ } catch (error) {
1656
+ const message = normalizeString(error?.message || error, 240);
1657
+ recordAuthAttempt('rejected', message);
1658
+ res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1659
+ res.end();
1660
+ }
1661
+ return;
1662
+ }
1663
+ if (pathname === '/api/auth/session' && req.method === 'POST') {
1664
+ const body = parseJsonBody(await readBody(req));
1665
+ const normalized = normalizeRuntimeAuthSession(body, { requireRefreshToken: true });
1666
+ if (!normalized.ok) {
1667
+ recordAuthAttempt('rejected', normalized.error);
1668
+ respondJson(400, { ok: false, error: normalized.error });
1669
+ return;
1670
+ }
1671
+ const accessToken = normalized.session.access_token;
1672
+ recordAuthAttempt('verifying');
1673
+ const verifyResponse = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
1671
1674
  headers: { apikey: SUPABASE_PUBLISHABLE_KEY, Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
1672
1675
  });
1673
- if (!verifyResponse.ok) {
1674
- recordAuthAttempt('rejected', `supabase-user-verification-failed:${verifyResponse.status}`);
1675
- respondJson(401, { ok: false, error: `supabase-user-verification-failed:${verifyResponse.status}` });
1676
+ if (!verifyResponse.ok) {
1677
+ recordAuthAttempt('rejected', `supabase-user-verification-failed:${verifyResponse.status}`);
1678
+ respondJson(401, { ok: false, error: `supabase-user-verification-failed:${verifyResponse.status}` });
1676
1679
  return;
1677
1680
  }
1678
- const verifiedUser = await verifyResponse.json().catch(() => ({}));
1679
- const verifiedSession = {
1680
- ...normalized.session,
1681
- user: {
1682
- id: normalizeString(verifiedUser?.id || body.user?.id || body.userId, 160),
1683
- email: normalizeString(verifiedUser?.email || body.user?.email || body.email, 320)
1684
- }
1685
- };
1686
- const session = mergeClientAccountProfile(verifiedSession, verifiedUser);
1687
- try {
1688
- if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1689
- state.auth.persisted = true;
1690
- } catch (error) {
1691
- const message = `session-persistence-failed:${normalizeString(error?.message || error, 160)}`;
1692
- recordAuthAttempt('rejected', message);
1693
- respondJson(500, { ok: false, error: message });
1694
- return;
1695
- }
1696
- complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1697
- respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
1681
+ const verifiedUser = await verifyResponse.json().catch(() => ({}));
1682
+ const verifiedSession = {
1683
+ ...normalized.session,
1684
+ user: {
1685
+ id: normalizeString(verifiedUser?.id || body.user?.id || body.userId, 160),
1686
+ email: normalizeString(verifiedUser?.email || body.user?.email || body.email, 320)
1687
+ }
1688
+ };
1689
+ const session = mergeClientAccountProfile(verifiedSession, verifiedUser);
1690
+ try {
1691
+ if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1692
+ state.auth.persisted = true;
1693
+ } catch (error) {
1694
+ const message = `session-persistence-failed:${normalizeString(error?.message || error, 160)}`;
1695
+ recordAuthAttempt('rejected', message);
1696
+ respondJson(500, { ok: false, error: message });
1697
+ return;
1698
+ }
1699
+ complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1700
+ respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
1701
+ return;
1702
+ }
1703
+ if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1704
+ const activeSession = lastChoice?.session || savedSession || readSavedSession();
1705
+ const providerLogout = await revokeClientProviderSession(activeSession, {
1706
+ revokeProviderSession: options.revokeProviderSession,
1707
+ fetchImpl: options.fetchImpl
1708
+ });
1709
+ clearSavedSession();
1710
+ loggedOut = true;
1711
+ completed = false;
1712
+ lastChoice = null;
1713
+ state.manager = '';
1714
+ state.message = 'Saved sign-in was cleared. Sign in again to start the Client.';
1715
+ state.lastError = '';
1716
+ state.auth = {
1717
+ lastAttemptAt: new Date().toISOString(),
1718
+ lastResult: 'cleared',
1719
+ lastError: '',
1720
+ persisted: false,
1721
+ email: '',
1722
+ name: '',
1723
+ avatarUrl: ''
1724
+ };
1725
+ runtime.setAuthenticated(false);
1726
+ respondJson(providerLogout.ok ? 200 : 502, {
1727
+ ok: providerLogout.ok,
1728
+ authenticated: false,
1729
+ localCleared: true,
1730
+ role: 'client',
1731
+ providerLogout
1732
+ });
1698
1733
  return;
1699
- }
1700
- if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1701
- const activeSession = lastChoice?.session || savedSession || readSavedSession();
1702
- const providerLogout = await revokeClientProviderSession(activeSession, {
1703
- revokeProviderSession: options.revokeProviderSession,
1704
- fetchImpl: options.fetchImpl
1705
- });
1706
- clearSavedSession();
1707
- loggedOut = true;
1708
- completed = false;
1709
- lastChoice = null;
1710
- state.manager = '';
1711
- state.message = 'Saved sign-in was cleared. Sign in again to start the Client.';
1712
- state.lastError = '';
1713
- state.auth = {
1714
- lastAttemptAt: new Date().toISOString(),
1715
- lastResult: 'cleared',
1716
- lastError: '',
1717
- persisted: false,
1718
- email: '',
1719
- name: '',
1720
- avatarUrl: ''
1721
- };
1722
- runtime.setAuthenticated(false);
1723
- respondJson(providerLogout.ok ? 200 : 502, {
1724
- ok: providerLogout.ok,
1725
- authenticated: false,
1726
- localCleared: true,
1727
- role: 'client',
1728
- providerLogout
1729
- });
1730
- return;
1731
1734
  }
1732
1735
  if (pathname === '/api/runtime/restart' && req.method === 'POST') {
1733
1736
  runtime.emit('runtime.restart.requested', { role: 'client' });
1734
- respondJson(200, { ok: true, restarting: true, role: 'client' });
1737
+ respondJson(200, { ok: true, restarting: true, role: 'client' });
1735
1738
  setTimeout(() => options.onRestart?.(), 150);
1736
1739
  return;
1737
1740
  }
1738
- if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
1739
- runtime.emit('runtime.shutdown.requested', { role: 'client' });
1740
- respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
1741
- setTimeout(() => options.onShutdown?.(), 150);
1742
- return;
1743
- }
1744
- if (pathname === '/api/client/slot' && req.method === 'POST') {
1745
- const body = parseJsonBody(await readBody(req));
1746
- const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
1747
- if (!slotNumber) {
1748
- respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
1749
- return;
1750
- }
1751
- if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
1752
- respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
1753
- return;
1754
- }
1755
- if (typeof options.assignSlot !== 'function') {
1756
- respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
1757
- return;
1758
- }
1759
- runtime.emit('client.slot.assignment-requested', {
1760
- deviceId,
1761
- previousSlotNumber: state.slotNumber,
1762
- slotNumber
1763
- });
1764
- try {
1765
- const result = await options.assignSlot({
1766
- manager: state.manager,
1767
- pairToken: state.pairToken,
1768
- deviceId,
1769
- slotNumber
1770
- });
1771
- if (result?.ok !== true) {
1772
- const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
1773
- runtime.emit('client.slot.assignment-rejected', {
1774
- deviceId,
1775
- slotNumber,
1776
- error,
1777
- conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
1778
- });
1779
- respondJson(error === 'slot-taken' ? 409 : 502, {
1780
- ...(result && typeof result === 'object' ? result : {}),
1781
- ok: false,
1782
- error,
1783
- slotNumber: state.slotNumber
1784
- });
1785
- return;
1786
- }
1787
- state.slotNumber = slotNumber;
1788
- state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
1789
- runtime.emit('client.slot.assigned', { deviceId, slotNumber });
1790
- respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
1791
- } catch (error) {
1792
- const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
1793
- runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
1794
- respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
1795
- }
1796
- return;
1797
- }
1798
- if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
1799
- if (process.platform !== 'linux') {
1800
- respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
1801
- return;
1802
- }
1803
- if (state.videoAcceleration?.busy) {
1804
- respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
1805
- return;
1806
- }
1807
- if (typeof options.installVideoAcceleration !== 'function') {
1808
- respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
1809
- return;
1810
- }
1811
- state.videoAcceleration = {
1812
- ...(state.videoAcceleration || {}),
1813
- supported: true,
1814
- state: 'installing',
1815
- busy: true,
1816
- ready: false,
1817
- error: '',
1818
- message: 'Installing Linux hardware video support…'
1819
- };
1820
- runtime.emit('client.video-acceleration.installing', {
1821
- packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
1822
- });
1823
- try {
1824
- const result = await options.installVideoAcceleration();
1825
- state.videoAcceleration = {
1826
- ...(result && typeof result === 'object' ? result : {}),
1827
- busy: false
1828
- };
1829
- const ok = state.videoAcceleration.ready === true;
1830
- runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
1831
- encoder: normalizeString(state.videoAcceleration.encoder, 80),
1832
- driver: normalizeString(state.videoAcceleration.driver, 80),
1833
- error: normalizeString(state.videoAcceleration.error, 240)
1834
- });
1835
- respondJson(ok ? 200 : 409, { ...getState(), ok });
1836
- if (ok && state.videoAcceleration.restartAgent) {
1837
- setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
1838
- }
1839
- } catch (error) {
1840
- state.videoAcceleration = {
1841
- ...(state.videoAcceleration || {}),
1842
- state: 'error',
1843
- busy: false,
1844
- ready: false,
1845
- error: normalizeString(error?.message || error, 320)
1846
- };
1847
- runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
1848
- respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
1849
- }
1850
- return;
1851
- }
1852
- if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
1741
+ if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
1742
+ runtime.emit('runtime.shutdown.requested', { role: 'client' });
1743
+ respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
1744
+ setTimeout(() => options.onShutdown?.(), 150);
1745
+ return;
1746
+ }
1747
+ if (pathname === '/api/client/slot' && req.method === 'POST') {
1748
+ const body = parseJsonBody(await readBody(req));
1749
+ const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
1750
+ if (!slotNumber) {
1751
+ respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
1752
+ return;
1753
+ }
1754
+ if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
1755
+ respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
1756
+ return;
1757
+ }
1758
+ if (typeof options.assignSlot !== 'function') {
1759
+ respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
1760
+ return;
1761
+ }
1762
+ runtime.emit('client.slot.assignment-requested', {
1763
+ deviceId,
1764
+ previousSlotNumber: state.slotNumber,
1765
+ slotNumber
1766
+ });
1767
+ try {
1768
+ const result = await options.assignSlot({
1769
+ manager: state.manager,
1770
+ pairToken: state.pairToken,
1771
+ deviceId,
1772
+ slotNumber
1773
+ });
1774
+ if (result?.ok !== true) {
1775
+ const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
1776
+ runtime.emit('client.slot.assignment-rejected', {
1777
+ deviceId,
1778
+ slotNumber,
1779
+ error,
1780
+ conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
1781
+ });
1782
+ respondJson(error === 'slot-taken' ? 409 : 502, {
1783
+ ...(result && typeof result === 'object' ? result : {}),
1784
+ ok: false,
1785
+ error,
1786
+ slotNumber: state.slotNumber
1787
+ });
1788
+ return;
1789
+ }
1790
+ state.slotNumber = slotNumber;
1791
+ state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
1792
+ runtime.emit('client.slot.assigned', { deviceId, slotNumber });
1793
+ respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
1794
+ } catch (error) {
1795
+ const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
1796
+ runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
1797
+ respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
1798
+ }
1799
+ return;
1800
+ }
1801
+ if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
1802
+ if (process.platform !== 'linux') {
1803
+ respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
1804
+ return;
1805
+ }
1806
+ if (state.videoAcceleration?.busy) {
1807
+ respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
1808
+ return;
1809
+ }
1810
+ if (typeof options.installVideoAcceleration !== 'function') {
1811
+ respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
1812
+ return;
1813
+ }
1814
+ state.videoAcceleration = {
1815
+ ...(state.videoAcceleration || {}),
1816
+ supported: true,
1817
+ state: 'installing',
1818
+ busy: true,
1819
+ ready: false,
1820
+ error: '',
1821
+ message: 'Installing Linux hardware video support…'
1822
+ };
1823
+ runtime.emit('client.video-acceleration.installing', {
1824
+ packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
1825
+ });
1826
+ try {
1827
+ const result = await options.installVideoAcceleration();
1828
+ state.videoAcceleration = {
1829
+ ...(result && typeof result === 'object' ? result : {}),
1830
+ busy: false
1831
+ };
1832
+ const ok = state.videoAcceleration.ready === true;
1833
+ runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
1834
+ encoder: normalizeString(state.videoAcceleration.encoder, 80),
1835
+ driver: normalizeString(state.videoAcceleration.driver, 80),
1836
+ error: normalizeString(state.videoAcceleration.error, 240)
1837
+ });
1838
+ respondJson(ok ? 200 : 409, { ...getState(), ok });
1839
+ if (ok && state.videoAcceleration.restartAgent) {
1840
+ setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
1841
+ }
1842
+ } catch (error) {
1843
+ state.videoAcceleration = {
1844
+ ...(state.videoAcceleration || {}),
1845
+ state: 'error',
1846
+ busy: false,
1847
+ ready: false,
1848
+ error: normalizeString(error?.message || error, 320)
1849
+ };
1850
+ runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
1851
+ respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
1852
+ }
1853
+ return;
1854
+ }
1855
+ if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
1853
1856
  const body = parseJsonBody(await readBody(req));
1854
1857
  if (String(body.role || '').trim().toLowerCase() !== 'hub') {
1855
- respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1858
+ respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1856
1859
  return;
1857
1860
  }
1858
1861
  try {
1859
- const result = await options.changeRole?.(
1860
- 'hub',
1861
- runtime.getSnapshot(),
1862
- lastChoice?.session || savedSession || null
1863
- );
1864
- respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1862
+ const result = await options.changeRole?.(
1863
+ 'hub',
1864
+ runtime.getSnapshot(),
1865
+ lastChoice?.session || savedSession || null
1866
+ );
1867
+ respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1865
1868
  } catch (error) {
1866
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1869
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1867
1870
  }
1868
1871
  return;
1869
1872
  }
1870
1873
  if (pathname === '/api/client/role' && req.method === 'POST') {
1871
1874
  const body = parseJsonBody(await readBody(req));
1872
1875
  if (String(body.role || '').trim().toLowerCase() !== 'hub') {
1873
- respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1876
+ respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1874
1877
  return;
1875
1878
  }
1876
1879
  try {
1877
- const result = await options.changeRole?.(
1878
- 'hub',
1879
- runtime.getSnapshot(),
1880
- lastChoice?.session || savedSession || null
1881
- );
1882
- respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1880
+ const result = await options.changeRole?.(
1881
+ 'hub',
1882
+ runtime.getSnapshot(),
1883
+ lastChoice?.session || savedSession || null
1884
+ );
1885
+ respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1883
1886
  } catch (error) {
1884
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1887
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1885
1888
  }
1886
1889
  return;
1887
- }
1888
- if (pathname === '/api/client/reconnect' && req.method === 'POST') {
1889
- const body = parseJsonBody(await readBody(req));
1890
- if (body.onlyIfWaiting === true && state.agent.state === 'running') {
1891
- respondJson(200, { ok: true, skipped: true, reason: 'already-connected', ...getState() });
1892
- return;
1893
- }
1894
- const previousManager = state.manager;
1895
- state.manager = '';
1896
- state.agent.state = 'reconnecting';
1897
- state.lastError = '';
1898
- state.message = 'Refreshing the LiveDesk Hub discovery now.';
1899
- runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'client-reconnect-requested');
1900
- runtime.emit('client.reconnect.requested', { deviceId, previousManager: previousManager || '' });
1901
- await options.onReconnect?.();
1902
- respondJson(200, { ok: true, ...getState() });
1890
+ }
1891
+ if (pathname === '/api/client/reconnect' && req.method === 'POST') {
1892
+ const body = parseJsonBody(await readBody(req));
1893
+ if (body.onlyIfWaiting === true && state.agent.state === 'running') {
1894
+ respondJson(200, { ok: true, skipped: true, reason: 'already-connected', ...getState() });
1895
+ return;
1896
+ }
1897
+ const previousManager = state.manager;
1898
+ state.manager = '';
1899
+ state.agent.state = 'reconnecting';
1900
+ state.lastError = '';
1901
+ state.message = 'Refreshing the LiveDesk Hub discovery now.';
1902
+ runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'client-reconnect-requested');
1903
+ runtime.emit('client.reconnect.requested', { deviceId, previousManager: previousManager || '' });
1904
+ await options.onReconnect?.();
1905
+ respondJson(200, { ok: true, ...getState() });
1903
1906
  return;
1904
1907
  }
1905
1908
  if (pathname === '/api/client/disconnect' && req.method === 'POST') {
1906
1909
  await options.onDisconnect?.();
1907
1910
  state.manager = '';
1908
1911
  state.agent.state = 'waiting';
1909
- respondJson(200, { ok: true, ...getState() });
1912
+ respondJson(200, { ok: true, ...getState() });
1910
1913
  return;
1911
1914
  }
1912
1915
  if (pathname === '/api/client/pin' && req.method === 'POST') {
@@ -1917,181 +1920,192 @@ export function createClientRuntimeServer(options = {}) {
1917
1920
  if (!resolved) throw new Error('pin-not-resolved');
1918
1921
  state.message = 'PIN accepted. Starting the Client.';
1919
1922
  complete({ type: 'pin', ...resolved }, state.message);
1920
- respondJson(200, { ok: true, ...getState() });
1923
+ respondJson(200, { ok: true, ...getState() });
1921
1924
  } catch (error) {
1922
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1925
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1923
1926
  }
1924
1927
  return;
1925
1928
  }
1926
1929
  if (pathname.startsWith('/api/')) {
1927
- respondJson(409, runtimeRoleError('client', 'client'));
1930
+ respondJson(409, runtimeRoleError('client', 'client'));
1928
1931
  return;
1929
1932
  }
1930
1933
  serveApp(req, res, pathname);
1931
1934
  };
1932
1935
 
1933
- const start = () => new Promise((resolveStart, rejectStart) => {
1934
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1935
- let settled = false;
1936
- server = createServer((req, res) => {
1936
+ const start = () => new Promise((resolveStart, rejectStart) => {
1937
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1938
+ let settled = false;
1939
+ server = createServer((req, res) => {
1937
1940
  void handleRequest(req, res).catch(error => {
1938
- if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1941
+ if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1939
1942
  else res.end();
1940
- });
1941
- });
1942
- const bindTimer = setTimeout(() => {
1943
- if (settled) return;
1944
- settled = true;
1945
- try { server.close(); } catch { /* bind never completed */ }
1946
- rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
1947
- }, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
1948
- bindTimer.unref?.();
1949
- server.once('error', error => {
1950
- if (settled) return;
1951
- settled = true;
1952
- clearTimeout(bindTimer);
1953
- rejectStart(error);
1954
- });
1955
- server.listen(port, host, () => {
1956
- if (settled) {
1957
- try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
1958
- return;
1959
- }
1960
- settled = true;
1961
- clearTimeout(bindTimer);
1962
- runtime.emit('runtime.http.started', { host, port });
1963
- if (initialChoice) {
1964
- setImmediate(() => {
1965
- complete(initialChoice, initialChoiceMessage);
1966
- });
1967
- } else {
1968
- const saved = normalizeRuntimeAuthSession(savedSession, { requireRefreshToken: true });
1969
- if (saved.ok) {
1970
- setImmediate(() => {
1971
- void (async () => {
1972
- try {
1973
- // Older persisted sessions kept only id/email on `user`, while
1974
- // Supabase still carries Google name/avatar claims in the
1975
- // access-token payload. Recover those display-only claims
1976
- // locally before any best-effort network hydration.
1977
- const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
1978
- const hydratedSession = await hydrateClientAccountProfile(normalizedSession);
1979
- if (!writeSavedSession(hydratedSession)) throw new Error('refresh-token-required');
1980
- state.auth.persisted = true;
1981
- complete({ type: 'google', session: hydratedSession }, 'Saved sign-in found. Finding the LiveDesk Hub.');
1982
- } catch (error) {
1983
- recordAuthAttempt('rejected', `session-persistence-failed:${normalizeString(error?.message || error, 160)}`);
1984
- }
1985
- })();
1986
- });
1987
- }
1988
- }
1989
- resolveStart();
1990
- });
1991
- });
1992
-
1993
- const closeHttpServer = timeoutMs => new Promise(resolveClose => {
1994
- if (!server) {
1995
- resolveClose({ ok: true, listening: false });
1996
- return;
1997
- }
1998
- let settled = false;
1999
- const finish = ok => {
2000
- if (settled) return;
2001
- settled = true;
2002
- clearTimeout(timer);
2003
- resolveClose({ ok, listening: Boolean(server?.listening) });
2004
- };
2005
- const timer = setTimeout(() => {
2006
- try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
2007
- finish(!server.listening);
2008
- }, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
2009
- try {
2010
- server.close(error => finish(!error));
2011
- server.closeIdleConnections?.();
2012
- } catch {
2013
- finish(true);
2014
- }
2015
- });
2016
-
2017
- const closeRuntime = async () => {
2018
- const closeStartedAt = Date.now();
2019
- closed = true;
2020
- runtime.emit('runtime.http.stopping', { port });
2021
-
2022
- const taskOwners = [...diagnosticTasks];
2023
- const [diagnosticClose, httpClose] = await Promise.all([
2024
- diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
2025
- closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
2026
- ]);
2027
- if (taskOwners.length > 0) {
2028
- const remainingCloseMs = Math.max(
2029
- 0,
2030
- CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
2031
- );
2032
- if (remainingCloseMs > 0) {
2033
- await Promise.race([
2034
- Promise.allSettled(taskOwners),
2035
- new Promise(resolve => setTimeout(resolve, remainingCloseMs))
2036
- ]);
2037
- }
2038
- }
2039
- const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
2040
- const result = {
2041
- ok: diagnosticClose.ok === true
2042
- && httpClose.ok === true
2043
- && diagnosticTasks.size === 0
2044
- && finalDiagnostics.activeCount === 0
2045
- && finalDiagnostics.redirectedStreamCount === 0,
2046
- diagnostics: {
2047
- ...diagnosticClose,
2048
- taskCount: diagnosticTasks.size
2049
- },
2050
- http: httpClose
2051
- };
2052
- runtime.emit(
2053
- result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
2054
- {
2055
- ownerId: finalDiagnostics.ownerId,
2056
- activeCount: finalDiagnostics.activeCount,
2057
- redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
2058
- taskCount: diagnosticTasks.size
2059
- }
2060
- );
2061
- return result;
2062
- };
2063
-
2064
- return {
1943
+ });
1944
+ });
1945
+ const bindTimer = setTimeout(() => {
1946
+ if (settled) return;
1947
+ settled = true;
1948
+ try { server.close(); } catch { /* bind never completed */ }
1949
+ rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
1950
+ }, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
1951
+ bindTimer.unref?.();
1952
+ server.once('error', error => {
1953
+ if (settled) return;
1954
+ settled = true;
1955
+ clearTimeout(bindTimer);
1956
+ rejectStart(error);
1957
+ });
1958
+ server.listen(port, host, () => {
1959
+ if (settled) {
1960
+ try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
1961
+ return;
1962
+ }
1963
+ settled = true;
1964
+ clearTimeout(bindTimer);
1965
+ runtime.emit('runtime.http.started', { host, port });
1966
+ if (initialChoice) {
1967
+ setImmediate(() => {
1968
+ complete(initialChoice, initialChoiceMessage);
1969
+ });
1970
+ } else {
1971
+ const saved = normalizeRuntimeAuthSession(savedSession, { requireRefreshToken: true });
1972
+ if (saved.ok) {
1973
+ setImmediate(() => {
1974
+ // Hub discovery must not wait for a display-only user-profile
1975
+ // request. The launcher already refreshed and installed this
1976
+ // DPAPI/keychain-backed session before starting the runtime.
1977
+ const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
1978
+ complete(
1979
+ { type: 'google', session: normalizedSession },
1980
+ 'Saved sign-in restored. Finding the LiveDesk Hub.'
1981
+ );
1982
+ void trackDiagnosticTask(async () => {
1983
+ try {
1984
+ const hydratedSession = await hydrateAccountProfile(normalizedSession);
1985
+ if (closed || !hydratedSession?.access_token) return;
1986
+ if (writeSavedSession(hydratedSession)) {
1987
+ state.auth.persisted = true;
1988
+ }
1989
+ // complete() updates profile fields after startup without
1990
+ // restarting discovery or opening another OAuth flow.
1991
+ complete(
1992
+ { type: 'google', session: hydratedSession },
1993
+ 'Saved sign-in restored. Finding the LiveDesk Hub.'
1994
+ );
1995
+ } catch {
1996
+ // Name/avatar hydration is optional. A provider timeout must
1997
+ // never invalidate the local session or interrupt remote work.
1998
+ }
1999
+ });
2000
+ });
2001
+ }
2002
+ }
2003
+ resolveStart();
2004
+ });
2005
+ });
2006
+
2007
+ const closeHttpServer = timeoutMs => new Promise(resolveClose => {
2008
+ if (!server) {
2009
+ resolveClose({ ok: true, listening: false });
2010
+ return;
2011
+ }
2012
+ let settled = false;
2013
+ const finish = ok => {
2014
+ if (settled) return;
2015
+ settled = true;
2016
+ clearTimeout(timer);
2017
+ resolveClose({ ok, listening: Boolean(server?.listening) });
2018
+ };
2019
+ const timer = setTimeout(() => {
2020
+ try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
2021
+ finish(!server.listening);
2022
+ }, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
2023
+ try {
2024
+ server.close(error => finish(!error));
2025
+ server.closeIdleConnections?.();
2026
+ } catch {
2027
+ finish(true);
2028
+ }
2029
+ });
2030
+
2031
+ const closeRuntime = async () => {
2032
+ const closeStartedAt = Date.now();
2033
+ closed = true;
2034
+ runtime.emit('runtime.http.stopping', { port });
2035
+
2036
+ const taskOwners = [...diagnosticTasks];
2037
+ const [diagnosticClose, httpClose] = await Promise.all([
2038
+ diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
2039
+ closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
2040
+ ]);
2041
+ if (taskOwners.length > 0) {
2042
+ const remainingCloseMs = Math.max(
2043
+ 0,
2044
+ CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
2045
+ );
2046
+ if (remainingCloseMs > 0) {
2047
+ await Promise.race([
2048
+ Promise.allSettled(taskOwners),
2049
+ new Promise(resolve => setTimeout(resolve, remainingCloseMs))
2050
+ ]);
2051
+ }
2052
+ }
2053
+ const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
2054
+ const result = {
2055
+ ok: diagnosticClose.ok === true
2056
+ && httpClose.ok === true
2057
+ && diagnosticTasks.size === 0
2058
+ && finalDiagnostics.activeCount === 0
2059
+ && finalDiagnostics.redirectedStreamCount === 0,
2060
+ diagnostics: {
2061
+ ...diagnosticClose,
2062
+ taskCount: diagnosticTasks.size
2063
+ },
2064
+ http: httpClose
2065
+ };
2066
+ runtime.emit(
2067
+ result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
2068
+ {
2069
+ ownerId: finalDiagnostics.ownerId,
2070
+ activeCount: finalDiagnostics.activeCount,
2071
+ redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
2072
+ taskCount: diagnosticTasks.size
2073
+ }
2074
+ );
2075
+ return result;
2076
+ };
2077
+
2078
+ return {
2065
2079
  runtime,
2066
- waitForChoice,
2067
- start,
2068
- get url() { return `http://${host}:${port}/`; },
2069
- getLastChoice() { return lastChoice; },
2070
- acceptChoice(choice, message) {
2071
- complete(choice, message);
2072
- return lastChoice;
2073
- },
2074
- update(patch = {}) {
2080
+ waitForChoice,
2081
+ start,
2082
+ get url() { return `http://${host}:${port}/`; },
2083
+ getLastChoice() { return lastChoice; },
2084
+ acceptChoice(choice, message) {
2085
+ complete(choice, message);
2086
+ return lastChoice;
2087
+ },
2088
+ update(patch = {}) {
2075
2089
  Object.assign(state, patch);
2076
2090
  if (patch.agent && typeof patch.agent === 'object') {
2077
2091
  state.agent = { ...state.agent, ...patch.agent };
2078
2092
  runtime.setAgentPid(Number.isInteger(patch.agent.pid) ? patch.agent.pid : runtime.getSnapshot().agentPid);
2079
2093
  if (patch.agent.state === 'running') runtime.update({ state: RuntimeState.RUNNING }, 'agent-running');
2080
2094
  }
2081
- if (patch.manager) state.manager = normalizeString(patch.manager, 256);
2082
- if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
2083
- if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
2084
- if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
2085
- state.assignedHubId = normalizeString(patch.assignedHubId, 160);
2086
- }
2095
+ if (patch.manager) state.manager = normalizeString(patch.manager, 256);
2096
+ if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
2097
+ if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
2098
+ if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
2099
+ state.assignedHubId = normalizeString(patch.assignedHubId, 160);
2100
+ }
2087
2101
  if (patch.message) state.message = normalizeString(patch.message, 1000);
2088
2102
  if (Number.isInteger(patch.roleVersion)) state.roleVersion = patch.roleVersion;
2089
2103
  if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;
2090
2104
  },
2091
- close() {
2092
- if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2093
- return runtimeClosePromise;
2094
- },
2105
+ close() {
2106
+ if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2107
+ return runtimeClosePromise;
2108
+ },
2095
2109
  getRoleRestartRequest() { return options.getRoleRestartRequest?.() || null; }
2096
2110
  };
2097
2111
  }