@livedesk/hub 0.1.30 → 0.1.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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.2",
19
+ "@livedesk/runtime-core": "0.1.3",
20
20
  "@openai/codex-sdk": "0.145.0",
21
21
  "cors": "^2.8.5",
22
22
  "express": "^4.21.2",
@@ -1,4 +1,4 @@
1
- import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import crypto from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
@@ -84,10 +84,20 @@ export function createAgentAuditStore({ dataDir = path.join(os.homedir(), '.live
84
84
  await writeQueue;
85
85
  return record;
86
86
  },
87
- async list({ runId = '', limit = 200 } = {}) {
87
+ async list({ runId = '', limit = 200 } = {}) {
88
88
  const current = await load();
89
89
  const normalizedLimit = Math.max(1, Math.min(500, Number(limit) || 200));
90
- return current.filter(item => !runId || item.runId === String(runId)).slice(-normalizedLimit).reverse();
91
- }
92
- };
93
- }
90
+ return current.filter(item => !runId || item.runId === String(runId)).slice(-normalizedLimit).reverse();
91
+ },
92
+ async clear() {
93
+ writeQueue = writeQueue.then(async () => {
94
+ await rm(filePath, { force: true });
95
+ records = [];
96
+ fileRecordCount = 0;
97
+ rewriteNeeded = false;
98
+ });
99
+ await writeQueue;
100
+ return { ok: true };
101
+ }
102
+ };
103
+ }
@@ -181,9 +181,15 @@ function safeAutoConstraint(tool, args, mode) {
181
181
  return '';
182
182
  }
183
183
 
