livedesk 0.1.479 → 0.1.481

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
@@ -65,6 +65,20 @@ const MANAGER_STATE_DIR = resolve(
65
65
  const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
66
66
  const TERMINAL_HUB_UPDATE_OUTCOMES = new Set(['updated', 'restored', 'failed']);
67
67
 
68
+ async function preflightPackagedRuntimeDependencies() {
69
+ try {
70
+ await Promise.all([
71
+ import('@livedesk/runtime-core'),
72
+ import('@livedesk/runtime-core/os-secret-store')
73
+ ]);
74
+ } catch (error) {
75
+ throw new Error(
76
+ 'LiveDesk runtime dependency preflight failed before replacing the existing runtime: '
77
+ + `${error instanceof Error ? error.message : String(error)}`
78
+ );
79
+ }
80
+ }
81
+
68
82
  function safeUpdatePathSegment(value, fallback = 'unknown') {
69
83
  return String(value || '')
70
84
  .replace(/[^A-Za-z0-9._-]/g, '_')
@@ -3193,6 +3207,7 @@ async function main() {
3193
3207
  }
3194
3208
  }
3195
3209
  await assertNoActiveMacPhysicalDiagnosticLease();
3210
+ await preflightPackagedRuntimeDependencies();
3196
3211
  const roleStateOptions = {
3197
3212
  stateDir: MANAGER_STATE_DIR,
3198
3213
  legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.224",
3
+ "version": "0.1.225",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,16 +36,16 @@
36
36
  "dependencies": {
37
37
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
38
38
  "ffmpeg-static": "^5.3.0",
39
- "@livedesk/runtime-core": "0.1.3",
39
+ "@livedesk/runtime-core": "0.1.4",
40
40
  "@supabase/supabase-js": "^2.110.0",
41
41
  "node-screenshots": "^0.2.8",
42
42
  "ws": "^8.18.3"
43
43
  },
44
44
  "optionalDependencies": {
45
- "@livedesk/fast-linux-x64": "0.1.429",
46
- "@livedesk/fast-osx-arm64": "0.1.429",
47
- "@livedesk/fast-osx-x64": "0.1.429",
48
- "@livedesk/fast-win-x64": "0.1.429"
45
+ "@livedesk/fast-linux-x64": "0.1.430",
46
+ "@livedesk/fast-osx-arm64": "0.1.430",
47
+ "@livedesk/fast-osx-x64": "0.1.430",
48
+ "@livedesk/fast-win-x64": "0.1.430"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
19
- "@livedesk/runtime-core": "0.1.3",
19
+ "@livedesk/runtime-core": "0.1.4",
20
20
  "@openai/codex-sdk": "0.145.0",
21
21
  "cors": "^2.8.5",
22
22
  "express": "^4.21.2",
@@ -322,7 +322,11 @@ export class HubTransferJobs {
322
322
  try {
323
323
  await job.onComplete({ completed, job: snapshot(job) });
324
324
  } catch (error) {
325
- job.error = error instanceof Error ? error.message : String(error);
325
+ // A completion callback can be the durable security-audit gate. Never
326
+ // leave a job looking successful when that final record was not stored.
327
+ job.state = 'failed';
328
+ job.error = `completion-callback-failed:${error instanceof Error ? error.message : String(error)}`;
329
+ job.completedAt ||= new Date().toISOString();
326
330
  markUpdated(job);
327
331
  }
328
332
  }
@@ -2331,11 +2331,12 @@ export function createRemoteHub(options = {}) {
2331
2331
  const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
2332
2332
  ? options.getSecurityIdentity
2333
2333
  : () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
2334
- udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, ttlMs }) => {
2334
+ udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, role, ttlMs }) => {
2335
2335
  const identity = getSecurityIdentity() || {};
2336
2336
  return deviceCredentialAuthority.issueRendezvousProof({
2337
2337
  roomId,
2338
2338
  deviceId,
2339
+ role,
2339
2340
  ttlMs,
2340
2341
  accountId: safeString(identity.accountId, 128)
2341
2342
  });
@@ -8321,7 +8322,7 @@ export function createRemoteHub(options = {}) {
8321
8322
  };
8322
8323
  }
8323
8324
 
8324
- function sendCommand(deviceId, command) {
8325
+ function sendCommand(deviceId, command) {
8325
8326
  const device = devices.get(String(deviceId || ''));
8326
8327
  const commandName = safeString(command?.command || 'ping', 80);
8327
8328
  const requiredPermission = commandName === 'input.control'
@@ -8387,8 +8388,62 @@ export function createRemoteHub(options = {}) {
8387
8388
  command: payload.command,
8388
8389
  channel: dedicatedFileSocket ? 'file' : 'control'
8389
8390
  });
8390
- return { ok: true, commandId };
8391
- }
8391
+ return { ok: true, commandId };
8392
+ }
8393
+
8394
+ async function sendCommandAwaitResult(deviceId, command, options = {}) {
8395
+ const normalizedDeviceId = safeString(deviceId, 160);
8396
+ const device = devices.get(normalizedDeviceId);
8397
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8398
+ const commandWithId = { ...(command || {}), commandId };
8399
+
8400
+ if (device?.synthetic === true && device.connected) {
8401
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8402
+ return {
8403
+ ...sent,
8404
+ queued: sent.ok === true,
8405
+ acknowledged: sent.ok === true,
8406
+ acknowledgement: sent.ok === true
8407
+ ? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
8408
+ : { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
8409
+ };
8410
+ }
8411
+
8412
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8413
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8414
+ return {
8415
+ ...sent,
8416
+ queued: false,
8417
+ acknowledged: false,
8418
+ acknowledgement: {
8419
+ ok: false,
8420
+ commandId,
8421
+ result: null,
8422
+ error: sent.error || 'device-not-connected'
8423
+ }
8424
+ };
8425
+ }
8426
+
8427
+ const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
8428
+ // Register before writing: a loopback Agent can return command.result in
8429
+ // the same event-loop turn as the command write.
8430
+ const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
8431
+ const sent = sendCommand(normalizedDeviceId, commandWithId);
8432
+ if (!sent.ok) {
8433
+ failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
8434
+ }
8435
+ const acknowledgement = await acknowledgementPromise;
8436
+ return {
8437
+ ...sent,
8438
+ ok: sent.ok === true && acknowledgement.ok === true,
8439
+ queued: sent.ok === true,
8440
+ acknowledged: acknowledgement.ok === true,
8441
+ acknowledgement,
8442
+ error: acknowledgement.ok === true
8443
+ ? undefined
8444
+ : acknowledgement.error || sent.error || 'command-result-failed'
8445
+ };
8446
+ }
8392
8447
 
