livedesk 0.1.498 → 0.1.501

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/livedesk.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
35
35
  import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
36
36
  import {
37
+ buildUpdateHostWorkerEnvironment,
37
38
  cancelUpdateHostJob,
38
39
  ensureUpdateHost,
39
40
  getUpdateHostJob,
@@ -2640,9 +2641,9 @@ async function runManager(args, resolvedRole = null, runtimeLock = null, updateH
2640
2641
  );
2641
2642
  const packageTarget = `livedesk@${requestedVersion}`;
2642
2643
  const nextArgs = [
2643
- '-y',
2644
- '--prefer-online',
2645
- '--prefix',
2644
+ '-y',
2645
+ '--prefer-offline',
2646
+ '--prefix',
2646
2647
  neutralCwd,
2647
2648
  '--workspaces=false',
2648
2649
  packageTarget,
@@ -2680,8 +2681,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null, updateH
2680
2681
  reportLauncherUpdate(`[LiveDesk Hub] Could not persist the restart supervisor state: ${error instanceof Error ? error.message : String(error)}.`, true);
2681
2682
  throw error;
2682
2683
  }
2683
- const workerEnvironment = {
2684
- ...process.env,
2684
+ const workerEnvironment = buildUpdateHostWorkerEnvironment(process.env, {
2685
2685
  LIVEDESK_RESTART_WAIT_PID: String(process.pid),
2686
2686
  LIVEDESK_RESTART_PREVIOUS_RUNTIME_PID: String(request?.pid || 0),
2687
2687
  LIVEDESK_RESTART_OPERATION_ID: operationId,
@@ -2701,7 +2701,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null, updateH
2701
2701
  LIVEDESK_RESTART_FALLBACK_CWD: originalCwd,
2702
2702
  LIVEDESK_UPDATE_ORIGINAL_CWD: originalCwd,
2703
2703
  LIVEDESK_RESTART_LOG_PATH: hubLogPath
2704
- };
2704
+ });
2705
2705
  const hostJobId = `hub-${safeUpdatePathSegment(operationId)}`;
2706
2706
  let hostJob;
2707
2707
  try {
@@ -20,6 +20,12 @@ import {
20
20
  createWindowsProcessTreeTracker,
21
21
  drainOwnedWindowsAgentTreeUntilStopped
22
22
  } from '../client/src/runtime/agent-process-lifecycle.js';
23
+ import {
24
+ addExactNpxRecoveryCacheArgument,
25
+ isInterruptedExactNpxInstallFailure,
26
+ normalizeExactNpxRecoveryCache,
27
+ prepareExactNpxCacheRecovery
28
+ } from './npm-update-cache.mjs';
23
29
 
24
30
  const DEFAULT_RUNTIME_PORT = 5179;
25
31
  const TARGET_ATTEMPT_LIMIT = 3;
@@ -169,8 +175,8 @@ export function buildLegacyClientUpdateNpxEnvironment(baseEnv, neutralCwd) {
169
175
  return env;
170
176
  }
171
177
 
172
- function buildExactNpxArgs(neutralCwd, preference, version, args = []) {
173
- return [
178
+ function buildExactNpxArgs(neutralCwd, preference, version, args = [], recoveryCache = '') {
179
+ return addExactNpxRecoveryCacheArgument([
174
180
  '-y',
175
181
  preference,
176
182
  '--prefix',
@@ -178,7 +184,7 @@ function buildExactNpxArgs(neutralCwd, preference, version, args = []) {
178
184
  '--workspaces=false',
179
185
  `livedesk@${version}`,
180
186
  ...args
181
- ];
187
+ ], recoveryCache);
182
188
  }
183
189
 
184
190
  function positivePid(value) {
@@ -1018,21 +1024,36 @@ export function prefetchExactNpxRestore(config, env = process.env, options = {})
1018
1024
  throw new Error('LiveDesk cannot prefetch an exact restore without the current product version.');
1019
1025
  }
1020
1026
  const neutralCwd = prepareNeutralCwd(config?.neutralCwd);
1021
- const invocation = resolveNpxInvocation(
1022
- buildExactNpxArgs(neutralCwd, '--prefer-online', version, ['--version']),
1023
- env
1024
- );
1025
- const result = (options.spawnSyncImpl || spawnSync)(
1026
- invocation.command,
1027
- invocation.args,
1028
- {
1027
+ const packageSpec = `livedesk@${version}`;
1028
+ const spawnSyncImpl = options.spawnSyncImpl || spawnSync;
1029
+ const runPrefetch = args => {
1030
+ const invocation = resolveNpxInvocation(args, env);
1031
+ return spawnSyncImpl(invocation.command, invocation.args, {
1029
1032
  cwd: neutralCwd,
1030
1033
  env: buildLegacyClientUpdateNpxEnvironment(env, neutralCwd),
1031
1034
  encoding: 'utf8',
1032
1035
  timeout: options.timeoutMs ?? 120_000,
1033
1036
  windowsHide: true
1034
- }
1035
- );
1037
+ });
1038
+ };
1039
+ let prepared = prepareExactNpxCacheRecovery({
1040
+ args: buildExactNpxArgs(neutralCwd, '--prefer-offline', version, ['--version']),
1041
+ env,
1042
+ operationId: config?.operationId,
1043
+ phase: 'restore-prefetch'
1044
+ });
1045
+ let result = runPrefetch(prepared.args);
1046
+ if (!prepared.recoveryCache
1047
+ && isInterruptedExactNpxInstallFailure(result, packageSpec, env)) {
1048
+ prepared = prepareExactNpxCacheRecovery({
1049
+ args: prepared.args,
1050
+ env,
1051
+ operationId: config?.operationId,
1052
+ phase: 'restore-prefetch-retry',
1053
+ force: true
1054
+ });
1055
+ result = runPrefetch(prepared.args);
1056
+ }
1036
1057
  const output = `${result?.stdout || ''}\n${result?.stderr || ''}`.trim();
1037
1058
  if (result?.error || result?.status !== 0) {
1038
1059
  throw new Error(
@@ -1055,7 +1076,8 @@ export function prefetchExactNpxRestore(config, env = process.env, options = {})
1055
1076
  strategy: 'npx-exact-connected-proof',
1056
1077
  productVersion: version,
1057
1078
  agentVersion: cleanVersion(config?.currentAgentVersion),
1058
- preference: '--prefer-offline'
1079
+ preference: '--prefer-offline',
1080
+ recoveryCache: prepared.recoveryCache
1059
1081
  };
1060
1082
  }
1061
1083
 
@@ -1076,9 +1098,15 @@ export function spawnReplacement(version, preference, env = process.env, restart
1076
1098
  const neutralCwd = isLocalRestore
1077
1099
  ? ''
1078
1100
  : prepareNeutralCwd(env.LIVEDESK_UPDATE_NEUTRAL_CWD);
1101
+ const recoveryCache = isLocalRestore
1102
+ ? ''
1103
+ : normalizeExactNpxRecoveryCache(env.LIVEDESK_CLIENT_UPDATE_NPX_CACHE);
1079
1104
  const invocation = isLocalRestore
1080
1105
  ? { command: process.execPath, args: [localRestoreLauncher, ...roleArgs] }
1081
- : resolveNpxInvocation(buildExactNpxArgs(neutralCwd, preference, version, roleArgs), env);
1106
+ : resolveNpxInvocation(
1107
+ buildExactNpxArgs(neutralCwd, preference, version, roleArgs, recoveryCache),
1108
+ env
1109
+ );
1082
1110
  const replacementEnv = isLocalRestore
1083
1111
  ? { ...env }
1084
1112
  : buildLegacyClientUpdateNpxEnvironment(env, neutralCwd);
@@ -1344,6 +1372,9 @@ export async function launchAndVerifyLegacyClientReplacement(
1344
1372
  LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER: kind === 'restore'
1345
1373
  ? String(config.localRestoreLauncher?.entryPath || '')
1346
1374
  : '',
1375
+ LIVEDESK_CLIENT_UPDATE_NPX_CACHE: kind === 'restore'
1376
+ ? String(config.npxRestore?.recoveryCache || '')
1377
+ : '',
1347
1378
  LIVEDESK_CLIENT_RUNTIME_PORT: String(config.runtimePort),
1348
1379
  LIVEDESK_DEVICE_ID: config.deviceId,
1349
1380
  LIVEDESK_SKIP_BROWSER_OPEN: '1'
@@ -1691,7 +1722,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
1691
1722
  config.targetProductVersion,
1692
1723
  'target',
1693
1724
  attempt,
1694
- '--prefer-online',
1725
+ '--prefer-offline',
1695
1726
  config.targetVersion
1696
1727
  );
1697
1728
  ownershipCheck();
@@ -0,0 +1,158 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import { isAbsolute, join, resolve, sep } from 'node:path';
5
+
6
+ const EXACT_LIVEDESK_PACKAGE_PATTERN = /^livedesk@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
7
+
8
+ function environmentValue(env, name) {
9
+ const expected = String(name || '').toLowerCase();
10
+ const entry = Object.entries(env || {}).find(([key]) => String(key).toLowerCase() === expected);
11
+ return String(entry?.[1] || '').trim();
12
+ }
13
+
14
+ function safePathSegment(value, fallback = 'unknown') {
15
+ return String(value || '')
16
+ .replace(/[^A-Za-z0-9._-]/g, '_')
17
+ .slice(0, 100) || fallback;
18
+ }
19
+
20
+ function pathInside(root, candidate) {
21
+ const base = resolve(root);
22
+ const target = resolve(candidate);
23
+ return target === base || target.startsWith(`${base}${sep}`);
24
+ }
25
+
26
+ function readJson(filePath) {
27
+ try {
28
+ return JSON.parse(readFileSync(filePath, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function parseExactLiveDeskPackageSpec(value) {
35
+ const match = EXACT_LIVEDESK_PACKAGE_PATTERN.exec(String(value || '').trim());
36
+ return match ? { packageSpec: match[0], version: match[1] } : null;
37
+ }
38
+
39
+ export function getExactNpxInstallHash(packageSpec) {
40
+ return createHash('sha512').update(String(packageSpec || '')).digest('hex').slice(0, 16);
41
+ }
42
+
43
+ export function resolveNpmCacheRoot(env = process.env) {
44
+ const configured = environmentValue(env, 'npm_config_cache');
45
+ if (configured) return resolve(configured);
46
+ if (process.platform === 'win32') {
47
+ const localAppData = environmentValue(env, 'LOCALAPPDATA')
48
+ || join(os.homedir(), 'AppData', 'Local');
49
+ return resolve(localAppData, 'npm-cache');
50
+ }
51
+ return resolve(os.homedir(), '.npm');
52
+ }
53
+
54
+ export function inspectExactNpxInstall(packageSpec, env = process.env) {
55
+ const exact = parseExactLiveDeskPackageSpec(packageSpec);
56
+ if (!exact) return { state: 'not-exact-livedesk', packageSpec: '', version: '', installDir: '' };
57
+ const installDir = join(resolveNpmCacheRoot(env), '_npx', getExactNpxInstallHash(exact.packageSpec));
58
+ if (!existsSync(installDir)) {
59
+ return { state: 'absent', ...exact, installDir };
60
+ }
61
+ const rootManifest = readJson(join(installDir, 'package.json'));
62
+ const productManifest = readJson(join(installDir, 'node_modules', 'livedesk', 'package.json'));
63
+ const healthy = rootManifest
64
+ && typeof rootManifest.dependencies?.livedesk === 'string'
65
+ && productManifest?.name === 'livedesk'
66
+ && String(productManifest.version || '') === exact.version;
67
+ return {
68
+ state: healthy ? 'healthy' : 'interrupted',
69
+ ...exact,
70
+ installDir
71
+ };
72
+ }
73
+
74
+ export function createExactNpxRecoveryCache(operationId, phase = 'package', options = {}) {
75
+ const root = resolve(
76
+ String(options.tempDirectory || os.tmpdir()),
77
+ 'livedesk-update-npm-cache'
78
+ );
79
+ const nonce = safePathSegment(
80
+ options.nonce || `${process.pid}-${Date.now()}-${randomBytes(5).toString('hex')}`,
81
+ String(process.pid)
82
+ );
83
+ return join(
84
+ root,
85
+ `${safePathSegment(operationId, 'operation')}-${safePathSegment(phase, 'package')}-${nonce}`
86
+ );
87
+ }
88
+
89
+ export function normalizeExactNpxRecoveryCache(value, options = {}) {
90
+ const configured = String(value || '').trim();
91
+ if (!configured || !isAbsolute(configured)) return '';
92
+ const root = resolve(
93
+ String(options.tempDirectory || os.tmpdir()),
94
+ 'livedesk-update-npm-cache'
95
+ );
96
+ const candidate = resolve(configured);
97
+ return candidate !== root && pathInside(root, candidate) ? candidate : '';
98
+ }
99
+
100
+ export function addExactNpxRecoveryCacheArgument(args, recoveryCache) {
101
+ const values = Array.isArray(args) ? args.map(value => String(value)) : [];
102
+ if (!recoveryCache
103
+ || values.some(value => value === '--cache' || value.startsWith('--cache='))) {
104
+ return values;
105
+ }
106
+ const packageIndex = values.findIndex(value => parseExactLiveDeskPackageSpec(value));
107
+ if (packageIndex < 0) return values;
108
+ return [
109
+ ...values.slice(0, packageIndex),
110
+ '--cache',
111
+ recoveryCache,
112
+ ...values.slice(packageIndex)
113
+ ];
114
+ }
115
+
116
+ export function prepareExactNpxCacheRecovery({
117
+ args,
118
+ env = process.env,
119
+ operationId,
120
+ phase,
121
+ force = false,
122
+ nonce,
123
+ tempDirectory
124
+ } = {}) {
125
+ const values = Array.isArray(args) ? args.map(value => String(value)) : [];
126
+ const exact = values.map(parseExactLiveDeskPackageSpec).find(Boolean);
127
+ if (!exact || values.some(value => value === '--cache' || value.startsWith('--cache='))) {
128
+ return { args: values, recoveryCache: '', recovered: false, inspection: null };
129
+ }
130
+ const inspection = inspectExactNpxInstall(exact.packageSpec, env);
131
+ if (!force && inspection.state !== 'interrupted') {
132
+ return { args: values, recoveryCache: '', recovered: false, inspection };
133
+ }
134
+ const recoveryCache = createExactNpxRecoveryCache(operationId, phase, {
135
+ nonce,
136
+ tempDirectory
137
+ });
138
+ return {
139
+ args: addExactNpxRecoveryCacheArgument(values, recoveryCache),
140
+ recoveryCache,
141
+ recovered: true,
142
+ inspection
143
+ };
144
+ }
145
+
146
+ export function isInterruptedExactNpxInstallFailure(result, packageSpec, env = process.env) {
147
+ const exact = parseExactLiveDeskPackageSpec(packageSpec);
148
+ if (!exact) return false;
149
+ const output = `${result?.error?.message || ''}\n${result?.stdout || ''}\n${result?.stderr || ''}`;
150
+ if (!/\bENOENT\b|Could not read package\.json/i.test(output)) return false;
151
+ const expectedPath = join(
152
+ resolveNpmCacheRoot(env),
153
+ '_npx',
154
+ getExactNpxInstallHash(exact.packageSpec),
155
+ 'package.json'
156
+ ).replaceAll('\\', '/').toLowerCase();
157
+ return output.replaceAll('\\', '/').toLowerCase().includes(expectedPath);
158
+ }
@@ -31,23 +31,30 @@ function writePrivateJson(path, value) {
31
31
  }
32
32
  }
33
33
 
34
- function originAllowed(req, host, port) {
35
- const origin = clean(req.headers.origin, 300);
34
+ function originAllowed(req, host, port) {
35
+ const origin = clean(req.headers.origin, 300);
36
36
  const requestHost = clean(req.headers.host, 300).split(':')[0].toLowerCase();
37
37
  const allowedHost = host.toLowerCase() === 'localhost' || host === '127.0.0.1' ? host.toLowerCase() : '127.0.0.1';
38
38
  if (requestHost && requestHost !== allowedHost && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
39
- // Electron can load the local runtime from a shell whose serialized origin
40
- // is `null`. The request is still restricted to a loopback Host above, so
41
- // allow this local-shell origin for the bootstrap API and role selection.
42
- if (!origin || origin === 'null') return true;
39
+ if (!origin) {
40
+ const fetchSite = clean(req.headers['sec-fetch-site'], 40).toLowerCase();
41
+ return !fetchSite
42
+ || fetchSite === 'same-origin'
43
+ || fetchSite === 'same-site'
44
+ || fetchSite === 'none';
45
+ }
46
+ // Electron loads the bootstrap UI from the loopback HTTP origin. An opaque
47
+ // origin cannot distinguish that UI from an attacker-controlled local file
48
+ // or sandbox and therefore receives no bootstrap API privilege.
49
+ if (origin === 'null') return false;
43
50
  return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
44
51
  }
45
52
 
46
53
  function sendJson(req, res, status, payload, port) {
47
54
  const origin = clean(req.headers.origin, 300);
48
- const allowedOrigin = origin === 'null'
49
- ? 'null'
50
- : origin === `http://localhost:${port}` ? origin : `http://127.0.0.1:${port}`;
55
+ const allowedOrigin = origin === `http://localhost:${port}`
56
+ ? origin
57
+ : `http://127.0.0.1:${port}`;
51
58
  res.writeHead(status, {
52
59
  'Content-Type': 'application/json; charset=utf-8',
53
60
  'Cache-Control': 'no-store',
@@ -9,6 +9,61 @@ export const UPDATE_HOST_PROTOCOL_VERSION = 1;
9
9
  export const UPDATE_HOST_DEFAULT_START_TIMEOUT_MS = 8_000;
10
10
  export const UPDATE_HOST_DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
11
11
  export const UPDATE_HOST_MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
12
+ export const UPDATE_HOST_MAX_ENVIRONMENT_KEYS = 512;
13
+ export const UPDATE_HOST_MAX_ENVIRONMENT_VALUE_BYTES = 128 * 1024;
14
+
15
+ const UPDATE_HOST_ENVIRONMENT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_()]*$/;
16
+
17
+ export function isValidUpdateHostEnvironmentEntry(key, value) {
18
+ return UPDATE_HOST_ENVIRONMENT_KEY_PATTERN.test(String(key || ''))
19
+ && Buffer.byteLength(String(value ?? ''), 'utf8') <= UPDATE_HOST_MAX_ENVIRONMENT_VALUE_BYTES;
20
+ }
21
+
22
+ function updateHostEnvironmentPriority(key) {
23
+ const normalized = String(key || '').toUpperCase();
24
+ if (normalized.startsWith('LIVEDESK_')
25
+ || normalized.startsWith('LIVE_DESK_')
26
+ || normalized.startsWith('REMOTE_HUB_')) {
27
+ return 0;
28
+ }
29
+ if (/^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|TMPDIR|HOME|HOMEDRIVE|HOMEPATH|USERPROFILE|APPDATA|LOCALAPPDATA|LANG|LC_ALL|TZ|PORT)$/.test(normalized)
30
+ || normalized.startsWith('NPM_')
31
+ || normalized.startsWith('NODE_')
32
+ || normalized.endsWith('_PROXY')
33
+ || normalized.startsWith('OPENAI_')
34
+ || normalized.startsWith('CODEX_')) {
35
+ return 1;
36
+ }
37
+ return 2;
38
+ }
39
+
40
+ export function buildUpdateHostWorkerEnvironment(baseEnv = process.env, requiredEnv = {}) {
41
+ const requiredEntries = Object.entries(requiredEnv || {}).map(([key, value]) => [String(key), String(value ?? '')]);
42
+ for (const [key, value] of requiredEntries) {
43
+ if (!isValidUpdateHostEnvironmentEntry(key, value)) {
44
+ throw new Error(`invalid-required-update-host-environment-entry:${key}`);
45
+ }
46
+ }
47
+ if (requiredEntries.length > UPDATE_HOST_MAX_ENVIRONMENT_KEYS) {
48
+ throw new Error('invalid-required-update-host-environment-count');
49
+ }
50
+
51
+ const requiredKeys = new Set(requiredEntries.map(([key]) => key.toUpperCase()));
52
+ const availableBaseEntries = UPDATE_HOST_MAX_ENVIRONMENT_KEYS - requiredEntries.length;
53
+ const baseEntries = Object.entries(baseEnv || {})
54
+ .filter(([key, value]) => value != null
55
+ && !requiredKeys.has(String(key).toUpperCase())
56
+ && isValidUpdateHostEnvironmentEntry(key, value))
57
+ .map(([key, value]) => [String(key), String(value)])
58
+ .sort((left, right) => {
59
+ const priorityDifference = updateHostEnvironmentPriority(left[0]) - updateHostEnvironmentPriority(right[0]);
60
+ if (priorityDifference) return priorityDifference;
61
+ return left[0] < right[0] ? -1 : (left[0] > right[0] ? 1 : 0);
62
+ })
63
+ .slice(0, availableBaseEntries);
64
+
65
+ return Object.fromEntries([...baseEntries, ...requiredEntries]);
66
+ }
12
67
 
13
68
  function normalizedStateDir(stateDir) {
14
69
  return resolve(String(stateDir || join(os.homedir(), '.livedesk')).trim());
@@ -8,15 +8,16 @@ import { dirname, isAbsolute, resolve, sep } from 'node:path';
8
8
  import { spawn } from 'node:child_process';
9
9
  import {
10
10
  getUpdateHostPaths,
11
+ isValidUpdateHostEnvironmentEntry,
12
+ UPDATE_HOST_MAX_ENVIRONMENT_KEYS,
11
13
  UPDATE_HOST_MAX_MESSAGE_BYTES,
12
14
  UPDATE_HOST_PROTOCOL_VERSION
13
15
  } from './update-host-client.mjs';
16
+ import { prepareExactNpxCacheRecovery } from './npm-update-cache.mjs';
14
17
 
15
18
  const MAX_LOG_BYTES = 1024 * 1024;
16
19
  const MAX_ARGUMENT_COUNT = 512;
17
20
  const MAX_ARGUMENT_BYTES = 1024 * 1024;
18
- const MAX_ENVIRONMENT_KEYS = 512;
19
- const MAX_ENVIRONMENT_VALUE_BYTES = 128 * 1024;
20
21
  const ALLOWED_JOB_KINDS = new Set(['hub-update', 'client-update']);
21
22
  const TERMINAL_RESULT_STAGES = new Set(['updated', 'restored', 'failed', 'connected', 'cancelled']);
22
23
 
@@ -173,9 +174,9 @@ function validateJob(value) {
173
174
  }
174
175
  if (args.length === 0 || args.length > MAX_ARGUMENT_COUNT) throw new Error('invalid-update-host-arguments');
175
176
  if (Buffer.byteLength(JSON.stringify(args), 'utf8') > MAX_ARGUMENT_BYTES) throw new Error('update-host-arguments-too-large');
176
- if (!env || Object.keys(env).length > MAX_ENVIRONMENT_KEYS) throw new Error('invalid-update-host-environment');
177
+ if (!env || Object.keys(env).length > UPDATE_HOST_MAX_ENVIRONMENT_KEYS) throw new Error('invalid-update-host-environment');
177
178
  for (const [key, item] of Object.entries(env)) {
178
- if (!/^[A-Za-z_][A-Za-z0-9_()]*$/.test(key) || Buffer.byteLength(item, 'utf8') > MAX_ENVIRONMENT_VALUE_BYTES) {
179
+ if (!isValidUpdateHostEnvironmentEntry(key, item)) {
179
180
  throw new Error('invalid-update-host-environment-entry');
180
181
  }
181
182
  }
@@ -270,13 +271,26 @@ function beginHostReplacement() {
270
271
  }
271
272
 
272
273
  function startJob(value) {
273
- const job = validateJob(value);
274
+ const validatedJob = validateJob(value);
274
275
  if (currentJob && ['accepted', 'running'].includes(currentJob.state)) {
275
- if (currentJob.jobId === job.jobId && currentJob.operationId === job.operationId) return currentJob;
276
+ if (currentJob.jobId === validatedJob.jobId && currentJob.operationId === validatedJob.operationId) return currentJob;
276
277
  const error = new Error(`LiveDesk Update Host is busy with ${currentJob.operationId}.`);
277
278
  error.code = 'LIVEDESK_UPDATE_HOST_BUSY';
278
279
  throw error;
279
280
  }
281
+ const cacheRecovery = validatedJob.kind === 'client-update'
282
+ ? prepareExactNpxCacheRecovery({
283
+ args: validatedJob.args,
284
+ env: validatedJob.env,
285
+ operationId: validatedJob.operationId,
286
+ phase: 'target-worker'
287
+ })
288
+ : { args: validatedJob.args, recoveryCache: '', recovered: false };
289
+ const job = {
290
+ ...validatedJob,
291
+ args: cacheRecovery.args,
292
+ recoveryCache: cacheRecovery.recoveryCache
293
+ };
280
294
  currentJob = {
281
295
  ...job,
282
296
  state: 'accepted',
@@ -307,6 +321,9 @@ function startJob(value) {
307
321
  currentJob.startedAt = new Date().toISOString();
308
322
  currentJob.state = 'running';
309
323
  workerSpawnCount += 1;
324
+ if (cacheRecovery.recovered) {
325
+ appendLog(`job=${job.jobId} operation=${job.operationId} npm-cache-recovery=isolated reason=interrupted-exact-npx-install`);
326
+ }
310
327
  appendLog(`job=${job.jobId} kind=${job.kind} operation=${job.operationId} state=running workerPid=${currentJob.workerPid}`);
311
328
  publishStatus();
312
329
  child.once('error', error => finishJob(currentJob, 'failed', { error: error.message }));
@@ -251,7 +251,7 @@ async function waitForClientUpdateShutdownSignal(
251
251
  return { ready: false, error };
252
252
  }
253
253
 
254
- function markClientUpdateConnected() {
254
+ function markClientUpdateConnected() {
255
255
  const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || '').trim();
256
256
  if (!operationId) return;
257
257
  void writeClientUpdateState('connected', {
@@ -262,8 +262,59 @@ function markClientUpdateConnected() {
262
262
  error: ''
263
263
  }).catch(error => {
264
264
  console.error(`LiveDesk client update diagnostics could not be persisted: ${error?.message || error}`);
265
- });
266
- }
265
+ });
266
+ }
267
+
268
+ async function publishClientUpdateSupervisorProof(socket) {
269
+ const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || '').trim();
270
+ const attemptId = String(process.env.LIVEDESK_CLIENT_UPDATE_ATTEMPT_ID || '').trim();
271
+ const expectedLauncherPid = Number(process.env.LIVEDESK_CLIENT_PARENT_PID || 0);
272
+ if (!operationId || !attemptId || !Number.isInteger(expectedLauncherPid) || expectedLauncherPid <= 1) return;
273
+ const deadline = Date.now() + 60_000;
274
+ while (Date.now() < deadline && !socket.destroyed) {
275
+ try {
276
+ const state = JSON.parse(await fs.readFile(getClientUpdateStatePath(), 'utf8'));
277
+ if (String(state?.operationId || '') !== operationId
278
+ || String(state?.attemptId || '') !== attemptId) {
279
+ await wait(100);
280
+ continue;
281
+ }
282
+ if (['failed', 'restored', 'cancelled'].includes(String(state.stage || ''))) return;
283
+ const launcherPid = Number(state.launcherPid || 0);
284
+ const agentPid = Number(state.agentPid || 0);
285
+ const supervisorPid = Number(state.supervisorPid || 0);
286
+ const completedAt = String(state.completedAt || '').trim();
287
+ const exactSupervisorCompletion = state.stage === 'connected'
288
+ && state.restartVerified === true
289
+ && Number.isFinite(Date.parse(completedAt))
290
+ && launcherPid === expectedLauncherPid
291
+ && agentPid === process.pid
292
+ && Number.isInteger(supervisorPid)
293
+ && supervisorPid > 1
294
+ && String(state.productVersion || '') === PRODUCT_VERSION
295
+ && String(state.agentVersion || '') === AGENT_VERSION
296
+ && String(state.targetProductVersion || '') === PRODUCT_VERSION
297
+ && String(state.targetAgentVersion || state.targetVersion || '') === AGENT_VERSION;
298
+ if (exactSupervisorCompletion) {
299
+ writeJsonLine(socket, {
300
+ type: 'client.update.verified',
301
+ operationId,
302
+ attemptId,
303
+ launcherPid,
304
+ agentPid,
305
+ supervisorPid,
306
+ productVersion: PRODUCT_VERSION,
307
+ agentVersion: AGENT_VERSION,
308
+ completedAt
309
+ });
310
+ return;
311
+ }
312
+ } catch {
313
+ // The exact package supervisor may be replacing the atomic state file.
314
+ }
315
+ await wait(100);
316
+ }
317
+ }
267
318
 
268
319
  function printHelp() {
269
320
  console.log(`
@@ -1972,9 +2023,10 @@ function connectOnce(options, deviceId) {
1972
2023
  remoteFiles: true,
1973
2024
  fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1974
2025
  computerAgent: options.taskEnabled,
1975
- taskDispatch: options.taskEnabled,
1976
- clientUpdate: true,
1977
- productVersion: PRODUCT_VERSION,
2026
+ taskDispatch: options.taskEnabled,
2027
+ clientUpdate: true,
2028
+ clientUpdateVerifiedProof: true,
2029
+ productVersion: PRODUCT_VERSION,
1978
2030
  agentApproval: options.taskEnabled,
1979
2031
  agentAudit: options.taskEnabled,
1980
2032
  agentTools: [...NODE_AGENT_OPERATIONS],
@@ -2000,8 +2052,13 @@ function connectOnce(options, deviceId) {
2000
2052
  options.effectivePolicy = message.effectivePolicy && typeof message.effectivePolicy === 'object'
2001
2053
  ? message.effectivePolicy
2002
2054
  : null;
2003
- console.log(`Connected to LiveDesk Hub as ${options.name} (${deviceId})`);
2004
- markClientUpdateConnected();
2055
+ console.log(`Connected to LiveDesk Hub as ${options.name} (${deviceId})`);
2056
+ markClientUpdateConnected();
2057
+ void publishClientUpdateSupervisorProof(socket).catch(error => {
2058
+ if (!socket.destroyed) {
2059
+ console.error(`LiveDesk client update verification proof failed: ${error?.message || error}`);
2060
+ }
2061
+ });
2005
2062
  writeJsonLine(socket, {
2006
2063
  type: 'status',
2007
2064
  status: getStatus(options)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.238",
3
+ "version": "0.1.239",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,10 +42,10 @@
42
42
  "ws": "^8.18.3"
43
43
  },
44
44
  "optionalDependencies": {
45
- "@livedesk/fast-linux-x64": "0.1.436",
46
- "@livedesk/fast-osx-arm64": "0.1.436",
47
- "@livedesk/fast-osx-x64": "0.1.436",
48
- "@livedesk/fast-win-x64": "0.1.436"
45
+ "@livedesk/fast-linux-x64": "0.1.437",
46
+ "@livedesk/fast-osx-arm64": "0.1.437",
47
+ "@livedesk/fast-osx-x64": "0.1.437",
48
+ "@livedesk/fast-win-x64": "0.1.437"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
@@ -1022,10 +1022,9 @@ function isLoopbackRequest(req) {
1022
1022
  return address === '127.0.0.1' || address === '::1' || address === 'localhost' || !address;
1023
1023
  }
1024
1024
 
1025
- function isTrustedApiOrigin(value, trustedWebOrigins) {
1026
- const rawOrigin = normalizeString(value, 300);
1027
- if (rawOrigin === 'null') return true;
1028
- const origin = normalizeOrigin(rawOrigin);
1025
+ function isTrustedApiOrigin(value, trustedWebOrigins) {
1026
+ const rawOrigin = normalizeString(value, 300);
1027
+ const origin = normalizeOrigin(rawOrigin);
1029
1028
  if (!origin) return false;
1030
1029
  if (trustedWebOrigins.has(origin)) return true;
1031
1030
  try {
@@ -1049,11 +1048,10 @@ function isTrustedLocalRequest(req, host, port, options = {}) {
1049
1048
  const requestHost = normalizeString(req.headers.host, 300).split(':')[0].toLowerCase();
1050
1049
  if (requestHost && requestHost !== host.toLowerCase() && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
1051
1050
 
1052
- // Chromium can mark modulepreload/stylesheet requests as cross-site, or
1053
- // send Origin: null when the page was opened from a local shell. Static
1054
- // assets are not privileged, and the local shell still needs to read the
1055
- // loopback runtime API to determine the active role. Mutating API routes
1056
- // remain protected by the CSRF token check below.
1051
+ // Chromium can mark modulepreload/stylesheet requests as cross-site. Static
1052
+ // assets are not privileged. An opaque Origin (`null`) is never accepted for
1053
+ // an API route because it cannot distinguish the LiveDesk shell from an
1054
+ // attacker-controlled file, sandbox, or data page.
1057
1055
  if (options.allowStaticCrossSite === true) {
1058
1056
  return true;
1059
1057
  }
@@ -1073,10 +1071,9 @@ function isTrustedLocalRequest(req, host, port, options = {}) {
1073
1071
  || fetchSite === 'none';
1074
1072
  }
1075
1073
 
1076
- function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1077
- const origin = normalizeString(requestOrigin, 300);
1078
- if (origin === 'null') return 'null';
1079
- return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1074
+ function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1075
+ const origin = normalizeString(requestOrigin, 300);
1076
+ return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1080
1077
  ? normalizeOrigin(origin)
1081
1078
  : `http://127.0.0.1:${port}`;
1082
1079
  }
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",