livedesk 0.1.477 → 0.1.480
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/README.md +34 -3
- package/bin/livedesk.js +5 -1
- package/bootstrap/hub-auth-handoff.js +60 -7
- package/client/bin/livedesk-client-node.js +189 -81
- package/client/bin/livedesk-client.js +65 -24
- package/client/package.json +6 -6
- package/client/src/runtime/client-runtime-server.js +83 -11
- package/client/src/security/device-credential-store.js +190 -0
- package/client/src/security/secure-direct-client.js +224 -0
- package/electron/electron-builder.mac-arm64.yml +1 -1
- package/electron/electron-builder.mac-x64.yml +1 -1
- package/electron/main.mjs +65 -16
- package/electron/update-manager.mjs +56 -11
- package/hub/package.json +2 -2
- package/hub/src/agents/agent-audit-store.js +16 -6
- package/hub/src/agents/agent-permissions.js +9 -3
- package/hub/src/agents/agent-tool-registry.js +19 -1
- package/hub/src/captures/capture-store.js +50 -3
- package/hub/src/filesystem/shared-folders.js +8 -0
- package/hub/src/filesystem/transfer-jobs.js +36 -5
- package/hub/src/live-desk-update.js +87 -19
- package/hub/src/remote-hub.js +722 -173
- package/hub/src/security/device-credential-authority.js +406 -0
- package/hub/src/security/security-audit-store.js +260 -0
- package/hub/src/server.js +1012 -277
- package/hub/src/settings/settings-schema.js +19 -39
- package/hub/src/transport/relay-hub-control.js +330 -3
- package/hub/src/transport/secure-direct-acceptor.js +433 -0
- package/hub/src/transport/udp-hub-transport.js +28 -5
- package/hub/src/transport/udp-rendezvous.js +179 -13
- package/package.json +6 -6
- package/runtime-core/package.json +6 -1
- package/runtime-core/src/index.js +2 -0
- package/runtime-core/src/os-secret-store.js +142 -0
- package/runtime-core/src/secure-session-protocol.js +347 -0
- package/runtime-core/src/signed-update-manifest.js +220 -0
- package/web/dist/assets/{icons-CpeZfjFK.js → icons-BtzQBJR4.js} +1 -1
- package/web/dist/assets/index-CNcg85GR.js +186 -0
- package/web/dist/assets/{react-CSxCfTKV.js → react-BDGiddLC.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/livedesk-build-evidence.json +13 -13
- package/web/dist/assets/index-Cn1YPcj3.js +0 -186
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ device, Hub URL, timeout, and report-path options.
|
|
|
148
148
|
|
|
149
149
|
Monthly and yearly subscriptions are planned after the LTD launch.
|
|
150
150
|
|
|
151
|
-
## Free ad slot
|
|
151
|
+
## Free ad slot
|
|
152
152
|
|
|
153
153
|
Free accounts show a standard bottom ad slot. Set these Vite build variables
|
|
154
154
|
for a real AdSense placement:
|
|
@@ -158,5 +158,36 @@ VITE_LIVEDESK_ADSENSE_CLIENT=ca-pub-...
|
|
|
158
158
|
VITE_LIVEDESK_ADSENSE_SLOT=...
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
Without those values, LiveDesk renders a neutral sponsored-placement placeholder
|
|
162
|
-
so the free-plan layout can still be tested.
|
|
161
|
+
Without those values, LiveDesk renders a neutral sponsored-placement placeholder
|
|
162
|
+
so the free-plan layout can still be tested.
|
|
163
|
+
|
|
164
|
+
## Signed update configuration
|
|
165
|
+
|
|
166
|
+
Production update checks fail closed until all applicable values are set:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
LIVEDESK_UPDATE_MANIFEST_URL=https://updates.example.com/stable.json
|
|
170
|
+
LIVEDESK_UPDATE_PUBLIC_KEY=<Ed25519 SPKI PEM or base64 DER public key>
|
|
171
|
+
LIVEDESK_UPDATE_URL=https://updates.example.com/desktop
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The public key may instead be bundled at
|
|
175
|
+
`electron/update-public-key.pem` for desktop builds. Keep the Ed25519 private
|
|
176
|
+
key offline and outside this repository. Release automation supplies it only to
|
|
177
|
+
the signing command as `LIVEDESK_UPDATE_PRIVATE_KEY` or `--private-key`:
|
|
178
|
+
|
|
179
|
+
```powershell
|
|
180
|
+
npm run sign:update-manifest -- --input release-payload.json --output stable.json --private-key E:\secure\livedesk-update-ed25519.pem
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The payload must include exact npm `dist.integrity` values for `livedesk` and
|
|
184
|
+
`@livedesk/client`, desktop version/platform/architecture SHA-512 values, a
|
|
185
|
+
maximum 31-day validity window, and emergency blocked-version lists.
|
|
186
|
+
|
|
187
|
+
Desktop publisher signing is independent of the LiveDesk manifest signature.
|
|
188
|
+
Configure electron-builder secrets only in the release environment: `CSC_LINK`
|
|
189
|
+
and `CSC_KEY_PASSWORD` for the Windows/macOS certificate; for Apple
|
|
190
|
+
notarization also configure `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and
|
|
191
|
+
`APPLE_TEAM_ID`. Do not put any private key, certificate password, OAuth secret,
|
|
192
|
+
Supabase service key, Dodo webhook secret, or API token in source, Vite public
|
|
193
|
+
variables, logs, or the signed manifest.
|
package/bin/livedesk.js
CHANGED
|
@@ -2506,7 +2506,8 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
|
2506
2506
|
: (existsSync(internalHubEntry)
|
|
2507
2507
|
? internalHubEntry
|
|
2508
2508
|
: resolvePackageEntry('@livedesk/hub', '@livedesk/hub/src/server.js'));
|
|
2509
|
-
const packagedWebDist = resolve(packageRoot, 'web', 'dist');
|
|
2509
|
+
const packagedWebDist = resolve(packageRoot, 'web', 'dist');
|
|
2510
|
+
const launcherShutdownToken = randomBytes(32).toString('base64url');
|
|
2510
2511
|
const env = {
|
|
2511
2512
|
...process.env,
|
|
2512
2513
|
LIVEDESK_HUB_HTTP_HOST: configuredHttpHost,
|
|
@@ -2527,6 +2528,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
|
2527
2528
|
LIVEDESK_DEVICE_NAME: String(resolvedRole?.deviceName || process.env.LIVEDESK_DEVICE_NAME || os.hostname()),
|
|
2528
2529
|
LIVEDESK_ROLE_CACHE_PATH: join(MANAGER_STATE_DIR, 'device-role.json'),
|
|
2529
2530
|
LIVEDESK_AUTH_STATE_PATH: join(MANAGER_STATE_DIR, 'auth.json'),
|
|
2531
|
+
LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN: launcherShutdownToken,
|
|
2530
2532
|
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
|
|
2531
2533
|
};
|
|
2532
2534
|
const debugConsole = process.env.LIVEDESK_DEBUG === '1';
|
|
@@ -2601,6 +2603,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
|
2601
2603
|
try {
|
|
2602
2604
|
const response = await fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
|
|
2603
2605
|
method: 'POST',
|
|
2606
|
+
headers: { 'X-LiveDesk-Launcher-Shutdown': launcherShutdownToken },
|
|
2604
2607
|
signal: AbortSignal.timeout(750)
|
|
2605
2608
|
});
|
|
2606
2609
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
@@ -2855,6 +2858,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
|
2855
2858
|
updateStopTimer.unref?.();
|
|
2856
2859
|
void fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
|
|
2857
2860
|
method: 'POST',
|
|
2861
|
+
headers: { 'X-LiveDesk-Launcher-Shutdown': launcherShutdownToken },
|
|
2858
2862
|
signal: AbortSignal.timeout(2_500)
|
|
2859
2863
|
}).then(response => {
|
|
2860
2864
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../runtime-core/src/auth-config.js';
|
|
15
15
|
import { fetchAuthResponse } from '../runtime-core/src/auth-http.js';
|
|
16
16
|
import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
|
|
17
|
+
import { createOsSecretStore, OS_SECRET_REFERENCE } from '../runtime-core/src/os-secret-store.js';
|
|
17
18
|
|
|
18
19
|
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
19
20
|
const MINIMUM_SESSION_LIFETIME_SECONDS = 30;
|
|
@@ -61,6 +62,28 @@ function sessionIsFresh(session, nowSeconds) {
|
|
|
61
62
|
&& expiresAt - Number(nowSeconds || 0) > MINIMUM_SESSION_LIFETIME_SECONDS;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function clientRefreshSecretStore(stateDir) {
|
|
66
|
+
return createOsSecretStore({
|
|
67
|
+
service: 'LiveDesk',
|
|
68
|
+
account: 'client-refresh-token',
|
|
69
|
+
dataDir: stateDir
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function persistProtectedClientSession(stateDir, session) {
|
|
74
|
+
const normalized = normalizeRuntimeAuthSession(session, { requireRefreshToken: true });
|
|
75
|
+
if (!normalized.ok) return false;
|
|
76
|
+
const secretStore = clientRefreshSecretStore(stateDir);
|
|
77
|
+
if (!secretStore.write(normalized.session.refresh_token)) return false;
|
|
78
|
+
const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
|
|
79
|
+
delete persisted.refresh_token;
|
|
80
|
+
const authPath = join(stateDir, 'auth.json');
|
|
81
|
+
const authState = readAuthState(authPath);
|
|
82
|
+
authState[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
|
|
83
|
+
writePrivateJsonAtomic(authPath, authState);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function resolveSupabaseAuthConfig(env = process.env) {
|
|
65
88
|
const supabaseUrl = String(
|
|
66
89
|
env.LIVEDESK_RUNTIME_SUPABASE_URL
|
|
@@ -82,9 +105,22 @@ function resolveSupabaseAuthConfig(env = process.env) {
|
|
|
82
105
|
* - Never logs or serializes token values outside the loopback handoff.
|
|
83
106
|
*/
|
|
84
107
|
export async function readSavedClientSessionForHub(stateDir) {
|
|
85
|
-
const
|
|
108
|
+
const authPath = join(stateDir, 'auth.json');
|
|
109
|
+
const authState = readAuthState(authPath);
|
|
86
110
|
const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
|
|
87
|
-
|
|
111
|
+
if (!candidate) return null;
|
|
112
|
+
const plaintextRefreshToken = String(candidate.refresh_token || '').trim();
|
|
113
|
+
const refreshToken = plaintextRefreshToken || (candidate.refresh_token_ref === OS_SECRET_REFERENCE
|
|
114
|
+
? clientRefreshSecretStore(stateDir).read()
|
|
115
|
+
: '');
|
|
116
|
+
const normalized = normalizeRuntimeAuthSession(
|
|
117
|
+
{ ...candidate, refresh_token: refreshToken },
|
|
118
|
+
{ requireRefreshToken: true }
|
|
119
|
+
);
|
|
120
|
+
if (normalized.ok && plaintextRefreshToken
|
|
121
|
+
&& !persistProtectedClientSession(stateDir, normalized.session)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
88
124
|
return normalized.ok ? normalized.session : null;
|
|
89
125
|
}
|
|
90
126
|
|
|
@@ -122,10 +158,9 @@ async function refreshSavedClientSessionForHub(
|
|
|
122
158
|
throw new Error('saved-client-session-refresh-invalid');
|
|
123
159
|
}
|
|
124
160
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
writePrivateJsonAtomic(authPath, authState);
|
|
161
|
+
if (!persistProtectedClientSession(stateDir, normalized.session)) {
|
|
162
|
+
throw new Error('saved-client-session-secure-store-unavailable');
|
|
163
|
+
}
|
|
129
164
|
return normalized.session;
|
|
130
165
|
}
|
|
131
166
|
|
|
@@ -152,11 +187,29 @@ export async function transferSavedClientSessionToHub(
|
|
|
152
187
|
if (!resolved) {
|
|
153
188
|
return { ok: false, skipped: true, reason: 'no-refreshable-saved-client-session' };
|
|
154
189
|
}
|
|
190
|
+
const adminSessionResponse = await fetchAuthResponse(fetchImpl, new URL('/api/security/session', baseUrl), {
|
|
191
|
+
method: 'GET',
|
|
192
|
+
headers: { Accept: 'application/json' }
|
|
193
|
+
}, 5_000);
|
|
194
|
+
const adminSession = adminSessionResponse.status === 404
|
|
195
|
+
? null
|
|
196
|
+
: await adminSessionResponse.json().catch(() => null);
|
|
197
|
+
if (adminSessionResponse.status !== 404 && (
|
|
198
|
+
!adminSessionResponse.ok
|
|
199
|
+
|| !adminSession?.adminToken
|
|
200
|
+
|| !adminSession?.csrfToken
|
|
201
|
+
)) {
|
|
202
|
+
throw new Error(`hub-admin-session-http-${adminSessionResponse.status}`);
|
|
203
|
+
}
|
|
155
204
|
const response = await fetchAuthResponse(fetchImpl, new URL('/api/auth/session', baseUrl), {
|
|
156
205
|
method: 'POST',
|
|
157
206
|
headers: {
|
|
158
207
|
'Content-Type': 'application/json',
|
|
159
|
-
Accept: 'application/json'
|
|
208
|
+
Accept: 'application/json',
|
|
209
|
+
...(adminSession ? {
|
|
210
|
+
'X-LiveDesk-Admin-Session': String(adminSession.adminToken),
|
|
211
|
+
'X-LiveDesk-Admin-CSRF': String(adminSession.csrfToken)
|
|
212
|
+
} : {})
|
|
160
213
|
},
|
|
161
214
|
body: JSON.stringify({
|
|
162
215
|
accessToken: resolved.session.access_token,
|
|
@@ -4,10 +4,12 @@ import net from 'net';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
-
import { existsSync, promises as fs, statfsSync } from 'fs';
|
|
7
|
+
import { constants as fsConstants, existsSync, promises as fs, statfsSync } from 'fs';
|
|
8
8
|
import { spawn } from 'child_process';
|
|
9
|
-
import { createRequire } from 'node:module';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { createClientDeviceCredentialStore } from '../src/security/device-credential-store.js';
|
|
12
|
+
import { connectSecureDirect } from '../src/security/secure-direct-client.js';
|
|
11
13
|
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
13
15
|
const CLIENT_UPDATE_BOOTSTRAP_PATH = fileURLToPath(
|
|
@@ -657,7 +659,7 @@ function sanitizePathSegment(value, fallback = 'file') {
|
|
|
657
659
|
return normalized.slice(0, 160);
|
|
658
660
|
}
|
|
659
661
|
|
|
660
|
-
function sanitizeRelativeFilePath(value, fallbackName = 'file') {
|
|
662
|
+
function sanitizeRelativeFilePath(value, fallbackName = 'file') {
|
|
661
663
|
const parts = String(value || '')
|
|
662
664
|
.replace(/\0/g, '')
|
|
663
665
|
.split(/[\\/]+/)
|
|
@@ -668,7 +670,52 @@ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
|
|
|
668
670
|
return sanitizePathSegment(fallbackName, 'file');
|
|
669
671
|
}
|
|
670
672
|
return path.join(...parts.slice(-8));
|
|
671
|
-
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isPathInsideRoot(root, candidate) {
|
|
676
|
+
const relative = path.relative(root, candidate);
|
|
677
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function resolveReceiveDirectory(options, remoteDirectory) {
|
|
681
|
+
const configuredRoot = path.resolve(options.filesDir || getDefaultFilesDir());
|
|
682
|
+
await fs.mkdir(configuredRoot, { recursive: true });
|
|
683
|
+
const rootStat = await fs.lstat(configuredRoot);
|
|
684
|
+
if (rootStat.isSymbolicLink()) throw new Error('The LiveDesk receive root cannot be a symbolic link or junction.');
|
|
685
|
+
const canonicalRoot = await fs.realpath(configuredRoot);
|
|
686
|
+
const relativeDirectory = String(remoteDirectory || '').replace(/\0/g, '').trim();
|
|
687
|
+
if (relativeDirectory.length > 600 || path.isAbsolute(relativeDirectory)) {
|
|
688
|
+
throw new Error('The remote receive directory must be relative to the LiveDesk files root.');
|
|
689
|
+
}
|
|
690
|
+
const candidate = path.resolve(canonicalRoot, relativeDirectory || '.');
|
|
691
|
+
if (!isPathInsideRoot(canonicalRoot, candidate)) throw new Error('The remote receive directory escapes the LiveDesk files root.');
|
|
692
|
+
await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
|
|
693
|
+
await fs.mkdir(candidate, { recursive: true });
|
|
694
|
+
await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
|
|
695
|
+
const canonicalCandidate = await fs.realpath(candidate);
|
|
696
|
+
if (!isPathInsideRoot(canonicalRoot, canonicalCandidate)) throw new Error('The remote receive directory resolves outside the LiveDesk files root.');
|
|
697
|
+
return { root: canonicalRoot, directory: canonicalCandidate };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function requireFileSha256(value) {
|
|
701
|
+
const hash = String(value || '').trim().toLowerCase();
|
|
702
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error('A valid SHA-256 file hash is required.');
|
|
703
|
+
return hash;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function hashBuffer(buffer) {
|
|
707
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function commitNewFile(tempPath, targetPath) {
|
|
711
|
+
try {
|
|
712
|
+
await fs.link(tempPath, targetPath);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (error?.code === 'EEXIST') throw new Error(`Refusing to overwrite an existing file: ${path.basename(targetPath)}`);
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
await fs.rm(tempPath, { force: true });
|
|
718
|
+
}
|
|
672
719
|
|
|
673
720
|
async function handleFileTransferCommand(options, payload = {}) {
|
|
674
721
|
const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
|
|
@@ -676,32 +723,46 @@ async function handleFileTransferCommand(options, payload = {}) {
|
|
|
676
723
|
throw new Error('No files were included in the transfer.');
|
|
677
724
|
}
|
|
678
725
|
|
|
679
|
-
const baseDir =
|
|
680
|
-
await fs.mkdir(baseDir, { recursive: true });
|
|
726
|
+
const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
|
|
681
727
|
|
|
682
728
|
let totalBytes = 0;
|
|
683
729
|
const saved = [];
|
|
684
730
|
for (const file of files) {
|
|
685
731
|
const name = sanitizePathSegment(file?.name, 'file');
|
|
686
732
|
const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
|
|
687
|
-
const targetPath = path.resolve(baseDir, relativePath);
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
733
|
+
const targetPath = path.resolve(baseDir, relativePath);
|
|
734
|
+
if (!isPathInsideRoot(baseDir, targetPath)) {
|
|
735
|
+
throw new Error(`Unsafe file path: ${relativePath}`);
|
|
736
|
+
}
|
|
692
737
|
|
|
693
738
|
const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
694
739
|
if (!dataBase64) {
|
|
695
740
|
throw new Error(`Missing file data: ${name}`);
|
|
696
741
|
}
|
|
697
|
-
const buffer = Buffer.from(dataBase64, 'base64');
|
|
742
|
+
const buffer = Buffer.from(dataBase64, 'base64');
|
|
743
|
+
const expectedSha256 = requireFileSha256(file?.sha256);
|
|
744
|
+
if (hashBuffer(buffer) !== expectedSha256) throw new Error(`File SHA-256 mismatch: ${name}`);
|
|
698
745
|
totalBytes += buffer.length;
|
|
699
746
|
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
700
747
|
throw new Error('File transfer exceeded the local size limit.');
|
|
701
748
|
}
|
|
702
749
|
|
|
703
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
704
|
-
await
|
|
750
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
751
|
+
await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
|
|
752
|
+
await assertAgentPathDoesNotTraverseLink(root, targetPath);
|
|
753
|
+
const tempPath = `${targetPath}.livedesk-${crypto.randomBytes(12).toString('hex')}.part`;
|
|
754
|
+
try {
|
|
755
|
+
const handle = await fs.open(tempPath, 'wx', 0o600);
|
|
756
|
+
try {
|
|
757
|
+
await handle.writeFile(buffer);
|
|
758
|
+
await handle.sync();
|
|
759
|
+
} finally {
|
|
760
|
+
await handle.close();
|
|
761
|
+
}
|
|
762
|
+
await commitNewFile(tempPath, targetPath);
|
|
763
|
+
} finally {
|
|
764
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
765
|
+
}
|
|
705
766
|
saved.push({
|
|
706
767
|
name,
|
|
707
768
|
relativePath,
|
|
@@ -722,12 +783,11 @@ async function handleFileTransferCommand(options, payload = {}) {
|
|
|
722
783
|
}
|
|
723
784
|
|
|
724
785
|
async function handleFileTransferChunkCommand(options, payload = {}) {
|
|
725
|
-
const baseDir =
|
|
786
|
+
const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
|
|
726
787
|
const name = sanitizePathSegment(payload.name, 'file');
|
|
727
788
|
const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
|
|
728
789
|
const targetPath = path.resolve(baseDir, relativePath);
|
|
729
|
-
|
|
730
|
-
if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
|
|
790
|
+
if (!isPathInsideRoot(baseDir, targetPath)) {
|
|
731
791
|
throw new Error(`Unsafe file path: ${relativePath}`);
|
|
732
792
|
}
|
|
733
793
|
|
|
@@ -736,19 +796,31 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
|
|
|
736
796
|
const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
|
|
737
797
|
const final = payload.final === true;
|
|
738
798
|
const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
|
|
739
|
-
if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
|
|
799
|
+
if (!transferId || totalBytes > MAX_FILE_TRANSFER_BYTES || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
|
|
740
800
|
throw new Error('Invalid file transfer chunk.');
|
|
741
801
|
}
|
|
742
802
|
if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
743
803
|
throw new Error('Empty file chunks are not allowed.');
|
|
744
804
|
}
|
|
745
805
|
|
|
746
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
747
|
-
|
|
748
|
-
|
|
806
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
807
|
+
await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
|
|
808
|
+
await assertAgentPathDoesNotTraverseLink(root, targetPath);
|
|
809
|
+
const tempPath = `${targetPath}.livedesk-${transferId}.part`;
|
|
810
|
+
if (offset > 0) {
|
|
811
|
+
const staging = await fs.lstat(tempPath);
|
|
812
|
+
if (!staging.isFile() || staging.isSymbolicLink() || staging.nlink !== 1) {
|
|
813
|
+
throw new Error('Unsafe file transfer staging path.');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const openFlags = offset === 0
|
|
817
|
+
? fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR
|
|
818
|
+
: fsConstants.O_RDWR | (fsConstants.O_NOFOLLOW || 0);
|
|
819
|
+
const handle = await fs.open(tempPath, openFlags, 0o600);
|
|
749
820
|
let completedSize = 0;
|
|
750
821
|
try {
|
|
751
|
-
const before = await handle.stat();
|
|
822
|
+
const before = await handle.stat();
|
|
823
|
+
if (!before.isFile() || before.nlink !== 1) throw new Error('Unsafe file transfer staging path.');
|
|
752
824
|
if (offset > before.size) {
|
|
753
825
|
throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
|
|
754
826
|
}
|
|
@@ -767,9 +839,19 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
|
|
|
767
839
|
await handle.close();
|
|
768
840
|
}
|
|
769
841
|
|
|
770
|
-
if (final) {
|
|
771
|
-
|
|
772
|
-
await fs.
|
|
842
|
+
if (final) {
|
|
843
|
+
const expectedSha256 = requireFileSha256(payload.sha256);
|
|
844
|
+
const received = await fs.readFile(tempPath);
|
|
845
|
+
if (hashBuffer(received) !== expectedSha256) {
|
|
846
|
+
await fs.rm(tempPath, { force: true });
|
|
847
|
+
throw new Error('File SHA-256 mismatch.');
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
await commitNewFile(tempPath, targetPath);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
await fs.rm(tempPath, { force: true });
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
773
855
|
const lastModified = Number(payload.lastModified || 0);
|
|
774
856
|
if (lastModified > 0) {
|
|
775
857
|
const modifiedAt = new Date(lastModified);
|
|
@@ -1430,7 +1512,20 @@ function normalizeNodeAgentTaskResult(result) {
|
|
|
1430
1512
|
return { ...result, ok, status: ok ? 'completed' : 'failed', error };
|
|
1431
1513
|
}
|
|
1432
1514
|
|
|
1433
|
-
const NODE_AGENT_OPERATIONS = new Set(['
|
|
1515
|
+
const NODE_AGENT_OPERATIONS = new Set(['file.read', 'file.list', 'network.status', 'logs.collect']);
|
|
1516
|
+
const RETIRED_NODE_AGENT_MUTATING_OPERATIONS = new Set([
|
|
1517
|
+
'process.control',
|
|
1518
|
+
'service.control',
|
|
1519
|
+
'application.launch',
|
|
1520
|
+
'application.close',
|
|
1521
|
+
'file.write',
|
|
1522
|
+
'file.delete',
|
|
1523
|
+
'command.run',
|
|
1524
|
+
'script.run',
|
|
1525
|
+
'software.install',
|
|
1526
|
+
'system.power',
|
|
1527
|
+
'system.configure'
|
|
1528
|
+
]);
|
|
1434
1529
|
|
|
1435
1530
|
function remotePolicyAllows(options, command) {
|
|
1436
1531
|
const policy = options.effectivePolicy;
|
|
@@ -1587,8 +1682,8 @@ async function scheduleClientUpdate(options, payload = {}) {
|
|
|
1587
1682
|
}
|
|
1588
1683
|
}
|
|
1589
1684
|
|
|
1590
|
-
async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
|
|
1591
|
-
const command = String(message.command || '');
|
|
1685
|
+
async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
|
|
1686
|
+
const command = String(message.command || '');
|
|
1592
1687
|
if (command === 'ping') {
|
|
1593
1688
|
writeJsonLine(socket, {
|
|
1594
1689
|
type: 'command.result',
|
|
@@ -1598,8 +1693,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
|
|
|
1598
1693
|
at: new Date().toISOString()
|
|
1599
1694
|
}
|
|
1600
1695
|
});
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// LD-SEC-AI-001 is enforced before settings or permission modes. A stale
|
|
1700
|
+
// Hub can therefore never revive a retired mutation with full-access.
|
|
1701
|
+
if (RETIRED_NODE_AGENT_MUTATING_OPERATIONS.has(command)) {
|
|
1702
|
+
writeJsonLine(socket, {
|
|
1703
|
+
type: 'command.result',
|
|
1704
|
+
commandId: message.commandId,
|
|
1705
|
+
error: 'agent-mutating-tool-disabled',
|
|
1706
|
+
result: { ok: false, status: 'rejected', error: 'agent-mutating-tool-disabled', sideEffects: 'none' }
|
|
1707
|
+
});
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1603
1710
|
|
|
1604
1711
|
const policyDecision = remotePolicyAllows(options, command);
|
|
1605
1712
|
if (!policyDecision.ok) {
|
|
@@ -1900,18 +2007,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
|
|
|
1900
2007
|
});
|
|
1901
2008
|
}
|
|
1902
2009
|
|
|
1903
|
-
function connectOnce(options, deviceId) {
|
|
1904
|
-
const manager = parseManagerAddress(options.manager);
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
2010
|
+
async function connectOnce(options, deviceId) {
|
|
2011
|
+
const manager = parseManagerAddress(options.manager);
|
|
2012
|
+
const credentialStore = createClientDeviceCredentialStore({ deviceId });
|
|
2013
|
+
const socket = await connectSecureDirect({
|
|
2014
|
+
host: manager.host,
|
|
2015
|
+
port: manager.port,
|
|
2016
|
+
channel: 'control',
|
|
2017
|
+
deviceId,
|
|
2018
|
+
enrollmentToken: options.pair,
|
|
2019
|
+
credentialStore,
|
|
2020
|
+
timeoutMs: 5000
|
|
2021
|
+
});
|
|
2022
|
+
return new Promise((resolve, reject) => {
|
|
2023
|
+
let buffer = '';
|
|
1915
2024
|
let heartbeatTimer = null;
|
|
1916
2025
|
let resolved = false;
|
|
1917
2026
|
let frameSeq = 0;
|
|
@@ -1945,44 +2054,43 @@ function connectOnce(options, deviceId) {
|
|
|
1945
2054
|
socket.setNoDelay(true);
|
|
1946
2055
|
socket.setKeepAlive(true, options.heartbeatMs);
|
|
1947
2056
|
|
|
1948
|
-
socket
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
});
|
|
2057
|
+
writeJsonLine(socket, {
|
|
2058
|
+
type: 'hello',
|
|
2059
|
+
deviceId,
|
|
2060
|
+
deviceName: options.name,
|
|
2061
|
+
slotNumber: options.slotNumber || undefined,
|
|
2062
|
+
hostname: os.hostname(),
|
|
2063
|
+
platform: os.platform(),
|
|
2064
|
+
arch: os.arch(),
|
|
2065
|
+
pid: process.pid,
|
|
2066
|
+
agentVersion: AGENT_VERSION,
|
|
2067
|
+
productVersion: PRODUCT_VERSION,
|
|
2068
|
+
capabilities: {
|
|
2069
|
+
status: true,
|
|
2070
|
+
thumbnail: options.thumbnailEnabled,
|
|
2071
|
+
liveStream: options.liveEnabled,
|
|
2072
|
+
monitorSelection: true,
|
|
2073
|
+
screenCount: 1,
|
|
2074
|
+
monitorCount: 1,
|
|
2075
|
+
control: false,
|
|
2076
|
+
audio: false,
|
|
2077
|
+
remoteAudio: false,
|
|
2078
|
+
fileTransfer: true,
|
|
2079
|
+
remoteFiles: true,
|
|
2080
|
+
fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
|
|
2081
|
+
computerAgent: options.taskEnabled,
|
|
2082
|
+
taskDispatch: options.taskEnabled,
|
|
2083
|
+
clientUpdate: true,
|
|
2084
|
+
productVersion: PRODUCT_VERSION,
|
|
2085
|
+
agentApproval: options.taskEnabled,
|
|
2086
|
+
agentAudit: options.taskEnabled,
|
|
2087
|
+
agentTools: [...NODE_AGENT_OPERATIONS],
|
|
2088
|
+
elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
|
|
2089
|
+
externalEffects: false,
|
|
2090
|
+
authenticatedDeviceCredential: true,
|
|
2091
|
+
encryptedDirect: true
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
1986
2094
|
|
|
1987
2095
|
socket.on('data', chunk => {
|
|
1988
2096
|
buffer += chunk;
|