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 +6 -6
- package/bootstrap/legacy-client-update.js +47 -16
- package/bootstrap/npm-update-cache.mjs +158 -0
- package/bootstrap/role-bootstrap-server.js +16 -9
- package/bootstrap/update-host-client.mjs +55 -0
- package/bootstrap/update-host.mjs +23 -6
- package/client/bin/livedesk-client-node.js +65 -8
- package/client/package.json +5 -5
- package/client/src/runtime/client-runtime-server.js +10 -13
- package/hub/package.json +1 -1
- package/hub/src/http/hub-ui-session.js +119 -0
- package/hub/src/live-desk-update.js +88 -39
- package/hub/src/remote-hub.js +61 -9
- package/hub/src/server.js +130 -41
- package/package.json +7 -7
- package/web/dist/assets/index-PHlwNiVH.js +188 -0
- package/web/dist/index.html +1 -1
- package/web/dist/livedesk-build-evidence.json +9 -9
- package/web/dist/assets/index-VWc8JQDq.js +0 -186
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-
|
|
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
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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(
|
|
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-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 ===
|
|
49
|
-
?
|
|
50
|
-
:
|
|
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 >
|
|
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 (
|
|
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
|
|
274
|
+
const validatedJob = validateJob(value);
|
|
274
275
|
if (currentJob && ['accepted', 'running'].includes(currentJob.state)) {
|
|
275
|
-
if (currentJob.jobId ===
|
|
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
|
-
|
|
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)
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
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
|
-
|
|
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
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
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
|
-
|
|
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
|
}
|