8393
8448
  function refreshDevicePolicies(deviceIds = undefined) {
8394
8449
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
@@ -11277,9 +11332,10 @@ export function createRemoteHub(options = {}) {
11277
11332
  retryTaskBatch,
11278
11333
  listDeviceFrames,
11279
11334
  disconnectDevice,
11280
- assignDeviceSlot,
11281
- sendCommand,
11282
- refreshDevicePolicies,
11335
+ assignDeviceSlot,
11336
+ sendCommand,
11337
+ sendCommandAwaitResult,
11338
+ refreshDevicePolicies,
11283
11339
  sendLegacyClientUpdate,
11284
11340
  sendInputControl,
11285
11341
  releaseInputOwner,
@@ -11311,6 +11367,7 @@ export function createRemoteHub(options = {}) {
11311
11367
  getSecurityStatus: () => ({
11312
11368
  hubId: deviceCredentialAuthority.hubId,
11313
11369
  hubPublicKey: deviceCredentialAuthority.hubPublicKey,
11370
+ hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
11314
11371
  direct: secureDirectAcceptor.getStatus(),
11315
11372
  devices: deviceCredentialAuthority.listDevices()
11316
11373
  }),
@@ -174,6 +174,9 @@ export function createDeviceCredentialAuthority({
174
174
  if (!privateKeyText) throw securityError('hub-private-key-secure-store-unavailable');
175
175
  const hubPrivateKey = requireP256PrivateKey(privateKeyText);
176
176
  const hubPublic = requireP256PublicKey(authority.publicKey);
177
+ const hubIssuerKeyId = crypto.createHash('sha256')
178
+ .update(Buffer.from(hubPublic.text, 'base64url'))
179
+ .digest('base64url');
177
180
  const derivedPublic = crypto.createPublicKey(hubPrivateKey).export({ format: 'der', type: 'spki' }).toString('base64url');
178
181
  if (derivedPublic !== hubPublic.text) throw securityError('hub-device-authority-key-mismatch');
179
182
  const hubId = clean(authority.hubId, 128);
@@ -183,18 +186,33 @@ export function createDeviceCredentialAuthority({
183
186
  registry = { version: AUTHORITY_VERSION, devices: {} };
184
187
  }
185
188
 
186
- function persistRegistry() {
187
- atomicPrivateJson(registryPath, registry);
189
+ function persistRegistry(nextRegistry = registry) {
190
+ atomicPrivateJson(registryPath, nextRegistry);
188
191
  }
189
192
 
190
- function issueCredential({ accountId, deviceId, devicePublicKey, replace = false }) {
193
+ function normalizeCredentialRequest({ accountId, deviceId, devicePublicKey } = {}) {
191
194
  const owner = clean(accountId, 128);
192
195
  const id = clean(deviceId, 128);
193
196
  if (!owner) throw securityError('device-account-required');
194
197
  if (!id) throw securityError('device-id-required');
195
198
  const deviceKey = requireP256PublicKey(devicePublicKey);
199
+ return { owner, id, deviceKey };
200
+ }
201
+
202
+ function assertEnrollmentAllowed(request) {
203
+ const normalized = normalizeCredentialRequest(request);
204
+ const existing = registry.devices[normalized.id];
205
+ if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
206
+ if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) {
207
+ throw securityError('device-registry-capacity');
208
+ }
209
+ return true;
210
+ }
211
+
212
+ function issueCredential({ accountId, deviceId, devicePublicKey }) {
213
+ const { owner, id, deviceKey } = normalizeCredentialRequest({ accountId, deviceId, devicePublicKey });
196
214
  const existing = registry.devices[id];
197
- if (existing && !existing.revokedAt && !replace) throw securityError('device-already-enrolled');
215
+ if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
198
216
  if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) throw securityError('device-registry-capacity');
199
217
  const issuedAt = Math.floor(now());
200
218
  const payload = {
@@ -213,18 +231,25 @@ export function createDeviceCredentialAuthority({
213
231
  dsaEncoding: 'ieee-p1363'
214
232
  }).toString('base64url');
215
233
  const credential = `${payloadText}.${signature}`;
216
- registry.devices[id] = {
217
- serial: payload.serial,
218
- accountId: owner,
219
- hubId,
220
- devicePublicKey: deviceKey.text,
221
- credential,
222
- issuedAt,
223
- expiresAt: payload.expiresAt,
224
- revokedAt: '',
225
- lastConnectedAt: ''
234
+ const nextRegistry = {
235
+ ...registry,
236
+ devices: {
237
+ ...registry.devices,
238
+ [id]: {
239
+ serial: payload.serial,
240
+ accountId: owner,
241
+ hubId,
242
+ devicePublicKey: deviceKey.text,
243
+ credential,
244
+ issuedAt,
245
+ expiresAt: payload.expiresAt,
246
+ revokedAt: '',
247
+ lastConnectedAt: ''
248
+ }
249
+ }
226
250
  };
227
- persistRegistry();
251
+ persistRegistry(nextRegistry);
252
+ registry = nextRegistry;
228
253
  return { credential, payload: { ...payload }, hubPublicKey: hubPublic.text };
229
254
  }
230
255
 
@@ -279,23 +304,26 @@ export function createDeviceCredentialAuthority({
279
304
  }).toString('base64url');
280
305
  }
281
306
 
282
- function issueRendezvousProof({ roomId, accountId, deviceId, ttlMs = 60_000 } = {}) {
307
+ function issueRendezvousProof({ roomId, accountId, deviceId, role, ttlMs = 60_000 } = {}) {
283
308
  const room = clean(roomId, 160);
284
309
  const owner = clean(accountId, 128);
285
310
  const device = clean(deviceId, 128);
311
+ const normalizedRole = clean(role, 16).toLowerCase();
286
312
  if (!room || !owner || !device) throw securityError('rendezvous-proof-binding-required');
313
+ if (!['hub', 'client'].includes(normalizedRole)) throw securityError('rendezvous-proof-role-required');
287
314
  const issuedAt = Math.floor(now());
288
315
  const payload = {
289
- version: 1,
290
- protocol: 'livedesk.udp.rendezvous-proof.v1',
316
+ version: 2,
317
+ protocol: 'livedesk.udp.rendezvous-proof.v2',
318
+ issuerKeyId: hubIssuerKeyId,
291
319
  roomId: room,
320
+ role: normalizedRole,
292
321
  accountId: owner,
293
322
  hubId,
294
323
  deviceId: device,
295
324
  issuedAt,
296
325
  expiresAt: issuedAt + Math.max(10_000, Math.min(120_000, Number(ttlMs) || 60_000)),
297
- nonce: crypto.randomBytes(16).toString('base64url'),
298
- hubPublicKey: hubPublic.text
326
+ nonce: crypto.randomBytes(16).toString('base64url')
299
327
  };
300
328
  const payloadText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
301
329
  return { proof: `${payloadText}.${signHubMessage(payloadText)}`, payload };
@@ -305,9 +333,19 @@ export function createDeviceCredentialAuthority({
305
333
  const id = clean(deviceId, 128);
306
334
  const record = registry.devices[id];
307
335
  if (!record || record.revokedAt) return false;
308
- record.revokedAt = new Date(now()).toISOString();
309
- record.revokeReason = clean(reason, 160);
310
- persistRegistry();
336
+ const nextRegistry = {
337
+ ...registry,
338
+ devices: {
339
+ ...registry.devices,
340
+ [id]: {
341
+ ...record,
342
+ revokedAt: new Date(now()).toISOString(),
343
+ revokeReason: clean(reason, 160)
344
+ }
345
+ }
346
+ };
347
+ persistRegistry(nextRegistry);
348
+ registry = nextRegistry;
311
349
  return true;
312
350
  }
313
351
 
@@ -315,8 +353,15 @@ export function createDeviceCredentialAuthority({
315
353
  const id = clean(deviceId, 128);
316
354
  const record = registry.devices[id];
317
355
  if (!record || record.revokedAt) return false;
318
- record.lastConnectedAt = new Date(now()).toISOString();
319
- persistRegistry();
356
+ const nextRegistry = {
357
+ ...registry,
358
+ devices: {
359
+ ...registry.devices,
360
+ [id]: { ...record, lastConnectedAt: new Date(now()).toISOString() }
361
+ }
362
+ };
363
+ persistRegistry(nextRegistry);
364
+ registry = nextRegistry;
320
365
  return true;
321
366
  }
322
367
 
@@ -335,14 +380,17 @@ export function createDeviceCredentialAuthority({
335
380
 
336
381
  function clearDevices() {
337
382
  const removed = Object.keys(registry.devices).length;
338
- registry = { version: AUTHORITY_VERSION, devices: {} };
339
- persistRegistry();
383
+ const nextRegistry = { version: AUTHORITY_VERSION, devices: {} };
384
+ persistRegistry(nextRegistry);
385
+ registry = nextRegistry;
340
386
  return removed;
341
387
  }
342
388
 
343
389
  return Object.freeze({
344
390
  hubId,
345
391
  hubPublicKey: hubPublic.text,
392
+ hubIssuerKeyId,
393
+ assertEnrollmentAllowed,
346
394
  issueCredential,
347
395
  verifyCredential,
348
396
  verifyDeviceSignature,
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
@@ -85,6 +85,25 @@ function parseSeal(raw) {
85
85
  }
86
86
  }
87
87
 
88
+ async function syncFile(filePath, flags = 'r') {
89
+ const handle = await open(filePath, flags, 0o600);
90
+ try {
91
+ await handle.sync();
92
+ } finally {
93
+ await handle.close();
94
+ }
95
+ }
96
+
97
+ async function appendDurably(filePath, value) {
98
+ const handle = await open(filePath, 'a', 0o600);
99
+ try {
100
+ await handle.writeFile(value, { encoding: 'utf8' });
101
+ await handle.sync();
102
+ } finally {
103
+ await handle.close();
104
+ }
105
+ }
106
+
88
107
  export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
89
108
  const filePath = path.join(dataDir, 'security', 'security-audit-v1.jsonl');
90
109
  const sealStore = createOsSecretStore({
@@ -175,6 +194,7 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
175
194
  }
176
195
  const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
177
196
  await writeFile(temporary, `${rebuilt.map(record => JSON.stringify(record)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
197
+ await syncFile(temporary);
178
198
  await rename(temporary, filePath);
179
199
  records = rebuilt;
180
200
  seal.headHash = previousHash;
@@ -195,7 +215,9 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
195
215
  const recordHash = hashRecord(previousHash, payloadHash, mac);
196
216
  written = { version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload };
197
217
  await mkdir(path.dirname(filePath), { recursive: true });
198
- await appendFile(filePath, `${JSON.stringify(written)}\n`, { encoding: 'utf8', mode: 0o600 });
218
+ // A successful record() is the mutation gate for security-sensitive
219
+ // operations. Resolve only after the JSONL record is on stable storage.
220
+ await appendDurably(filePath, `${JSON.stringify(written)}\n`);
199
221
  current.push(written);
200
222
  seal.headHash = recordHash;
201
223
  seal.recordCount = current.length;