184
- export function evaluateAgentToolPermission({ policy, toolName, arguments: args = {}, deviceIds = [] } = {}) {
185
- const tool = getAgentToolDefinition(toolName);
186
- if (!tool) return { decision: 'deny', category: 'unknown', risk: 'critical', reason: 'Unknown or unregistered Agent tool.' };
184
+ export function evaluateAgentToolPermission({ policy, toolName, arguments: args = {}, deviceIds = [] } = {}) {
185
+ const tool = getAgentToolDefinition(toolName);
186
+ if (!tool) return { decision: 'deny', category: 'unknown', risk: 'critical', reason: 'Unknown or unregistered Agent tool.' };
187
+ // This is deliberately redundant with the filtered MCP registry. A future
188
+ // registry edit must not make an AI mutation executable merely by exposing
189
+ // a definition again.
190
+ if (tool.mutating === true || tool.readOnly !== true) {
191
+ return { decision: 'deny', category: tool.category, risk: 'critical', reason: 'AI mutating tools are disabled in the first public product.' };
192
+ }
187
193
  const allowed = new Set(normalizeIds(policy?.deviceIds));
188
194
  const requested = targetIdsFromArgs(args, deviceIds);
189
195
  if (requested.some(deviceId => !allowed.has(deviceId))) {
@@ -24,7 +24,7 @@ function withRequiredInput(properties, required = []) {
24
24
  const taskPermission = { ask: 'ask', safeAuto: 'ask', fullAccess: 'allow' };
25
25
  const readTaskPermission = { ask: 'allow', safeAuto: 'allow', fullAccess: 'allow' };
26
26
 
27
- export const AGENT_TOOL_DEFINITIONS = Object.freeze([
27
+ const ALL_AGENT_TOOL_DEFINITIONS = Object.freeze([
28
28
  {
29
29
  name: 'livedesk.list_devices',
30
30
  description: 'List connected LiveDesk Clients. This is read-only.',
@@ -313,6 +313,20 @@ export const AGENT_TOOL_DEFINITIONS = Object.freeze([
313
313
  }
314
314
  ]);
315
315
 
316
+ // LD-SEC-AI-001: the first public product exposes read-only AI tools only.
317
+ // Keep the retired names explicit so every boundary can fail closed for an old
318
+ // model, stale MCP schema, or rolling-update Client without advertising the
319
+ // capability or relying on a permission preset.
320
+ export const RETIRED_AGENT_MUTATING_TOOL_NAMES = Object.freeze(
321
+ ALL_AGENT_TOOL_DEFINITIONS.filter(tool => tool.mutating === true).map(tool => tool.name)
322
+ );
323
+
324
+ const retiredMutatingToolNames = new Set(RETIRED_AGENT_MUTATING_TOOL_NAMES);
325
+
326
+ export const AGENT_TOOL_DEFINITIONS = Object.freeze(
327
+ ALL_AGENT_TOOL_DEFINITIONS.filter(tool => tool.readOnly === true && tool.mutating !== true)
328
+ );
329
+
316
330
  export const AGENT_TOOL_NAMES = Object.freeze(AGENT_TOOL_DEFINITIONS.map(tool => tool.name));
317
331
 
318
332
  const toolByName = new Map(AGENT_TOOL_DEFINITIONS.map(tool => [tool.name, tool]));
@@ -320,3 +334,7 @@ const toolByName = new Map(AGENT_TOOL_DEFINITIONS.map(tool => [tool.name, tool])
320
334
  export function getAgentToolDefinition(name) {
321
335
  return toolByName.get(String(name || '').trim()) || null;
322
336
  }
337
+
338
+ export function isRetiredAgentMutatingToolName(name) {
339
+ return retiredMutatingToolNames.has(String(name || '').trim());
340
+ }
@@ -53,11 +53,13 @@ async function atomicJson(filePath, value) {
53
53
  }
54
54
 
55
55
  export class CaptureStore {
56
- constructor({ dataDir } = {}) {
56
+ constructor({ dataDir, getRetentionPolicy = () => 'never', onRetentionDelete = () => {} } = {}) {
57
57
  this.root = captureRoot(dataDir);
58
58
  this.indexPath = path.join(this.root, 'index.json');
59
59
  this.sessions = new Map();
60
60
  this.index = null;
61
+ this.getRetentionPolicy = typeof getRetentionPolicy === 'function' ? getRetentionPolicy : () => 'never';
62
+ this.onRetentionDelete = typeof onRetentionDelete === 'function' ? onRetentionDelete : () => {};
61
63
  }
62
64
 
63
65
  async initialize() {
@@ -199,6 +201,7 @@ export class CaptureStore {
199
201
 
200
202
  async list() {
201
203
  await this.ready();
204
+ await this.enforceRetention();
202
205
  return this.index.captures.map(publicRecord);
203
206
  }
204
207
 
@@ -218,10 +221,54 @@ export class CaptureStore {
218
221
  }
219
222
 
220
223
  resolveFilePath(record) {
221
- if (record?.filePath) return record.filePath;
222
224
  const folder = record?.kind === 'screenshot' ? 'images' : record?.kind === 'timelapse' ? 'timelapses' : 'recordings';
223
225
  const extension = record?.mimeType === 'image/png' ? '.png' : '.webm';
224
- return path.join(this.root, folder, `${safeName(record?.id, 'missing')}${extension}`);
226
+ const fallback = path.join(this.root, folder, `${safeName(record?.id, 'missing')}${extension}`);
227
+ const candidate = path.resolve(record?.filePath || fallback);
228
+ const relative = path.relative(path.resolve(this.root), candidate);
229
+ return !relative || (!relative.startsWith('..') && !path.isAbsolute(relative)) ? candidate : fallback;
230
+ }
231
+
232
+ async enforceRetention({ now = Date.now() } = {}) {
233
+ await this.ready();
234
+ const policy = String(this.getRetentionPolicy() || 'never').trim();
235
+ const retentionDays = policy === '7-days' ? 7 : policy === '30-days' ? 30 : 0;
236
+ if (!retentionDays) return { policy: 'never', removed: 0, removedBytes: 0 };
237
+ const cutoff = Number(now) - retentionDays * 24 * 60 * 60_000;
238
+ const expired = this.index.captures.filter(record => {
239
+ const createdAt = Date.parse(record?.createdAt || '');
240
+ return Number.isFinite(createdAt) && createdAt < cutoff;
241
+ });
242
+ if (expired.length === 0) return { policy, removed: 0, removedBytes: 0 };
243
+ const expiredIds = new Set(expired.map(record => record.id));
244
+ this.index.captures = this.index.captures.filter(record => !expiredIds.has(record.id));
245
+ await Promise.all(expired.map(record => rm(this.resolveFilePath(record), { force: true })));
246
+ await atomicJson(this.indexPath, { captures: this.index.captures });
247
+ const result = {
248
+ policy,
249
+ removed: expired.length,
250
+ removedBytes: expired.reduce((sum, record) => sum + Math.max(0, Number(record.sizeBytes) || 0), 0)
251
+ };
252
+ await this.onRetentionDelete(result);
253
+ return result;
254
+ }
255
+
256
+ async removeAll() {
257
+ await this.ready();
258
+ const records = [...this.index.captures];
259
+ const sessions = [...this.sessions.values()];
260
+ this.sessions.clear();
261
+ await Promise.all([
262
+ ...records.map(record => rm(this.resolveFilePath(record), { force: true })),
263
+ ...sessions.map(session => rm(session.tempPath, { force: true }))
264
+ ]);
265
+ this.index.captures = [];
266
+ await atomicJson(this.indexPath, { captures: [] });
267
+ return {
268
+ removed: records.length,
269
+ removedBytes: records.reduce((sum, record) => sum + Math.max(0, Number(record.sizeBytes) || 0), 0),
270
+ cancelledSessions: sessions.length
271
+ };
225
272
  }
226
273
 
227
274
  async _record(record) {
@@ -104,6 +104,14 @@ export class HubSharedFolders {
104
104
  return { ok: removed };
105
105
  }
106
106
 
107
+ async clear() {
108
+ await this.ensureLoaded();
109
+ const removed = this.folders.size;
110
+ this.folders.clear();
111
+ await this.persist();
112
+ return { ok: true, removed };
113
+ }
114
+
107
115
  async sync(id, deviceIds, remoteDirectory) {
108
116
  await this.ensureLoaded();
109
117
  const folder = this.folders.get(String(id || ''));
@@ -29,11 +29,14 @@ function markUpdated(job) {
29
29
  }
30
30
 
31
31
  export class HubTransferJobs {
32
- constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS } = {}) {
32
+ constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS, getMaxFileSizeBytes = () => Number.MAX_SAFE_INTEGER } = {}) {
33
33
  this.filesystem = filesystem;
34
34
  this.remoteHub = remoteHub;
35
35
  this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
36
36
  this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
37
+ this.getMaxFileSizeBytes = typeof getMaxFileSizeBytes === 'function'
38
+ ? getMaxFileSizeBytes
39
+ : () => Number.MAX_SAFE_INTEGER;
37
40
  this.jobs = new Map();
38
41
  this.queue = [];
39
42
  this.running = 0;
@@ -98,6 +101,21 @@ export class HubTransferJobs {
98
101
  return snapshot(job);
99
102
  }
100
103
 
104
+ clear() {
105
+ let cancelled = 0;
106
+ for (const job of this.jobs.values()) {
107
+ if (!['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) {
108
+ this.cancel(job.jobId);
109
+ cancelled += 1;
110
+ }
111
+ }
112
+ const removed = this.jobs.size;
113
+ this.jobs.clear();
114
+ this.queue = [];
115
+ this.pendingCommands.clear();
116
+ return { cancelled, removed };
117
+ }
118
+
101
119
  pump() {
102
120
  while (this.running < this.maxConcurrent && this.queue.length > 0) {
103
121
  const job = this.queue.shift();
@@ -118,6 +136,10 @@ export class HubTransferJobs {
118
136
  ? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
119
137
  : await this.filesystem.scan(job.itemIds);
120
138
  if (job.cancelled) return;
139
+ const maxFileSizeBytes = Math.max(1, Number(this.getMaxFileSizeBytes()) || 1);
140
+ if (scan.files.some(file => Number(file.size || 0) > maxFileSizeBytes)) {
141
+ throw new Error('file-transfer-file-too-large');
142
+ }
121
143
  job.totalFiles = scan.files.length;
122
144
  job.totalBytes = scan.totalBytes;
123
145
  job.state = 'sending';
@@ -160,10 +182,12 @@ export class HubTransferJobs {
160
182
  async sendFile(job, file) {
161
183
  const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
162
184
  if (Number(file.size || 0) === 0) {
163
- await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets());
185
+ const sha256 = crypto.createHash('sha256').update(Buffer.alloc(0)).digest('hex');
186
+ await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets(), sha256);
164
187
  return;
165
188
  }
166
189
  const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
190
+ const hasher = crypto.createHash('sha256');
167
191
  job.currentStream = stream;
168
192
  let offset = 0;
169
193
  try {
@@ -171,9 +195,11 @@ export class HubTransferJobs {
171
195
  if (job.cancelled) return;
172
196
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
173
197
  const final = offset + buffer.length >= Number(file.size || 0);
198
+ hasher.update(buffer);
199
+ const sha256 = final ? hasher.digest('hex') : '';
174
200
  const targets = activeTargets();
175
201
  if (targets.length === 0) throw new Error('all-targets-failed');
176
- await this.sendChunk(job, file, offset, buffer, final, targets);
202
+ await this.sendChunk(job, file, offset, buffer, final, targets, sha256);
177
203
  offset += buffer.length;
178
204
  job.sentBytes += buffer.length;
179
205
  markUpdated(job);
@@ -184,7 +210,7 @@ export class HubTransferJobs {
184
210
  }
185
211
  }
186
212
 
187
- async sendChunk(job, file, offset, buffer, final, targets) {
213
+ async sendChunk(job, file, offset, buffer, final, targets, sha256 = '') {
188
214
  const outcomes = await Promise.all(targets.map(async deviceId => {
189
215
  if (job.cancelled) return { deviceId, ok: false, error: 'transfer-cancelled' };
190
216
  const commandId = crypto.randomUUID();
@@ -200,6 +226,7 @@ export class HubTransferJobs {
200
226
  offset,
201
227
  totalBytes: Number(file.size || 0),
202
228
  final,
229
+ sha256,
203
230
  dataBase64: buffer.toString('base64'),
204
231
  lastModified: Number(file.modifiedMs || 0) || 0,
205
232
  requestedAt: new Date().toISOString()
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { fetchSignedUpdateManifest } from '@livedesk/runtime-core/signed-update-manifest';
2
3
 
3
4
  export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
4
5
  // 0.1.172 delegates to the exact-version, identity-preserving package
@@ -138,27 +139,27 @@ function buildLegacyUpdateCommand(
138
139
  return buildLegacyPlatformCommand(device.platform, payload);
139
140
  }
140
141
 
141
- async function fetchLatestPackage(packageName, fetchImpl) {
142
- const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
143
- const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/latest`, {
142
+ async function fetchPackageMetadata(packageName, version, fetchImpl) {
143
+ const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
144
+ const encodedVersion = encodeURIComponent(String(version || 'latest'));
145
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/${encodedVersion}`, {
144
146
  headers: { Accept: 'application/json' },
145
147
  signal: AbortSignal.timeout(12_000)
146
148
  });
147
149
  if (!response.ok) {
148
- throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
150
+ throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}@${version}.`);
149
151
  }
150
152
  const payload = await response.json();
151
- const version = cleanVersion(payload?.version);
152
- if (!version) throw new Error(`npm registry returned no version for ${packageName}.`);
153
- return { version, package: payload };
154
- }
155
-
156
- export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
157
- if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
158
- const [manager, client] = await Promise.all([
159
- fetchLatestPackage('livedesk', fetchImpl),
160
- fetchLatestPackage('@livedesk/client', fetchImpl)
161
- ]);
153
+ const resolvedVersion = cleanVersion(payload?.version);
154
+ if (!resolvedVersion) throw new Error(`npm registry returned no version for ${packageName}@${version}.`);
155
+ return { version: resolvedVersion, package: payload };
156
+ }
157
+
158
+ async function fetchUnsignedRegistryReleaseForTests(fetchImpl) {
159
+ const [manager, client] = await Promise.all([
160
+ fetchPackageMetadata('livedesk', 'latest', fetchImpl),
161
+ fetchPackageMetadata('@livedesk/client', 'latest', fetchImpl)
162
+ ]);
162
163
  const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
163
164
  if (!bundledClientVersion) {
164
165
  throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
@@ -172,7 +173,47 @@ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
172
173
  : `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
173
174
  managerPackage: manager.package,
174
175
  clientPackage: client.package,
175
- checkedAt: new Date().toISOString()
176
+ checkedAt: new Date().toISOString(),
177
+ manifestVerified: false,
178
+ blockedVersions: { manager: [], client: [], desktop: [] }
179
+ };
180
+ }
181
+
182
+ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch, {
183
+ manifestUrl = '',
184
+ publicKey = '',
185
+ now = () => Date.now(),
186
+ allowUnsignedRegistryForTests = false
187
+ } = {}) {
188
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
189
+ if (allowUnsignedRegistryForTests === true) return fetchUnsignedRegistryReleaseForTests(fetchImpl);
190
+ const manifest = await fetchSignedUpdateManifest({ fetchImpl, url: manifestUrl, publicKey, now });
191
+ const managerRelease = manifest.release.manager;
192
+ const clientRelease = manifest.release.client;
193
+ const [manager, client] = await Promise.all([
194
+ fetchPackageMetadata('livedesk', managerRelease.version, fetchImpl),
195
+ fetchPackageMetadata('@livedesk/client', clientRelease.version, fetchImpl)
196
+ ]);
197
+ const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
198
+ if (manager.version !== managerRelease.version
199
+ || client.version !== clientRelease.version
200
+ || manager.package?.dist?.integrity !== managerRelease.integrity
201
+ || client.package?.dist?.integrity !== clientRelease.integrity
202
+ || bundledClientVersion !== clientRelease.version) {
203
+ throw new Error('signed-update-registry-binding-invalid');
204
+ }
205
+ return {
206
+ latestManagerVersion: managerRelease.version,
207
+ latestClientVersion: clientRelease.version,
208
+ registryClientVersion: client.version,
209
+ releaseSkew: '',
210
+ managerPackage: manager.package,
211
+ clientPackage: client.package,
212
+ checkedAt: new Date(now()).toISOString(),
213
+ manifestVerified: true,
214
+ manifestGeneratedAt: manifest.generatedAt,
215
+ manifestExpiresAt: manifest.expiresAt,
216
+ blockedVersions: manifest.blockedVersions
176
217
  };
177
218
  }
178
219
 
@@ -239,6 +280,9 @@ export function createLiveDeskUpdateManager({
239
280
  requestHubRestart,
240
281
  fetchImpl = globalThis.fetch,
241
282
  now = () => Date.now(),
283
+ updateManifestUrl = '',
284
+ updatePublicKey = '',
285
+ allowUnsignedRegistryForTests = false,
242
286
  clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
243
287
  targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
244
288
  operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
@@ -275,9 +319,30 @@ export function createLiveDeskUpdateManager({
275
319
 
276
320
  const checkLatest = async () => {
277
321
  if (checkPromise) return checkPromise;
278
- checkPromise = fetchLatestLiveDeskRelease(fetchImpl)
279
- .then(release => {
280
- latestRelease = release;
322
+ checkPromise = fetchLatestLiveDeskRelease(fetchImpl, {
323
+ manifestUrl: updateManifestUrl,
324
+ publicKey: updatePublicKey,
325
+ now,
326
+ allowUnsignedRegistryForTests
327
+ })
328
+ .then(release => {
329
+ if (cleanVersion(currentManagerVersion)
330
+ && compareVersions(release.latestManagerVersion, currentManagerVersion) < 0) {
331
+ throw new Error('signed-update-manager-downgrade-rejected');
332
+ }
333
+ if (cleanVersion(currentClientVersion)
334
+ && compareVersions(release.latestClientVersion, currentClientVersion) < 0) {
335
+ throw new Error('signed-update-client-downgrade-rejected');
336
+ }
337
+ if (release.blockedVersions?.manager?.includes(cleanVersion(currentManagerVersion))
338
+ && compareVersions(release.latestManagerVersion, currentManagerVersion) <= 0) {
339
+ throw new Error('signed-update-blocked-manager-without-safe-upgrade');
340
+ }
341
+ if (release.blockedVersions?.client?.includes(cleanVersion(currentClientVersion))
342
+ && compareVersions(release.latestClientVersion, currentClientVersion) <= 0) {
343
+ throw new Error('signed-update-blocked-client-without-safe-upgrade');
344
+ }
345
+ latestRelease = release;
281
346
  checkError = '';
282
347
  return release;
283
348
  })
@@ -321,6 +386,9 @@ export function createLiveDeskUpdateManager({
321
386
  latestClientVersion: latestRelease?.latestClientVersion || '',
322
387
  registryClientVersion: latestRelease?.registryClientVersion || '',
323
388
  releaseSkew: latestRelease?.releaseSkew || '',
389
+ manifestVerified: latestRelease?.manifestVerified === true,
390
+ manifestGeneratedAt: latestRelease?.manifestGeneratedAt || '',
391
+ manifestExpiresAt: latestRelease?.manifestExpiresAt || '',
324
392
  updateAvailable,
325
393
  managerUpdateAvailable,
326
394
  clientUpdateAvailable,